mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02: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
@@ -1,4 +1,6 @@
|
||||
import { probeAcpxClaudeInstallation, probeAcpxPiInstallation } from "../vendor/paperclip-runner/live/index.js";
|
||||
import { probeCopilotExecutionTarget } from "../services/copilot-connection-probe.js";
|
||||
import { HttpError } from "../errors.js";
|
||||
import { probeAcpxClaudeInstallation, probeAcpxCursorInstallation, probeAcpxPiInstallation } 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,6 +19,8 @@ import {
|
||||
setOverridePaused,
|
||||
} from "../adapters/registry.js";
|
||||
|
||||
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),
|
||||
@@ -348,20 +352,29 @@ describe("server adapter registry", () => {
|
||||
});
|
||||
expect(probe).toHaveBeenLastCalledWith("openrouter/deepseek/deepseek-v4-flash-0731");
|
||||
});
|
||||
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");
|
||||
|
||||
@@ -32,6 +32,7 @@ const mockEnvironmentService = vi.hoisted(() => ({
|
||||
releaseLease: vi.fn(),
|
||||
listBoundCompanyIds: vi.fn(async () => [] as string[]),
|
||||
findManagedSandboxEnvironment: vi.fn(async () => null as Record<string, unknown> | null),
|
||||
findKubernetesEnvironment: vi.fn(async () => null as Record<string, unknown> | null),
|
||||
}));
|
||||
|
||||
const mockReleaseRunLease = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
@@ -46,7 +47,7 @@ const mockEnvironmentRuntime = vi.hoisted(() => ({
|
||||
const mockResolveEnvironmentExecutionTarget = vi.hoisted(() => vi.fn());
|
||||
const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
get: vi.fn(async () => ({ defaultEnvironmentId: null as string | null })),
|
||||
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })),
|
||||
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false, executionMode: "local" })),
|
||||
getExperimental: vi.fn(async () => ({ enableManagedSandboxOnly: false })),
|
||||
}));
|
||||
|
||||
@@ -121,6 +122,10 @@ vi.mock("../routes/ai-connections.js", async (importOriginal) => ({
|
||||
validateAiApiKey: mockValidateAiApiKey,
|
||||
}));
|
||||
const mockMarkAuthenticationFailed = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
const mockCopilotProbe = vi.hoisted(() => vi.fn(async () => ({ status: "verified", promptSent: false })));
|
||||
vi.mock("../services/copilot-connection-probe.js", async importOriginal => ({
|
||||
...(await importOriginal<object>()), probeCopilotExecutionTarget: mockCopilotProbe,
|
||||
}));
|
||||
vi.mock("../services/ai-connections.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../services/ai-connections.js")>()),
|
||||
aiConnectionService: () => ({ markAuthenticationFailed: mockMarkAuthenticationFailed }),
|
||||
@@ -192,6 +197,8 @@ describe("agent test-environment route", () => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
mockInstanceSettingsService.get.mockResolvedValue({ defaultEnvironmentId: null });
|
||||
mockInstanceSettingsService.getGeneral.mockResolvedValue({ censorUsernameInLogs: false, executionMode: "local" });
|
||||
mockEnvironmentService.findKubernetesEnvironment.mockResolvedValue(null);
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: false });
|
||||
mockEnvironmentService.findManagedSandboxEnvironment.mockResolvedValue(null);
|
||||
mockAccessService.decide.mockResolvedValue({
|
||||
@@ -327,6 +334,41 @@ describe("agent test-environment route", () => {
|
||||
expect(testEnvironmentSpy).toHaveBeenCalledWith(expect.objectContaining({ executionTarget: target }));
|
||||
});
|
||||
|
||||
it.each([null, "33333333-3333-4333-8333-333333333333"])("verifies Copilot on forced Kubernetes before the host selection (%s)", async environmentId => {
|
||||
const kubernetesId = "11111111-1111-4111-8111-111111111111";
|
||||
mockInstanceSettingsService.getGeneral.mockResolvedValue({ censorUsernameInLogs: false, executionMode: "kubernetes" });
|
||||
mockEnvironmentService.findKubernetesEnvironment.mockResolvedValue({ id: kubernetesId });
|
||||
const target = { kind: "remote", transport: "sandbox", remoteCwd: "/workspace", providerKey: "kubernetes", runner: { execute: vi.fn() } };
|
||||
mockResolveEnvironmentExecutionTarget.mockResolvedValue(target);
|
||||
mockPrepareManagedAiRuntime.mockImplementation(async (_db, input) => ({
|
||||
config: { ...input.config, env: { COPILOT_GITHUB_TOKEN: "private-fixture" } },
|
||||
attribution: { provider: "github", method: "api_key" }, cleanup: vi.fn(),
|
||||
}));
|
||||
const app = await createApp();
|
||||
const response = await request(app).post("/api/companies/company-1/adapters/paperclip_runner/test-environment").send({
|
||||
environmentId, adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" },
|
||||
aiConnection: { provider: "github", method: "api_key", mode: "responsible_user" },
|
||||
});
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
||||
expect(response.body.status).toBe("pass");
|
||||
expect(mockEnvironmentRuntime.acquireRunLease).toHaveBeenCalledWith(expect.objectContaining({ environment: expect.objectContaining({ id: kubernetesId }) }));
|
||||
expect(mockCopilotProbe).toHaveBeenCalledExactlyOnceWith("private-fixture", target, "gpt-5.6-luna");
|
||||
expect(mockReleaseRunLease).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("refuses a Copilot test before credentials when forced Kubernetes is unavailable", async () => {
|
||||
mockInstanceSettingsService.getGeneral.mockResolvedValue({ censorUsernameInLogs: false, executionMode: "kubernetes" });
|
||||
const app = await createApp();
|
||||
const response = await request(app).post("/api/companies/company-1/adapters/paperclip_runner/test-environment").send({
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" },
|
||||
aiConnection: { provider: "github", method: "api_key", mode: "responsible_user" },
|
||||
});
|
||||
expect(response.status).toBe(422);
|
||||
expect(response.body.details.code).toBe("copilot_environment_unavailable");
|
||||
expect(mockPrepareManagedAiRuntime).not.toHaveBeenCalled();
|
||||
expect(mockCopilotProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checks the inherited environment's company before resolving credentials", async () => {
|
||||
mockInstanceSettingsService.get.mockResolvedValue({ defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" });
|
||||
mockEnvironmentService.listBoundCompanyIds.mockResolvedValue(["company-2"]);
|
||||
|
||||
@@ -32,6 +32,10 @@ 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 * as settingsModule from "../services/instance-settings.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: () => {} }),
|
||||
}));
|
||||
@@ -61,6 +65,219 @@ beforeAll(async () => {
|
||||
afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); if (home) await rm(home, { recursive: true, force: true }); });
|
||||
|
||||
describe("managed AI connections", () => {
|
||||
it.each(["create", "hire", "edit"])("refuses Copilot %s before probing or persisting when forced Kubernetes is missing", async action => {
|
||||
const { errorHandler } = await import("../middleware/index.js");
|
||||
const owner = `copilot-kubernetes-${action}`, id = randomUUID();
|
||||
const config = { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" };
|
||||
const aiConnection = { provider: "github", method: "api_key", mode: "responsible_user" } as const;
|
||||
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
||||
await db.insert(agents).values({ id, companyId, name: "Kubernetes fixture", adapterType: "paperclip_runner", adapterConfig: config, runtimeConfig: { aiConnection } });
|
||||
const account = await service.save(companyId, owner, { provider: "github", method: "api_key", ownership: "personal", name: "Kubernetes fixture", apiKey: "github_pat_fixture_kubernetes", agentIds: [id], allAgents: false }, "github_pat_fixture_kubernetes");
|
||||
await service.setDefault(companyId, owner, account.grantId);
|
||||
const [company] = await db.select().from(companies).where(eq(companies.id, companyId));
|
||||
await db.update(companies).set({ requireBoardApprovalForNewAgents: false }).where(eq(companies.id, companyId));
|
||||
const before = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, companyId));
|
||||
const originalSettings = settingsModule.instanceSettingsService;
|
||||
const settings = vi.spyOn(settingsModule, "instanceSettingsService").mockImplementation(database => {
|
||||
const service = originalSettings(database);
|
||||
return { ...service, getGeneral: async () => ({ ...await service.getGeneral(), executionMode: "kubernetes" as const }) };
|
||||
});
|
||||
const probe = vi.spyOn(copilotProbe, "probeCopilotExecutionTarget");
|
||||
const app = express(); app.use(express.json());
|
||||
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: owner, companyIds: [companyId], isInstanceAdmin: true }; next(); });
|
||||
app.use("/api", agentRoutes(db)); app.use(errorHandler);
|
||||
try {
|
||||
const changedConfig = { ...config, model: "prospective-model" };
|
||||
const response = action === "edit"
|
||||
? await request(app).patch(`/api/agents/${id}`).send({ adapterConfig: changedConfig })
|
||||
: await request(app).post(`/api/companies/${companyId}/${action === "hire" ? "agent-hires" : "agents"}`).send({ name: "Forced Kubernetes", role: "general", adapterType: "paperclip_runner", adapterConfig: changedConfig, runtimeConfig: { aiConnection } });
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(422);
|
||||
expect(response.body.details.code).toBe("copilot_environment_unavailable");
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
expect((await db.select().from(agents).where(eq(agents.id, id)))[0].adapterConfig).toEqual(config);
|
||||
expect(await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, companyId))).toHaveLength(before.length);
|
||||
} finally {
|
||||
probe.mockRestore(); settings.mockRestore();
|
||||
await db.update(companies).set({ requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents }).where(eq(companies.id, companyId));
|
||||
}
|
||||
});
|
||||
it.each(["create", "hire", "edit"].flatMap(action => [true, false].map(connectionProvided => ({ action, connectionProvided }))))(
|
||||
"refuses unavailable Copilot models before $action persists configuration (connection binding: $connectionProvided)", async ({ action, connectionProvided }) => {
|
||||
const { unprocessable } = await import("../errors.js");
|
||||
const { errorHandler } = await import("../middleware/index.js");
|
||||
const owner = `copilot-model-${action}-${connectionProvided}`, id = randomUUID();
|
||||
const savedConfig = { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" };
|
||||
const aiConnection = { provider: "github", method: "api_key", mode: "responsible_user" } as const;
|
||||
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
||||
await db.insert(agents).values({ id, companyId, name: `Copilot ${action} fixture`, adapterType: "paperclip_runner", adapterConfig: savedConfig, runtimeConfig: connectionProvided ? { aiConnection } : {} });
|
||||
const token = `github_pat_fixture_model_${action}`;
|
||||
const account = await service.save(companyId, owner, { provider: "github", method: "api_key", ownership: "personal", name: `Model ${action}`, apiKey: token, agentIds: [id], allAgents: false }, token);
|
||||
await service.setDefault(companyId, owner, account.grantId);
|
||||
const [company] = await db.select().from(companies).where(eq(companies.id, companyId));
|
||||
await db.update(companies).set({ requireBoardApprovalForNewAgents: false }).where(eq(companies.id, companyId));
|
||||
const before = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, companyId));
|
||||
const probe = vi.spyOn(copilotProbe, "probeCopilotExecutionTarget").mockRejectedValue(unprocessable("The selected Copilot model is unavailable for this account.", { code: "COPILOT_MODEL_UNAVAILABLE" }));
|
||||
const app = express(); app.use(express.json());
|
||||
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: owner, companyIds: [companyId], isInstanceAdmin: true }; next(); });
|
||||
app.use("/api", agentRoutes(db)); app.use(errorHandler);
|
||||
const config = { ...savedConfig, model: "unavailable-copilot-model" };
|
||||
try {
|
||||
const response = action === "edit"
|
||||
? await request(app).patch(`/api/agents/${id}`).send({ adapterConfig: config })
|
||||
: await request(app).post(`/api/companies/${companyId}/${action === "hire" ? "agent-hires" : "agents"}`).send({ name: `Unavailable ${action}`, role: "general", adapterType: "paperclip_runner", adapterConfig: config, ...(connectionProvided ? { runtimeConfig: { aiConnection } } : {}) });
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(422);
|
||||
expect(response.body).toMatchObject({ error: "The selected Copilot model is unavailable for this account." });
|
||||
expect(probe).toHaveBeenCalledExactlyOnceWith(token, null, "unavailable-copilot-model");
|
||||
expect((await db.select().from(agents).where(eq(agents.id, id)))[0].adapterConfig).toEqual(savedConfig);
|
||||
expect(await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, companyId))).toHaveLength(before.length);
|
||||
expect(JSON.stringify(response.body)).not.toContain(token);
|
||||
} finally {
|
||||
probe.mockRestore();
|
||||
await db.update(companies).set({ requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents }).where(eq(companies.id, companyId));
|
||||
}
|
||||
});
|
||||
|
||||
it("verifies the exact available Copilot model before a model-only edit with an unchanged connection", async () => {
|
||||
const { errorHandler } = await import("../middleware/index.js");
|
||||
const owner = "copilot-model-valid", id = randomUUID();
|
||||
const aiConnection = { provider: "github", method: "api_key", mode: "responsible_user" } as const;
|
||||
const config = { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" };
|
||||
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
||||
await db.insert(agents).values({ id, companyId, name: "Copilot available model", adapterType: "paperclip_runner", adapterConfig: { ...config, model: "previous-model" }, runtimeConfig: { aiConnection } });
|
||||
const token = "github_pat_fixture_model_valid";
|
||||
const account = await service.save(companyId, owner, { provider: "github", method: "api_key", ownership: "personal", name: "Valid model", apiKey: token, agentIds: [id], allAgents: false }, token);
|
||||
await service.setDefault(companyId, owner, account.grantId);
|
||||
const probe = vi.spyOn(copilotProbe, "probeCopilotExecutionTarget").mockResolvedValue({ status: "verified", version: "1.0.88", profileDigest: "fixture-profile", promptSent: false, models: [{ id: config.model, label: "GPT" }] });
|
||||
const app = express(); app.use(express.json());
|
||||
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: owner, companyIds: [companyId], isInstanceAdmin: true }; next(); });
|
||||
app.use("/api", agentRoutes(db)); app.use(errorHandler);
|
||||
try {
|
||||
const response = await request(app).patch(`/api/agents/${id}`).send({ adapterConfig: config });
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
||||
expect(probe).toHaveBeenCalledExactlyOnceWith(token, null, config.model);
|
||||
expect((await db.select().from(agents).where(eq(agents.id, id)))[0].adapterConfig).toMatchObject(config);
|
||||
expect(response.body.runtimeConfig.aiConnection).toEqual(aiConnection);
|
||||
} finally { probe.mockRestore(); }
|
||||
});
|
||||
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("keeps Copilot model discovery behind shared installation and agent configuration authority", async () => {
|
||||
const { errorHandler } = await import("../middleware/index.js");
|
||||
const owner = "copilot-discovery-owner", audience = "copilot-discovery-audience";
|
||||
await db.insert(companyMemberships).values([owner, audience].map(principalId => ({ companyId, principalId, principalType: "user", status: "active", membershipRole: "member" })));
|
||||
await db.insert(principalPermissionGrants).values([owner, audience].map(principalId => ({ companyId, principalId, principalType: "user", permissionKey: "agents:create" })));
|
||||
const token = "github_pat_fixture_shared_discovery";
|
||||
const account = await service.save(companyId, owner, { provider: "github", method: "api_key", ownership: "shared", name: "Shared discovery", apiKey: token, agentIds: [], allAgents: false }, token);
|
||||
await db.insert(connectionGrantMembers).values([owner, audience].map(subjectId => ({ companyId, grantId: account.grantId, subjectType: "user", subjectId })));
|
||||
const binding = { provider: "github", method: "api_key", mode: "shared", ...account };
|
||||
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: "session", userId: String(req.headers["x-test-user"] ?? audience), companyIds: [companyId], memberships: [{ companyId, membershipRole: "member", status: "active" }] }; next(); });
|
||||
app.use("/api", agentRoutes(db)); app.use(errorHandler);
|
||||
const url = `/api/companies/${companyId}/adapters/paperclip_runner/models`;
|
||||
const query = { provider: "acpx", acpxAgent: "copilot", aiConnection: JSON.stringify(binding) };
|
||||
try {
|
||||
const denied = await request(app).get(url).query(query);
|
||||
expect(denied.status, JSON.stringify(denied.body)).toBe(403);
|
||||
expect(denied.body.error).toContain("not permitted for this agent");
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
const allowed = await request(app).get(url).set("x-test-user", owner).query(query);
|
||||
expect(allowed.status, JSON.stringify(allowed.body)).toBe(200);
|
||||
expect(probe).toHaveBeenCalledExactlyOnceWith(db, companyId, token, null, undefined, { pluginWorkerManager: undefined });
|
||||
probe.mockClear();
|
||||
const foreign = await request(app).get(url).set("x-test-user", owner).query({ ...query, agentId: randomUUID() });
|
||||
expect(foreign.status).toBe(404);
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify([denied.body, allowed.body, foreign.body])).not.toContain(token);
|
||||
} finally { probe.mockRestore(); }
|
||||
});
|
||||
|
||||
it("preserves repository GitHub credentials for managed non-Copilot execution", async () => {
|
||||
const owner = "repo-github-owner";
|
||||
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
||||
await create(owner, owner);
|
||||
for (const configured of [{ GH_TOKEN: "repo-gh-fixture", GITHUB_TOKEN: "repo-github-fixture" }, {}]) {
|
||||
const runtime = await prepareManagedAiRuntime(db, { ...input, responsibleUserId: owner, config: { env: configured } });
|
||||
try {
|
||||
expect(runtime.config.env).toMatchObject({ COPILOT_GITHUB_TOKEN: "", ANTHROPIC_API_KEY: `fixture-${owner}` });
|
||||
for (const key of ["GH_TOKEN", "GITHUB_TOKEN"] as const) {
|
||||
if (key in configured) expect(runtime.config.env?.[key]).toBe(configured[key as keyof typeof configured]);
|
||||
else expect(runtime.config.env).not.toHaveProperty(key);
|
||||
}
|
||||
} finally { await runtime.cleanup(); }
|
||||
}
|
||||
});
|
||||
|
||||
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("marks explicit AI selection rejections without changing their HTTP contract", async () => {
|
||||
const owner = "selection-blocker-owner";
|
||||
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
||||
@@ -807,12 +1024,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/0320_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")) {
|
||||
@@ -825,6 +1048,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,101 @@
|
||||
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("../middleware/logger.js", () => ({ logger: { warn: vi.fn() } }));
|
||||
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 { logger } from "../middleware/logger.js";
|
||||
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.each(["ssh", "sandbox", "plugin"])("keeps an explicitly selected %s environment under managed-only execution", async driver => {
|
||||
mocks.experimental.mockResolvedValue({ enableManagedSandboxOnly: true });
|
||||
mocks.environment.mockResolvedValue({ id: "selected", driver, status: "active", config: { provider: "daytona" } });
|
||||
expect(await probeCopilotConnection({} as Db, "company", "private-token", "selected")).toEqual(verified);
|
||||
expect(mocks.managed).not.toHaveBeenCalled();
|
||||
expect(mocks.resolve).toHaveBeenCalledWith(expect.objectContaining({ environment: expect.objectContaining({ id: "selected", driver }) }));
|
||||
expect(mocks.metadata).not.toHaveBeenCalled();
|
||||
expect(mocks.command).toHaveBeenCalledOnce();
|
||||
});
|
||||
it.each([null, "local"])("redirects only a local selection (%s) onto the managed environment", async selection => {
|
||||
mocks.experimental.mockResolvedValue({ enableManagedSandboxOnly: true });
|
||||
mocks.managed.mockResolvedValue({ id: "managed" });
|
||||
mocks.environment.mockImplementation(async id => ({ id, driver: id === "local" ? "local" : "sandbox", status: "active", config: { provider: "daytona" } }));
|
||||
expect(await probeCopilotConnection({} as Db, "company", "private-token", selection)).toEqual(verified);
|
||||
expect(mocks.resolve).toHaveBeenCalledWith(expect.objectContaining({ environment: expect.objectContaining({ id: "managed" }) }));
|
||||
expect(mocks.metadata).not.toHaveBeenCalled();
|
||||
});
|
||||
it("gives forced Kubernetes precedence over an explicitly selected remote and managed-only policy", async () => {
|
||||
mocks.general.mockResolvedValue({ executionMode: "kubernetes" });
|
||||
mocks.experimental.mockResolvedValue({ enableManagedSandboxOnly: true });
|
||||
mocks.kubernetes.mockResolvedValue({ id: "kubernetes" });
|
||||
mocks.environment.mockImplementation(async id => ({ id, driver: "sandbox", status: "active", config: { provider: "kubernetes" } }));
|
||||
expect(await probeCopilotConnection({} as Db, "company", "private-token", "selected")).toEqual(verified);
|
||||
expect(mocks.resolve).toHaveBeenCalledWith(expect.objectContaining({ environment: expect.objectContaining({ id: "kubernetes" }) }));
|
||||
expect(mocks.managed).not.toHaveBeenCalled();
|
||||
expect(mocks.metadata).not.toHaveBeenCalled();
|
||||
});
|
||||
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("preserves authentication classification when lease cleanup also fails", async () => {
|
||||
mocks.command.mockResolvedValue({ timedOut: false, stdout: JSON.stringify({ status: "failed", code: "COPILOT_AUTH_REQUIRED", promptSent: false, message: "private-token" }), exitCode: 1 });
|
||||
mocks.release.mockRejectedValue(new Error("cleanup private-token"));
|
||||
const error = await probeCopilotConnection({} as Db, "company", "private-token", "selected").catch(e => e);
|
||||
expect(error).toMatchObject({ status: 422, details: { code: "COPILOT_AUTH_REQUIRED" } });
|
||||
expect(error.message).not.toContain("private-token");
|
||||
expect(logger.warn).toHaveBeenCalledWith({ companyId: "company", environmentId: "selected", leaseId: "lease", code: "COPILOT_PROBE_CLEANUP_FAILED" }, expect.any(String));
|
||||
expect(JSON.stringify(vi.mocked(logger.warn).mock.calls)).not.toContain("private-token");
|
||||
expect(mocks.release).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("does not verify a connection when its successful probe cannot clean up", async () => {
|
||||
mocks.release.mockRejectedValue(new Error("cleanup private-token"));
|
||||
const error = await probeCopilotConnection({} as Db, "company", "private-token", "selected").catch(e => e);
|
||||
expect(error).toMatchObject({ status: 422, details: { code: "COPILOT_REQUEST_FAILED", cleanupCode: "COPILOT_PROBE_CLEANUP_FAILED" } });
|
||||
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" } });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { createServer as createViteServer } from "vite";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { idleAdmissionMiddleware, trackIdleRequestHandlers } from "../middleware/idle-admission.js";
|
||||
import { idleWorkSnapshot, startTaskDrain, stopTaskDrain, readTaskDrain, beginIdleTrackedWork } from "../services/task-admission.js";
|
||||
@@ -49,6 +50,36 @@ describe("idle admission", () => {
|
||||
expect(idleWorkSnapshot().active).toBe(0);
|
||||
});
|
||||
|
||||
it("tracks ordinary middleware with non-router stack metadata", async () => {
|
||||
const server = app(), done = deferred();
|
||||
const middleware = Object.assign(async (_req: express.Request, res: express.Response) => {
|
||||
res.sendStatus(202); await done.promise;
|
||||
}, { stack: { name: "middleware metadata" } });
|
||||
server.use(middleware);
|
||||
expect(() => trackIdleRequestHandlers(server)).not.toThrow();
|
||||
await request(server).get("/work").expect(202);
|
||||
expect(idleWorkSnapshot().active).toBe(1);
|
||||
done.resolve();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(idleWorkSnapshot().active).toBe(0);
|
||||
});
|
||||
|
||||
it("supports real Vite Connect route metadata without losing async work", async () => {
|
||||
const vite = await createViteServer({ configFile: false, appType: "custom",
|
||||
server: { middlewareMode: true, watch: null }, optimizeDeps: { noDiscovery: true, include: [] } });
|
||||
const server = app(), done = deferred();
|
||||
try {
|
||||
server.use(vite.middlewares);
|
||||
server.post("/work", async (_req, res) => { res.sendStatus(202); await done.promise; });
|
||||
expect(() => trackIdleRequestHandlers(server)).not.toThrow();
|
||||
await request(server).post("/work").expect(202);
|
||||
expect(idleWorkSnapshot().active).toBe(1);
|
||||
done.resolve();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(idleWorkSnapshot().active).toBe(0);
|
||||
} finally { await vite.close(); }
|
||||
});
|
||||
|
||||
it("tracks real Vite middleware without confusing Connect mount paths with Express routes", async () => {
|
||||
const { createServer } = await import("vite");
|
||||
const vite = await createServer({
|
||||
|
||||
@@ -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,16 +411,21 @@ 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"].includes(profile.acpxAgent)) {
|
||||
// The profile resolver already validated the isolated host's exact
|
||||
// qualification pair. Do not report a production readiness pass.
|
||||
return {
|
||||
adapterType: "paperclip_runner", status: "warn" as const, testedAt: new Date().toISOString(),
|
||||
checks: [{ code: "acpx_candidate_qualification_only", level: "warn" as const,
|
||||
message: "This exact candidate and model are admitted for operator-controlled qualification only. Verified runtime installation, bound credentials, and model access are checked before execution; production support remains pending." }],
|
||||
};
|
||||
}
|
||||
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" && profile.acpxAgent !== "pi") throw new Error("Select Codex to use the native Codex runner.");
|
||||
const target = context.executionTarget;
|
||||
if (target?.kind === "remote") {
|
||||
@@ -445,6 +451,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 +539,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/Pi through the Rust Paperclip runner and authenticated PRP transport. Pi accepts an explicit provider/model ID and uses the company credentials bound to the agent environment. GitHub Copilot awaits local and Daytona qualification. 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/Pi through the Rust Paperclip runner and authenticated PRP transport. Pi accepts an explicit provider/model ID and uses the company credentials bound to the agent environment. GitHub Copilot requires a saved GitHub token and an explicitly selected available model. Managed providers use company-scoped qualified profiles, explicit retention acknowledgement, and spend limits.\n",
|
||||
getConfigSchema: () => ({
|
||||
fields: [
|
||||
{
|
||||
@@ -544,7 +558,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
@@ -888,7 +888,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,
|
||||
|
||||
@@ -60,10 +60,9 @@ export function trackIdleRequestHandlers(app: Application): void {
|
||||
for (const layer of stack) {
|
||||
if (seen.has(layer)) continue;
|
||||
seen.add(layer);
|
||||
// Connect/Vite layers use a mount-path string for route; only Express
|
||||
// Route objects contain a nested handler stack.
|
||||
if (layer.route && typeof layer.route !== "string") { visit(layer.route.stack, true); continue; }
|
||||
if (layer.handle.stack) { visit(layer.handle.stack); continue; }
|
||||
// Connect/Vite mount paths are strings; Express Route stacks are arrays.
|
||||
if (layer.route && typeof layer.route !== "string" && Array.isArray(layer.route.stack)) { visit(layer.route.stack, true); continue; }
|
||||
if (Array.isArray(layer.handle.stack)) { visit(layer.handle.stack); continue; }
|
||||
const original = layer.handle;
|
||||
if (original === idleAdmissionMiddleware) continue;
|
||||
const invoke = (req: Request, args: unknown[]) => {
|
||||
|
||||
+82
-11
@@ -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";
|
||||
@@ -1224,7 +1225,14 @@ export function agentRoutes(
|
||||
// A null agent override inherits the instance default, just like dispatch.
|
||||
// Resolve this before secrets or probes so a default remote environment can
|
||||
// never accidentally validate the account on the control-plane host.
|
||||
async function resolveAdapterTestEnvironmentId(companyId: string, environmentId: string | null | undefined) {
|
||||
async function resolveAdapterTestEnvironmentId(companyId: string, environmentId: string | null | undefined, copilot = false) {
|
||||
// Copilot admission must verify the same forced environment as dispatch,
|
||||
// even when the agent explicitly selects the controller or another host.
|
||||
if (copilot && (await instanceSettings.getGeneral()).executionMode === "kubernetes") {
|
||||
const kubernetes = await environmentsSvc.findKubernetesEnvironment(companyId);
|
||||
if (!kubernetes) throw unprocessable("The Kubernetes execution environment is unavailable.", { code: "copilot_environment_unavailable" });
|
||||
return kubernetes.id;
|
||||
}
|
||||
if (environmentId) return environmentId;
|
||||
const settings = await instanceSettings.get();
|
||||
if (settings.defaultEnvironmentId) return settings.defaultEnvironmentId;
|
||||
@@ -2516,11 +2524,20 @@ export function agentRoutes(
|
||||
runtimeConfig: unknown,
|
||||
) {
|
||||
const normalized = normalizeNewAgentRuntimeConfig(runtimeConfig);
|
||||
if (req.actor.type !== "agent" || normalized.aiConnection) return normalized;
|
||||
const manager = req.actor.agentId ? await svc.getById(req.actor.agentId) : null;
|
||||
if (!manager || manager.companyId !== companyId) throw forbidden("Hiring agent is unavailable");
|
||||
const binding = defaultAiConnectionForHire(adapterType, adapterConfig, manager.runtimeConfig?.aiConnection);
|
||||
if (binding) normalized.aiConnection = binding;
|
||||
if (normalized.aiConnection) return normalized;
|
||||
if (req.actor.type === "agent") {
|
||||
const manager = req.actor.agentId ? await svc.getById(req.actor.agentId) : null;
|
||||
if (!manager || manager.companyId !== companyId) throw forbidden("Hiring agent is unavailable");
|
||||
const binding = defaultAiConnectionForHire(adapterType, adapterConfig, manager.runtimeConfig?.aiConnection);
|
||||
if (binding) normalized.aiConnection = binding;
|
||||
}
|
||||
// Copilot has no ambient-auth setup path. Omitting a binding still selects
|
||||
// the responsible user's saved account and must pass the same metadata
|
||||
// admission as an explicit connection before configuration is persisted.
|
||||
if (!normalized.aiConnection && adapterType === "paperclip_runner"
|
||||
&& adapterConfig.provider === "acpx" && adapterConfig.acpxAgent === "copilot") {
|
||||
normalized.aiConnection = { provider: "github", method: "api_key", mode: "responsible_user" };
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -3385,6 +3402,30 @@ 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 modelAgentId = asNonEmptyString(req.query.agentId);
|
||||
if (modelAgentId) {
|
||||
const modelAgent = await svc.getById(modelAgentId);
|
||||
if (!modelAgent || modelAgent.companyId !== companyId) throw notFound("Agent not found");
|
||||
await assertCanUpdateAgent(req, modelAgent);
|
||||
} else await assertCanCreateAgentsForCompany(req, companyId);
|
||||
const allowUninstalledShared = !modelAgentId && await canInstallSharedAiConnectionForNewAgent(db, req, companyId, binding);
|
||||
const service = aiConnectionService(db);
|
||||
const selection = await service.select({ companyId, userId, agentId: modelAgentId ?? "00000000-0000-0000-0000-000000000000",
|
||||
adapterType: type, runnerProvider: "acpx", acpxAgent: "copilot", binding, allowUninstalledPersonal: true, allowUninstalledShared });
|
||||
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 +3497,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 +3508,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;
|
||||
@@ -3546,21 +3599,28 @@ export function agentRoutes(
|
||||
// Hiring is allowed before the responsible user has connected this
|
||||
// provider. Execution still resolves credentials and creates the normal
|
||||
// task connection request; compatibility and access denials stay errors.
|
||||
if (newAgent && !test && binding.mode === "responsible_user" && error instanceof HttpError
|
||||
if (newAgent && !test && binding.provider !== "github" && binding.mode === "responsible_user" && error instanceof HttpError
|
||||
&& ["ai_connection_default_missing", "ai_connection_missing", "ai_connection_unavailable", "ai_connection_responsible_user_missing"].includes(String(asRecord(error.details)?.code))) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (!selection) return undefined;
|
||||
if (test) {
|
||||
const testEnvironmentId = await resolveAdapterTestEnvironmentId(companyId, environmentId);
|
||||
// Copilot model availability is account-specific. Verify the selected
|
||||
// packaged runtime before creation/adoption even when a token is saved.
|
||||
// This metadata-only path sends no model prompt.
|
||||
if (test || binding.provider === "github") {
|
||||
const testEnvironmentId = await resolveAdapterTestEnvironmentId(companyId, environmentId, binding.provider === "github");
|
||||
if (testEnvironmentId) await assertAdapterTestEnvironmentForCompany(companyId, testEnvironmentId);
|
||||
const target = await resolveAdapterTestExecutionContext({ companyId, adapterType, environmentId: testEnvironmentId });
|
||||
try {
|
||||
if (!target.executionTarget && target.fallbackChecks.length > 0) throw unprocessable("The agent environment is not available for adoption");
|
||||
await withManagedAiProbe(db, { companyId, agentId, responsibleUserId: userId, adapterType, binding, config, allowUninstalledPersonal: newAgent, allowUninstalledShared, allowLegacyValidation: true }, async managed => {
|
||||
const result = await testManagedEnvironment(adapterType, { companyId, adapterType, config: managed.config, executionTarget: target.executionTarget, environmentName: target.environmentName }, binding, managed, agentId);
|
||||
if (binding.provider === "github" && result.status === "fail") {
|
||||
const failure = result.checks.find(check => check.level === "error");
|
||||
throw unprocessable(failure?.message ?? "Copilot metadata verification failed.", { code: failure?.code ?? "COPILOT_REQUEST_FAILED" });
|
||||
}
|
||||
if (result.status === "fail" || result.checks.some(check => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)) throw unprocessable("The selected AI connection failed validation in this agent’s environment. Run the agent test to see the failing checks.", {
|
||||
code: "ai_connection_validation_failed",
|
||||
checks: result.checks.filter(check => check.level === "error" || check.code === ADAPTER_AUTH_MISSING_CHECK_CODE).map(check => ({ code: check.code, level: check.level })),
|
||||
@@ -3608,6 +3668,7 @@ export function agentRoutes(
|
||||
req.body.environmentId === undefined
|
||||
? savedAgent?.defaultEnvironmentId
|
||||
: asNonEmptyString(req.body.environmentId),
|
||||
aiBinding?.provider === "github",
|
||||
);
|
||||
// Fail closed on a foreign environment before any secret resolution, env
|
||||
// merge, target resolution, sandbox lease, or adapter test runs.
|
||||
@@ -5793,13 +5854,23 @@ export function agentRoutes(
|
||||
});
|
||||
}
|
||||
if (existing.runtimeConfig.aiConnection && requestedRuntimeConfig && !requestedRuntimeConfig.aiConnection) requestedRuntimeConfig.aiConnection = existing.runtimeConfig.aiConnection;
|
||||
const nextAiConfig = (patchData.adapterConfig ?? existing.adapterConfig) as Record<string, unknown>;
|
||||
if (!requestedRuntimeConfig?.aiConnection && !existing.runtimeConfig.aiConnection
|
||||
&& requestedAdapterType === "paperclip_runner" && nextAiConfig.provider === "acpx"
|
||||
&& nextAiConfig.acpxAgent === "copilot"
|
||||
&& (touchesAdapterConfiguration || Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId"))) {
|
||||
requestedRuntimeConfig = { ...(requestedRuntimeConfig ?? existing.runtimeConfig),
|
||||
aiConnection: { provider: "github", method: "api_key", mode: "responsible_user" } };
|
||||
}
|
||||
const nextAiBinding = aiRuntimeConnectionBindingSchema.safeParse(requestedRuntimeConfig?.aiConnection ?? existing.runtimeConfig.aiConnection).data;
|
||||
if (nextAiBinding) {
|
||||
await assertCanUpdateAgent(req, existing);
|
||||
const changed = JSON.stringify(nextAiBinding) !== JSON.stringify(existing.runtimeConfig.aiConnection);
|
||||
const aiConfig = (patchData.adapterConfig ?? existing.adapterConfig) as Record<string, unknown>;
|
||||
if (nextAiBinding.mode !== "router" && !isAiConnectionCompatible(nextAiBinding, requestedAdapterType, aiConfig.model, aiConfig.provider, aiConfig.acpxAgent)) throw unprocessable("Select an AI connection compatible with the new harness and model");
|
||||
if (changed || (nextAiBinding.mode === "router" && requestedAdapterType !== existing.adapterType)) await validateManagedAgentBinding(req, existing.companyId, existing.id, requestedAdapterType, aiConfig, nextAiBinding, (patchData.defaultEnvironmentId !== undefined ? patchData.defaultEnvironmentId : existing.defaultEnvironmentId) as string | null, true);
|
||||
const copilotConfigurationChanged = requestedAdapterType === "paperclip_runner" && aiConfig.provider === "acpx" && aiConfig.acpxAgent === "copilot"
|
||||
&& (touchesAdapterConfiguration || Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId"));
|
||||
if (changed || copilotConfigurationChanged || (nextAiBinding.mode === "router" && requestedAdapterType !== existing.adapterType)) await validateManagedAgentBinding(req, existing.companyId, existing.id, requestedAdapterType, aiConfig, nextAiBinding, (patchData.defaultEnvironmentId !== undefined ? patchData.defaultEnvironmentId : existing.defaultEnvironmentId) as string | null, true);
|
||||
}
|
||||
if (requestedRuntimeConfig) patchData.runtimeConfig = requestedRuntimeConfig;
|
||||
if (touchesAdapterConfiguration || Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId")) {
|
||||
|
||||
@@ -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,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
|
||||
) {
|
||||
|
||||
+14
-7
@@ -1,7 +1,14 @@
|
||||
/** Development boundary; the server build replaces this with Runner dist/live. */
|
||||
export {
|
||||
probeAcpxClaudeInstallation,
|
||||
probeAcpxGrokInstallation,
|
||||
probeAcpxCursorInstallation,
|
||||
probeAcpxPiInstallation,
|
||||
} 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;
|
||||
export const probeAcpxPiInstallation = runner.probeAcpxPiInstallation;
|
||||
Reference in new issue
Block a user