mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 16:35:48 +01:00
Add saved-token Copilot setup and normal runtime admission
Reviewed partition of the current-master Copilot integration. Complete v30 live qualification is retained; current v31 recovery compatibility, CI and review are tracked explicitly. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
50368e9c50
commit
fcbb80554e
65 files changed
+57392
-175
No files matched your search
@@ -1,4 +1,6 @@
|
||||
import { probeAcpxClaudeInstallation, probeAcpxCursorInstallation } from "@paperclipai/paperclip-runner/live";
|
||||
import { probeCopilotExecutionTarget } from "../services/copilot-connection-probe.js";
|
||||
import { HttpError } from "../errors.js";
|
||||
import { probeAcpxClaudeInstallation, probeAcpxCursorInstallation } from "../vendor/paperclip-runner/live/index.js";
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
||||
import { buildSandboxNpmInstallCommand } from "@paperclipai/adapter-utils";
|
||||
import type { ServerAdapterModule } from "../adapters/index.js";
|
||||
@@ -17,7 +19,9 @@ import {
|
||||
setOverridePaused,
|
||||
} from "../adapters/registry.js";
|
||||
|
||||
vi.mock("@paperclipai/paperclip-runner/live", () => ({
|
||||
vi.mock("../services/copilot-connection-probe.js",()=>({probeCopilotExecutionTarget:vi.fn(async()=>({status:"verified",models:[{id:"gpt-5.6-luna"}]}))}));
|
||||
|
||||
vi.mock("../vendor/paperclip-runner/live/index.js", () => ({
|
||||
probeAcpxClaudeInstallation: vi.fn(async () => undefined),
|
||||
probeAcpxGrokInstallation: vi.fn(async () => undefined),
|
||||
probeAcpxCursorInstallation: vi.fn(async () => undefined),
|
||||
@@ -359,20 +363,29 @@ describe("server adapter registry", () => {
|
||||
checks: [{ code: "paperclip_runner_acpx_agent_unavailable" }],
|
||||
});
|
||||
});
|
||||
it("reports qualification-only readiness for an exact host-authorized candidate", async () => {
|
||||
const key = "PAPERCLIP_RUNNER_ACPX_QUALIFICATION";
|
||||
const previous = process.env[key];
|
||||
process.env[key] = JSON.stringify([{ agent: "copilot", model: "exact-model" }]);
|
||||
try {
|
||||
const result = await requireServerAdapter("paperclip_runner").testEnvironment({
|
||||
companyId: "company-1", adapterType: "paperclip_runner",
|
||||
config: { provider: "acpx", acpxAgent: "copilot", model: "exact-model" },
|
||||
});
|
||||
expect(result).toMatchObject({ status: "warn", checks: [{ code: "acpx_candidate_qualification_only" }] });
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env[key];
|
||||
else process.env[key] = previous;
|
||||
}
|
||||
it("requires a saved Copilot token rather than ambient authentication",async()=>{
|
||||
const prior=process.env.COPILOT_GITHUB_TOKEN;process.env.COPILOT_GITHUB_TOKEN="ambient-not-authority";
|
||||
const probe=vi.mocked(probeCopilotExecutionTarget);probe.mockClear();
|
||||
try{
|
||||
const result=await requireServerAdapter("paperclip_runner").testEnvironment({companyId:"company-1",adapterType:"paperclip_runner",config:{provider:"acpx",acpxAgent:"copilot",model:"gpt-5.6-luna"}});
|
||||
expect(result).toMatchObject({status:"fail",checks:[{code:"COPILOT_AUTH_REQUIRED"}]});expect(probe).not.toHaveBeenCalled();
|
||||
}finally{if(prior===undefined)delete process.env.COPILOT_GITHUB_TOKEN;else process.env.COPILOT_GITHUB_TOKEN=prior;}
|
||||
});
|
||||
it.each([undefined,{kind:"remote",remoteCwd:"/workspace"}])("verifies the selected Copilot runtime and model without a prompt",async(executionTarget)=>{
|
||||
const result=await requireServerAdapter("paperclip_runner").testEnvironment({companyId:"company-1",adapterType:"paperclip_runner",config:{provider:"acpx",acpxAgent:"copilot",model:"gpt-5.6-luna",env:{COPILOT_GITHUB_TOKEN:"fixture-token"}},executionTarget} as any);
|
||||
expect(probeCopilotExecutionTarget).toHaveBeenLastCalledWith("fixture-token",executionTarget,"gpt-5.6-luna");
|
||||
expect(result).toMatchObject({status:"pass",checks:[{code:"copilot_metadata_verified"}]});
|
||||
});
|
||||
it.each(["COPILOT_AUTH_REQUIRED","COPILOT_ENTITLEMENT_DENIED","COPILOT_MODEL_UNAVAILABLE","COPILOT_INSTALLATION_INVALID","COPILOT_REQUEST_FAILED"])("reports distinct Copilot failure %s",async code=>{
|
||||
vi.mocked(probeCopilotExecutionTarget).mockRejectedValueOnce(new HttpError(422,"Fixture metadata failure",{code}));
|
||||
const result=await requireServerAdapter("paperclip_runner").testEnvironment({companyId:"company-1",adapterType:"paperclip_runner",config:{provider:"acpx",acpxAgent:"copilot",model:"gpt-5.6-luna",env:{COPILOT_GITHUB_TOKEN:"fixture-token"}}});
|
||||
expect(result).toMatchObject({status:"fail",checks:[{code}]});
|
||||
});
|
||||
it("sanitizes unexpected Copilot metadata failures",async()=>{
|
||||
vi.mocked(probeCopilotExecutionTarget).mockRejectedValueOnce(new Error("fixture private implementation detail"));
|
||||
const result=await requireServerAdapter("paperclip_runner").testEnvironment({companyId:"company-1",adapterType:"paperclip_runner",config:{provider:"acpx",acpxAgent:"copilot",model:"gpt-5.6-luna",env:{COPILOT_GITHUB_TOKEN:"fixture-token"}}});
|
||||
expect(result).toMatchObject({status:"fail",checks:[{code:"COPILOT_REQUEST_FAILED",message:"Copilot metadata verification failed."}]});
|
||||
expect(JSON.stringify(result)).not.toContain("private implementation detail");
|
||||
});
|
||||
it("wraps built-in npm runtime installs with the sandbox-aware install helper", () => {
|
||||
const expectedClaudeInstall = `if ! command -v 'claude' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@anthropic-ai/claude-code")}; fi`;
|
||||
|
||||
@@ -389,6 +389,7 @@ describe("adapter routes", () => {
|
||||
options: [
|
||||
{ value: "claude", label: "Claude" },
|
||||
{ value: "grok", label: "Grok Build" },
|
||||
{ value: "copilot", label: "GitHub Copilot" },
|
||||
],
|
||||
});
|
||||
expect(JSON.stringify(res.body)).not.toContain("Codex via ACPX");
|
||||
|
||||
@@ -31,6 +31,9 @@ import express from "express";
|
||||
import request from "supertest";
|
||||
import { aiConnectionRoutes, canInstallSharedAiConnectionForNewAgent, responsibleUserForAiRequest } from "../routes/ai-connections.js";
|
||||
import { validateAiApiKey } from "../routes/ai-connections.js";
|
||||
import * as copilotProbe from "../services/copilot-connection-probe.js";
|
||||
import { agentRoutes } from "../routes/agents.js";
|
||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
||||
vi.mock("../services/local-ai-browser-login.js", () => ({
|
||||
startLocalBrowserLogin: () => ({ authorizationUrl: "https://auth.openai.com/codex/device", code: "ABCD-EFGHJ", abort: () => {} }),
|
||||
}));
|
||||
@@ -60,6 +63,78 @@ beforeAll(async () => {
|
||||
afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); if (home) await rm(home, { recursive: true, force: true }); });
|
||||
|
||||
describe("managed AI connections", () => {
|
||||
it("passes the live sandbox worker manager to Copilot connection verification", async () => {
|
||||
const pluginWorkerManager = {} as PluginWorkerManager;
|
||||
const probe = vi.spyOn(copilotProbe, "probeCopilotConnection").mockResolvedValue({
|
||||
status: "verified", version: "1.0.88", profileDigest: "fixture-profile",
|
||||
promptSent: false, models: [{ id: "gpt-5.6-luna", label: "GPT" }],
|
||||
});
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = { type: "board", source: "local_implicit", userId: "alice", companyIds: [companyId] };
|
||||
next();
|
||||
});
|
||||
app.use("/api", aiConnectionRoutes(db, { pluginWorkerManager }));
|
||||
try {
|
||||
const response = await request(app).post(`/api/companies/${companyId}/ai-connections`).send({
|
||||
provider: "github", method: "api_key", name: "Copilot remote route", ownership: "personal",
|
||||
apiKey: "github_pat_fixture_remote", agentIds: [], allAgents: false,
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
expect(probe).toHaveBeenCalledWith(db, companyId, "github_pat_fixture_remote", undefined, undefined, { pluginWorkerManager });
|
||||
} finally { probe.mockRestore(); }
|
||||
});
|
||||
|
||||
it("discovers Copilot models in the selected environment with its sandbox worker", async () => {
|
||||
const pluginWorkerManager = {} as PluginWorkerManager;
|
||||
const [environment] = await db.insert(environments).values({ name: "Copilot model discovery", driver: "sandbox", config: { provider: "daytona" } }).returning();
|
||||
const token = "github_pat_fixture_model_discovery";
|
||||
const account = await service.save(companyId, "alice", { provider: "github", method: "api_key", ownership: "personal", name: "Model discovery account", apiKey: token, agentIds: [agentId], allAgents: false }, token);
|
||||
await service.setDefault(companyId, "alice", account.grantId);
|
||||
const probe = vi.spyOn(copilotProbe, "probeCopilotConnection").mockResolvedValue({ status: "verified", version: "1.0.88", profileDigest: "fixture-profile", promptSent: false, models: [{ id: "gpt-5.6-luna", label: "GPT" }] });
|
||||
const app = express(); app.use(express.json());
|
||||
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: "alice", companyIds: [companyId] }; next(); });
|
||||
app.use("/api", agentRoutes(db, { pluginWorkerManager }));
|
||||
try {
|
||||
const response = await request(app).get(`/api/companies/${companyId}/adapters/paperclip_runner/models`).query({ provider: "acpx", acpxAgent: "copilot", environmentId: environment.id, agentId, aiConnection: JSON.stringify({ provider: "github", method: "api_key", mode: "responsible_user" }) });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers["cache-control"]).toBe("no-store");
|
||||
expect(response.body).toEqual([{ id: "gpt-5.6-luna", label: "GPT" }]);
|
||||
expect(probe).toHaveBeenCalledWith(db, companyId, token, environment.id, undefined, { pluginWorkerManager });
|
||||
expect(JSON.stringify(response.body)).not.toContain(token);
|
||||
} finally { probe.mockRestore(); }
|
||||
});
|
||||
|
||||
it("uses a vaulted Copilot token with GitHub attribution and refuses revoked or foreign grants", async () => {
|
||||
const owner = "copilot-owner";
|
||||
const token = "github_pat_fixture_copilot";
|
||||
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
||||
const account = await service.save(companyId, owner, { provider: "github", method: "api_key", ownership: "personal", name: "Copilot", apiKey: token, agentIds: [agentId], allAgents: false }, token);
|
||||
const selectedBinding = { provider: "github", method: "api_key", mode: "responsible_user" } as const;
|
||||
const config = { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna", env: { COPILOT_GITHUB_TOKEN: "ambient-never-use", GH_TOKEN: "project-never-use" } };
|
||||
const runInput = { companyId, agentId, responsibleUserId: owner, adapterType: "paperclip_runner", binding: selectedBinding, config };
|
||||
const selected = await service.select({ ...runInput, userId: owner, runnerProvider: "acpx", acpxAgent: "copilot", model: config.model });
|
||||
expect(await service.credential(selected)).toBe(token);
|
||||
expect(JSON.stringify(await service.list(companyId, owner))).not.toContain(token);
|
||||
const runtime = await prepareManagedAiRuntime(db, runInput);
|
||||
try {
|
||||
expect(runtime.config).toMatchObject({ provider: "acpx", acpxAgent: "copilot", model: config.model, env: { COPILOT_GITHUB_TOKEN: token, GH_TOKEN: "", GITHUB_TOKEN: "", OPENAI_API_KEY: "", ANTHROPIC_API_KEY: "" } });
|
||||
expect(runtime.attribution).toMatchObject({ provider: "github", method: "api_key", connectionId: account.connectionId, grantId: account.grantId });
|
||||
expect(config.env.COPILOT_GITHUB_TOKEN).toBe("ambient-never-use");
|
||||
} finally { await runtime.cleanup(); }
|
||||
await expect(access(runtime.home!)).rejects.toThrow();
|
||||
await expect(service.select({ ...runInput, userId: "bob", runnerProvider: "acpx", acpxAgent: "copilot" })).rejects.toThrow();
|
||||
await expect(service.select({ ...runInput, companyId: otherCompanyId, userId: owner, binding: { ...selectedBinding, mode: "shared", ...account }, runnerProvider: "acpx", acpxAgent: "copilot" })).rejects.toThrow();
|
||||
const network = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("usage must not send credentials"));
|
||||
try {
|
||||
expect(await service.probeUsage(companyId, owner, account.connectionId, account.grantId)).toMatchObject({ provider: "github", status: "unsupported", limits: [] });
|
||||
expect(network).not.toHaveBeenCalled();
|
||||
} finally { network.mockRestore(); }
|
||||
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, account.grantId));
|
||||
await expect(prepareManagedAiRuntime(db, runInput)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("persists first-time recovery directories before consuming a single-use token", async () => {
|
||||
const owner = "quota-durable-path";
|
||||
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
||||
@@ -787,12 +862,18 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
|
||||
await expect(access(runtime.home!)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("preserves Google account defaults when the provider constraint migration is reapplied", async () => {
|
||||
it("preserves Google and Copilot account defaults when the current provider constraint migration is reapplied", async () => {
|
||||
const saved = await service.save(companyId, "bob", {
|
||||
provider: "google", method: "api_key", ownership: "personal", name: "Google migration",
|
||||
apiKey: "fixture", allAgents: true, agentIds: [],
|
||||
}, "google-migration-key");
|
||||
const migration = await readFile(new URL("../../../packages/db/src/migrations/0306_familiar_titania.sql", import.meta.url), "utf8");
|
||||
await db.insert(companyMemberships).values({ companyId, principalId: "migration-copilot", principalType: "user", status: "active", membershipRole: "member" });
|
||||
const copilot = await service.save(companyId, "migration-copilot", {
|
||||
provider: "github", method: "api_key", ownership: "personal", name: "Copilot migration",
|
||||
apiKey: "fixture", allAgents: true, agentIds: [],
|
||||
}, "github_pat_fixture_migration");
|
||||
await service.setDefault(companyId, "migration-copilot", copilot.grantId);
|
||||
const migration = await readFile(new URL("../../../packages/db/src/migrations/0319_copilot_defaults.sql", import.meta.url), "utf8");
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
await db.transaction(async (tx) => {
|
||||
for (const statement of migration.split("--> statement-breakpoint")) {
|
||||
@@ -805,6 +886,12 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
|
||||
binding: { provider: "google", method: "api_key", mode: "responsible_user" },
|
||||
});
|
||||
expect(selected.grant.id).toBe(saved.grantId);
|
||||
const selectedCopilot = await service.select({
|
||||
companyId, agentId, userId: "migration-copilot", adapterType: "paperclip_runner",
|
||||
runnerProvider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna",
|
||||
binding: { provider: "github", method: "api_key", mode: "responsible_user" },
|
||||
});
|
||||
expect(selectedCopilot.grant.id).toBe(copilot.grantId);
|
||||
});
|
||||
|
||||
it.each([false, true])("reports the authoritative connection-manager capability for custom grants (manager: %s)", async (manager) => {
|
||||
|
||||
@@ -372,7 +372,7 @@ describeEmbeddedPostgres("connectionIntentService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("only advertises GitHub tool methods in search and setup options", async () => {
|
||||
it("separates GitHub runtime discovery from tool setup options", async () => {
|
||||
const service = connectionIntentService(db);
|
||||
const search = await service.search(claims, "github");
|
||||
const github = search.results.find((result) => result.service === "github");
|
||||
@@ -383,6 +383,12 @@ describeEmbeddedPostgres("connectionIntentService", () => {
|
||||
key: "mcp-key",
|
||||
label: "Personal access token (advanced)",
|
||||
auth: "api_key",
|
||||
purpose: "tool",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: "copilot-token",
|
||||
purpose: "ai",
|
||||
setupPath: expect.stringContaining("/apps/connect?source=github"),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -407,6 +413,7 @@ describeEmbeddedPostgres("connectionIntentService", () => {
|
||||
expect(setup.service.methods.map((method) => method.key)).not.toContain(
|
||||
"chat-agent",
|
||||
);
|
||||
expect(setup.service.methods.map((method) => method.key)).not.toContain("copilot-token");
|
||||
});
|
||||
|
||||
it("serializes OAuth intent completion behind addressed-user membership revocation", async () => {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { environmentRuntimeService } from "../services/environment-runtime.js";
|
||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
||||
const mocks = vi.hoisted(() => ({ metadata: vi.fn(), command: vi.fn(), environment: vi.fn(), bindings: vi.fn(), acquire: vi.fn(), realize: vi.fn(), release: vi.fn(), resolve: vi.fn(), general: vi.fn(), experimental: vi.fn(), defaults: vi.fn(), kubernetes: vi.fn(), managed: vi.fn() }));
|
||||
vi.mock("../vendor/paperclip-runner/live/index.js", async importOriginal => ({ ...await importOriginal<object>(), probeCopilotMetadata: mocks.metadata }));
|
||||
vi.mock("@paperclipai/adapter-utils/execution-target", () => ({ runAdapterExecutionTargetShellCommand: mocks.command }));
|
||||
vi.mock("../services/environments.js", () => ({ environmentService: () => ({ getById: mocks.environment, listBoundCompanyIds: mocks.bindings, findKubernetesEnvironment: mocks.kubernetes, findManagedSandboxEnvironment: mocks.managed }) }));
|
||||
vi.mock("../services/instance-settings.js", () => ({ instanceSettingsService: () => ({ get: mocks.defaults, getGeneral: mocks.general, getExperimental: mocks.experimental }) }));
|
||||
vi.mock("../services/environment-runtime.js", () => ({ environmentRuntimeService: vi.fn(() => ({ acquireRunLease: mocks.acquire, realizeWorkspace: mocks.realize, getDriver: () => ({ releaseRunLease: mocks.release }) })) }));
|
||||
vi.mock("../services/environment-execution-target.js", () => ({ resolveEnvironmentExecutionTarget: mocks.resolve }));
|
||||
import { probeCopilotConnection, probeCopilotExecutionTarget } from "../services/copilot-connection-probe.js";
|
||||
import { QUALIFIED_ACPX_PROFILES } from "../../../packages/paperclip-runner/src/drivers/acpx/qualified-profiles.js";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
const verified = { status: "verified", version: "1.0.88", profileDigest: QUALIFIED_ACPX_PROFILES.copilot.commandDigest, promptSent: false, models: [{ id: "gpt-5.6-luna", label: "GPT" }] };
|
||||
const remote = { kind: "remote", remoteCwd: "/workspace" } as AdapterExecutionTarget;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); mocks.general.mockResolvedValue({ executionMode: "local" }); mocks.experimental.mockResolvedValue({}); mocks.defaults.mockResolvedValue({ defaultEnvironmentId: null });
|
||||
mocks.environment.mockResolvedValue({ id: "selected", driver: "sandbox", status: "active", config: { provider: "daytona" } }); mocks.bindings.mockResolvedValue(["company"]);
|
||||
mocks.acquire.mockResolvedValue({ lease: { id: "lease", metadata: {} } }); mocks.realize.mockResolvedValue({}); mocks.release.mockResolvedValue(undefined); mocks.resolve.mockResolvedValue(remote);
|
||||
mocks.command.mockResolvedValue({ timedOut: false, stdout: JSON.stringify(verified), exitCode: 0 }); mocks.metadata.mockResolvedValue(verified);
|
||||
});
|
||||
describe("Copilot connection verification", () => {
|
||||
it("probes the selected environment and releases its owned lease", async () => {
|
||||
const db = {} as Db;
|
||||
const pluginWorkerManager = {} as PluginWorkerManager;
|
||||
expect(await probeCopilotConnection(db, "company", "private-token", "selected", "gpt-5.6-luna", { pluginWorkerManager })).toEqual(verified);
|
||||
expect(environmentRuntimeService).toHaveBeenCalledWith(db, { pluginWorkerManager });
|
||||
expect(mocks.metadata).not.toHaveBeenCalled(); expect(mocks.acquire).toHaveBeenCalledOnce();
|
||||
expect(mocks.command.mock.calls[0]![3]).toMatchObject({ env: { COPILOT_GITHUB_TOKEN: "private-token", PAPERCLIP_COPILOT_PROBE_MODEL: "gpt-5.6-luna" }, timeoutSec: 35 });
|
||||
expect(mocks.command.mock.calls[0]![2]).not.toContain("private-token");
|
||||
expect(mocks.release).toHaveBeenCalledWith(expect.objectContaining({ status: "released" }));
|
||||
});
|
||||
it("rejects a foreign company before creating a lease or transmitting the token", async () => {
|
||||
mocks.bindings.mockResolvedValue(["other-company"]);
|
||||
await expect(probeCopilotConnection({} as Db, "company", "private-token", "selected")).rejects.toMatchObject({ status: 403 });
|
||||
expect(mocks.command).not.toHaveBeenCalled(); expect(mocks.metadata).not.toHaveBeenCalled(); expect(mocks.acquire).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not fall back to host credentials when the remote runtime is missing", async () => {
|
||||
mocks.command.mockResolvedValue({ timedOut: false, stdout: "", exitCode: 127 });
|
||||
await expect(probeCopilotConnection({} as Db, "company", "private-token", "selected")).rejects.toMatchObject({ details: { code: "COPILOT_INSTALLATION_INVALID" } });
|
||||
expect(mocks.metadata).not.toHaveBeenCalled(); expect(mocks.release).toHaveBeenCalledWith(expect.objectContaining({ status: "failed" }));
|
||||
});
|
||||
it("sanitizes remote errors and releases the lease on authentication failure", async () => {
|
||||
mocks.command.mockResolvedValue({ timedOut: false, stdout: JSON.stringify({ status: "failed", code: "COPILOT_AUTH_REQUIRED", promptSent: false, message: "private-token" }), exitCode: 1 });
|
||||
const error = await probeCopilotConnection({} as Db, "company", "private-token", "selected").catch(e => e);
|
||||
expect(error.details.code).toBe("COPILOT_AUTH_REQUIRED"); expect(error.message).not.toContain("private-token"); expect(mocks.release).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("rejects stale profile results and unavailable model selections", async () => {
|
||||
mocks.command.mockResolvedValue({ timedOut: false, stdout: JSON.stringify({ ...verified, profileDigest: "stale" }), exitCode: 0 });
|
||||
await expect(probeCopilotExecutionTarget("private-token", remote)).rejects.toMatchObject({ details: { code: "COPILOT_REQUEST_FAILED" } });
|
||||
mocks.command.mockResolvedValue({ timedOut: false, stdout: JSON.stringify(verified), exitCode: 0 });
|
||||
await expect(probeCopilotExecutionTarget("private-token", remote, "missing")).rejects.toMatchObject({ details: { code: "COPILOT_MODEL_UNAVAILABLE" } });
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ const { probeInstallation, probeGrokInstallation } = vi.hoisted(() => ({
|
||||
probeInstallation: vi.fn(),
|
||||
probeGrokInstallation: vi.fn(),
|
||||
}));
|
||||
vi.mock("@paperclipai/paperclip-runner/live", () => ({
|
||||
vi.mock("../vendor/paperclip-runner/live/index.js", () => ({
|
||||
probeAcpxClaudeInstallation: probeInstallation,
|
||||
probeAcpxGrokInstallation: probeGrokInstallation,
|
||||
probeAcpxCursorInstallation: vi.fn(async () => undefined),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { HttpError } from "../errors.js";
|
||||
import type { AdapterRuntimeCommandSpec, ServerAdapterModule } from "./types.js";
|
||||
import { parseAdapterModelsEnv } from "../services/adapter-models-env.js";
|
||||
import { stampClaudeAgentIdHeader } from "./claude-agent-id-header.js";
|
||||
@@ -410,7 +411,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
checks: [{ code: "dot_event_test_required", level: "warn" as const, message: "Dot manages its model and billing. Validate the dedicated agent binding and event round trip in Paperclip; this read-only check does not wake the Dot." }] };
|
||||
}
|
||||
if (profile.provider === "acpx") {
|
||||
if (["copilot", "pi"].includes(profile.acpxAgent)) {
|
||||
if (profile.acpxAgent === "pi") {
|
||||
// The profile resolver already validated the isolated host's exact
|
||||
// qualification pair. Do not report a production readiness pass.
|
||||
return {
|
||||
@@ -420,6 +421,20 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
};
|
||||
}
|
||||
try {
|
||||
if (profile.acpxAgent === "copilot") {
|
||||
const configured = context.config.env;
|
||||
const token = configured && typeof configured === "object" && !Array.isArray(configured)
|
||||
? (configured as Record<string, unknown>).COPILOT_GITHUB_TOKEN : undefined;
|
||||
if (typeof token !== "string" || !token) {
|
||||
return { adapterType: "paperclip_runner", status: "fail" as const, testedAt: new Date().toISOString(),
|
||||
checks: [{ code: "COPILOT_AUTH_REQUIRED", level: "error" as const, message: "Select a saved Copilot token connection." }] };
|
||||
}
|
||||
const { probeCopilotExecutionTarget } = await import("../services/copilot-connection-probe.js");
|
||||
await probeCopilotExecutionTarget(token, context.executionTarget, profile.model);
|
||||
return { adapterType: "paperclip_runner", status: "pass" as const, testedAt: new Date().toISOString(),
|
||||
checks: [{ code: "copilot_metadata_verified", level: "info" as const,
|
||||
message: "The verified Copilot runtime authenticated this account and accepted the selected model. No model prompt was sent." }] };
|
||||
}
|
||||
if (profile.acpxAgent !== "claude" && profile.acpxAgent !== "grok" && profile.acpxAgent !== "cursor") throw new Error("Select Codex to use the native Codex runner.");
|
||||
const target = context.executionTarget;
|
||||
if (target?.kind === "remote") {
|
||||
@@ -445,6 +460,14 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
checks: [{ code: "acpx_runtime_ready", level: "info" as const, message: `ACPX ${profile.acpxAgent} runtime is installed and verified. Model access is checked when it runs.` }],
|
||||
};
|
||||
} catch (error) {
|
||||
if (profile.acpxAgent === "copilot") {
|
||||
const details = error instanceof HttpError && error.details && typeof error.details === "object" && !Array.isArray(error.details)
|
||||
? error.details as Record<string, unknown> : {};
|
||||
const codes = ["COPILOT_AUTH_REQUIRED", "COPILOT_ENTITLEMENT_DENIED", "COPILOT_MODEL_UNAVAILABLE", "COPILOT_INSTALLATION_INVALID", "COPILOT_REQUEST_FAILED"];
|
||||
const code = typeof details.code === "string" && codes.includes(details.code) ? details.code : "COPILOT_REQUEST_FAILED";
|
||||
return { adapterType: "paperclip_runner", status: "fail" as const, testedAt: new Date().toISOString(),
|
||||
checks: [{ code, level: "error" as const, message: error instanceof HttpError ? error.message : "Copilot metadata verification failed." }] };
|
||||
}
|
||||
return {
|
||||
adapterType: "paperclip_runner", status: "fail" as const, testedAt: new Date().toISOString(),
|
||||
checks: [{ code: "acpx_runtime_unavailable", level: "error" as const, message: error instanceof Error ? error.message : "ACPX Claude runtime could not be verified." }],
|
||||
@@ -525,7 +548,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
)
|
||||
: buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex@0.160.0"),
|
||||
agentConfigurationDoc:
|
||||
"# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex, OpenCode, Claude Managed, AWS AgentCore, or ACPX Claude/Grok Build/Cursor through the Rust Paperclip runner and authenticated PRP transport. GitHub Copilot and Pi are awaiting local and Daytona qualification and are not enabled for production runs. Managed providers use company-scoped qualified profiles, explicit retention acknowledgement, and spend limits.\n",
|
||||
"# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex, OpenCode, Claude Managed, AWS AgentCore, or ACPX Claude/Grok Build/Cursor through the Rust Paperclip runner and authenticated PRP transport. GitHub Copilot requires a saved GitHub token and an explicitly selected available model. Pi is awaiting local and Daytona qualification and is not enabled for production runs. Managed providers use company-scoped qualified profiles, explicit retention acknowledgement, and spend limits.\n",
|
||||
getConfigSchema: () => ({
|
||||
fields: [
|
||||
{
|
||||
@@ -544,7 +567,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
},
|
||||
{
|
||||
key: "acpxAgent", label: "ACP agent", type: "select" as const, default: "claude",
|
||||
options: [{ value: "claude", label: "Claude" }, { value: "grok", label: "Grok Build" }],
|
||||
options: [{ value: "claude", label: "Claude" }, { value: "grok", label: "Grok Build" }, { value: "copilot", label: "GitHub Copilot" }],
|
||||
meta: { visibleWhen: { key: "provider", value: "acpx" } },
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -882,7 +882,7 @@ export async function createApp(
|
||||
app.locals.toolActionDeliveries = toolActionDeliveries;
|
||||
app.use(mcpGatewayProtocolRoutes(toolGateway));
|
||||
api.use(decisionModelRoutes(db));
|
||||
api.use(aiConnectionRoutes(db, { deploymentMode: opts.deploymentMode, deploymentExposure: opts.deploymentExposure, trustedLocalStdioRuntimeHost }));
|
||||
api.use(aiConnectionRoutes(db, { deploymentMode: opts.deploymentMode, deploymentExposure: opts.deploymentExposure, trustedLocalStdioRuntimeHost, pluginWorkerManager: workerManager }));
|
||||
api.use(
|
||||
toolAccessRoutes(db, {
|
||||
deploymentMode: opts.deploymentMode,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { prepareManagedAiRuntime, withManagedAiProbe, assertManagedAiProjectAuth
|
||||
import { ADAPTER_AUTH_MISSING_CHECK_CODE, AI_CONNECTION_CAPABILITIES, aiConnectionBindingSchema, type AiConnectionBinding, aiRuntimeConnectionBindingSchema, type AiRuntimeConnectionBinding } from "@paperclipai/shared";
|
||||
import { toolConnections } from "@paperclipai/db";
|
||||
import { aiConnectionService } from "../services/ai-connections.js";
|
||||
import { probeCopilotConnection, probeCopilotExecutionTarget } from "../services/copilot-connection-probe.js";
|
||||
import { defaultAiConnectionForHire } from "../services/agent-ai-connection-default.js";
|
||||
import { assertAiConnectionCreateAccess, canInstallSharedAiConnectionForNewAgent, responsibleUserForAiRequest, validateAiApiKey } from "./ai-connections.js";
|
||||
import { isAiConnectionCompatible } from "@paperclipai/shared";
|
||||
@@ -3385,6 +3386,23 @@ export function agentRoutes(
|
||||
return;
|
||||
}
|
||||
const provider = asNonEmptyString(req.query.provider);
|
||||
if (type === "paperclip_runner" && provider === "acpx" && req.query.acpxAgent === "copilot") {
|
||||
const userId = responsibleUserForAiRequest(req);
|
||||
let binding: AiConnectionBinding = { provider: "github", method: "api_key", mode: "responsible_user" };
|
||||
if (typeof req.query.aiConnection === "string") {
|
||||
if (req.query.aiConnection.length > 4096) throw unprocessable("Invalid AI connection binding");
|
||||
try { binding = aiConnectionBindingSchema.parse(JSON.parse(req.query.aiConnection)); }
|
||||
catch { throw unprocessable("Invalid AI connection binding"); }
|
||||
}
|
||||
if (binding.provider !== "github") throw unprocessable("Select a GitHub Copilot AI connection");
|
||||
const service = aiConnectionService(db);
|
||||
const selection = await service.select({ companyId, userId, agentId: asNonEmptyString(req.query.agentId) ?? "00000000-0000-0000-0000-000000000000",
|
||||
adapterType: type, runnerProvider: "acpx", acpxAgent: "copilot", binding, allowUninstalledPersonal: true, allowUninstalledShared: req.actor.type === "board" });
|
||||
const metadata = await probeCopilotConnection(db, companyId, await service.credential(selection), environmentId, undefined, { pluginWorkerManager: options.pluginWorkerManager });
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.json(metadata.models);
|
||||
return;
|
||||
}
|
||||
if (type === "opencode_local" && provider === "openrouter") {
|
||||
res.json(await listOpenRouterModels(refresh));
|
||||
return;
|
||||
@@ -3456,7 +3474,7 @@ export function agentRoutes(
|
||||
// missing CLI, unavailable environment, or other runtime error does not.
|
||||
if (result.status === "fail" && result.checks.some(check =>
|
||||
check.code === ADAPTER_AUTH_MISSING_CHECK_CODE || /_hello_probe_auth_required$/.test(check.code)
|
||||
|| check.code === "ai_connection_api_key_rejected",
|
||||
|| check.code === "ai_connection_api_key_rejected" || check.code === "COPILOT_AUTH_REQUIRED",
|
||||
)) {
|
||||
await aiConnectionService(db).markAuthenticationFailed({
|
||||
companyId: context.companyId, agentId, runStartedAt: startedAt,
|
||||
@@ -3467,6 +3485,18 @@ export function agentRoutes(
|
||||
}
|
||||
|
||||
async function probeManagedEnvironment(adapterType: string, context: Parameters<ReturnType<typeof requireServerAdapter>["testEnvironment"]>[0], binding: AiConnectionBinding) {
|
||||
if (binding.provider === "github") {
|
||||
try {
|
||||
const token = parseObject(context.config.env).COPILOT_GITHUB_TOKEN;
|
||||
if (typeof token !== "string" || !token) throw unprocessable("Select a saved Copilot token.", { code: "COPILOT_AUTH_REQUIRED" });
|
||||
const model = asNonEmptyString(context.config.model);
|
||||
if (!model || ["auto", "default"].includes(model.toLowerCase())) throw unprocessable("Select an available Copilot model.", { code: "COPILOT_MODEL_UNAVAILABLE" });
|
||||
await probeCopilotExecutionTarget(token, context.executionTarget, model);
|
||||
return { adapterType, status: "pass" as const, testedAt: new Date().toISOString(), checks: [{ code: "copilot_metadata_verified", level: "info" as const, message: "The verified Copilot runtime authenticated this account and accepted the selected model. No model prompt was sent." }] };
|
||||
} catch (error) {
|
||||
return { adapterType, status: "fail" as const, testedAt: new Date().toISOString(), checks: [{ code: error instanceof HttpError ? String(asRecord(error.details)?.code ?? "COPILOT_REQUEST_FAILED") : "COPILOT_REQUEST_FAILED", level: "error" as const, message: error instanceof HttpError ? error.message : "Copilot metadata verification failed." }] };
|
||||
}
|
||||
}
|
||||
await assertManagedAiProjectAuth(context.config, binding.provider, context.executionTarget);
|
||||
const result = await requireServerAdapter(adapterType).testEnvironment(context);
|
||||
if (result.status === "fail") return result;
|
||||
|
||||
@@ -31,6 +31,8 @@ import { logActivity } from "../services/activity-log.js";
|
||||
import { aiConnectionRouterService } from "../services/ai-connection-router.js";
|
||||
import { aiConnectionService } from "../services/ai-connections.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { probeCopilotConnection } from "../services/copilot-connection-probe.js";
|
||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
||||
|
||||
/** Agent API calls inherit authenticated run identity, never the agent's own ID. */
|
||||
export function responsibleUserForAiRequest(req: Request): string | null {
|
||||
@@ -139,6 +141,7 @@ export async function validateAiApiKey(
|
||||
key: string,
|
||||
request: typeof fetch = fetch,
|
||||
) {
|
||||
if (provider === "github") throw unprocessable("Copilot requires verification in the selected execution environment.", { code: "copilot_environment_required" });
|
||||
const endpoints = {
|
||||
anthropic: "https://api.anthropic.com/v1/models?limit=1",
|
||||
openai: "https://api.openai.com/v1/models",
|
||||
@@ -169,7 +172,7 @@ export async function validateAiApiKey(
|
||||
);
|
||||
}
|
||||
|
||||
export function aiConnectionRoutes(db: Db, options: Parameters<typeof supportsLocalAiLogin>[0] = {}) {
|
||||
export function aiConnectionRoutes(db: Db, options: Parameters<typeof supportsLocalAiLogin>[0] & { pluginWorkerManager?: PluginWorkerManager } = {}) {
|
||||
function assertLocalLoginAvailable() {
|
||||
if (!supportsLocalAiLogin(options)) throw unprocessable("Server-host subscription sign-in is unavailable on this hosted instance. Choose a supported sign-in environment or use an API key.");
|
||||
}
|
||||
@@ -336,9 +339,10 @@ export function aiConnectionRoutes(db: Db, options: Parameters<typeof supportsLo
|
||||
"Use the existing provider sign-in flow to connect a subscription",
|
||||
);
|
||||
const attemptStartedAt = new Date();
|
||||
// Custom destinations are exercised in the selected execution environment,
|
||||
// never fetched by the control plane (including localhost/private URLs).
|
||||
if (!input.routing || input.routing.kind === "openrouter") await validateAiApiKey(input.provider, input.apiKey!);
|
||||
if (input.provider === "github") {
|
||||
if (!input.apiKey?.startsWith("github_pat_")) throw unprocessable("Use a personal fine-grained token with Copilot Requests permission.", { code: "COPILOT_AUTH_REQUIRED" });
|
||||
await probeCopilotConnection(db, companyId, input.apiKey, input.environmentId, undefined, { pluginWorkerManager: options.pluginWorkerManager });
|
||||
} else if (!input.routing || input.routing.kind === "openrouter") await validateAiApiKey(input.provider, input.apiKey!);
|
||||
const result = await service.save(
|
||||
companyId,
|
||||
userId,
|
||||
|
||||
@@ -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,9 @@ 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",
|
||||
"GH_TOKEN",
|
||||
"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",
|
||||
|
||||
@@ -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,78 @@
|
||||
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 { 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) {
|
||||
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;
|
||||
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;
|
||||
} finally {
|
||||
if (lease) {
|
||||
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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -55,6 +55,7 @@ import { admitExplicitNativeContinuation, admitExplicitContinuationRetry, undeli
|
||||
import { canRetryStoppedRun, isCancelledNativeStartup } from "./cancelled-native-startup.js";
|
||||
import { connectionIntentService } from "./connection-intents.js";
|
||||
import { managedAiSessionFingerprintConfig, prepareManagedAiRuntime, assertManagedAiProjectAuth, stripAiAuthBindings, 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";
|
||||
@@ -24036,8 +24037,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");
|
||||
@@ -24342,11 +24342,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,11 +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", "pi"])("admits %s through agent validation and native input only for the exact host pair", (agent) => {
|
||||
it.each(["pi"])("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" }));
|
||||
@@ -19,10 +19,11 @@ describe("host ACPX qualification admission", () => {
|
||||
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("keeps normal candidate execution closed and admits only the exact operator pair", () => {
|
||||
it("keeps Pi execution closed and admits only the exact operator pair", () => {
|
||||
const provider = { kind: "acpx", agent: "pi", model: "exact-model" } as Extract<NativeExecutionInput["provider"], { kind: "acpx" }>;
|
||||
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([{ agent: "pi", model: "exact-model" }]))).toBe("pi");
|
||||
for (const entries of [[{ agent: "copilot", model: "exact-model" }], [{ agent: "pi", model: "other-model" }]]) {
|
||||
expect(() => resolveAcpxQualification(provider, authorize(entries))).toThrow("exact model");
|
||||
}
|
||||
});
|
||||
@@ -30,7 +31,7 @@ describe("host ACPX qualification admission", () => {
|
||||
[{ 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" };
|
||||
@@ -40,6 +41,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.
|
||||
// Pending Pi continues through the exact host-only qualification check 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));
|
||||
|
||||
@@ -8713,6 +8713,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",
|
||||
{
|
||||
@@ -8761,7 +8771,7 @@ describe("native process ownership", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["pi", "copilot"])("rejects ACPX candidate %s without host authorization before constructing a backend", async (agent) => {
|
||||
it.each(["pi"])("rejects ACPX candidate %s without host authorization before constructing a backend", async (agent) => {
|
||||
const piExecution = {
|
||||
...execution,
|
||||
binding: { ...execution.binding, runId: "run-acpx-pi-rejected" },
|
||||
|
||||
@@ -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 { readNativeLocalProcessStop, PROCESS_START_REQUESTED } from "../native-local-process-stop.js";
|
||||
import { remoteLeaseCleanupScope } from "../remote-execution-termination.js";
|
||||
import { resolveConnectorAssignments, isConnectorSkill } from "../connector-runtime.js";
|
||||
@@ -9792,7 +9792,7 @@ type RemoteProviderPackManifest = {
|
||||
distDigest: string;
|
||||
bridgeDigest: string;
|
||||
acpxProfileDigests: typeof REMOTE_PROVIDER_PACK_PROFILE_DIGESTS;
|
||||
providers?: Partial<Record<"cursor", {
|
||||
providers?: Partial<Record<"cursor" | "copilot", {
|
||||
version: string; profileDigest: string; closureDigest: string; qualification: "qualified" | "pending";
|
||||
path: string; sha256: string;
|
||||
}>>;
|
||||
@@ -9981,9 +9981,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" ? ["cursor"] : ["cursor", "copilot", "pi"]).includes(provider) || !candidate
|
||||
if (!(inventory === "providers" ? ["cursor", "copilot"] : ["cursor", "copilot", "pi"]).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
|
||||
|| candidate.qualification !== (provider === "cursor" || (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)
|
||||
@@ -10001,6 +10001,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");
|
||||
}
|
||||
@@ -11253,9 +11268,11 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
const configuredProviderPackRoot =
|
||||
input.runnerRemoteProviderPackPath?.trim() || 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 (
|
||||
@@ -11284,7 +11301,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() || (useBundledCursorImageAssets ? bundledRemoteRunnerBinary() : resolvePaperclipRunnerBinary())
|
||||
? input.runnerRemoteBinaryPath?.trim() || (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");
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export const DEFAULT_ACPX_RUNNER_MODELS = {
|
||||
// These profiles require explicit configuration. Admission belongs to the runner.
|
||||
codex: null,
|
||||
cursor: null,
|
||||
copilot: null,
|
||||
} as const;
|
||||
|
||||
export type QualifiedPaperclipRunnerAcpxAgent =
|
||||
@@ -470,6 +471,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.`);
|
||||
@@ -489,7 +493,7 @@ export function resolvePaperclipRunnerProviderProfile(
|
||||
if (acpxAgent === "cursor" && !model) {
|
||||
throw new PaperclipRunnerProviderProfileError("paperclip_runner_acpx_model_required", "Cursor requires an explicit model ID; there is no default model.");
|
||||
}
|
||||
if (acpxAgent !== "claude" && acpxAgent !== "codex" && acpxAgent !== "grok" && acpxAgent !== "cursor") {
|
||||
if (acpxAgent !== "claude" && acpxAgent !== "codex" && acpxAgent !== "grok" && acpxAgent !== "cursor" && acpxAgent !== "copilot") {
|
||||
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>;
|
||||
|
||||
@@ -1038,7 +1038,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
|
||||
@@ -12421,7 +12422,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
|
||||
) {
|
||||
|
||||
+13
-6
@@ -1,6 +1,13 @@
|
||||
/** Development boundary; the server build replaces this with Runner dist/live. */
|
||||
export {
|
||||
probeAcpxClaudeInstallation,
|
||||
probeAcpxGrokInstallation,
|
||||
probeAcpxCursorInstallation,
|
||||
} from "@paperclipai/paperclip-runner/live";
|
||||
/** Source shim; server build replaces it with the runner's compiled live tree. */
|
||||
type RunnerLiveModule = typeof import("@paperclipai/paperclip-runner/live");
|
||||
const sourceUrl = new URL(
|
||||
"../../../../../packages/paperclip-runner/src/live/index.ts",
|
||||
import.meta.url,
|
||||
);
|
||||
const runner = (await import(sourceUrl.href)) as RunnerLiveModule;
|
||||
|
||||
export const probeCopilotMetadata = runner.probeCopilotMetadata;
|
||||
export const validateCopilotMetadata = runner.validateCopilotMetadata;
|
||||
export const probeAcpxClaudeInstallation = runner.probeAcpxClaudeInstallation;
|
||||
export const probeAcpxGrokInstallation = runner.probeAcpxGrokInstallation;
|
||||
export const probeAcpxCursorInstallation = runner.probeAcpxCursorInstallation;
|
||||
Reference in new issue
Block a user