mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
Add saved-token Copilot setup and normal runtime admission
Reviewed partition of the current-master Copilot integration. Complete v30 live qualification is retained; current v31 recovery compatibility, CI and review are tracked explicitly. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
50368e9c50
commit
fcbb80554e
65 files changed
+57392
-175
No files matched your search
@@ -0,0 +1,61 @@
|
||||
# GitHub Copilot runtime connection
|
||||
|
||||
The current candidate uses the pinned Copilot CLI **1.0.88** and Paperclip Runner
|
||||
profile **v31**. Profile v30 completed all 20 local/Daytona Product cases, seven
|
||||
Runner protocol cases, and ordinary installed local/Daytona canaries. V31 preserves
|
||||
master's saved-prompt recovery fix and updates profile decoding; its compatibility
|
||||
checks pass. Current-head CI, review and final qualification remain required
|
||||
before rollout. See [Copilot production readiness](../plans/2026-10-03-copilot-production-readiness.md)
|
||||
and the [retained v30 qualification record](../plans/2026-10-08-copilot-qualification-v30.json).
|
||||
|
||||
On first setup, choose **GitHub Copilot** when creating your first agent. In an
|
||||
existing organization, open **Connectors → GitHub → Connect Copilot**. Create a
|
||||
fine-grained personal access token owned by your personal GitHub account and
|
||||
add the **Copilot Requests** account permission. Organization-owned tokens and
|
||||
classic personal tokens are unsupported for this setup. Follow
|
||||
[GitHub's authentication instructions](https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/authenticate-copilot-cli).
|
||||
|
||||
Save the token as a personal or shared AI connection and select the execution
|
||||
environment. Paperclip uses its encrypted credential store, grants, access
|
||||
checks, reconnect/revocation, and activity logging. GitHub repository/MCP access
|
||||
uses its separate connection and grants. Copilot execution receives only the
|
||||
selected runtime token as `COPILOT_GITHUB_TOKEN`; ambient GitHub credentials
|
||||
are not adopted.
|
||||
|
||||
Connection verification runs the pinned packaged runtime in the selected
|
||||
environment with a private temporary home. It initializes a session, discovers
|
||||
models, and confirms an explicitly selected model when testing an agent. It
|
||||
sends no model prompt. Authentication, entitlement/policy, installation, and
|
||||
unavailable-model errors are reported separately. Remote verification releases
|
||||
its owned login lease and does not fall back to the controller host.
|
||||
|
||||
In an agent's **Harness / Runtime** settings, select **Paperclip Runner**,
|
||||
**ACP agents**, **GitHub Copilot**, and a model from authenticated discovery.
|
||||
Saving and reopening retain `provider: acpx`, `acpxAgent: copilot`, and that exact
|
||||
model. Auto/default and unavailable models are rejected. The qualification
|
||||
model is `gpt-5.6-luna`, subject to current authenticated availability.
|
||||
|
||||
Normal package installation materializes the pinned runtime for macOS ARM64,
|
||||
macOS x64, and Linux x64. Keep installation scripts enabled. No qualification
|
||||
environment override or ambient GitHub login is required. **Test configuration**
|
||||
verifies the saved connection and exact model without sending a prompt.
|
||||
|
||||
For Daytona, install the first-party Daytona provider and configure an execution
|
||||
environment with its saved API key and the matching release image. Environment
|
||||
assignment currently uses Paperclip's existing **Enable Environments** instance
|
||||
setting. Select that environment for connection verification and agent execution;
|
||||
successful local verification alone does not establish remote readiness. The
|
||||
admitted daemon and provider assets must belong to the same verified build.
|
||||
|
||||
Core workflows use Paperclip's semantic questions, plans, and completion tools.
|
||||
Permission cards identify actual targets. Denied writes have no effects; Stop
|
||||
cancels unanswered permissions and refuses late answers. Provider death must
|
||||
expire stale input without replay. Detached work, native steering, native plan
|
||||
mode, and additional native event presentation are outside this release.
|
||||
Unsupported native blocking callbacks fail explicitly.
|
||||
|
||||
Usage is attributed to **GitHub**. Provider credits and model multipliers are
|
||||
supplemental notices, not USD billing receipts. USD cost remains unknown when
|
||||
GitHub supplies no invoice value; included credits are not recorded as measured
|
||||
zero-dollar usage. Session-scoped notices cannot establish task completion,
|
||||
artifacts, or charges.
|
||||
@@ -245,6 +245,6 @@ export const PAPERCLIP_RUNNER_ACPX_PROFILES = Object.freeze([
|
||||
{ value: "grok", label: "Grok Build", qualified: true, credentialEnvironment: ["XAI_API_KEY"] },
|
||||
{ value: "claude", label: "Claude", qualified: true, credentialEnvironment: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"] },
|
||||
{ value: "cursor", label: "Cursor", qualified: true, credentialEnvironment: ["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"] },
|
||||
{ value: "copilot", label: "GitHub Copilot", qualified: false, credentialEnvironment: ["COPILOT_GITHUB_TOKEN"] },
|
||||
{ value: "copilot", label: "GitHub Copilot", qualified: true, credentialEnvironment: ["COPILOT_GITHUB_TOKEN"] },
|
||||
{ value: "pi", label: "Pi", qualified: false, credentialEnvironment: ["OPENROUTER_API_KEY"] },
|
||||
] as const);
|
||||
@@ -36,6 +36,17 @@ function makeValues(overrides: Partial<CreateConfigValues> = {}): CreateConfigVa
|
||||
}
|
||||
|
||||
describe("buildCodexLocalConfig", () => {
|
||||
describe("qualified Copilot config", () => {
|
||||
it("round-trips Copilot with the exact selected model", () => {
|
||||
const first = buildPaperclipRunnerConfig(makeValues({ model: "gpt-5.6-luna", adapterSchemaValues: { provider: "acpx", acpxAgent: "copilot" } }));
|
||||
const reopened = buildPaperclipRunnerConfig(makeValues({ model: "", adapterSchemaValues: JSON.parse(JSON.stringify(first)) }));
|
||||
expect(reopened).toMatchObject({ provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" });
|
||||
});
|
||||
it.each(["", "auto", "default", "Auto", "DEFAULT"])("rejects a missing or implicit Copilot model (%s)", model => {
|
||||
expect(() => buildPaperclipRunnerConfig(makeValues({ model, adapterSchemaValues: { provider: "acpx", acpxAgent: "copilot" } }))).toThrow("Select an available Copilot model");
|
||||
});
|
||||
});
|
||||
|
||||
it.each([undefined, "approve-all", "approve-paperclip", "approve-reads", "deny-all"])(
|
||||
"defaults Grok to full auto while preserving an explicit %s permission mode",
|
||||
(acpxPermissionMode) => {
|
||||
@@ -231,7 +242,7 @@ describe("buildPaperclipRunnerConfig", () => {
|
||||
expect(config).not.toHaveProperty("acpxAgent");
|
||||
});
|
||||
|
||||
it.each(["pi", "copilot"])("rejects unavailable ACPX %s without selecting another provider", (acpxAgent) => {
|
||||
it.each(["pi"])("rejects unavailable ACPX %s without selecting another provider", (acpxAgent) => {
|
||||
expect(() => buildPaperclipRunnerConfig(makeValues({
|
||||
adapterType: "paperclip_runner",
|
||||
model: "explicit-provider-model",
|
||||
|
||||
@@ -114,6 +114,9 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
|
||||
const configuredModel = typeof config.model === "string"
|
||||
? config.model.trim()
|
||||
: "";
|
||||
if (provider === "acpx" && acpxAgent === "copilot" && (!configuredModel && !schemaModel || ["auto", "default"].includes((configuredModel || schemaModel).toLowerCase()))) {
|
||||
throw new Error("Select an available Copilot model before saving.");
|
||||
}
|
||||
if (provider === "acpx" && acpxAgent === "cursor" && !configuredModel && !schemaModel) {
|
||||
throw new Error(`${acpxAgent} requires an explicit provider model`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "ai_connection_defaults" DROP CONSTRAINT IF EXISTS "ai_connection_defaults_provider_check";--> statement-breakpoint
|
||||
ALTER TABLE "ai_provider_defaults" DROP CONSTRAINT IF EXISTS "ai_provider_defaults_provider_check";--> statement-breakpoint
|
||||
ALTER TABLE "ai_connection_defaults" ADD CONSTRAINT "ai_connection_defaults_provider_check" CHECK ("ai_connection_defaults"."provider" in ('anthropic','openai','openrouter','xai','google','github'));--> statement-breakpoint
|
||||
ALTER TABLE "ai_provider_defaults" ADD CONSTRAINT "ai_provider_defaults_provider_check" CHECK ("ai_provider_defaults"."provider" in ('anthropic','openai','openrouter','xai','google','github'));
|
||||
+3
-3
@@ -4266,7 +4266,7 @@
|
||||
"checkConstraints": {
|
||||
"ai_connection_defaults_provider_check": {
|
||||
"name": "ai_connection_defaults_provider_check",
|
||||
"value": "\"ai_connection_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai','google')"
|
||||
"value": "\"ai_connection_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai','google','github')"
|
||||
},
|
||||
"ai_connection_defaults_method_check": {
|
||||
"name": "ai_connection_defaults_method_check",
|
||||
@@ -4739,7 +4739,7 @@
|
||||
"checkConstraints": {
|
||||
"ai_provider_defaults_provider_check": {
|
||||
"name": "ai_provider_defaults_provider_check",
|
||||
"value": "\"ai_provider_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai','google')"
|
||||
"value": "\"ai_provider_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai','google','github')"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
@@ -56355,4 +56355,4 @@
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
+56358
File diff suppressed because it is too large.
Load diff
+8
-1
@@ -2220,6 +2220,13 @@
|
||||
"when": 1791456875836,
|
||||
"tag": "0318_strong_blacklash",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 319,
|
||||
"version": "7",
|
||||
"when": 1791456875837,
|
||||
"tag": "0319_copilot_defaults",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export const aiConnectionDefaults = pgTable(
|
||||
}),
|
||||
check(
|
||||
"ai_connection_defaults_provider_check",
|
||||
sql`${t.provider} in ('anthropic','openai','openrouter','xai','google')`,
|
||||
sql`${t.provider} in ('anthropic','openai','openrouter','xai','google','github')`,
|
||||
),
|
||||
check(
|
||||
"ai_connection_defaults_method_check",
|
||||
|
||||
@@ -16,5 +16,5 @@ export const aiProviderDefaults = pgTable("ai_provider_defaults", {
|
||||
}, (t) => [
|
||||
uniqueIndex("ai_provider_defaults_owner_provider_uq").on(t.companyId, t.userId, t.provider),
|
||||
foreignKey({ columns: [t.companyId, t.grantId], foreignColumns: [connectionGrants.companyId, connectionGrants.id], name: "ai_provider_defaults_company_grant_fk" }),
|
||||
check("ai_provider_defaults_provider_check", sql`${t.provider} in ('anthropic','openai','openrouter','xai','google')`),
|
||||
check("ai_provider_defaults_provider_check", sql`${t.provider} in ('anthropic','openai','openrouter','xai','google','github')`),
|
||||
]);
|
||||
@@ -32,6 +32,7 @@ export const AI_PROVIDERS = [
|
||||
"openai",
|
||||
"openrouter",
|
||||
"xai",
|
||||
"github",
|
||||
"google",
|
||||
] as const;
|
||||
export const aiProviderSchema = z.enum(AI_PROVIDERS);
|
||||
@@ -79,6 +80,10 @@ export const AI_CONNECTION_CAPABILITIES: Record<
|
||||
>;
|
||||
}
|
||||
> = {
|
||||
github: {
|
||||
name: "GitHub Copilot",
|
||||
methods: { api_key: { adapters: ["copilot_runtime"], envKey: "COPILOT_GITHUB_TOKEN" } },
|
||||
},
|
||||
google: { name: "Google", methods: { api_key: { adapters: ["gemini_local"], envKey: "GEMINI_API_KEY" } } },
|
||||
anthropic: {
|
||||
name: "Claude",
|
||||
@@ -123,7 +128,7 @@ export function isAiConnectionCompatible(
|
||||
// A fixed binding contains identity only. The service checks authoritative
|
||||
// connection metadata before resolving credentials or running the harness.
|
||||
if ("mode" in requirement && requirement.mode !== "responsible_user" && requirement.method === "api_key")
|
||||
return ["claude_local", "codex_local", "opencode_local", "hermes_local", "gemini_local", "grok_local"].includes(adapterType);
|
||||
return ["claude_local", "codex_local", "opencode_local", "hermes_local", "gemini_local", "grok_local", "copilot_runtime"].includes(adapterType);
|
||||
const methods = AI_CONNECTION_CAPABILITIES[requirement.provider].methods;
|
||||
const candidates = "mode" in requirement && requirement.mode === "responsible_user"
|
||||
? Object.values(methods)
|
||||
@@ -185,6 +190,7 @@ export const createAiConnectionSchema = z
|
||||
ownership: z.enum(["personal", "shared"]),
|
||||
routing: aiProviderRoutingSchema.optional(),
|
||||
apiKey: z.string().trim().min(1).max(32768).optional(),
|
||||
environmentId: z.string().uuid().nullable().optional(),
|
||||
loginSessionId: z.string().max(128).optional(),
|
||||
connectionId: z.string().uuid().optional(),
|
||||
agentIds: z.array(z.string().uuid()).max(1000).default([]),
|
||||
|
||||
@@ -81,6 +81,7 @@ export function aiRoutingHarness(
|
||||
acpxAgent?: unknown,
|
||||
): string {
|
||||
if (adapter !== "paperclip_runner") return adapter;
|
||||
if (provider === "acpx" && acpxAgent === "copilot") return "copilot_runtime";
|
||||
const runner =
|
||||
provider === "acpx" && (acpxAgent === "claude" || acpxAgent === "grok")
|
||||
? acpxAgent
|
||||
|
||||
@@ -299,8 +299,9 @@ describe("AppDefinition catalog", () => {
|
||||
it("separates GitHub tools from the review bot without changing the provider identity", () => {
|
||||
const github = getConnectableAppDefinition("github")!;
|
||||
const bot = getConnectableAppDefinition("github-code-review-bot")!;
|
||||
expect(github.methods.map((method) => method.key)).toEqual(["managed", "mcp-key"]);
|
||||
expect(github.methods.every((method) => method.purpose === "tool")).toBe(true);
|
||||
expect(github.methods.map((method) => method.key)).toEqual(["managed", "mcp-key", "copilot-token"]);
|
||||
expect(github.methods.filter((method) => method.purpose === "tool").map((method) => method.key)).toEqual(["managed", "mcp-key"]);
|
||||
expect(github.methods.find((method) => method.key === "copilot-token")).toMatchObject({ purpose: "ai", transport: "runtime_auth" });
|
||||
expect(bot.name).toBe("GitHub Code Review Bot");
|
||||
expect(bot.methods).toHaveLength(1);
|
||||
expect(bot.methods[0]).toMatchObject({ provider: "github", purpose: "channel", transport: "chat_sdk" });
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
"schemaVersion": 1,
|
||||
"slug": "github",
|
||||
"name": "GitHub",
|
||||
"description": "Give agents access to GitHub repositories, issues, and pull requests.",
|
||||
"description": "Connect GitHub repository access or authenticate GitHub Copilot agents.",
|
||||
"categories": [
|
||||
"developer"
|
||||
"developer",
|
||||
"ai"
|
||||
],
|
||||
"featured": true,
|
||||
"branding": {
|
||||
@@ -68,7 +69,7 @@
|
||||
"required": true,
|
||||
"placeholder": "github_pat_...",
|
||||
"secret": true,
|
||||
"helperMd": "Repository contents, issues, pull requests and other granted toolsets; selected repos and installation/PAT permissions apply. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions."
|
||||
"helperMd": "Repository contents, issues, pull requests and other granted toolsets; selected repos and installation/PAT permissions apply. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
@@ -80,6 +81,42 @@
|
||||
"organization",
|
||||
"repository"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "copilot-token",
|
||||
"label": "GitHub Copilot personal access token",
|
||||
"purpose": "ai",
|
||||
"transport": "runtime_auth",
|
||||
"auth": "api_key",
|
||||
"ai": {
|
||||
"provider": "github",
|
||||
"method": "api_key"
|
||||
},
|
||||
"grantKinds": [
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
],
|
||||
"whenToUse": "Authenticate the GitHub Copilot agent runtime with a personal or explicitly shared account.",
|
||||
"guidanceMd": "Create a personal fine-grained token with the Copilot Requests permission. Copilot authentication is separate from repository and MCP access. [GitHub authentication instructions](https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/authenticate-copilot-cli).",
|
||||
"riskTier": "S3",
|
||||
"credentialFields": [
|
||||
{
|
||||
"key": "apiKey",
|
||||
"label": "Personal access token",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "github_pat_...",
|
||||
"secret": true,
|
||||
"helperMd": "Use a personal fine-grained token with Copilot Requests permission."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
"location": "env",
|
||||
"name": "COPILOT_GITHUB_TOKEN"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+10
-3
@@ -29,19 +29,21 @@
|
||||
"files": [
|
||||
"dist",
|
||||
"ui-dist",
|
||||
"skills"
|
||||
"skills",
|
||||
"scripts/install-copilot-assets.mjs"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "tsx src/index.ts",
|
||||
"dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx ./scripts/dev-watch.ts",
|
||||
"prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh",
|
||||
"build": "pnpm run prepare:runner-vendor && node scripts/verify-runner-vendor-dependencies.mjs && tsc && mkdir -p dist/onboarding-assets dist/built-ins dist/services/scripts dist/vendor/paperclip-runner && cp -R src/onboarding-assets/. dist/onboarding-assets/ && cp -R src/built-ins/. dist/built-ins/ && cp -R src/services/scripts/. dist/services/scripts/ && cp -Rf ../packages/paperclip-runner/dist/. dist/vendor/paperclip-runner/ && node scripts/write-build-stamp.mjs",
|
||||
"build": "pnpm run prepare:runner-vendor && node scripts/verify-runner-vendor-dependencies.mjs && tsc && mkdir -p dist/onboarding-assets dist/built-ins dist/services/scripts dist/vendor/paperclip-runner && cp -R src/onboarding-assets/. dist/onboarding-assets/ && cp -R src/built-ins/. dist/built-ins/ && cp -R src/services/scripts/. dist/services/scripts/ && cp -Rf ../packages/paperclip-runner/dist/. dist/vendor/paperclip-runner/ && cp ../packages/paperclip-runner/scripts/install-copilot-assets.mjs ../packages/paperclip-runner/scripts/materialize-copilot-binary.mjs ../packages/paperclip-runner/scripts/copilot-inner-distribution.mjs dist/ && node dist/install-copilot-assets.mjs && node scripts/write-build-stamp.mjs",
|
||||
"prepack": "pnpm run prepare:ui-dist && pnpm run build",
|
||||
"postpack": "rm -rf ui-dist",
|
||||
"clean": "rm -rf dist",
|
||||
"start": "node dist/index.js",
|
||||
"prepare:runner-vendor": "pnpm --filter @paperclipai/paperclip-runner build",
|
||||
"typecheck": "pnpm run prepare:runner-vendor && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit"
|
||||
"typecheck": "pnpm run prepare:runner-vendor && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit",
|
||||
"postinstall": "node scripts/install-copilot-assets.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"ai": "7.0.130",
|
||||
@@ -171,5 +173,10 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.11.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@github/copilot-darwin-arm64": "1.0.88",
|
||||
"@github/copilot-darwin-x64": "1.0.88",
|
||||
"@github/copilot-linux-x64": "1.0.88"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { access } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const installer = new URL("../dist/install-copilot-assets.mjs", import.meta.url);
|
||||
try {
|
||||
await access(fileURLToPath(installer));
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
// Dependencies install before compilation in a source workspace. Published
|
||||
// server packages omit src and cannot take this bootstrap exception.
|
||||
await access(fileURLToPath(new URL("../src/services/copilot-connection-probe.ts", import.meta.url)));
|
||||
await access(fileURLToPath(new URL("../../pnpm-workspace.yaml", import.meta.url)));
|
||||
process.stdout.write("Copilot runtime assets: source-workspace-pending-build\n");
|
||||
process.exit(0);
|
||||
}
|
||||
const { installCopilotAssets } = await import(installer.href);
|
||||
const result = await installCopilotAssets();
|
||||
process.stdout.write(`Copilot runtime assets: ${result.status} (${result.target})\n`);
|
||||
@@ -1,4 +1,6 @@
|
||||
import { probeAcpxClaudeInstallation, probeAcpxCursorInstallation } from "@paperclipai/paperclip-runner/live";
|
||||
import { probeCopilotExecutionTarget } from "../services/copilot-connection-probe.js";
|
||||
import { HttpError } from "../errors.js";
|
||||
import { probeAcpxClaudeInstallation, probeAcpxCursorInstallation } from "../vendor/paperclip-runner/live/index.js";
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
||||
import { buildSandboxNpmInstallCommand } from "@paperclipai/adapter-utils";
|
||||
import type { ServerAdapterModule } from "../adapters/index.js";
|
||||
@@ -17,7 +19,9 @@ import {
|
||||
setOverridePaused,
|
||||
} from "../adapters/registry.js";
|
||||
|
||||
vi.mock("@paperclipai/paperclip-runner/live", () => ({
|
||||
vi.mock("../services/copilot-connection-probe.js",()=>({probeCopilotExecutionTarget:vi.fn(async()=>({status:"verified",models:[{id:"gpt-5.6-luna"}]}))}));
|
||||
|
||||
vi.mock("../vendor/paperclip-runner/live/index.js", () => ({
|
||||
probeAcpxClaudeInstallation: vi.fn(async () => undefined),
|
||||
probeAcpxGrokInstallation: vi.fn(async () => undefined),
|
||||
probeAcpxCursorInstallation: vi.fn(async () => undefined),
|
||||
@@ -359,20 +363,29 @@ describe("server adapter registry", () => {
|
||||
checks: [{ code: "paperclip_runner_acpx_agent_unavailable" }],
|
||||
});
|
||||
});
|
||||
it("reports qualification-only readiness for an exact host-authorized candidate", async () => {
|
||||
const key = "PAPERCLIP_RUNNER_ACPX_QUALIFICATION";
|
||||
const previous = process.env[key];
|
||||
process.env[key] = JSON.stringify([{ agent: "copilot", model: "exact-model" }]);
|
||||
try {
|
||||
const result = await requireServerAdapter("paperclip_runner").testEnvironment({
|
||||
companyId: "company-1", adapterType: "paperclip_runner",
|
||||
config: { provider: "acpx", acpxAgent: "copilot", model: "exact-model" },
|
||||
});
|
||||
expect(result).toMatchObject({ status: "warn", checks: [{ code: "acpx_candidate_qualification_only" }] });
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env[key];
|
||||
else process.env[key] = previous;
|
||||
}
|
||||
it("requires a saved Copilot token rather than ambient authentication",async()=>{
|
||||
const prior=process.env.COPILOT_GITHUB_TOKEN;process.env.COPILOT_GITHUB_TOKEN="ambient-not-authority";
|
||||
const probe=vi.mocked(probeCopilotExecutionTarget);probe.mockClear();
|
||||
try{
|
||||
const result=await requireServerAdapter("paperclip_runner").testEnvironment({companyId:"company-1",adapterType:"paperclip_runner",config:{provider:"acpx",acpxAgent:"copilot",model:"gpt-5.6-luna"}});
|
||||
expect(result).toMatchObject({status:"fail",checks:[{code:"COPILOT_AUTH_REQUIRED"}]});expect(probe).not.toHaveBeenCalled();
|
||||
}finally{if(prior===undefined)delete process.env.COPILOT_GITHUB_TOKEN;else process.env.COPILOT_GITHUB_TOKEN=prior;}
|
||||
});
|
||||
it.each([undefined,{kind:"remote",remoteCwd:"/workspace"}])("verifies the selected Copilot runtime and model without a prompt",async(executionTarget)=>{
|
||||
const result=await requireServerAdapter("paperclip_runner").testEnvironment({companyId:"company-1",adapterType:"paperclip_runner",config:{provider:"acpx",acpxAgent:"copilot",model:"gpt-5.6-luna",env:{COPILOT_GITHUB_TOKEN:"fixture-token"}},executionTarget} as any);
|
||||
expect(probeCopilotExecutionTarget).toHaveBeenLastCalledWith("fixture-token",executionTarget,"gpt-5.6-luna");
|
||||
expect(result).toMatchObject({status:"pass",checks:[{code:"copilot_metadata_verified"}]});
|
||||
});
|
||||
it.each(["COPILOT_AUTH_REQUIRED","COPILOT_ENTITLEMENT_DENIED","COPILOT_MODEL_UNAVAILABLE","COPILOT_INSTALLATION_INVALID","COPILOT_REQUEST_FAILED"])("reports distinct Copilot failure %s",async code=>{
|
||||
vi.mocked(probeCopilotExecutionTarget).mockRejectedValueOnce(new HttpError(422,"Fixture metadata failure",{code}));
|
||||
const result=await requireServerAdapter("paperclip_runner").testEnvironment({companyId:"company-1",adapterType:"paperclip_runner",config:{provider:"acpx",acpxAgent:"copilot",model:"gpt-5.6-luna",env:{COPILOT_GITHUB_TOKEN:"fixture-token"}}});
|
||||
expect(result).toMatchObject({status:"fail",checks:[{code}]});
|
||||
});
|
||||
it("sanitizes unexpected Copilot metadata failures",async()=>{
|
||||
vi.mocked(probeCopilotExecutionTarget).mockRejectedValueOnce(new Error("fixture private implementation detail"));
|
||||
const result=await requireServerAdapter("paperclip_runner").testEnvironment({companyId:"company-1",adapterType:"paperclip_runner",config:{provider:"acpx",acpxAgent:"copilot",model:"gpt-5.6-luna",env:{COPILOT_GITHUB_TOKEN:"fixture-token"}}});
|
||||
expect(result).toMatchObject({status:"fail",checks:[{code:"COPILOT_REQUEST_FAILED",message:"Copilot metadata verification failed."}]});
|
||||
expect(JSON.stringify(result)).not.toContain("private implementation detail");
|
||||
});
|
||||
it("wraps built-in npm runtime installs with the sandbox-aware install helper", () => {
|
||||
const expectedClaudeInstall = `if ! command -v 'claude' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@anthropic-ai/claude-code")}; fi`;
|
||||
|
||||
@@ -389,6 +389,7 @@ describe("adapter routes", () => {
|
||||
options: [
|
||||
{ value: "claude", label: "Claude" },
|
||||
{ value: "grok", label: "Grok Build" },
|
||||
{ value: "copilot", label: "GitHub Copilot" },
|
||||
],
|
||||
});
|
||||
expect(JSON.stringify(res.body)).not.toContain("Codex via ACPX");
|
||||
|
||||
@@ -31,6 +31,9 @@ import express from "express";
|
||||
import request from "supertest";
|
||||
import { aiConnectionRoutes, canInstallSharedAiConnectionForNewAgent, responsibleUserForAiRequest } from "../routes/ai-connections.js";
|
||||
import { validateAiApiKey } from "../routes/ai-connections.js";
|
||||
import * as copilotProbe from "../services/copilot-connection-probe.js";
|
||||
import { agentRoutes } from "../routes/agents.js";
|
||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
||||
vi.mock("../services/local-ai-browser-login.js", () => ({
|
||||
startLocalBrowserLogin: () => ({ authorizationUrl: "https://auth.openai.com/codex/device", code: "ABCD-EFGHJ", abort: () => {} }),
|
||||
}));
|
||||
@@ -60,6 +63,78 @@ beforeAll(async () => {
|
||||
afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); if (home) await rm(home, { recursive: true, force: true }); });
|
||||
|
||||
describe("managed AI connections", () => {
|
||||
it("passes the live sandbox worker manager to Copilot connection verification", async () => {
|
||||
const pluginWorkerManager = {} as PluginWorkerManager;
|
||||
const probe = vi.spyOn(copilotProbe, "probeCopilotConnection").mockResolvedValue({
|
||||
status: "verified", version: "1.0.88", profileDigest: "fixture-profile",
|
||||
promptSent: false, models: [{ id: "gpt-5.6-luna", label: "GPT" }],
|
||||
});
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = { type: "board", source: "local_implicit", userId: "alice", companyIds: [companyId] };
|
||||
next();
|
||||
});
|
||||
app.use("/api", aiConnectionRoutes(db, { pluginWorkerManager }));
|
||||
try {
|
||||
const response = await request(app).post(`/api/companies/${companyId}/ai-connections`).send({
|
||||
provider: "github", method: "api_key", name: "Copilot remote route", ownership: "personal",
|
||||
apiKey: "github_pat_fixture_remote", agentIds: [], allAgents: false,
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
expect(probe).toHaveBeenCalledWith(db, companyId, "github_pat_fixture_remote", undefined, undefined, { pluginWorkerManager });
|
||||
} finally { probe.mockRestore(); }
|
||||
});
|
||||
|
||||
it("discovers Copilot models in the selected environment with its sandbox worker", async () => {
|
||||
const pluginWorkerManager = {} as PluginWorkerManager;
|
||||
const [environment] = await db.insert(environments).values({ name: "Copilot model discovery", driver: "sandbox", config: { provider: "daytona" } }).returning();
|
||||
const token = "github_pat_fixture_model_discovery";
|
||||
const account = await service.save(companyId, "alice", { provider: "github", method: "api_key", ownership: "personal", name: "Model discovery account", apiKey: token, agentIds: [agentId], allAgents: false }, token);
|
||||
await service.setDefault(companyId, "alice", account.grantId);
|
||||
const probe = vi.spyOn(copilotProbe, "probeCopilotConnection").mockResolvedValue({ status: "verified", version: "1.0.88", profileDigest: "fixture-profile", promptSent: false, models: [{ id: "gpt-5.6-luna", label: "GPT" }] });
|
||||
const app = express(); app.use(express.json());
|
||||
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: "alice", companyIds: [companyId] }; next(); });
|
||||
app.use("/api", agentRoutes(db, { pluginWorkerManager }));
|
||||
try {
|
||||
const response = await request(app).get(`/api/companies/${companyId}/adapters/paperclip_runner/models`).query({ provider: "acpx", acpxAgent: "copilot", environmentId: environment.id, agentId, aiConnection: JSON.stringify({ provider: "github", method: "api_key", mode: "responsible_user" }) });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers["cache-control"]).toBe("no-store");
|
||||
expect(response.body).toEqual([{ id: "gpt-5.6-luna", label: "GPT" }]);
|
||||
expect(probe).toHaveBeenCalledWith(db, companyId, token, environment.id, undefined, { pluginWorkerManager });
|
||||
expect(JSON.stringify(response.body)).not.toContain(token);
|
||||
} finally { probe.mockRestore(); }
|
||||
});
|
||||
|
||||
it("uses a vaulted Copilot token with GitHub attribution and refuses revoked or foreign grants", async () => {
|
||||
const owner = "copilot-owner";
|
||||
const token = "github_pat_fixture_copilot";
|
||||
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
||||
const account = await service.save(companyId, owner, { provider: "github", method: "api_key", ownership: "personal", name: "Copilot", apiKey: token, agentIds: [agentId], allAgents: false }, token);
|
||||
const selectedBinding = { provider: "github", method: "api_key", mode: "responsible_user" } as const;
|
||||
const config = { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna", env: { COPILOT_GITHUB_TOKEN: "ambient-never-use", GH_TOKEN: "project-never-use" } };
|
||||
const runInput = { companyId, agentId, responsibleUserId: owner, adapterType: "paperclip_runner", binding: selectedBinding, config };
|
||||
const selected = await service.select({ ...runInput, userId: owner, runnerProvider: "acpx", acpxAgent: "copilot", model: config.model });
|
||||
expect(await service.credential(selected)).toBe(token);
|
||||
expect(JSON.stringify(await service.list(companyId, owner))).not.toContain(token);
|
||||
const runtime = await prepareManagedAiRuntime(db, runInput);
|
||||
try {
|
||||
expect(runtime.config).toMatchObject({ provider: "acpx", acpxAgent: "copilot", model: config.model, env: { COPILOT_GITHUB_TOKEN: token, GH_TOKEN: "", GITHUB_TOKEN: "", OPENAI_API_KEY: "", ANTHROPIC_API_KEY: "" } });
|
||||
expect(runtime.attribution).toMatchObject({ provider: "github", method: "api_key", connectionId: account.connectionId, grantId: account.grantId });
|
||||
expect(config.env.COPILOT_GITHUB_TOKEN).toBe("ambient-never-use");
|
||||
} finally { await runtime.cleanup(); }
|
||||
await expect(access(runtime.home!)).rejects.toThrow();
|
||||
await expect(service.select({ ...runInput, userId: "bob", runnerProvider: "acpx", acpxAgent: "copilot" })).rejects.toThrow();
|
||||
await expect(service.select({ ...runInput, companyId: otherCompanyId, userId: owner, binding: { ...selectedBinding, mode: "shared", ...account }, runnerProvider: "acpx", acpxAgent: "copilot" })).rejects.toThrow();
|
||||
const network = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("usage must not send credentials"));
|
||||
try {
|
||||
expect(await service.probeUsage(companyId, owner, account.connectionId, account.grantId)).toMatchObject({ provider: "github", status: "unsupported", limits: [] });
|
||||
expect(network).not.toHaveBeenCalled();
|
||||
} finally { network.mockRestore(); }
|
||||
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, account.grantId));
|
||||
await expect(prepareManagedAiRuntime(db, runInput)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("persists first-time recovery directories before consuming a single-use token", async () => {
|
||||
const owner = "quota-durable-path";
|
||||
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
||||
@@ -787,12 +862,18 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
|
||||
await expect(access(runtime.home!)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("preserves Google account defaults when the provider constraint migration is reapplied", async () => {
|
||||
it("preserves Google and Copilot account defaults when the current provider constraint migration is reapplied", async () => {
|
||||
const saved = await service.save(companyId, "bob", {
|
||||
provider: "google", method: "api_key", ownership: "personal", name: "Google migration",
|
||||
apiKey: "fixture", allAgents: true, agentIds: [],
|
||||
}, "google-migration-key");
|
||||
const migration = await readFile(new URL("../../../packages/db/src/migrations/0306_familiar_titania.sql", import.meta.url), "utf8");
|
||||
await db.insert(companyMemberships).values({ companyId, principalId: "migration-copilot", principalType: "user", status: "active", membershipRole: "member" });
|
||||
const copilot = await service.save(companyId, "migration-copilot", {
|
||||
provider: "github", method: "api_key", ownership: "personal", name: "Copilot migration",
|
||||
apiKey: "fixture", allAgents: true, agentIds: [],
|
||||
}, "github_pat_fixture_migration");
|
||||
await service.setDefault(companyId, "migration-copilot", copilot.grantId);
|
||||
const migration = await readFile(new URL("../../../packages/db/src/migrations/0319_copilot_defaults.sql", import.meta.url), "utf8");
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
await db.transaction(async (tx) => {
|
||||
for (const statement of migration.split("--> statement-breakpoint")) {
|
||||
@@ -805,6 +886,12 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
|
||||
binding: { provider: "google", method: "api_key", mode: "responsible_user" },
|
||||
});
|
||||
expect(selected.grant.id).toBe(saved.grantId);
|
||||
const selectedCopilot = await service.select({
|
||||
companyId, agentId, userId: "migration-copilot", adapterType: "paperclip_runner",
|
||||
runnerProvider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna",
|
||||
binding: { provider: "github", method: "api_key", mode: "responsible_user" },
|
||||
});
|
||||
expect(selectedCopilot.grant.id).toBe(copilot.grantId);
|
||||
});
|
||||
|
||||
it.each([false, true])("reports the authoritative connection-manager capability for custom grants (manager: %s)", async (manager) => {
|
||||
|
||||
@@ -372,7 +372,7 @@ describeEmbeddedPostgres("connectionIntentService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("only advertises GitHub tool methods in search and setup options", async () => {
|
||||
it("separates GitHub runtime discovery from tool setup options", async () => {
|
||||
const service = connectionIntentService(db);
|
||||
const search = await service.search(claims, "github");
|
||||
const github = search.results.find((result) => result.service === "github");
|
||||
@@ -383,6 +383,12 @@ describeEmbeddedPostgres("connectionIntentService", () => {
|
||||
key: "mcp-key",
|
||||
label: "Personal access token (advanced)",
|
||||
auth: "api_key",
|
||||
purpose: "tool",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: "copilot-token",
|
||||
purpose: "ai",
|
||||
setupPath: expect.stringContaining("/apps/connect?source=github"),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -407,6 +413,7 @@ describeEmbeddedPostgres("connectionIntentService", () => {
|
||||
expect(setup.service.methods.map((method) => method.key)).not.toContain(
|
||||
"chat-agent",
|
||||
);
|
||||
expect(setup.service.methods.map((method) => method.key)).not.toContain("copilot-token");
|
||||
});
|
||||
|
||||
it("serializes OAuth intent completion behind addressed-user membership revocation", async () => {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { environmentRuntimeService } from "../services/environment-runtime.js";
|
||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
||||
const mocks = vi.hoisted(() => ({ metadata: vi.fn(), command: vi.fn(), environment: vi.fn(), bindings: vi.fn(), acquire: vi.fn(), realize: vi.fn(), release: vi.fn(), resolve: vi.fn(), general: vi.fn(), experimental: vi.fn(), defaults: vi.fn(), kubernetes: vi.fn(), managed: vi.fn() }));
|
||||
vi.mock("../vendor/paperclip-runner/live/index.js", async importOriginal => ({ ...await importOriginal<object>(), probeCopilotMetadata: mocks.metadata }));
|
||||
vi.mock("@paperclipai/adapter-utils/execution-target", () => ({ runAdapterExecutionTargetShellCommand: mocks.command }));
|
||||
vi.mock("../services/environments.js", () => ({ environmentService: () => ({ getById: mocks.environment, listBoundCompanyIds: mocks.bindings, findKubernetesEnvironment: mocks.kubernetes, findManagedSandboxEnvironment: mocks.managed }) }));
|
||||
vi.mock("../services/instance-settings.js", () => ({ instanceSettingsService: () => ({ get: mocks.defaults, getGeneral: mocks.general, getExperimental: mocks.experimental }) }));
|
||||
vi.mock("../services/environment-runtime.js", () => ({ environmentRuntimeService: vi.fn(() => ({ acquireRunLease: mocks.acquire, realizeWorkspace: mocks.realize, getDriver: () => ({ releaseRunLease: mocks.release }) })) }));
|
||||
vi.mock("../services/environment-execution-target.js", () => ({ resolveEnvironmentExecutionTarget: mocks.resolve }));
|
||||
import { probeCopilotConnection, probeCopilotExecutionTarget } from "../services/copilot-connection-probe.js";
|
||||
import { QUALIFIED_ACPX_PROFILES } from "../../../packages/paperclip-runner/src/drivers/acpx/qualified-profiles.js";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
const verified = { status: "verified", version: "1.0.88", profileDigest: QUALIFIED_ACPX_PROFILES.copilot.commandDigest, promptSent: false, models: [{ id: "gpt-5.6-luna", label: "GPT" }] };
|
||||
const remote = { kind: "remote", remoteCwd: "/workspace" } as AdapterExecutionTarget;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); mocks.general.mockResolvedValue({ executionMode: "local" }); mocks.experimental.mockResolvedValue({}); mocks.defaults.mockResolvedValue({ defaultEnvironmentId: null });
|
||||
mocks.environment.mockResolvedValue({ id: "selected", driver: "sandbox", status: "active", config: { provider: "daytona" } }); mocks.bindings.mockResolvedValue(["company"]);
|
||||
mocks.acquire.mockResolvedValue({ lease: { id: "lease", metadata: {} } }); mocks.realize.mockResolvedValue({}); mocks.release.mockResolvedValue(undefined); mocks.resolve.mockResolvedValue(remote);
|
||||
mocks.command.mockResolvedValue({ timedOut: false, stdout: JSON.stringify(verified), exitCode: 0 }); mocks.metadata.mockResolvedValue(verified);
|
||||
});
|
||||
describe("Copilot connection verification", () => {
|
||||
it("probes the selected environment and releases its owned lease", async () => {
|
||||
const db = {} as Db;
|
||||
const pluginWorkerManager = {} as PluginWorkerManager;
|
||||
expect(await probeCopilotConnection(db, "company", "private-token", "selected", "gpt-5.6-luna", { pluginWorkerManager })).toEqual(verified);
|
||||
expect(environmentRuntimeService).toHaveBeenCalledWith(db, { pluginWorkerManager });
|
||||
expect(mocks.metadata).not.toHaveBeenCalled(); expect(mocks.acquire).toHaveBeenCalledOnce();
|
||||
expect(mocks.command.mock.calls[0]![3]).toMatchObject({ env: { COPILOT_GITHUB_TOKEN: "private-token", PAPERCLIP_COPILOT_PROBE_MODEL: "gpt-5.6-luna" }, timeoutSec: 35 });
|
||||
expect(mocks.command.mock.calls[0]![2]).not.toContain("private-token");
|
||||
expect(mocks.release).toHaveBeenCalledWith(expect.objectContaining({ status: "released" }));
|
||||
});
|
||||
it("rejects a foreign company before creating a lease or transmitting the token", async () => {
|
||||
mocks.bindings.mockResolvedValue(["other-company"]);
|
||||
await expect(probeCopilotConnection({} as Db, "company", "private-token", "selected")).rejects.toMatchObject({ status: 403 });
|
||||
expect(mocks.command).not.toHaveBeenCalled(); expect(mocks.metadata).not.toHaveBeenCalled(); expect(mocks.acquire).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not fall back to host credentials when the remote runtime is missing", async () => {
|
||||
mocks.command.mockResolvedValue({ timedOut: false, stdout: "", exitCode: 127 });
|
||||
await expect(probeCopilotConnection({} as Db, "company", "private-token", "selected")).rejects.toMatchObject({ details: { code: "COPILOT_INSTALLATION_INVALID" } });
|
||||
expect(mocks.metadata).not.toHaveBeenCalled(); expect(mocks.release).toHaveBeenCalledWith(expect.objectContaining({ status: "failed" }));
|
||||
});
|
||||
it("sanitizes remote errors and releases the lease on authentication failure", async () => {
|
||||
mocks.command.mockResolvedValue({ timedOut: false, stdout: JSON.stringify({ status: "failed", code: "COPILOT_AUTH_REQUIRED", promptSent: false, message: "private-token" }), exitCode: 1 });
|
||||
const error = await probeCopilotConnection({} as Db, "company", "private-token", "selected").catch(e => e);
|
||||
expect(error.details.code).toBe("COPILOT_AUTH_REQUIRED"); expect(error.message).not.toContain("private-token"); expect(mocks.release).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("rejects stale profile results and unavailable model selections", async () => {
|
||||
mocks.command.mockResolvedValue({ timedOut: false, stdout: JSON.stringify({ ...verified, profileDigest: "stale" }), exitCode: 0 });
|
||||
await expect(probeCopilotExecutionTarget("private-token", remote)).rejects.toMatchObject({ details: { code: "COPILOT_REQUEST_FAILED" } });
|
||||
mocks.command.mockResolvedValue({ timedOut: false, stdout: JSON.stringify(verified), exitCode: 0 });
|
||||
await expect(probeCopilotExecutionTarget("private-token", remote, "missing")).rejects.toMatchObject({ details: { code: "COPILOT_MODEL_UNAVAILABLE" } });
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ const { probeInstallation, probeGrokInstallation } = vi.hoisted(() => ({
|
||||
probeInstallation: vi.fn(),
|
||||
probeGrokInstallation: vi.fn(),
|
||||
}));
|
||||
vi.mock("@paperclipai/paperclip-runner/live", () => ({
|
||||
vi.mock("../vendor/paperclip-runner/live/index.js", () => ({
|
||||
probeAcpxClaudeInstallation: probeInstallation,
|
||||
probeAcpxGrokInstallation: probeGrokInstallation,
|
||||
probeAcpxCursorInstallation: vi.fn(async () => undefined),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { HttpError } from "../errors.js";
|
||||
import type { AdapterRuntimeCommandSpec, ServerAdapterModule } from "./types.js";
|
||||
import { parseAdapterModelsEnv } from "../services/adapter-models-env.js";
|
||||
import { stampClaudeAgentIdHeader } from "./claude-agent-id-header.js";
|
||||
@@ -410,7 +411,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
checks: [{ code: "dot_event_test_required", level: "warn" as const, message: "Dot manages its model and billing. Validate the dedicated agent binding and event round trip in Paperclip; this read-only check does not wake the Dot." }] };
|
||||
}
|
||||
if (profile.provider === "acpx") {
|
||||
if (["copilot", "pi"].includes(profile.acpxAgent)) {
|
||||
if (profile.acpxAgent === "pi") {
|
||||
// The profile resolver already validated the isolated host's exact
|
||||
// qualification pair. Do not report a production readiness pass.
|
||||
return {
|
||||
@@ -420,6 +421,20 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
};
|
||||
}
|
||||
try {
|
||||
if (profile.acpxAgent === "copilot") {
|
||||
const configured = context.config.env;
|
||||
const token = configured && typeof configured === "object" && !Array.isArray(configured)
|
||||
? (configured as Record<string, unknown>).COPILOT_GITHUB_TOKEN : undefined;
|
||||
if (typeof token !== "string" || !token) {
|
||||
return { adapterType: "paperclip_runner", status: "fail" as const, testedAt: new Date().toISOString(),
|
||||
checks: [{ code: "COPILOT_AUTH_REQUIRED", level: "error" as const, message: "Select a saved Copilot token connection." }] };
|
||||
}
|
||||
const { probeCopilotExecutionTarget } = await import("../services/copilot-connection-probe.js");
|
||||
await probeCopilotExecutionTarget(token, context.executionTarget, profile.model);
|
||||
return { adapterType: "paperclip_runner", status: "pass" as const, testedAt: new Date().toISOString(),
|
||||
checks: [{ code: "copilot_metadata_verified", level: "info" as const,
|
||||
message: "The verified Copilot runtime authenticated this account and accepted the selected model. No model prompt was sent." }] };
|
||||
}
|
||||
if (profile.acpxAgent !== "claude" && profile.acpxAgent !== "grok" && profile.acpxAgent !== "cursor") throw new Error("Select Codex to use the native Codex runner.");
|
||||
const target = context.executionTarget;
|
||||
if (target?.kind === "remote") {
|
||||
@@ -445,6 +460,14 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
checks: [{ code: "acpx_runtime_ready", level: "info" as const, message: `ACPX ${profile.acpxAgent} runtime is installed and verified. Model access is checked when it runs.` }],
|
||||
};
|
||||
} catch (error) {
|
||||
if (profile.acpxAgent === "copilot") {
|
||||
const details = error instanceof HttpError && error.details && typeof error.details === "object" && !Array.isArray(error.details)
|
||||
? error.details as Record<string, unknown> : {};
|
||||
const codes = ["COPILOT_AUTH_REQUIRED", "COPILOT_ENTITLEMENT_DENIED", "COPILOT_MODEL_UNAVAILABLE", "COPILOT_INSTALLATION_INVALID", "COPILOT_REQUEST_FAILED"];
|
||||
const code = typeof details.code === "string" && codes.includes(details.code) ? details.code : "COPILOT_REQUEST_FAILED";
|
||||
return { adapterType: "paperclip_runner", status: "fail" as const, testedAt: new Date().toISOString(),
|
||||
checks: [{ code, level: "error" as const, message: error instanceof HttpError ? error.message : "Copilot metadata verification failed." }] };
|
||||
}
|
||||
return {
|
||||
adapterType: "paperclip_runner", status: "fail" as const, testedAt: new Date().toISOString(),
|
||||
checks: [{ code: "acpx_runtime_unavailable", level: "error" as const, message: error instanceof Error ? error.message : "ACPX Claude runtime could not be verified." }],
|
||||
@@ -525,7 +548,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
)
|
||||
: buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex@0.160.0"),
|
||||
agentConfigurationDoc:
|
||||
"# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex, OpenCode, Claude Managed, AWS AgentCore, or ACPX Claude/Grok Build/Cursor through the Rust Paperclip runner and authenticated PRP transport. GitHub Copilot and Pi are awaiting local and Daytona qualification and are not enabled for production runs. Managed providers use company-scoped qualified profiles, explicit retention acknowledgement, and spend limits.\n",
|
||||
"# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex, OpenCode, Claude Managed, AWS AgentCore, or ACPX Claude/Grok Build/Cursor through the Rust Paperclip runner and authenticated PRP transport. GitHub Copilot requires a saved GitHub token and an explicitly selected available model. Pi is awaiting local and Daytona qualification and is not enabled for production runs. Managed providers use company-scoped qualified profiles, explicit retention acknowledgement, and spend limits.\n",
|
||||
getConfigSchema: () => ({
|
||||
fields: [
|
||||
{
|
||||
@@ -544,7 +567,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
},
|
||||
{
|
||||
key: "acpxAgent", label: "ACP agent", type: "select" as const, default: "claude",
|
||||
options: [{ value: "claude", label: "Claude" }, { value: "grok", label: "Grok Build" }],
|
||||
options: [{ value: "claude", label: "Claude" }, { value: "grok", label: "Grok Build" }, { value: "copilot", label: "GitHub Copilot" }],
|
||||
meta: { visibleWhen: { key: "provider", value: "acpx" } },
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -882,7 +882,7 @@ export async function createApp(
|
||||
app.locals.toolActionDeliveries = toolActionDeliveries;
|
||||
app.use(mcpGatewayProtocolRoutes(toolGateway));
|
||||
api.use(decisionModelRoutes(db));
|
||||
api.use(aiConnectionRoutes(db, { deploymentMode: opts.deploymentMode, deploymentExposure: opts.deploymentExposure, trustedLocalStdioRuntimeHost }));
|
||||
api.use(aiConnectionRoutes(db, { deploymentMode: opts.deploymentMode, deploymentExposure: opts.deploymentExposure, trustedLocalStdioRuntimeHost, pluginWorkerManager: workerManager }));
|
||||
api.use(
|
||||
toolAccessRoutes(db, {
|
||||
deploymentMode: opts.deploymentMode,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { prepareManagedAiRuntime, withManagedAiProbe, assertManagedAiProjectAuth
|
||||
import { ADAPTER_AUTH_MISSING_CHECK_CODE, AI_CONNECTION_CAPABILITIES, aiConnectionBindingSchema, type AiConnectionBinding, aiRuntimeConnectionBindingSchema, type AiRuntimeConnectionBinding } from "@paperclipai/shared";
|
||||
import { toolConnections } from "@paperclipai/db";
|
||||
import { aiConnectionService } from "../services/ai-connections.js";
|
||||
import { probeCopilotConnection, probeCopilotExecutionTarget } from "../services/copilot-connection-probe.js";
|
||||
import { defaultAiConnectionForHire } from "../services/agent-ai-connection-default.js";
|
||||
import { assertAiConnectionCreateAccess, canInstallSharedAiConnectionForNewAgent, responsibleUserForAiRequest, validateAiApiKey } from "./ai-connections.js";
|
||||
import { isAiConnectionCompatible } from "@paperclipai/shared";
|
||||
@@ -3385,6 +3386,23 @@ export function agentRoutes(
|
||||
return;
|
||||
}
|
||||
const provider = asNonEmptyString(req.query.provider);
|
||||
if (type === "paperclip_runner" && provider === "acpx" && req.query.acpxAgent === "copilot") {
|
||||
const userId = responsibleUserForAiRequest(req);
|
||||
let binding: AiConnectionBinding = { provider: "github", method: "api_key", mode: "responsible_user" };
|
||||
if (typeof req.query.aiConnection === "string") {
|
||||
if (req.query.aiConnection.length > 4096) throw unprocessable("Invalid AI connection binding");
|
||||
try { binding = aiConnectionBindingSchema.parse(JSON.parse(req.query.aiConnection)); }
|
||||
catch { throw unprocessable("Invalid AI connection binding"); }
|
||||
}
|
||||
if (binding.provider !== "github") throw unprocessable("Select a GitHub Copilot AI connection");
|
||||
const service = aiConnectionService(db);
|
||||
const selection = await service.select({ companyId, userId, agentId: asNonEmptyString(req.query.agentId) ?? "00000000-0000-0000-0000-000000000000",
|
||||
adapterType: type, runnerProvider: "acpx", acpxAgent: "copilot", binding, allowUninstalledPersonal: true, allowUninstalledShared: req.actor.type === "board" });
|
||||
const metadata = await probeCopilotConnection(db, companyId, await service.credential(selection), environmentId, undefined, { pluginWorkerManager: options.pluginWorkerManager });
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.json(metadata.models);
|
||||
return;
|
||||
}
|
||||
if (type === "opencode_local" && provider === "openrouter") {
|
||||
res.json(await listOpenRouterModels(refresh));
|
||||
return;
|
||||
@@ -3456,7 +3474,7 @@ export function agentRoutes(
|
||||
// missing CLI, unavailable environment, or other runtime error does not.
|
||||
if (result.status === "fail" && result.checks.some(check =>
|
||||
check.code === ADAPTER_AUTH_MISSING_CHECK_CODE || /_hello_probe_auth_required$/.test(check.code)
|
||||
|| check.code === "ai_connection_api_key_rejected",
|
||||
|| check.code === "ai_connection_api_key_rejected" || check.code === "COPILOT_AUTH_REQUIRED",
|
||||
)) {
|
||||
await aiConnectionService(db).markAuthenticationFailed({
|
||||
companyId: context.companyId, agentId, runStartedAt: startedAt,
|
||||
@@ -3467,6 +3485,18 @@ export function agentRoutes(
|
||||
}
|
||||
|
||||
async function probeManagedEnvironment(adapterType: string, context: Parameters<ReturnType<typeof requireServerAdapter>["testEnvironment"]>[0], binding: AiConnectionBinding) {
|
||||
if (binding.provider === "github") {
|
||||
try {
|
||||
const token = parseObject(context.config.env).COPILOT_GITHUB_TOKEN;
|
||||
if (typeof token !== "string" || !token) throw unprocessable("Select a saved Copilot token.", { code: "COPILOT_AUTH_REQUIRED" });
|
||||
const model = asNonEmptyString(context.config.model);
|
||||
if (!model || ["auto", "default"].includes(model.toLowerCase())) throw unprocessable("Select an available Copilot model.", { code: "COPILOT_MODEL_UNAVAILABLE" });
|
||||
await probeCopilotExecutionTarget(token, context.executionTarget, model);
|
||||
return { adapterType, status: "pass" as const, testedAt: new Date().toISOString(), checks: [{ code: "copilot_metadata_verified", level: "info" as const, message: "The verified Copilot runtime authenticated this account and accepted the selected model. No model prompt was sent." }] };
|
||||
} catch (error) {
|
||||
return { adapterType, status: "fail" as const, testedAt: new Date().toISOString(), checks: [{ code: error instanceof HttpError ? String(asRecord(error.details)?.code ?? "COPILOT_REQUEST_FAILED") : "COPILOT_REQUEST_FAILED", level: "error" as const, message: error instanceof HttpError ? error.message : "Copilot metadata verification failed." }] };
|
||||
}
|
||||
}
|
||||
await assertManagedAiProjectAuth(context.config, binding.provider, context.executionTarget);
|
||||
const result = await requireServerAdapter(adapterType).testEnvironment(context);
|
||||
if (result.status === "fail") return result;
|
||||
|
||||
@@ -31,6 +31,8 @@ import { logActivity } from "../services/activity-log.js";
|
||||
import { aiConnectionRouterService } from "../services/ai-connection-router.js";
|
||||
import { aiConnectionService } from "../services/ai-connections.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { probeCopilotConnection } from "../services/copilot-connection-probe.js";
|
||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
||||
|
||||
/** Agent API calls inherit authenticated run identity, never the agent's own ID. */
|
||||
export function responsibleUserForAiRequest(req: Request): string | null {
|
||||
@@ -139,6 +141,7 @@ export async function validateAiApiKey(
|
||||
key: string,
|
||||
request: typeof fetch = fetch,
|
||||
) {
|
||||
if (provider === "github") throw unprocessable("Copilot requires verification in the selected execution environment.", { code: "copilot_environment_required" });
|
||||
const endpoints = {
|
||||
anthropic: "https://api.anthropic.com/v1/models?limit=1",
|
||||
openai: "https://api.openai.com/v1/models",
|
||||
@@ -169,7 +172,7 @@ export async function validateAiApiKey(
|
||||
);
|
||||
}
|
||||
|
||||
export function aiConnectionRoutes(db: Db, options: Parameters<typeof supportsLocalAiLogin>[0] = {}) {
|
||||
export function aiConnectionRoutes(db: Db, options: Parameters<typeof supportsLocalAiLogin>[0] & { pluginWorkerManager?: PluginWorkerManager } = {}) {
|
||||
function assertLocalLoginAvailable() {
|
||||
if (!supportsLocalAiLogin(options)) throw unprocessable("Server-host subscription sign-in is unavailable on this hosted instance. Choose a supported sign-in environment or use an API key.");
|
||||
}
|
||||
@@ -336,9 +339,10 @@ export function aiConnectionRoutes(db: Db, options: Parameters<typeof supportsLo
|
||||
"Use the existing provider sign-in flow to connect a subscription",
|
||||
);
|
||||
const attemptStartedAt = new Date();
|
||||
// Custom destinations are exercised in the selected execution environment,
|
||||
// never fetched by the control plane (including localhost/private URLs).
|
||||
if (!input.routing || input.routing.kind === "openrouter") await validateAiApiKey(input.provider, input.apiKey!);
|
||||
if (input.provider === "github") {
|
||||
if (!input.apiKey?.startsWith("github_pat_")) throw unprocessable("Use a personal fine-grained token with Copilot Requests permission.", { code: "COPILOT_AUTH_REQUIRED" });
|
||||
await probeCopilotConnection(db, companyId, input.apiKey, input.environmentId, undefined, { pluginWorkerManager: options.pluginWorkerManager });
|
||||
} else if (!input.routing || input.routing.kind === "openrouter") await validateAiApiKey(input.provider, input.apiKey!);
|
||||
const result = await service.save(
|
||||
companyId,
|
||||
userId,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
// Only keys read by the child's provider express a child auth override.
|
||||
// A config copied from another provider can retain unrelated keys.
|
||||
const PROVIDER_AUTH_ENV_KEYS: Record<AiProvider, readonly string[]> = {
|
||||
github: ["COPILOT_GITHUB_TOKEN"],
|
||||
google: ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GEMINI_BASE_URL"],
|
||||
anthropic: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_CONFIG_DIR", "ANTHROPIC_BASE_URL", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY"],
|
||||
openai: ["OPENAI_API_KEY", "CODEX_API_KEY", "CODEX_HOME", "OPENAI_BASE_URL"],
|
||||
|
||||
@@ -46,7 +46,7 @@ export function isAiAuthenticationRepairable(run: AuthenticationFailure): boolea
|
||||
/** Provider authentication signals only. Tool authorization and quotas need different repairs. */
|
||||
export function isAiAuthenticationFailure(code: string | null | undefined): boolean {
|
||||
return Boolean(code && (
|
||||
/^(acpx|claude|codex|grok|opencode|gemini|kimi|pi|cursor)_auth_required$/.test(code)
|
||||
/^(acpx|claude|codex|grok|opencode|gemini|kimi|pi|cursor|copilot)_auth_required$/i.test(code)
|
||||
|| ["adapter_auth_missing", "authentication_required", "auth_required", "refresh_token_reused", "refresh_token_expired", "refresh_token_invalidated"].includes(code)
|
||||
));
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ export function isAiConnectionBusy(error: unknown): error is HttpError {
|
||||
|
||||
// Blank values intentionally override inherited credentials in CLI child environments.
|
||||
export const AI_AUTH_ENV_KEYS = [
|
||||
"COPILOT_GITHUB_TOKEN",
|
||||
"GH_TOKEN",
|
||||
"GITHUB_TOKEN",
|
||||
"PAPERCLIP_AI_PROVIDER_KEY", "PAPERCLIP_AI_PROVIDER_URL", "PAPERCLIP_CODEX_PROVIDERS",
|
||||
"GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GEMINI_BASE_URL", "GOOGLE_GENAI_USE_VERTEXAI",
|
||||
"HERMES_HOME", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_BEARER_TOKEN_BEDROCK", "AWS_PROFILE", "AWS_DEFAULT_PROFILE", "AWS_SHARED_CREDENTIALS_FILE", "AWS_CONFIG_FILE", "AWS_WEB_IDENTITY_TOKEN_FILE", "AWS_ROLE_ARN", "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "AWS_CONTAINER_CREDENTIALS_FULL_URI",
|
||||
|
||||
@@ -332,7 +332,7 @@ export async function probeAiConnectionUsage(
|
||||
options: { request?: typeof fetch } = {},
|
||||
): Promise<Omit<AiConnectionUsage, "connectionId" | "grantId">> {
|
||||
const base = { ...metadata, checkedAt: new Date().toISOString(), source: null, planType: null, limits: [], overage: null };
|
||||
if (metadata.provider === "google" || !supportsAiConnectionUsage(metadata.provider, metadata.method)) {
|
||||
if (metadata.provider === "google" || metadata.provider === "github" || !supportsAiConnectionUsage(metadata.provider, metadata.method)) {
|
||||
return { ...base, status: "unsupported", errorCode: "unsupported", message: messages.unsupported };
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { probeCopilotMetadata, validateCopilotMetadata } from "../vendor/paperclip-runner/live/index.js";
|
||||
import { runAdapterExecutionTargetShellCommand, type AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
import { environmentService } from "./environments.js";
|
||||
import { environmentRuntimeService } from "./environment-runtime.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
import { resolveEnvironmentExecutionTarget } from "./environment-execution-target.js";
|
||||
import { assertEnvironmentSelectionForCompany } from "../routes/environment-selection.js";
|
||||
import { buildLoginLeaseAcquireArgs } from "./adapter-login-lease.js";
|
||||
import { forbidden, unprocessable } from "../errors.js";
|
||||
|
||||
export async function probeCopilotConnection(db: Db, companyId: string, token: string, suppliedEnvironmentId?: string | null, model?: string, runtimeOptions: Parameters<typeof environmentRuntimeService>[1] = {}) {
|
||||
const environments = environmentService(db), settings = instanceSettingsService(db);
|
||||
const experimental = await settings.getExperimental();
|
||||
let environmentId = suppliedEnvironmentId ?? (await settings.get()).defaultEnvironmentId;
|
||||
if ((await settings.getGeneral()).executionMode === "kubernetes") {
|
||||
const kubernetes = await environments.findKubernetesEnvironment(companyId);
|
||||
if (!kubernetes) throw unprocessable("The Kubernetes execution environment is unavailable.", { code: "copilot_environment_unavailable" });
|
||||
environmentId = kubernetes.id;
|
||||
} else if (experimental.enableManagedSandboxOnly) {
|
||||
const managed = await environments.findManagedSandboxEnvironment(companyId);
|
||||
if (!managed) throw unprocessable("The managed environment is unavailable.", { code: "copilot_environment_unavailable" });
|
||||
environmentId = managed.id;
|
||||
}
|
||||
if (!environmentId) return requireVerified(await probeCopilotMetadata(token, model));
|
||||
await assertEnvironmentSelectionForCompany(environments, companyId, environmentId, { allowedDrivers: ["local", "ssh", "sandbox", "plugin"] });
|
||||
const boundCompanyIds = await environments.listBoundCompanyIds(environmentId);
|
||||
if (boundCompanyIds.length > 0 && !boundCompanyIds.includes(companyId)) throw forbidden("The selected environment belongs to another company.", { code: "environment_company_mismatch" });
|
||||
const environment = await environments.getById(environmentId);
|
||||
if (!environment || environment.status !== "active") throw unprocessable("Choose an active Copilot execution environment.", { code: "copilot_environment_unavailable" });
|
||||
if (environment.driver === "local") return requireVerified(await probeCopilotMetadata(token, model));
|
||||
const runtime = environmentRuntimeService(db, runtimeOptions);
|
||||
let lease: Awaited<ReturnType<typeof runtime.acquireRunLease>> | undefined;
|
||||
let succeeded = false;
|
||||
try {
|
||||
if (environment.driver !== "ssh") {
|
||||
lease = await runtime.acquireRunLease(buildLoginLeaseAcquireArgs({ metadata: { companyId, environment, adapterType: "paperclip_runner" }, assertCompanyBinding: true, requestedExpiresAt: new Date(Date.now() + 90_000) }));
|
||||
await runtime.realizeWorkspace({ environment, lease: lease.lease, workspace: {} });
|
||||
}
|
||||
const target = await resolveEnvironmentExecutionTarget({ db, companyId, adapterType: "paperclip_runner", environment,
|
||||
leaseId: lease?.lease.id, lease: lease?.lease, leaseMetadata: lease?.lease.metadata ?? null, environmentRuntime: runtime });
|
||||
if (!target || target.kind !== "remote") throw unprocessable("The selected environment cannot run the Copilot metadata probe.", { code: "copilot_environment_unavailable" });
|
||||
const verified = await probeCopilotExecutionTarget(token, target, model);
|
||||
succeeded = true;
|
||||
return verified;
|
||||
} finally {
|
||||
if (lease) {
|
||||
const driver = runtime.getDriver(environment.driver);
|
||||
if (!driver) throw new Error("Copilot verification lease cleanup driver is unavailable");
|
||||
await driver.releaseRunLease({ environment, lease: lease.lease, status: succeeded ? "released" : "failed" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function requireVerified(value: unknown) {
|
||||
const result = validateCopilotMetadata(value);
|
||||
if (result.status !== "verified") throw unprocessable(result.message, { code: result.code });
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function probeCopilotExecutionTarget(token: string, target: AdapterExecutionTarget | null | undefined, model?: string) {
|
||||
if (!target || target.kind === "local") return requireVerified(await probeCopilotMetadata(token, model));
|
||||
// Same standard installed locations used by native runner admission. The
|
||||
// runtime verifier binds the inner executable and distribution before spawn.
|
||||
const command = 'for pack in /opt/paperclip-runner/provider-pack "$HOME/.local/share/paperclip-runner/provider-pack"; do if [ -f "$pack/provider-pack.json" ] && [ -f "$pack/dist/cli/copilot-metadata-probe.js" ]; then export PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT="$pack" PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST="$pack/package.json"; exec "$pack/node_modules/node/bin/node" "$pack/dist/cli/copilot-metadata-probe.js"; fi; done; exit 127';
|
||||
// The exact model is an environment value and never enters shell source.
|
||||
const response = await runAdapterExecutionTargetShellCommand(`copilot-metadata-${randomUUID()}`, target, command, {
|
||||
cwd: target.remoteCwd, env: { COPILOT_GITHUB_TOKEN: token, ...(model ? { PAPERCLIP_COPILOT_PROBE_MODEL: model } : {}) }, timeoutSec: 35, graceSec: 3,
|
||||
});
|
||||
if (response.timedOut || response.stdout.length > 64 * 1024) throw unprocessable("Copilot metadata verification timed out.", { code: "COPILOT_REQUEST_FAILED" });
|
||||
let result: unknown;
|
||||
try { result = JSON.parse(response.stdout.trim()); } catch { throw unprocessable("Install the verified Copilot runtime in this environment.", { code: "COPILOT_INSTALLATION_INVALID" }); }
|
||||
const verified = requireVerified(result);
|
||||
if (response.exitCode !== 0 || response.signal) throw unprocessable("Copilot metadata verification did not exit cleanly.", { code: "COPILOT_REQUEST_FAILED" });
|
||||
if (model && !verified.models.some(value => value.id === model)) throw unprocessable("The selected Copilot model is unavailable for this account.", { code: "COPILOT_MODEL_UNAVAILABLE" });
|
||||
return verified;
|
||||
}
|
||||
@@ -30,6 +30,23 @@ describe("Paperclip Runner native provider configuration", () => {
|
||||
expect(resolvePaperclipRunnerNativeProviderInput({ backend: "codex_app_server",
|
||||
adapterConfig: { provider: "codex", model }, codexCliVersion })).toMatchObject({ model });
|
||||
});
|
||||
it.each([undefined, "0.156.0", "0.159.0"])(
|
||||
"keeps the exact Copilot model independent of Codex CLI version %s",
|
||||
(codexCliVersion) => {
|
||||
const adapterConfig = {
|
||||
provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna",
|
||||
acpxPermissionMode: "approve-paperclip",
|
||||
};
|
||||
expect(resolvePaperclipRunnerNativeProviderInput({
|
||||
backend: "acpx_runtime", adapterConfig, codexCliVersion,
|
||||
})).toEqual({
|
||||
provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna",
|
||||
acpxPermissionMode: "approve-paperclip",
|
||||
});
|
||||
expect(adapterConfig.model).toBe("gpt-5.6-luna");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([undefined, "approve-all", "approve-paperclip", "approve-reads", "deny-all"])(
|
||||
"passes Grok's full-auto default or explicit %s policy to the native runner",
|
||||
(acpxPermissionMode) => {
|
||||
|
||||
@@ -55,6 +55,7 @@ import { admitExplicitNativeContinuation, admitExplicitContinuationRetry, undeli
|
||||
import { canRetryStoppedRun, isCancelledNativeStartup } from "./cancelled-native-startup.js";
|
||||
import { connectionIntentService } from "./connection-intents.js";
|
||||
import { managedAiSessionFingerprintConfig, prepareManagedAiRuntime, assertManagedAiProjectAuth, stripAiAuthBindings, isAiConnectionBusy, AI_AUTH_ENV_KEYS } from "./ai-connection-runtime.js";
|
||||
import { supportsManagedNativeWarmSession, supportsNativeWarmFiles } from "./native-runtime/native-warm-capability.js";
|
||||
import { aiConnectionBindingSchema, aiRuntimeConnectionBindingSchema, type AiConnectionRouterSelection } from "@paperclipai/shared";
|
||||
import { aiConnectionRouterService, AiConnectionPoolExhausted, applyAiConnectionRouterTaskSettings } from "./ai-connection-router.js";
|
||||
import { aiConnectionSessionCompatibilityInputs, managedAiSessionIdentityCompatible } from "./ai-connection-session.js";
|
||||
@@ -24036,8 +24037,7 @@ export function heartbeatService(
|
||||
const savedFileInput = parseObject(parseObject(run.runnerProfileJson).nativeExecutionInput);
|
||||
const priorFileInput = Object.keys(savedFileInput).length ? savedFileInput : parseObject(parseObject(priorFileRun?.profile).nativeExecutionInput);
|
||||
const priorWorkingCopy = parseObject(parseObject(parseObject(priorFileInput.runtimeContext).instructions).workingCopy);
|
||||
const warmFiles = nativeRuntimeResolution.kind === "native" && (nativeRuntimeResolution.profile.backend === "codex_app_server" ||
|
||||
(nativeRuntimeResolution.profile.backend === "acpx_runtime" && parseObject(agent.adapterConfig).acpxAgent === "cursor")) &&
|
||||
const warmFiles = nativeRuntimeResolution.kind === "native" && (supportsNativeWarmFiles({ ...nativeRuntimeResolution.profile, acpxAgent: parseObject(agent.adapterConfig).acpxAgent }) || (nativeRuntimeResolution.profile.backend === "acpx_runtime" && parseObject(agent.adapterConfig).acpxAgent === "cursor")) &&
|
||||
(executionTarget?.kind === "remote" && executionTarget.transport === "sandbox"
|
||||
? executionTarget.runnerLifecyclePolicy?.mode === "warm"
|
||||
: parseObject(agent.adapterConfig).lifecycleMode === "warm");
|
||||
@@ -24342,11 +24342,11 @@ export function heartbeatService(
|
||||
executionTarget.transport === "sandbox"
|
||||
? (executionTarget.runnerLifecyclePolicy ?? null)
|
||||
: null;
|
||||
// Native Codex owns a durable, session-scoped home. It flushes refreshed
|
||||
// auth into each invocation's private home before that home is removed.
|
||||
// Other managed harnesses still require per-turn credential cleanup.
|
||||
// Codex refreshes its durable auth home; Copilot owns a fenced home
|
||||
// and an explicitly bound token. Both retire on credential generation
|
||||
// changes. Other managed harnesses require per-turn cleanup.
|
||||
const supportsManagedWarmSession = agent.adapterType === "paperclip_runner" &&
|
||||
nativeRuntimeResolution.profile.backend === "codex_app_server";
|
||||
supportsManagedNativeWarmSession({ ...nativeRuntimeResolution.profile, acpxAgent: parseObject(agent.adapterConfig).acpxAgent }, managedAiRuntime?.attribution);
|
||||
const effectiveLifecyclePolicy = persistedNativeExecutionInput?.session.lifecyclePolicy ??
|
||||
(nativeRuntimeResolution.profile.backend === "openai_dot_mcp" || managedAiRuntime && !supportsManagedWarmSession
|
||||
? { mode: "per_turn" as const, idleTimeoutMs: null }
|
||||
|
||||
@@ -4,11 +4,11 @@ import { ACPX_QUALIFICATION_ENV, resolveAcpxQualification } from "./acpx-qualifi
|
||||
import type { NativeExecutionInput } from "../../vendor/paperclip-runner/index.js";
|
||||
import { resolvePaperclipRunnerProviderProfile, resolvePaperclipRunnerNativeProviderInput } from "./provider-profile.js";
|
||||
|
||||
const provider = { kind: "acpx", agent: "copilot", model: "exact-model", permissionMode: "approve-all" } as NativeExecutionInput["provider"];
|
||||
const provider = { kind: "acpx", agent: "copilot", model: "exact-model", permissionMode: "approve-all" } as Extract<NativeExecutionInput["provider"], { kind: "acpx" }>;
|
||||
const authorize = (value: unknown) => ({ [ACPX_QUALIFICATION_ENV]: JSON.stringify(value) });
|
||||
describe("host ACPX qualification admission", () => {
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
it.each(["copilot", "pi"])("admits %s through agent validation and native input only for the exact host pair", (agent) => {
|
||||
it.each(["pi"])("admits %s through agent validation and native input only for the exact host pair", (agent) => {
|
||||
const config = { provider: "acpx", acpxAgent: agent, model: "exact-model" };
|
||||
vi.stubEnv(ACPX_QUALIFICATION_ENV, undefined);
|
||||
expect(() => resolvePaperclipRunnerProviderProfile(config)).toThrow(expect.objectContaining({ code: "paperclip_runner_acpx_agent_unavailable" }));
|
||||
@@ -19,10 +19,11 @@ describe("host ACPX qualification admission", () => {
|
||||
expect(() => resolvePaperclipRunnerProviderProfile({ ...config, model: "other-model" })).toThrow(expect.objectContaining({ code: "paperclip_runner_acpx_qualification_invalid" }));
|
||||
expect(() => resolvePaperclipRunnerProviderProfile({ ...config, model: "" })).toThrow(expect.objectContaining({ code: "paperclip_runner_acpx_model_required" }));
|
||||
});
|
||||
it("keeps normal candidate execution closed and admits only the exact operator pair", () => {
|
||||
it("keeps Pi execution closed and admits only the exact operator pair", () => {
|
||||
const provider = { kind: "acpx", agent: "pi", model: "exact-model" } as Extract<NativeExecutionInput["provider"], { kind: "acpx" }>;
|
||||
expect(resolveAcpxQualification(provider, {})).toBeUndefined();
|
||||
expect(resolveAcpxQualification(provider, authorize([{ agent: "copilot", model: "exact-model" }]))).toBe("copilot");
|
||||
for (const entries of [[{ agent: "pi", model: "exact-model" }], [{ agent: "copilot", model: "other-model" }]]) {
|
||||
expect(resolveAcpxQualification(provider, authorize([{ agent: "pi", model: "exact-model" }]))).toBe("pi");
|
||||
for (const entries of [[{ agent: "copilot", model: "exact-model" }], [{ agent: "pi", model: "other-model" }]]) {
|
||||
expect(() => resolveAcpxQualification(provider, authorize(entries))).toThrow("exact model");
|
||||
}
|
||||
});
|
||||
@@ -30,7 +31,7 @@ describe("host ACPX qualification admission", () => {
|
||||
[{ agent: "cursor", model: "exact-model", allowAll: true }], [{ agent: "claude", model: "exact-model" }],
|
||||
[{ agent: "cursor", model: "exact-model" }, { agent: "cursor", model: "exact-model" }],
|
||||
])("rejects malformed or broad authorization %j", entries => {
|
||||
expect(() => resolveAcpxQualification(provider, authorize(entries))).toThrow("Invalid ACPX");
|
||||
expect(() => resolveAcpxQualification({ ...provider, agent: "pi" }, authorize(entries))).toThrow("Invalid ACPX");
|
||||
});
|
||||
it("admits Cursor through ordinary configuration and ignores unrelated qualification authority", () => {
|
||||
const config = { provider: "acpx", acpxAgent: "cursor", model: "exact-cursor-model" };
|
||||
@@ -40,6 +41,17 @@ describe("host ACPX qualification admission", () => {
|
||||
expect(resolveAcpxQualification({ ...provider, agent: "cursor" } as typeof provider, authorize([]))).toBeUndefined();
|
||||
expect(() => resolvePaperclipRunnerProviderProfile({ ...config, model: "" })).toThrow(expect.objectContaining({ code: "paperclip_runner_acpx_model_required" }));
|
||||
});
|
||||
it.each([undefined, "malformed", JSON.stringify([{agent:"pi",model:"pi-model"}])])("admits ordinary Copilot without host overrides (%s)", host => {
|
||||
vi.stubEnv(ACPX_QUALIFICATION_ENV,host);
|
||||
const config={provider:"acpx",acpxAgent:"copilot",model:"gpt-5.6-luna"};
|
||||
expect(resolvePaperclipRunnerProviderProfile(config)).toMatchObject({acpxAgent:"copilot",model:config.model});
|
||||
expect(resolvePaperclipRunnerNativeProviderInput({backend:"acpx_runtime",adapterConfig:config})).toMatchObject({acpxAgent:"copilot",model:config.model});
|
||||
expect(resolveAcpxQualification({...provider,model:config.model},{})).toBe("copilot");
|
||||
});
|
||||
it.each(["", " ", "auto", "default"])("refuses implicit Copilot model %s",model=>{
|
||||
expect(()=>resolvePaperclipRunnerProviderProfile({provider:"acpx",acpxAgent:"copilot",model})).toThrow(expect.objectContaining({code:"paperclip_runner_acpx_model_required"}));
|
||||
expect(()=>resolveAcpxQualification({...provider,model},{})).toThrow("explicit available model");
|
||||
});
|
||||
it("does not alter existing qualified providers", () => {
|
||||
expect(resolveAcpxQualification({ ...provider, agent: "codex" } as typeof provider, authorize([]))).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PAPERCLIP_RUNNER_ACPX_PROFILES } from "@paperclipai/adapter-utils";
|
||||
import type { NativeExecutionInput } from "../../vendor/paperclip-runner/index.js";
|
||||
|
||||
/** Operator-only admission for an exact candidate/model during qualification. */
|
||||
@@ -9,6 +10,15 @@ export function resolveAcpxQualification(
|
||||
hostEnvironment: NodeJS.ProcessEnv,
|
||||
): AcpxQualificationCandidate | undefined {
|
||||
if (provider.kind !== "acpx" || !["copilot", "pi"].includes(provider.agent)) return undefined;
|
||||
// The server release declaration admits qualified Copilot into the frozen
|
||||
// SDK's explicit native transport slot. No operator environment is required.
|
||||
// Pending Pi continues through the exact host-only qualification check below.
|
||||
if (provider.agent === "copilot" && PAPERCLIP_RUNNER_ACPX_PROFILES.some(profile => profile.value === "copilot" && profile.qualified)) {
|
||||
if (!provider.model || provider.model !== provider.model.trim() || ["auto", "default"].includes(provider.model.toLowerCase())) {
|
||||
throw new Error("GitHub Copilot requires an explicit available model ID");
|
||||
}
|
||||
return "copilot";
|
||||
}
|
||||
const encoded = hostEnvironment[ACPX_QUALIFICATION_ENV];
|
||||
if (!encoded) return undefined;
|
||||
let entries: unknown;
|
||||
|
||||
@@ -245,7 +245,13 @@ async function openedFilePath(fd: number): Promise<string> {
|
||||
execFile(
|
||||
"/usr/sbin/lsof",
|
||||
["-a", "-p", String(process.pid), "-d", String(fd), "-F0n"],
|
||||
{ encoding: "buffer", maxBuffer: 16_384, timeout: 1_000 },
|
||||
{
|
||||
encoding: "buffer",
|
||||
maxBuffer: 16_384,
|
||||
timeout: 1_000,
|
||||
// lsof escapes non-ASCII names under the C locale, breaking descriptor verification.
|
||||
env: { ...process.env, LC_ALL: "en_US.UTF-8" },
|
||||
},
|
||||
(error, stdout) => {
|
||||
if (error) reject(error);
|
||||
else resolve(Buffer.from(stdout));
|
||||
|
||||
@@ -8713,6 +8713,16 @@ describe("native process ownership", () => {
|
||||
},
|
||||
"acpx_runtime",
|
||||
],
|
||||
[
|
||||
"GitHub Copilot ACPX",
|
||||
{
|
||||
kind: "acpx",
|
||||
agent: "copilot",
|
||||
model: "gpt-5.6-luna",
|
||||
permissionMode: "approve-all",
|
||||
},
|
||||
"acpx_runtime",
|
||||
],
|
||||
[
|
||||
"Codex ACPX",
|
||||
{
|
||||
@@ -8761,7 +8771,7 @@ describe("native process ownership", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["pi", "copilot"])("rejects ACPX candidate %s without host authorization before constructing a backend", async (agent) => {
|
||||
it.each(["pi"])("rejects ACPX candidate %s without host authorization before constructing a backend", async (agent) => {
|
||||
const piExecution = {
|
||||
...execution,
|
||||
binding: { ...execution.binding, runId: "run-acpx-pi-rejected" },
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
import { nativeCompletionFeedback } from "./native-completion-feedback.js";
|
||||
import { hasAcknowledgedNativeReassignmentStopIntent, hasAcknowledgedNativeStopIntent } from "../acknowledged-native-stop.js";
|
||||
import { stoppedCodexTurnIsTextOnly } from "./stopped-codex-turn.js";
|
||||
import { prepareVerifiedRemoteProviderPack } from "./remote-provider-pack.js";
|
||||
import { prepareVerifiedRemoteProviderPack, shouldUseBundledAcpxImageAssets } from "./remote-provider-pack.js";
|
||||
import { readNativeLocalProcessStop, PROCESS_START_REQUESTED } from "../native-local-process-stop.js";
|
||||
import { remoteLeaseCleanupScope } from "../remote-execution-termination.js";
|
||||
import { resolveConnectorAssignments, isConnectorSkill } from "../connector-runtime.js";
|
||||
@@ -9792,7 +9792,7 @@ type RemoteProviderPackManifest = {
|
||||
distDigest: string;
|
||||
bridgeDigest: string;
|
||||
acpxProfileDigests: typeof REMOTE_PROVIDER_PACK_PROFILE_DIGESTS;
|
||||
providers?: Partial<Record<"cursor", {
|
||||
providers?: Partial<Record<"cursor" | "copilot", {
|
||||
version: string; profileDigest: string; closureDigest: string; qualification: "qualified" | "pending";
|
||||
path: string; sha256: string;
|
||||
}>>;
|
||||
@@ -9981,9 +9981,9 @@ function readRemoteProviderPackIdentity(packRoot: string, verifyControllerFiles:
|
||||
}
|
||||
for (const [provider, candidate] of Object.entries(candidates)) {
|
||||
const expectedPath = `provider-assets/${provider}/${payload.target.platform}-${payload.target.architecture}`;
|
||||
if (!(inventory === "providers" ? ["cursor"] : ["cursor", "copilot", "pi"]).includes(provider) || !candidate
|
||||
if (!(inventory === "providers" ? ["cursor", "copilot"] : ["cursor", "copilot", "pi"]).includes(provider) || !candidate
|
||||
|| Object.keys(candidate).some(key => !["version", "profileDigest", "closureDigest", "qualification", "path", "sha256"].includes(key))
|
||||
|| candidate.qualification !== (provider === "cursor" ? "qualified" : "pending") || candidate.path !== expectedPath
|
||||
|| candidate.qualification !== (provider === "cursor" || (inventory === "providers" && provider === "copilot") ? "qualified" : "pending") || candidate.path !== expectedPath
|
||||
|| typeof candidate.version !== "string" || !candidate.version || candidate.version.length > 120
|
||||
|| !/^sha256:[a-f0-9]{64}$/.test(candidate.profileDigest)
|
||||
|| !/^sha256:[a-f0-9]{64}$/.test(candidate.closureDigest)
|
||||
@@ -10001,6 +10001,21 @@ function readRemoteProviderPackIdentity(packRoot: string, verifyControllerFiles:
|
||||
throw new Error("runner_remote_provider_artifact_incompatible: Cursor profile or closure does not match this release");
|
||||
}
|
||||
}
|
||||
if (provider === "copilot") {
|
||||
const closurePins = {
|
||||
"darwin-arm64": "362f2663e967fb9e34a814bac4619cbf89e6b23069c4051ab1f2f686852d0a32",
|
||||
"darwin-x64": "c08b7c3dd4e7bcf9a3e16ec6eba29cfa10308e865d196c5f120a3a15f6b3116a",
|
||||
"linux-x64": "a3d8f4367cfa1694d79e3f8b7930b6fbfe42a229c272b375b11951d631db8d07"
|
||||
} as const;
|
||||
const target = `${payload.target.platform}-${payload.target.architecture}`;
|
||||
const closure = Object.hasOwn(closurePins, target) ? closurePins[target as keyof typeof closurePins] : undefined;
|
||||
if (!closure || (payload.providers?.copilot && payload.candidateProviders?.copilot)
|
||||
|| candidate.version !== QUALIFIED_ACPX_PROFILES.copilot.agentServerVersion
|
||||
|| candidate.profileDigest !== QUALIFIED_ACPX_PROFILES.copilot.commandDigest
|
||||
|| candidate.closureDigest !== `sha256:${closure}`) {
|
||||
throw new Error("runner_remote_provider_artifact_incompatible: Copilot profile or closure does not match this release");
|
||||
}
|
||||
}
|
||||
if (verifyControllerFiles && sha256DirectoryTree(resolve(packRoot, candidatePath)) !== candidate.sha256) {
|
||||
throw new Error("runner_remote_provider_artifact_incompatible: candidate asset tree digest mismatch");
|
||||
}
|
||||
@@ -11253,9 +11268,11 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
const configuredProviderPackRoot =
|
||||
input.runnerRemoteProviderPackPath?.trim() || null;
|
||||
let expectedProviderPackManifest: RemoteProviderPackManifest | null = null;
|
||||
const useBundledCursorImageAssets = requiresRemoteProviderPack && !configuredProviderPackRoot &&
|
||||
input.execution.provider.kind === "acpx" && input.execution.provider.agent === "cursor";
|
||||
if (useBundledCursorImageAssets) {
|
||||
const useBundledAcpxImageAssets = shouldUseBundledAcpxImageAssets({
|
||||
requiresRemoteProviderPack, configuredProviderPackRoot,
|
||||
provider: input.execution.provider,
|
||||
});
|
||||
if (useBundledAcpxImageAssets) {
|
||||
expectedProviderPackManifest = readBundledRemoteProviderPackManifest();
|
||||
} else if (requiresRemoteProviderPack) {
|
||||
if (
|
||||
@@ -11284,7 +11301,7 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
// When an explicit remote artifact is configured, prepareRemoteRunner stages
|
||||
// these exact bytes at remoteBinary before launch.
|
||||
const controllerRunnerBinary = remoteTarget
|
||||
? input.runnerRemoteBinaryPath?.trim() || (useBundledCursorImageAssets ? bundledRemoteRunnerBinary() : resolvePaperclipRunnerBinary())
|
||||
? input.runnerRemoteBinaryPath?.trim() || (useBundledAcpxImageAssets ? bundledRemoteRunnerBinary() : resolvePaperclipRunnerBinary())
|
||||
: resolvePaperclipRunnerBinary();
|
||||
const explicitRemoteCodex = input.runnerRemoteCodexPath?.trim() || null;
|
||||
const remoteCodexNpmSpec = input.runnerRemoteCodexNpmSpec?.trim() || null;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { supportsManagedNativeWarmSession, supportsNativeWarmFiles } from "./native-warm-capability.js";
|
||||
import type { PaperclipRunnerProviderProfile } from "./provider-profile.js";
|
||||
|
||||
describe("native warm credential and file admission", () => {
|
||||
const copilot: PaperclipRunnerProviderProfile = {
|
||||
provider: "acpx", backend: "acpx_runtime", acpxAgent: "copilot", model: "gpt-5.6-luna",
|
||||
};
|
||||
it("admits the saved Copilot token with its session-owned home and file handoff", () => {
|
||||
expect(supportsNativeWarmFiles(copilot)).toBe(true);
|
||||
expect(supportsManagedNativeWarmSession(copilot, { provider: "github", method: "api_key" })).toBe(true);
|
||||
});
|
||||
it.each([undefined, { provider: "github", method: "subscription" }, { provider: "openai", method: "api_key" }])(
|
||||
"refuses a missing or mismatched Copilot credential: %j", (attribution) => {
|
||||
expect(supportsManagedNativeWarmSession(copilot, attribution)).toBe(false);
|
||||
},
|
||||
);
|
||||
it.each(["cursor", "pi", "claude", "grok"] as const)("preserves %s managed per-turn admission", (acpxAgent) => {
|
||||
const profile = { ...copilot, acpxAgent };
|
||||
expect(supportsNativeWarmFiles(profile)).toBe(false);
|
||||
expect(supportsManagedNativeWarmSession(profile, { provider: "github", method: "api_key" })).toBe(false);
|
||||
});
|
||||
it("preserves Codex warm support", () => {
|
||||
const profile: PaperclipRunnerProviderProfile = { provider: "codex", backend: "codex_app_server", model: "gpt-5.6-sol" };
|
||||
expect(supportsNativeWarmFiles(profile)).toBe(true);
|
||||
expect(supportsManagedNativeWarmSession(profile, undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { PaperclipRunnerProviderProfile } from "./provider-profile.js";
|
||||
|
||||
type WarmProviderSelection = { backend: PaperclipRunnerProviderProfile["backend"]; acpxAgent?: unknown };
|
||||
|
||||
/** Providers with a session-owned home and the current instruction-grant handoff. */
|
||||
export function supportsNativeWarmFiles(profile: WarmProviderSelection): boolean {
|
||||
return profile.backend === "codex_app_server" ||
|
||||
(profile.backend === "acpx_runtime" && profile.acpxAgent === "copilot");
|
||||
}
|
||||
|
||||
/** Copilot carries a token in its fenced session environment, not a temporary auth file.
|
||||
* Credential generation changes still retire the exact warm owner before attachment. */
|
||||
export function supportsManagedNativeWarmSession(
|
||||
profile: WarmProviderSelection,
|
||||
attribution: { provider: string; method: string } | undefined,
|
||||
): boolean {
|
||||
return profile.backend === "codex_app_server" ||
|
||||
(profile.backend === "acpx_runtime" && profile.acpxAgent === "copilot" &&
|
||||
attribution?.provider === "github" && attribution.method === "api_key");
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export const DEFAULT_ACPX_RUNNER_MODELS = {
|
||||
// These profiles require explicit configuration. Admission belongs to the runner.
|
||||
codex: null,
|
||||
cursor: null,
|
||||
copilot: null,
|
||||
} as const;
|
||||
|
||||
export type QualifiedPaperclipRunnerAcpxAgent =
|
||||
@@ -470,6 +471,9 @@ export function resolvePaperclipRunnerProviderProfile(
|
||||
}
|
||||
|
||||
const acpxAgent = config.acpxAgent ?? "claude";
|
||||
if (acpxAgent === "copilot" && (!model || ["auto", "default"].includes(model.toLowerCase()))) {
|
||||
throw new PaperclipRunnerProviderProfileError("paperclip_runner_acpx_model_required", "GitHub Copilot requires an explicit model ID; there is no default model.");
|
||||
}
|
||||
const pendingAcpxProfile = PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === acpxAgent && !profile.qualified);
|
||||
if (pendingAcpxProfile) {
|
||||
if (!model) throw new PaperclipRunnerProviderProfileError("paperclip_runner_acpx_model_required", `${pendingAcpxProfile.label} requires an explicit model ID; there is no default model.`);
|
||||
@@ -489,7 +493,7 @@ export function resolvePaperclipRunnerProviderProfile(
|
||||
if (acpxAgent === "cursor" && !model) {
|
||||
throw new PaperclipRunnerProviderProfileError("paperclip_runner_acpx_model_required", "Cursor requires an explicit model ID; there is no default model.");
|
||||
}
|
||||
if (acpxAgent !== "claude" && acpxAgent !== "codex" && acpxAgent !== "grok" && acpxAgent !== "cursor") {
|
||||
if (acpxAgent !== "claude" && acpxAgent !== "codex" && acpxAgent !== "grok" && acpxAgent !== "cursor" && acpxAgent !== "copilot") {
|
||||
throw new PaperclipRunnerProviderProfileError(
|
||||
"paperclip_runner_acpx_agent_unavailable",
|
||||
"Paperclip Runner ACPX requires a qualified agent profile.",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { prepareVerifiedRemoteProviderPack } from "./remote-provider-pack.js";
|
||||
import { prepareVerifiedRemoteProviderPack, shouldUseBundledAcpxImageAssets } from "./remote-provider-pack.js";
|
||||
|
||||
describe("remote provider pack reuse", () => {
|
||||
it("keeps a verified staged pack without consulting the image or uploading again", async () => {
|
||||
@@ -37,3 +37,19 @@ describe("remote provider pack reuse", () => {
|
||||
await expect(prepareVerifiedRemoteProviderPack(input)).resolves.toBe("uploaded");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("ordinary ACPX image artifacts", () => {
|
||||
const provider = (agent: string) => ({ kind: "acpx", agent, model: "selected-model" } as Parameters<typeof shouldUseBundledAcpxImageAssets>[0]["provider"]);
|
||||
it.each(["cursor", "copilot"])("selects bundled artifacts for remote %s without an operator override", agent => {
|
||||
expect(shouldUseBundledAcpxImageAssets({ requiresRemoteProviderPack: true, configuredProviderPackRoot: null, provider: provider(agent) })).toBe(true);
|
||||
expect(shouldUseBundledAcpxImageAssets({ requiresRemoteProviderPack: false, configuredProviderPackRoot: null, provider: provider(agent) })).toBe(false);
|
||||
expect(shouldUseBundledAcpxImageAssets({ requiresRemoteProviderPack: true, configuredProviderPackRoot: "/explicit/pack", provider: provider(agent) })).toBe(false);
|
||||
});
|
||||
it.each(["pi", "claude", "codex", "grok"])("does not change the artifact authority for %s", agent => {
|
||||
expect(shouldUseBundledAcpxImageAssets({ requiresRemoteProviderPack: true, configuredProviderPackRoot: null, provider: provider(agent) })).toBe(false);
|
||||
});
|
||||
it("does not admit a non-ACPX provider through bundled image selection", () => {
|
||||
expect(shouldUseBundledAcpxImageAssets({ requiresRemoteProviderPack: true, configuredProviderPackRoot: null, provider: { kind: "codex", model: null } })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,16 @@
|
||||
import type { NativeExecutionInput } from "../../vendor/paperclip-runner/index.js";
|
||||
|
||||
/** Qualified ACPX releases carry their Linux image manifest and daemon. */
|
||||
export function shouldUseBundledAcpxImageAssets(input: {
|
||||
requiresRemoteProviderPack: boolean;
|
||||
configuredProviderPackRoot: string | null;
|
||||
provider: NativeExecutionInput["provider"];
|
||||
}): boolean {
|
||||
return input.requiresRemoteProviderPack && !input.configuredProviderPackRoot &&
|
||||
input.provider.kind === "acpx" &&
|
||||
["cursor", "copilot"].includes(input.provider.agent);
|
||||
}
|
||||
|
||||
/** Reuse only a pack that passes the same full verification as a new upload. */
|
||||
export async function prepareVerifiedRemoteProviderPack(input: {
|
||||
verifyStaged: () => Promise<void>;
|
||||
|
||||
@@ -1038,7 +1038,8 @@ function connectionMethodFor(app: AppDefinition, methodKey?: string | null) {
|
||||
const availableMethods = new Set(getAvailableConnectionMethods(app));
|
||||
const toolMethods = app.methods.filter(
|
||||
(candidate) =>
|
||||
candidate.purpose !== "channel" && candidate.transport !== "chat_sdk"
|
||||
candidate.purpose !== "channel" && candidate.purpose !== "ai"
|
||||
&& candidate.transport !== "chat_sdk" && candidate.transport !== "runtime_auth"
|
||||
&& (availableMethods.has(candidate) || isPaperclipCloudConnectorStrategy(candidate.oauthStrategy)),
|
||||
);
|
||||
const method = normalizedMethodKey
|
||||
@@ -12421,7 +12422,8 @@ export function toolAccessService(
|
||||
galleryEntry &&
|
||||
getAvailableConnectionMethods(galleryEntry).filter(
|
||||
(candidate) =>
|
||||
candidate.purpose !== "channel" && candidate.transport !== "chat_sdk",
|
||||
candidate.purpose !== "channel" && candidate.purpose !== "ai"
|
||||
&& candidate.transport !== "chat_sdk" && candidate.transport !== "runtime_auth",
|
||||
).length > 1 &&
|
||||
!inferredMethodKey
|
||||
) {
|
||||
|
||||
+13
-6
@@ -1,6 +1,13 @@
|
||||
/** Development boundary; the server build replaces this with Runner dist/live. */
|
||||
export {
|
||||
probeAcpxClaudeInstallation,
|
||||
probeAcpxGrokInstallation,
|
||||
probeAcpxCursorInstallation,
|
||||
} from "@paperclipai/paperclip-runner/live";
|
||||
/** Source shim; server build replaces it with the runner's compiled live tree. */
|
||||
type RunnerLiveModule = typeof import("@paperclipai/paperclip-runner/live");
|
||||
const sourceUrl = new URL(
|
||||
"../../../../../packages/paperclip-runner/src/live/index.ts",
|
||||
import.meta.url,
|
||||
);
|
||||
const runner = (await import(sourceUrl.href)) as RunnerLiveModule;
|
||||
|
||||
export const probeCopilotMetadata = runner.probeCopilotMetadata;
|
||||
export const validateCopilotMetadata = runner.validateCopilotMetadata;
|
||||
export const probeAcpxClaudeInstallation = runner.probeAcpxClaudeInstallation;
|
||||
export const probeAcpxGrokInstallation = runner.probeAcpxGrokInstallation;
|
||||
export const probeAcpxCursorInstallation = runner.probeAcpxCursorInstallation;
|
||||
@@ -60,6 +60,7 @@ export interface AdapterDisplayInfo {
|
||||
}
|
||||
|
||||
const adapterDisplayMap: Record<string, AdapterDisplayInfo> = {
|
||||
copilot_runtime: { label: "GitHub Copilot", description: "Copilot through Paperclip Runner", icon: Code, hideFromVisualSelection: true },
|
||||
acpx_local: {
|
||||
label: "ACPX (retired)",
|
||||
description: "Retired standalone ACPX adapter",
|
||||
|
||||
@@ -96,7 +96,7 @@ describe("Paperclip Runner Codex configuration", () => {
|
||||
expect(html).not.toContain("Ask for untrusted operations");
|
||||
});
|
||||
|
||||
it("offers qualified Claude and keeps candidate ACP agents visibly disabled", async () => {
|
||||
it("offers qualified Copilot and keeps Pi visibly disabled", async () => {
|
||||
const html = await renderRunner({
|
||||
provider: "acpx",
|
||||
acpxAgent: "claude",
|
||||
@@ -106,10 +106,11 @@ describe("Paperclip Runner Codex configuration", () => {
|
||||
expect(html).toContain('ACP agents');
|
||||
expect(html).toContain("ACP agent");
|
||||
expect(html).toContain('aria-label="ACP agent"');
|
||||
expect(html.match(/role="option"[^>]*data-disabled=""/g)).toHaveLength(2);
|
||||
expect(html.match(/role="option"[^>]*data-disabled=""/g)).toHaveLength(1);
|
||||
expect(html).toContain('Cursor');
|
||||
expect(html).not.toContain('Cursor — qualification pending');
|
||||
expect(html).toContain('GitHub Copilot — qualification pending');
|
||||
expect(html).toContain('GitHub Copilot');
|
||||
expect(html).not.toContain('GitHub Copilot — qualification pending');
|
||||
expect(html).toContain('Pi — qualification pending');
|
||||
expect(html).not.toContain("Codex via ACPX");
|
||||
expect(html).not.toContain("ACPX Codex");
|
||||
|
||||
@@ -229,11 +229,14 @@ export const agentsApi = {
|
||||
adapterModels: (
|
||||
companyId: string,
|
||||
type: string,
|
||||
options?: { refresh?: boolean; environmentId?: string | null; provider?: string; poolId?: string },
|
||||
options?: { refresh?: boolean; environmentId?: string | null; provider?: string; acpxAgent?: string; aiConnection?: import("@paperclipai/shared").AiConnectionBinding; agentId?: string; poolId?: string },
|
||||
) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.refresh) params.set("refresh", "1");
|
||||
if (options?.provider) params.set("provider", options.provider);
|
||||
if (options?.acpxAgent) params.set("acpxAgent", options.acpxAgent);
|
||||
if (options?.aiConnection) params.set("aiConnection", JSON.stringify(options.aiConnection));
|
||||
if (options?.agentId) params.set("agentId", options.agentId);
|
||||
if (options?.poolId) params.set("poolId", options.poolId);
|
||||
if (options?.environmentId) params.set("environmentId", options.environmentId);
|
||||
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||
|
||||
@@ -902,10 +902,14 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
const modelProvider = adapterType === "opencode_local" && aiConnectionBindingSchema.safeParse(
|
||||
(overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection,
|
||||
).data?.provider === "openrouter" ? "openrouter" : runnerProvider;
|
||||
const runnerAcpxAgent = adapterType === "paperclip_runner" && runnerProvider === "acpx"
|
||||
? String(isCreate ? props.values.adapterSchemaValues?.acpxAgent ?? "claude" : eff("adapterConfig", "acpxAgent", config.acpxAgent ?? "claude")) : undefined;
|
||||
const modelAiBinding = aiConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data;
|
||||
const modelCacheIdentity = runnerAcpxAgent === "copilot" ? `${modelProvider}:${runnerAcpxAgent}:${JSON.stringify(modelAiBinding ?? null)}` : modelProvider;
|
||||
const connectionModels = useConnectionModels(selectedCompanyId, isCreate ? undefined : aiConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data, aiRoutingHarness(adapterType, runnerProvider, eff("adapterConfig", "acpxAgent", config.acpxAgent)));
|
||||
// Fetch adapter models for the effective provider, including unsaved changes.
|
||||
const modelQueryKey = selectedCompanyId
|
||||
? queryKeys.agents.adapterModels(selectedCompanyId, adapterType, currentDefaultEnvironmentId || null, modelProvider)
|
||||
? queryKeys.agents.adapterModels(selectedCompanyId, adapterType, currentDefaultEnvironmentId || null, modelCacheIdentity)
|
||||
: ["agents", "none", "adapter-models", adapterType];
|
||||
const {
|
||||
data: fetchedModels,
|
||||
@@ -916,6 +920,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType, {
|
||||
environmentId: currentDefaultEnvironmentId || null,
|
||||
provider: modelProvider,
|
||||
acpxAgent: runnerAcpxAgent,
|
||||
aiConnection: modelAiBinding,
|
||||
agentId: isCreate ? undefined : props.agent.id,
|
||||
}),
|
||||
enabled: Boolean(selectedCompanyId) && !connectionModels,
|
||||
});
|
||||
@@ -1271,7 +1278,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
setRefreshingModels(true);
|
||||
setRefreshModelsError(null);
|
||||
try {
|
||||
const refreshed = await agentsApi.adapterModels(selectedCompanyId, adapterType, { refresh: true, environmentId: currentDefaultEnvironmentId || null, provider: modelProvider });
|
||||
const refreshed = await agentsApi.adapterModels(selectedCompanyId, adapterType, { refresh: true, environmentId: currentDefaultEnvironmentId || null, provider: modelProvider, ...(runnerAcpxAgent === "copilot" ? { acpxAgent: runnerAcpxAgent, aiConnection: modelAiBinding, agentId: !isCreate ? props.agent.id : undefined } : {}) });
|
||||
queryClient.setQueryData(modelQueryKey, refreshed);
|
||||
} catch (error) {
|
||||
setRefreshModelsError(error instanceof Error ? error.message : "Failed to refresh adapter models.");
|
||||
@@ -2169,6 +2176,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
{props.compactTestFeedback && showInlineAdapterTestEnvironmentFeedback && showAdapterTestEnvironmentButton && (
|
||||
<RuntimeTestCard
|
||||
variant={isDotRunner ? "prerequisites" : "connection"}
|
||||
metadataOnly={adapterType === "paperclip_runner" && runnerAcpxAgent === "copilot"}
|
||||
state={testActionPending ? "running" : testActionError || testEnvironment.error ? "fail" : testResult?.status ?? "idle"}
|
||||
result={testResult ?? null}
|
||||
error={testActionError ?? (testEnvironment.error instanceof Error ? testEnvironment.error.message : null)}
|
||||
|
||||
@@ -287,4 +287,12 @@ describe("OnboardingWizard adapter selection", () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("restores a qualified Copilot draft without changing its explicit model", async () => {
|
||||
mockAdapterRegistry.list = [{ type: "paperclip_runner" }];
|
||||
window.localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify({ step: 0, adapterType: "paperclip_runner", onboardingCopilot: true, model: "gpt-5.6-luna" }));
|
||||
const { root } = await mount();
|
||||
expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject({ adapterType: "paperclip_runner", onboardingCopilot: true, model: "gpt-5.6-luna" });
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,15 @@ const managedApi = vi.hoisted(() => ({
|
||||
create: vi.fn(async () => ({ connectionId: "managed-connection", grantId: "managed-grant" })),
|
||||
}));
|
||||
vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: managedApi }));
|
||||
// Keep the wizard tests focused on binding/model/creation boundaries. The
|
||||
// connection component has its own tests; the ordinary-install UI canary
|
||||
// exercises its real token storage and default selection.
|
||||
vi.mock("./ai-connections/AiConnectionField", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./ai-connections/AiConnectionField")>();
|
||||
return { ...actual, AiConnectionField: (props: { environmentId?: string; onChange: (value: import("@paperclipai/shared").AiConnectionBinding) => void }) => (
|
||||
<button data-environment={props.environmentId} onClick={() => props.onChange({ provider: "github", method: "api_key", mode: "responsible_user" })}>Use saved Copilot connection</button>
|
||||
) };
|
||||
});
|
||||
vi.mock("../api/auth", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../api/auth")>();
|
||||
return { ...actual, authApi: { ...actual.authApi, getSession: mockAuthApi.getSession } };
|
||||
@@ -374,6 +383,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
||||
// call history — so a test that customizes one of these must not leak
|
||||
// its override into the next test.
|
||||
mockAgentsApi.testEnvironment.mockReset();
|
||||
mockAgentsApi.adapterModels.mockReset().mockResolvedValue([]);
|
||||
mockAgentsApi.testEnvironment.mockResolvedValue({
|
||||
adapterType: "claude_local",
|
||||
status: "pass" as const,
|
||||
@@ -401,6 +411,76 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("qualified Copilot onboarding", () => {
|
||||
async function openCopilot() {
|
||||
mockCompany.companies = [{ id: "c1", name: "Canary", issuePrefix: "CAN" }];
|
||||
mockCompaniesApi.list.mockResolvedValue(mockCompany.companies);
|
||||
mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "paperclip_runner" }];
|
||||
window.localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify({ step: 4, createdCompanyId: "c1", agentName: "Copilot canary" }));
|
||||
mockAgentsApi.adapterModels.mockResolvedValue([{ id: "gpt-5.6-luna", label: "GPT 5.6 Luna" }]);
|
||||
mockAdapterBuild.buildAdapterConfig.mockImplementation((...args: unknown[]) => {
|
||||
const values = args[0] as { adapterSchemaValues: Record<string, unknown>; model: string };
|
||||
return { ...values.adapterSchemaValues, model: values.model };
|
||||
});
|
||||
const { root, queryClient } = render();
|
||||
await act(async () => root.render(<QueryClientProvider client={queryClient}><OnboardingWizard /></QueryClientProvider>));
|
||||
await flushReact();
|
||||
const click = async (label: string) => {
|
||||
const button = [...document.body.querySelectorAll("button")].find((entry) => entry.textContent?.includes(label))!;
|
||||
expect(button).toBeTruthy();
|
||||
await act(async () => button.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
await flushReact();
|
||||
};
|
||||
await click("GitHub Copilot");
|
||||
await flushReact();
|
||||
return { root, click };
|
||||
}
|
||||
|
||||
it.each([null, "copilot-daytona"])("discovers models and tests the same saved-token configuration it hires in environment %s", async (environmentId) => {
|
||||
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: environmentId });
|
||||
mockEnvironmentsApi.list.mockResolvedValue(environmentId ? [{ id: environmentId, name: "Daytona", driver: "sandbox", status: "active", config: { provider: "daytona" } }] : []);
|
||||
const { root, click } = await openCopilot();
|
||||
const claudeReadsBeforeCopilot = mockAgentsApi.getClaudeOAuthTokenStatus.mock.calls.length;
|
||||
expect((mockAgentsApi.adapterModels.mock.calls as unknown[][]).some((call) => call[1] === "paperclip_runner")).toBe(false);
|
||||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||||
await click("Use saved Copilot connection");
|
||||
expect(document.body.querySelector('[data-environment]')?.getAttribute("data-environment") ?? null).toBe(environmentId);
|
||||
expect(mockAgentsApi.adapterModels).toHaveBeenCalledWith("c1", "paperclip_runner", { environmentId, provider: "acpx", acpxAgent: "copilot", aiConnection: { provider: "github", method: "api_key", mode: "responsible_user" } });
|
||||
const select = document.body.querySelector('[aria-label="Copilot model"]') as HTMLSelectElement;
|
||||
expect(select.value).toBe("");
|
||||
await act(async () => { select.value = "gpt-5.6-luna"; select.dispatchEvent(new Event("change", { bubbles: true })); });
|
||||
await flushReact();
|
||||
await click("Connect");
|
||||
const tested = mockAgentsApi.testEnvironment.mock.calls[0] as unknown as [string, string, { adapterConfig: Record<string, unknown>; aiConnection: unknown }];
|
||||
const hired = mockAgentsApi.hire.mock.calls[0] as unknown[];
|
||||
expect(tested).toMatchObject(["c1", "paperclip_runner", { adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" }, aiConnection: { provider: "github", method: "api_key", mode: "responsible_user" } }]);
|
||||
expect(hired).toMatchObject(["c1", { adapterType: "paperclip_runner", adapterConfig: tested[2].adapterConfig, runtimeConfig: { aiConnection: tested[2].aiConnection } }]);
|
||||
expect(mockAgentsApi.getClaudeOAuthTokenStatus.mock.calls.length).toBe(claudeReadsBeforeCopilot);
|
||||
expect(JSON.stringify(hired)).not.toContain("COPILOT_GITHUB_TOKEN");
|
||||
expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject({ adapterType: "paperclip_runner", onboardingCopilot: true, model: "gpt-5.6-luna" });
|
||||
await act(async () => root.unmount());
|
||||
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null });
|
||||
mockEnvironmentsApi.list.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("refuses an unavailable model after refresh without substituting or hiring", async () => {
|
||||
const { root, click } = await openCopilot();
|
||||
await click("Use saved Copilot connection");
|
||||
const select = document.body.querySelector('[aria-label="Copilot model"]') as HTMLSelectElement;
|
||||
await act(async () => { select.value = "gpt-5.6-luna"; select.dispatchEvent(new Event("change", { bubbles: true })); });
|
||||
await flushReact();
|
||||
mockAgentsApi.adapterModels.mockResolvedValue([{ id: "other-model", label: "Other model" }]);
|
||||
await click("Refresh models");
|
||||
expect(select.value).toBe("gpt-5.6-luna");
|
||||
expect(select.textContent).toContain("gpt-5.6-luna (unavailable)");
|
||||
const primary = [...document.body.querySelectorAll("button")].find((entry) => entry.textContent?.trim() === "Connect");
|
||||
expect(primary?.disabled).toBe(true);
|
||||
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
|
||||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
describe("step 1 leads straight to the agent — there is no mission step 2", () => {
|
||||
// One path now: Name your organization → Name your agent → Connect → Get
|
||||
// started. The Build / Grow front door and both mission screens are gone,
|
||||
|
||||
@@ -2,7 +2,9 @@ import { healthApi } from "@/api/health";
|
||||
import { LocalProviderLoginInstructions } from "./AdapterLoginChrome";
|
||||
import { useLocalAiLogin } from "./ai-connections/useLocalAiLogin";
|
||||
import { aiConnectionsApi } from "@/api/ai-connections";
|
||||
import { aiProviderForAdapter } from "./ai-connections/AiConnectionField";
|
||||
import { AiConnectionField, aiProviderForAdapter } from "./ai-connections/AiConnectionField";
|
||||
import { PAPERCLIP_RUNNER_ACPX_PROFILES } from "@paperclipai/adapter-utils";
|
||||
import { aiConnectionBindingSchema } from "@paperclipai/shared";
|
||||
import type { AiConnectionBinding } from "@paperclipai/shared";
|
||||
import { storeProviderApiKey } from "../lib/provider-credential";
|
||||
import { SavedProviderKeySelect, useSavedProviderKeys } from "./onboarding/SavedProviderKeySelect";
|
||||
@@ -147,17 +149,20 @@ type Step = 0 | 1 | 2 | 3 | 4 | 5;
|
||||
// wizard's registry-driven approach rather than a fixed union.
|
||||
type AdapterType = string;
|
||||
|
||||
// First-run onboarding stays on the proven direct adapters even when an
|
||||
// instance administrator has opted into Paperclip Runner elsewhere. The
|
||||
// experimental flag only exposes the runner in explicit agent configuration.
|
||||
// Only the qualified Copilot profile is offered through Runner in onboarding.
|
||||
// Other Runner profiles remain explicit agent configuration choices.
|
||||
const ONBOARDING_EXCLUDED_ADAPTER_TYPES = new Set([
|
||||
"process",
|
||||
"http",
|
||||
"paperclip_runner",
|
||||
]);
|
||||
|
||||
function restoreOnboardingAdapterType(savedAdapterType: unknown): AdapterType {
|
||||
return typeof savedAdapterType === "string" && savedAdapterType !== "paperclip_runner"
|
||||
const copilotOnboardingQualified = PAPERCLIP_RUNNER_ACPX_PROFILES.some(
|
||||
(profile) => profile.value === "copilot" && profile.qualified,
|
||||
);
|
||||
|
||||
function restoreOnboardingAdapterType(savedAdapterType: unknown, savedCopilot: unknown): AdapterType {
|
||||
return typeof savedAdapterType === "string" && (savedAdapterType !== "paperclip_runner" || (savedCopilot === true && copilotOnboardingQualified))
|
||||
? savedAdapterType
|
||||
: "claude_local";
|
||||
}
|
||||
@@ -251,6 +256,7 @@ const MODEL_SOURCE_INLINE_MARKS: Record<string, ComponentType<{ className?: stri
|
||||
const API_KEY_ENV_KEYS: Record<string, string> = {
|
||||
claude_local: ANTHROPIC_API_KEY_ENV_KEY,
|
||||
codex_local: "OPENAI_API_KEY",
|
||||
paperclip_runner: "COPILOT_GITHUB_TOKEN",
|
||||
};
|
||||
|
||||
function apiKeyEnvKeyFor(adapterType: string): string {
|
||||
@@ -594,7 +600,7 @@ function OnboardingWizardInner({
|
||||
(saved?.agentRole as AgentRole) || DEFAULT_AGENT_ROLE,
|
||||
);
|
||||
const [adapterType, setAdapterType] = useState<AdapterType>(() =>
|
||||
restoreOnboardingAdapterType(saved?.adapterType),
|
||||
restoreOnboardingAdapterType(saved?.adapterType, saved?.onboardingCopilot),
|
||||
);
|
||||
/**
|
||||
* Whether a model source has been chosen, as opposed to which one
|
||||
@@ -619,7 +625,9 @@ function OnboardingWizardInner({
|
||||
* whether the row has been *answered* on this visit.
|
||||
*/
|
||||
const [sourcePicked, setSourcePicked] = useState(false);
|
||||
const savedNativeRunnerDraft = saved?.adapterType === "paperclip_runner";
|
||||
const isCopilot = adapterType === "paperclip_runner";
|
||||
const [copilotConnection, setCopilotConnection] = useState<AiConnectionBinding>();
|
||||
const savedNativeRunnerDraft = saved?.adapterType === "paperclip_runner" && !(saved?.onboardingCopilot === true && copilotOnboardingQualified);
|
||||
const [cwd, setCwd] = useState((saved?.cwd as string) ?? "");
|
||||
// Native drafts may carry provider-specific configuration that is invalid
|
||||
// for the legacy adapter selected above. Keep the portable working
|
||||
@@ -628,13 +636,13 @@ function OnboardingWizardInner({
|
||||
savedNativeRunnerDraft ? "" : (saved?.model as string) ?? "",
|
||||
);
|
||||
const [command, setCommand] = useState(
|
||||
savedNativeRunnerDraft ? "" : (saved?.command as string) ?? "",
|
||||
saved?.adapterType === "paperclip_runner" ? "" : (saved?.command as string) ?? "",
|
||||
);
|
||||
const [args, setArgs] = useState(
|
||||
savedNativeRunnerDraft ? "" : (saved?.args as string) ?? "",
|
||||
saved?.adapterType === "paperclip_runner" ? "" : (saved?.args as string) ?? "",
|
||||
);
|
||||
const [url, setUrl] = useState(
|
||||
savedNativeRunnerDraft ? "" : (saved?.url as string) ?? "",
|
||||
saved?.adapterType === "paperclip_runner" ? "" : (saved?.url as string) ?? "",
|
||||
);
|
||||
const [adapterEnvResult, setAdapterEnvResult] =
|
||||
useState<AdapterEnvironmentTestResult | null>(null);
|
||||
@@ -708,7 +716,7 @@ function OnboardingWizardInner({
|
||||
? selectedSavedKey.id
|
||||
: savedKeys.options[0]?.id;
|
||||
const selectedApiKey = savedKeys.options.find((option) => option.id === selectedApiKeyId);
|
||||
const credentialMode = credentialModeChoice ?? (
|
||||
const credentialMode = isCopilot ? "api" : credentialModeChoice ?? (
|
||||
(savedKeys.subscriptions.length > 0 || (adapterType === "claude_local" && savedKeys.storedLogin.data))
|
||||
? "subscription" : savedKeys.options.length || adapterType === "opencode_local" ? "api" : "subscription"
|
||||
);
|
||||
@@ -761,9 +769,10 @@ function OnboardingWizardInner({
|
||||
*/
|
||||
const apiKeySecretRef = useRef<{ key: string; companyId: string; envKey: string; binding?: Awaited<ReturnType<typeof storeProviderApiKey>>["binding"]; aiConnection?: AiConnectionBinding } | null>(null);
|
||||
const managedSubscriptionRef = useRef<{ companyId: string; binding: AiConnectionBinding } | null>(null);
|
||||
const managedProvider = aiProviderForAdapter(adapterType);
|
||||
const managedProvider = aiProviderForAdapter(isCopilot ? "copilot_runtime" : adapterType);
|
||||
const managedSubscriptionProvider = managedProvider === "anthropic" || managedProvider === "openai" || managedProvider === "xai" ? managedProvider : undefined;
|
||||
function managedBindingForStep(): AiConnectionBinding | undefined {
|
||||
if (isCopilot) return copilotConnection;
|
||||
if (credentialMode === "api") return selectedApiKey?.aiConnection ?? (
|
||||
!selectedApiKey && apiKeySecretRef.current?.companyId === createdCompanyId && apiKeySecretRef.current.envKey === apiKeyEnvKeyFor(adapterType)
|
||||
? apiKeySecretRef.current.aiConnection : undefined);
|
||||
@@ -800,6 +809,7 @@ function OnboardingWizardInner({
|
||||
* hand rather than the one before it.
|
||||
*/
|
||||
function clearCompanyScopedState() {
|
||||
setCopilotConnection(undefined);
|
||||
setAgentAppearance(randomAgentAppearance());
|
||||
setCreatedCompanyPrefix(null);
|
||||
setCompanyName("");
|
||||
@@ -905,6 +915,7 @@ function OnboardingWizardInner({
|
||||
const state = {
|
||||
step, companyName,
|
||||
agentName, agentAppearance, agentRole, adapterType, cwd, model, command, args, url,
|
||||
onboardingCopilot: isCopilot,
|
||||
// The mode, never the key: this blob is localStorage.
|
||||
credentialMode, credentialModeChoice,
|
||||
createdCompanyId, createdCompanyPrefix, createdAgentId,
|
||||
@@ -919,21 +930,6 @@ function OnboardingWizardInner({
|
||||
createdCompanyGoalId, createdProjectId, createdIssueRef,
|
||||
]);
|
||||
|
||||
const {
|
||||
data: adapterModels,
|
||||
error: adapterModelsError,
|
||||
isLoading: adapterModelsLoading,
|
||||
isFetching: adapterModelsFetching
|
||||
} = useQuery({
|
||||
// The wizard doesn't expose an environment selector, so models always
|
||||
// resolve against the local Paperclip host (environmentId = null).
|
||||
queryKey: createdCompanyId
|
||||
? queryKeys.agents.adapterModels(createdCompanyId, adapterType, null)
|
||||
: ["agents", "none", "adapter-models", adapterType, null],
|
||||
queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType, { environmentId: null }),
|
||||
// Models are picked on step 4 (Connect a model).
|
||||
enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4
|
||||
});
|
||||
const getCapabilities = useAdapterCapabilities();
|
||||
const adapterCaps = getCapabilities(adapterType);
|
||||
|
||||
@@ -943,14 +939,14 @@ function OnboardingWizardInner({
|
||||
// managed-sandbox-only redirect (see AgentConfigForm.tsx:618-640). A render
|
||||
// must not throw, so a resolver error yields no login environment rather
|
||||
// than an error boundary.
|
||||
const { data: loginEnvironmentList = [] } = useQuery({
|
||||
const { data: loginEnvironmentList = [], isSuccess: loginEnvironmentsReady } = useQuery({
|
||||
queryKey: createdCompanyId
|
||||
? queryKeys.environments.list(createdCompanyId)
|
||||
: ["environments", "none"],
|
||||
queryFn: () => environmentsApi.list(createdCompanyId!),
|
||||
enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4,
|
||||
});
|
||||
const { data: instanceSettingsForLogin } = useQuery({
|
||||
const { data: instanceSettingsForLogin, isSuccess: loginSettingsReady } = useQuery({
|
||||
queryKey: queryKeys.instance.settings,
|
||||
queryFn: () => instanceSettingsApi.get(),
|
||||
enabled: effectiveOnboardingOpen && step === 4,
|
||||
@@ -958,29 +954,49 @@ function OnboardingWizardInner({
|
||||
// Wanted across the whole arc, not just the connect step. The progress strip
|
||||
// reads it too — see `enteredFromCloud` — and a value fetched only on step 4
|
||||
// would let the strip change length as the customer walked through it.
|
||||
const { data: experimentalSettingsForLogin } = useQuery({
|
||||
const { data: experimentalSettingsForLogin, isSuccess: loginPolicyReady } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
enabled: effectiveOnboardingOpen && step >= 3 && step <= 5,
|
||||
});
|
||||
const resolvedLoginEnvironmentId = useMemo(() => {
|
||||
const loginEnvironmentResolution = useMemo(() => {
|
||||
try {
|
||||
return resolveAdapterTestEnvironmentId({
|
||||
return { environmentId: resolveAdapterTestEnvironmentId({
|
||||
agentDefaultEnvironmentId: null,
|
||||
instanceDefaultEnvironmentId: instanceSettingsForLogin?.defaultEnvironmentId ?? null,
|
||||
localDefaultEnvironmentId: resolveLocalDefaultEnvironmentId(loginEnvironmentList),
|
||||
managedSandboxOnly: experimentalSettingsForLogin?.enableManagedSandboxOnly === true,
|
||||
managedSandboxEnvironmentId: resolveManagedSandboxEnvironmentId(loginEnvironmentList),
|
||||
visibleEnvironmentIds: loginEnvironmentList.map((environment) => environment.id),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}), error: null };
|
||||
} catch (error) {
|
||||
return { environmentId: null, error: error instanceof Error ? error.message : "Could not resolve the execution environment." };
|
||||
}
|
||||
}, [
|
||||
instanceSettingsForLogin?.defaultEnvironmentId,
|
||||
loginEnvironmentList,
|
||||
experimentalSettingsForLogin?.enableManagedSandboxOnly,
|
||||
]);
|
||||
const resolvedLoginEnvironmentId = loginEnvironmentResolution.environmentId;
|
||||
const copilotEnvironmentReady = loginEnvironmentsReady && loginSettingsReady && loginPolicyReady && !loginEnvironmentResolution.error;
|
||||
const {
|
||||
data: adapterModels,
|
||||
error: adapterModelsError,
|
||||
isLoading: adapterModelsLoading,
|
||||
isFetching: adapterModelsFetching,
|
||||
refetch: refreshAdapterModels,
|
||||
} = useQuery({
|
||||
queryKey: isCopilot
|
||||
? ["onboarding-adapter-models", createdCompanyId, adapterType, resolvedLoginEnvironmentId, copilotConnection]
|
||||
: createdCompanyId ? queryKeys.agents.adapterModels(createdCompanyId, adapterType, null) : ["agents", "none", "adapter-models", adapterType, null],
|
||||
queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType, {
|
||||
environmentId: isCopilot ? resolvedLoginEnvironmentId : null,
|
||||
...(isCopilot ? { provider: "acpx", acpxAgent: "copilot", aiConnection: copilotConnection } : {}),
|
||||
}),
|
||||
enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4 && (!isCopilot || Boolean(copilotConnection && copilotEnvironmentReady)),
|
||||
retry: false,
|
||||
});
|
||||
const copilotModelReady = Boolean(copilotConnection && copilotEnvironmentReady && !adapterModelsError && !adapterModelsFetching && adapterModels?.some((entry) => entry.id === model.trim()));
|
||||
const resolvedLoginEnvironment = useMemo(
|
||||
() =>
|
||||
loginEnvironmentList.find((environment) => environment.id === resolvedLoginEnvironmentId) ??
|
||||
@@ -1123,7 +1139,7 @@ function OnboardingWizardInner({
|
||||
adapterCaps.supportsSkills ||
|
||||
adapterCaps.supportsLocalAgentJwt;
|
||||
const isLocalAdapter =
|
||||
isLocalAdapterCaps ||
|
||||
isLocalAdapterCaps || isCopilot ||
|
||||
adapterType === "claude_local" ||
|
||||
adapterType === "codex_local" ||
|
||||
adapterType === "gemini_local" ||
|
||||
@@ -1143,8 +1159,11 @@ function OnboardingWizardInner({
|
||||
)
|
||||
.map((a) => ({ ...getAdapterDisplay(a.type), type: a.type }));
|
||||
|
||||
const copilot = copilotOnboardingQualified && !disabledTypes.has("paperclip_runner") && listUIAdapters().some((a) => a.type === "paperclip_runner")
|
||||
? [{ ...getAdapterDisplay("copilot_runtime"), type: "paperclip_runner", label: "GitHub Copilot", recommended: true }]
|
||||
: [];
|
||||
return {
|
||||
recommendedAdapters: all.filter((a) => a.recommended),
|
||||
recommendedAdapters: [...all.filter((a) => a.recommended), ...copilot],
|
||||
moreAdapters: all.filter((a) => !a.recommended),
|
||||
};
|
||||
}, [disabledTypes]);
|
||||
@@ -1174,7 +1193,7 @@ function OnboardingWizardInner({
|
||||
* Anything that gates this step belongs in here, so the next one is added
|
||||
* once rather than twice.
|
||||
*/
|
||||
const connectStepReady = sourceSelected && !adapterEnvLoading && !savedKeys.loading;
|
||||
const connectStepReady = sourceSelected && !adapterEnvLoading && !savedKeys.loading && (!isCopilot || copilotModelReady);
|
||||
|
||||
/**
|
||||
* Whether this step has a sign-in to do before it can hire.
|
||||
@@ -1366,7 +1385,7 @@ function OnboardingWizardInner({
|
||||
* rather than offering — see `FooterNav`, where the label cross-fades over an
|
||||
* easing width so those changes read as one control rather than four.
|
||||
*/
|
||||
const connectSourceLabel = CONNECT_SOURCE_NAMES[adapterType] ?? adapterType;
|
||||
const connectSourceLabel = isCopilot ? "GitHub Copilot" : CONNECT_SOURCE_NAMES[adapterType] ?? adapterType;
|
||||
const connectCta: { label: string; icon: FooterPrimaryIcon; disabled: boolean } =
|
||||
connectProgress
|
||||
? { label: adapterEnvLoading ? "Testing…" : connectProgress, icon: "spinner", disabled: true }
|
||||
@@ -1385,7 +1404,7 @@ function OnboardingWizardInner({
|
||||
label: "Connect",
|
||||
icon: "arrow",
|
||||
disabled:
|
||||
!connectStepReady || (credentialMode === "api" && !apiKey.trim() && !selectedApiKey),
|
||||
!connectStepReady || (!isCopilot && credentialMode === "api" && !apiKey.trim() && !selectedApiKey),
|
||||
}
|
||||
: // Nothing is chosen on arrival, and the row is what chooses. Until
|
||||
// it has been answered the button has nothing to do.
|
||||
@@ -1488,7 +1507,7 @@ function OnboardingWizardInner({
|
||||
);
|
||||
if (visible.length === 0) return;
|
||||
if (visible.some((a) => a.type === adapterType)) return;
|
||||
const next = visible[0].type as AdapterType;
|
||||
const next = (visible.find((a) => a.type !== "paperclip_runner") ?? visible[0]).type as AdapterType;
|
||||
setAdapterType(next);
|
||||
// The snap is not a choice. It replaces a name the customer can no longer
|
||||
// see with the first one they can, which is the right thing to hold — but
|
||||
@@ -1541,7 +1560,7 @@ function OnboardingWizardInner({
|
||||
setAdapterEnvResult(null);
|
||||
adapterEnvResultAppliedStoredLoginRef.current = false;
|
||||
setAdapterEnvError(null);
|
||||
}, [step, adapterType, model, command, args, url, credentialMode, apiKey, selectedSavedKey, selectedApiKey?.id, savedSubscription?.id]);
|
||||
}, [step, adapterType, model, command, args, url, credentialMode, apiKey, selectedSavedKey, selectedApiKey?.id, savedSubscription?.id, copilotConnection, resolvedLoginEnvironmentId]);
|
||||
|
||||
/**
|
||||
* Leaving the step puts the row back to a question.
|
||||
@@ -1639,6 +1658,7 @@ function OnboardingWizardInner({
|
||||
setForceUnsetAnthropicApiKey(false);
|
||||
setUnsetAnthropicLoading(false);
|
||||
setClaudeOAuthStatus(null);
|
||||
setCopilotConnection(undefined);
|
||||
setCreatedCompanyId(null);
|
||||
setCreatedCompanyPrefix(null);
|
||||
setCreatedAgentId(null);
|
||||
@@ -1821,6 +1841,7 @@ function OnboardingWizardInner({
|
||||
const config = adapter.buildAdapterConfig({
|
||||
...defaultCreateValues,
|
||||
adapterType,
|
||||
...(isCopilot ? { adapterSchemaValues: { provider: "acpx", acpxAgent: "copilot" } } : {}),
|
||||
model:
|
||||
adapterType === "gemini_local"
|
||||
? model || DEFAULT_GEMINI_LOCAL_MODEL
|
||||
@@ -2018,13 +2039,8 @@ function OnboardingWizardInner({
|
||||
// doesn't hire a second agent.
|
||||
async function handleGiveHeartbeat() {
|
||||
if (!createdCompanyId) return;
|
||||
// The grid and restore path both exclude native runner. Keep this final
|
||||
// guard at the mutation boundary so a stale or modified client cannot use
|
||||
// first-run onboarding to create a native agent.
|
||||
if (adapterType === "paperclip_runner") {
|
||||
setAdapterType("claude_local");
|
||||
setModel("");
|
||||
setError("Paperclip Runner is not available during onboarding. Choose a legacy adapter.");
|
||||
if (isCopilot && (!copilotOnboardingQualified || !sourceSelected || !copilotModelReady)) {
|
||||
setError("Connect GitHub Copilot and select an available model before creating this agent.");
|
||||
return;
|
||||
}
|
||||
if (createdAgentId) {
|
||||
@@ -2090,7 +2106,7 @@ function OnboardingWizardInner({
|
||||
// hire describe it the same way — as a reference. A failure here stops the
|
||||
// hire rather than falling through to a configuration with no credential.
|
||||
let apiKeyStored = false;
|
||||
if (credentialMode === "api" && !selectedApiKey && apiKey.trim()) {
|
||||
if (!isCopilot && credentialMode === "api" && !selectedApiKey && apiKey.trim()) {
|
||||
apiKeyStored = await storeApiKeyUserSecret(createdCompanyId);
|
||||
if (!apiKeyStored || !isCurrent()) return;
|
||||
}
|
||||
@@ -2675,8 +2691,9 @@ function OnboardingWizardInner({
|
||||
label="Model source"
|
||||
sources={recommendedAdapters.map((opt) => ({
|
||||
id: opt.type,
|
||||
label: CONNECT_SOURCE_NAMES[opt.type] ?? opt.label,
|
||||
label: opt.type === "paperclip_runner" ? "GitHub Copilot" : CONNECT_SOURCE_NAMES[opt.type] ?? opt.label,
|
||||
icon: <ModelSourceMark type={opt.type} Fallback={opt.icon} />,
|
||||
...(opt.type === "paperclip_runner" ? { credentialMode: "api" as const } : {}),
|
||||
}))}
|
||||
mode={credentialMode}
|
||||
selectedId={
|
||||
@@ -2769,6 +2786,32 @@ function OnboardingWizardInner({
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{connectProgress}
|
||||
</p>
|
||||
) : isCopilot && createdCompanyId ? (
|
||||
<OnboardingLoginCard instruction="Connect GitHub Copilot and choose a model">
|
||||
{!copilotEnvironmentReady ? <p role="alert" className="text-sm text-destructive">{loginEnvironmentResolution.error ?? "Waiting for execution environment settings. If this persists, check your instance settings."}</p> : <>
|
||||
<AiConnectionField companyId={createdCompanyId} agentName={agentName} adapterType="copilot_runtime" environmentId={resolvedLoginEnvironmentId ?? undefined} value={copilotConnection} onChange={(binding) => {
|
||||
const parsed = aiConnectionBindingSchema.safeParse(binding);
|
||||
if (!parsed.success || parsed.data.provider !== "github" || parsed.data.method !== "api_key") {
|
||||
setError("Choose a GitHub Copilot token connection for this source.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setCopilotConnection(parsed.data);
|
||||
setModel("");
|
||||
}} />
|
||||
{copilotConnection && <label className="block space-y-2 text-sm">
|
||||
<span>Model</span>
|
||||
<select aria-label="Copilot model" className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm" value={model} disabled={adapterModelsFetching} onChange={(event) => setModel(event.target.value)}>
|
||||
<option value="">{adapterModelsFetching ? "Loading models…" : "Select a model"}</option>
|
||||
{model && !adapterModels?.some((entry) => entry.id === model) && <option value={model}>{model} (unavailable)</option>}
|
||||
{(adapterModels ?? []).map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}
|
||||
</select>
|
||||
{adapterModelsError && <p role="alert" className="text-destructive">{adapterModelsError instanceof Error ? adapterModelsError.message : "Could not discover Copilot models."}</p>}
|
||||
{!adapterModelsFetching && !adapterModelsError && adapterModels?.length === 0 && <p role="alert" className="text-destructive">No available Copilot models were discovered. Check this account’s Copilot access.</p>}
|
||||
<Button type="button" variant="ghost" disabled={adapterModelsFetching} onClick={() => void refreshAdapterModels()}>Refresh models</Button>
|
||||
</label>}
|
||||
</>}
|
||||
</OnboardingLoginCard>
|
||||
) : credentialMode === "api" ? (
|
||||
<OnboardingLoginCard
|
||||
instruction={savedKeys.options.length ? "Choose a saved API key or enter a new one" : `Provide your ${
|
||||
@@ -2897,11 +2940,8 @@ function OnboardingWizardInner({
|
||||
</motion.div>
|
||||
|
||||
{/* Conditional adapter fields */}
|
||||
{/* No model picker. Every adapter this step offers resolves
|
||||
its own default (see buildAdapterConfig), so the picker
|
||||
asked the customer to choose a model before they had any
|
||||
way to judge one — and the agent's model is changeable
|
||||
later, where its work gives the choice meaning. */}
|
||||
{/* Direct adapters resolve their defaults. Copilot requires
|
||||
an explicit authenticated model in its connection card. */}
|
||||
|
||||
{/* Progress is shown above; failed checks remain actionable here. */}
|
||||
{/* Not while the hire is in flight. The probe's result lands
|
||||
|
||||
@@ -53,6 +53,7 @@ export function RuntimeTestCard({
|
||||
onTest,
|
||||
disabled = false,
|
||||
variant = "connection",
|
||||
metadataOnly = false,
|
||||
}: {
|
||||
state: TestState | "warn";
|
||||
result: AdapterEnvironmentTestResult | null;
|
||||
@@ -60,8 +61,14 @@ export function RuntimeTestCard({
|
||||
onTest: () => void;
|
||||
disabled?: boolean;
|
||||
variant?: "connection" | "prerequisites";
|
||||
metadataOnly?: boolean;
|
||||
}) {
|
||||
const content = (variant === "prerequisites" ? prerequisiteCopy : copy)[state];
|
||||
const description = metadataOnly && state === "idle"
|
||||
? "Verify your saved token, runtime installation, and selected model without sending a model prompt."
|
||||
: metadataOnly && state === "running"
|
||||
? "Checking the saved token, runtime, and selected model…"
|
||||
: content.description;
|
||||
const Icon =
|
||||
state === "running"
|
||||
? Loader2
|
||||
@@ -102,7 +109,7 @@ export function RuntimeTestCard({
|
||||
>
|
||||
<h3 className="text-sm font-medium">{content.title}</h3>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{state === "fail" && error ? error : content.description}
|
||||
{state === "fail" && error ? error : description}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -34,7 +34,7 @@ type Props = {
|
||||
|
||||
/** Connections hosts the same provider step as agent setup, with its own save intent. */
|
||||
export function AiConnectionCredentialStep(props: Props) {
|
||||
if (props.provider === "openrouter" || props.provider === "google") return <ApiKeyConnectionStep {...props} />;
|
||||
if (props.provider === "openrouter" || props.provider === "github" || props.provider === "google") return <ApiKeyConnectionStep {...props} />;
|
||||
return <SubscriptionConnectionStep {...props} />;
|
||||
}
|
||||
|
||||
@@ -100,20 +100,33 @@ function SubscriptionConnectionStep({ companyId, provider, initialMethod, fixedM
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ApiKeyConnectionStep({ companyId, provider, connectionId, name: initialName, hideName, nameForMethod, ownership, agentIds, allAgents, onComplete, onCancel, defaults, disabled }: Props) {
|
||||
function ApiKeyConnectionStep({ companyId, provider, connectionId, name: initialName, hideName, nameForMethod, ownership, agentIds, allAgents, environmentId: suppliedEnvironmentId, defaults, disabled, onComplete, onCancel }: Props) {
|
||||
const [name, setName] = useState(initialName);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const client = useQueryClient();
|
||||
const [chosenEnvironment, setChosenEnvironment] = useState<string>();
|
||||
const copilot = provider === "github";
|
||||
const envs = useQuery({ queryKey: queryKeys.environments.list(companyId), queryFn: () => environmentsApi.list(companyId), enabled: copilot });
|
||||
const settings = useQuery({ queryKey: queryKeys.instance.settings, queryFn: instanceSettingsApi.get, enabled: copilot });
|
||||
const environmentId = suppliedEnvironmentId ?? chosenEnvironment ?? settings.data?.defaultEnvironmentId ?? resolveLocalDefaultEnvironmentId(envs.data);
|
||||
const environmentError = copilot ? envs.error?.message ?? settings.error?.message : undefined;
|
||||
const preparing = copilot && (envs.isPending || settings.isPending);
|
||||
const save = useMutation({
|
||||
mutationFn: () => aiConnectionsApi.create(companyId, { provider, method: "api_key", name: connectionId ? name : nameForMethod?.("api_key") ?? name, ownership, agentIds, allAgents, connectionId, apiKey }),
|
||||
mutationFn: () => aiConnectionsApi.create(companyId, { provider, method: "api_key", name: connectionId ? name : nameForMethod?.("api_key") ?? name, ownership, agentIds, allAgents, connectionId, apiKey, ...(copilot ? { environmentId } : {}) }),
|
||||
onSuccess: (result) => { void client.invalidateQueries({ queryKey: ["ai-connections", companyId] }); void client.invalidateQueries({ queryKey: ["tools"] }); onComplete({ ...result, method: "api_key" }); },
|
||||
onSettled: () => setApiKey(""),
|
||||
});
|
||||
return <div className="mx-auto w-full min-w-0 max-w-xl space-y-4">
|
||||
{!hideName && <label className="block space-y-2 text-sm">Connection name<Input value={name} onChange={(event) => setName(event.target.value)} disabled={Boolean(connectionId)} /></label>}
|
||||
{save.error && <p role="alert" className="text-sm text-destructive">{save.error.message}</p>}
|
||||
<ProviderApiKeyCard providerName={provider === "google" ? "Google" : "OpenRouter"} value={apiKey} onChange={setApiKey} onSubmit={() => save.mutate()} disabled={disabled || save.isPending} placeholder="Enter API key here" autoFocus />
|
||||
{copilot && <p className="text-sm text-muted-foreground">Use a personal fine-grained token with Copilot Requests permission. <a className="underline" href="https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/authenticate-copilot-cli" target="_blank" rel="noreferrer">GitHub token instructions</a>. Verification discovers models in the selected environment and sends no model prompt. Repository access uses a separate connection.</p>}
|
||||
{copilot && !suppliedEnvironmentId && <Select value={environmentId ?? ""} onValueChange={setChosenEnvironment} disabled={preparing || save.isPending}>
|
||||
<SelectTrigger aria-label="Copilot execution environment"><SelectValue placeholder="Execution environment" /></SelectTrigger>
|
||||
<SelectContent>{(envs.data ?? []).filter(env => env.status === "active").map(env => <SelectItem key={env.id} value={env.id}>{env.name}</SelectItem>)}</SelectContent>
|
||||
</Select>}
|
||||
{environmentError && <p role="alert" className="text-sm text-destructive">{environmentError}</p>}
|
||||
{defaults}
|
||||
<div className="flex justify-between gap-2"><Button variant="ghost" onClick={onCancel}>Cancel</Button><Button disabled={disabled || !name.trim() || !apiKey.trim() || save.isPending} onClick={() => save.mutate()}>{save.isPending ? "Connecting…" : "Connect"}</Button></div>
|
||||
<ProviderApiKeyCard providerName={copilot ? "GitHub Copilot" : provider === "google" ? "Google" : "OpenRouter"} value={apiKey} onChange={setApiKey} onSubmit={() => { if (!preparing && !environmentError && name.trim() && apiKey.trim()) save.mutate(); }} disabled={disabled || save.isPending || preparing || Boolean(environmentError)} placeholder={copilot ? "github_pat_..." : "Enter API key here"} autoFocus />
|
||||
<div className="flex justify-between gap-2"><Button variant="ghost" onClick={onCancel}>Cancel</Button><Button disabled={disabled || !name.trim() || !apiKey.trim() || save.isPending || preparing || Boolean(environmentError)} onClick={() => save.mutate()}>{save.isPending ? "Connecting…" : "Connect"}</Button></div>
|
||||
</div>;
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export function aiProviderForAdapter(
|
||||
codex_local: "openai",
|
||||
opencode_local: "openrouter",
|
||||
grok_local: "xai",
|
||||
copilot_runtime: "github",
|
||||
gemini_local: "google",
|
||||
hermes_local: "openrouter",
|
||||
} as Record<string, AiProvider>
|
||||
@@ -111,7 +112,7 @@ export function AiConnectionField({
|
||||
};
|
||||
const method: AiAuthMethod = (value && value.mode !== "router" && value.mode !== "responsible_user" ? value.method : undefined)
|
||||
?? accounts.data?.connections.find((account) => account.provider === provider && account.isDefault)?.method
|
||||
?? (provider === "openrouter" || provider === "google" ? "api_key" : "subscription");
|
||||
?? (provider === "openrouter" || provider === "google" || provider === "github" ? "api_key" : "subscription");
|
||||
const compatiblePools = agentId ? accounts.data?.pools?.filter(pool => pool.enabled && pool.members.some(member => isAiConnectionCompatible(member.binding, routerAdapterType ?? adapterType, member.profile.model, member.profile.provider, member.profile.acpxAgent))) ?? [] : [];
|
||||
if (!provider) return null;
|
||||
if (legacy && !value && !adopting)
|
||||
@@ -236,7 +237,7 @@ export function AiConnectionField({
|
||||
initialMethod={reconnecting?.method ?? method}
|
||||
fixedMethod={Boolean(reconnecting)}
|
||||
connectionId={reconnecting?.id}
|
||||
name={reconnecting?.name ?? `My ${provider === "anthropic" ? "Claude" : provider === "openai" ? "OpenAI" : provider === "xai" ? "Grok" : provider === "google" ? "Gemini" : "OpenRouter"} ${method === "subscription" ? "subscription" : "API"}`}
|
||||
name={reconnecting?.name ?? `My ${provider === "anthropic" ? "Claude" : provider === "openai" ? "OpenAI" : provider === "xai" ? "Grok" : provider === "google" ? "Gemini" : provider === "github" ? "GitHub Copilot" : "OpenRouter"} ${method === "subscription" ? "subscription" : "API"}`}
|
||||
ownership="personal"
|
||||
agentIds={agentId ? [agentId] : []}
|
||||
allAgents={allAgents}
|
||||
|
||||
@@ -120,7 +120,7 @@ export function AiConnectionPicker({
|
||||
})),
|
||||
]}
|
||||
onSelect={(id) => {
|
||||
if (id === "responsible_user") onChange({provider: requirement.provider, method: personalDefault?.method ?? requirement.method ?? (requirement.provider === "openrouter" ? "api_key" : "subscription"), mode: "responsible_user"});
|
||||
if (id === "responsible_user") onChange({provider: requirement.provider, method: personalDefault?.method ?? requirement.method ?? ((requirement.provider === "openrouter" || requirement.provider === "github") ? "api_key" : "subscription"), mode: "responsible_user"});
|
||||
else { const connection = compatible.find((item) => item.id === id)!; select("shared", connection); }
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -63,7 +63,7 @@ const providers = [
|
||||
advanced: true,
|
||||
},
|
||||
] as const;
|
||||
type ProviderChoice = (typeof providers)[number]["id"];
|
||||
type ProviderChoice = (typeof providers)[number]["id"] | "github";
|
||||
|
||||
export function AiProviderSetup({
|
||||
companyId,
|
||||
@@ -131,7 +131,7 @@ export function AiProviderSetup({
|
||||
queryKey: ["agents", companyId, "provider-access"],
|
||||
queryFn: () => agentsApi.list(companyId),
|
||||
});
|
||||
const label = providerLabel ?? providers.find((p) => p.id === provider)?.name ?? "provider";
|
||||
const label = providerLabel ?? providers.find((p) => p.id === provider)?.name ?? (provider === "github" ? "GitHub Copilot" : "provider");
|
||||
const advanced = reconnect ? Boolean(reconnect.routing) : ["openrouter", "bedrock", "gateway", "local"].includes(provider ?? "");
|
||||
const nativeProvider: AiProvider =
|
||||
provider === "bedrock"
|
||||
|
||||
@@ -7,6 +7,7 @@ export const AI_PROVIDERS: Record<
|
||||
AiProvider,
|
||||
{ name: string; subscriptionName?: string; logo?: string }
|
||||
> = {
|
||||
github: { name: "GitHub Copilot", logo: "/brands/apps/github.svg" },
|
||||
google: { name: "Google", logo: "/brands/apps/google.svg" },
|
||||
anthropic: {
|
||||
name: "Claude",
|
||||
@@ -53,7 +54,7 @@ export function defaultAiConnectionName(ownerName: string | undefined, provider:
|
||||
export function aiMethodLabel(provider: AiProvider, method: AiAuthMethod) {
|
||||
return method === "subscription"
|
||||
? (AI_PROVIDERS[provider].subscriptionName ?? "Subscription unavailable")
|
||||
: "API key";
|
||||
: provider === "github" ? "Personal access token" : "API key";
|
||||
}
|
||||
|
||||
export function matchesAiRequirement(
|
||||
|
||||
@@ -114,7 +114,9 @@ function Setup({
|
||||
const isRunner = adapterType === "paperclip_runner";
|
||||
const isDot = isRunner && runnerProvider === "openai_dot";
|
||||
const brandType = isDot ? "openai_dot" : isRunner
|
||||
? runnerProvider === "grok"
|
||||
? runnerProvider === "copilot"
|
||||
? "copilot_runtime"
|
||||
: runnerProvider === "grok"
|
||||
? "grok_local"
|
||||
: runnerProvider === "claude"
|
||||
? "claude_local"
|
||||
@@ -152,7 +154,9 @@ function Setup({
|
||||
null,
|
||||
);
|
||||
const [runtimeAiBinding, setRuntimeAiBinding] = useState<AiConnectionBinding | undefined>(() =>
|
||||
brandType === "opencode_local"
|
||||
brandType === "copilot_runtime"
|
||||
? { provider: "github", method: "api_key", mode: "responsible_user" }
|
||||
: brandType === "opencode_local"
|
||||
? { provider: "openrouter", method: "api_key", mode: "responsible_user" }
|
||||
: undefined,
|
||||
);
|
||||
@@ -225,12 +229,6 @@ function Setup({
|
||||
queryKey: queryKeys.environments.capabilities(companyId),
|
||||
queryFn: () => environmentsApi.capabilities(companyId),
|
||||
});
|
||||
const models = useQuery({
|
||||
queryKey: queryKeys.agents.adapterModels(companyId, brandType, null, aiBinding?.provider),
|
||||
queryFn: () => agentsApi.adapterModels(companyId, brandType, { provider: aiBinding?.provider }),
|
||||
enabled: Boolean(brandType) && showModel && !connectionModels,
|
||||
retry: false,
|
||||
});
|
||||
const companySecrets = useQuery({
|
||||
queryKey: queryKeys.secrets.list(companyId),
|
||||
queryFn: () => secretsApi.list(companyId),
|
||||
@@ -271,6 +269,14 @@ function Setup({
|
||||
? cause.message
|
||||
: "Could not resolve the environment.";
|
||||
}
|
||||
const models = useQuery({
|
||||
queryKey: queryKeys.agents.adapterModels(companyId, brandType, environmentId, brandType === "copilot_runtime" ? JSON.stringify(aiBinding ?? null) : aiBinding?.provider),
|
||||
queryFn: () => agentsApi.adapterModels(companyId, brandType === "copilot_runtime" ? "paperclip_runner" : brandType, brandType === "copilot_runtime"
|
||||
? { provider: "acpx", acpxAgent: "copilot", aiConnection: aiBinding, environmentId }
|
||||
: { provider: aiBinding?.provider, environmentId }),
|
||||
enabled: Boolean(brandType) && showModel && !connectionModels && (brandType !== "copilot_runtime" || Boolean(aiBinding)),
|
||||
retry: false,
|
||||
});
|
||||
const environment = envs.data?.find((env) => env.id === environmentId);
|
||||
const sandboxProvider =
|
||||
typeof environment?.config?.provider === "string"
|
||||
@@ -355,9 +361,9 @@ function Setup({
|
||||
...(isRunner
|
||||
? {
|
||||
adapterSchemaValues: {
|
||||
provider: (runnerProvider === "claude" || runnerProvider === "grok") ? "acpx" : runnerProvider,
|
||||
provider: (runnerProvider === "claude" || runnerProvider === "grok" || runnerProvider === "copilot") ? "acpx" : runnerProvider,
|
||||
...(isDot ? { allowUnmeteredProvider } : {}),
|
||||
...((runnerProvider === "claude" || runnerProvider === "grok") ? { acpxAgent: runnerProvider } : {}),
|
||||
...((runnerProvider === "claude" || runnerProvider === "grok" || runnerProvider === "copilot") ? { acpxAgent: runnerProvider } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
@@ -365,8 +371,8 @@ function Setup({
|
||||
const config = getUIAdapter(adapterType).buildAdapterConfig(values);
|
||||
if (isRunner)
|
||||
Object.assign(config, {
|
||||
provider: (runnerProvider === "claude" || runnerProvider === "grok") ? "acpx" : runnerProvider,
|
||||
...((runnerProvider === "claude" || runnerProvider === "grok") ? { acpxAgent: runnerProvider } : {}),
|
||||
provider: (runnerProvider === "claude" || runnerProvider === "grok" || runnerProvider === "copilot") ? "acpx" : runnerProvider,
|
||||
...((runnerProvider === "claude" || runnerProvider === "grok" || runnerProvider === "copilot") ? { acpxAgent: runnerProvider } : {}),
|
||||
...(model ? { model } : {}),
|
||||
});
|
||||
if (!aiBinding && !nextConnection?.aiConnection && hasCredentialField && binding) {
|
||||
@@ -1162,6 +1168,7 @@ function Setup({
|
||||
</fieldset>
|
||||
<RuntimeTestCard
|
||||
variant={isDot ? "prerequisites" : "connection"}
|
||||
metadataOnly={brandType === "copilot_runtime"}
|
||||
state={testState}
|
||||
result={result}
|
||||
error={error}
|
||||
|
||||
@@ -334,12 +334,19 @@ const OAUTH_SIGN_IN_STEP_LABELS = ["Sign in"];
|
||||
* original identity through the explicit reconnect hint.
|
||||
*/
|
||||
function defaultGrantKindFor(method: ConnectionMethodDef | null, preferPersonal = false): ConnectionGrantKind {
|
||||
// A Copilot PAT represents a person's paid runtime account. Sharing it must
|
||||
// be an explicit choice, independent of GitHub repository connections.
|
||||
if (method?.ai?.provider === "github") return "user";
|
||||
if (preferPersonal && method?.auth !== "none" && (!method?.grantKinds || method.grantKinds.includes("user"))) return "user";
|
||||
if (method?.grantKinds?.length === 1) return method.grantKinds[0]!;
|
||||
if (method?.grantKinds && !method.grantKinds.includes("organization")) return method.grantKinds[0]!;
|
||||
return "organization";
|
||||
}
|
||||
|
||||
function defaultInstallChoiceFor(method: ConnectionMethodDef | null, requestedAgentId?: string): "specific" | "all" {
|
||||
return requestedAgentId || method?.ai?.provider === "github" ? "specific" : "all";
|
||||
}
|
||||
|
||||
function configuredAgentIdentity(connection: ToolConnection): string | undefined {
|
||||
const oauth = connection.config?.oauth;
|
||||
if (!oauth || typeof oauth !== "object" || Array.isArray(oauth)) return undefined;
|
||||
@@ -944,7 +951,7 @@ function StandardConnectionSetupFlow({
|
||||
setGoogleSheetsError(null);
|
||||
setConnectResult(null);
|
||||
setInstallAgentIds(new Set(requestedAgentId ? [requestedAgentId] : []));
|
||||
setInstallChoice(requestedAgentId ? "specific" : "all");
|
||||
setInstallChoice(defaultInstallChoiceFor(initialMethod, requestedAgentId));
|
||||
setGrantKind(reconnectGrantKind ?? defaultGrantKindFor(initialMethod, Boolean(requestedAgentId)));
|
||||
setStep("key");
|
||||
navigate(
|
||||
@@ -1002,7 +1009,11 @@ function StandardConnectionSetupFlow({
|
||||
&& !connectionMethodSupportsAutomaticOAuth(candidate),
|
||||
);
|
||||
const selectedCustomOAuth = Boolean(customOAuthMethod && connectionMethodKey === customOAuthMethod.key);
|
||||
const requestedDefinitionUsesManagedConnector = !selectedCustomOAuth && Boolean(
|
||||
const selectedRuntimeAuth = Boolean(fullRequestedDefinition?.methods.some(candidate =>
|
||||
candidate.transport === "runtime_auth" && candidate.ai
|
||||
&& candidate.key === (connectionMethodKey || requestedMethodKey),
|
||||
));
|
||||
const requestedDefinitionUsesManagedConnector = !selectedCustomOAuth && !selectedRuntimeAuth && Boolean(
|
||||
fullRequestedDefinition?.methods.some((candidate) =>
|
||||
candidate.oauthStrategy === "paperclip_cloud_connector"
|
||||
|| candidate.oauthStrategy === "paperclip_id_connector"
|
||||
@@ -1583,7 +1594,7 @@ function StandardConnectionSetupFlow({
|
||||
setInstallAgentIds(new Set(
|
||||
matchingEnrollmentAccess?.agentIds ?? (requestedAgentId ? [requestedAgentId] : []),
|
||||
));
|
||||
setInstallChoice(matchingEnrollmentAccess?.installChoice ?? (requestedAgentId ? "specific" : "all"));
|
||||
setInstallChoice(matchingEnrollmentAccess?.installChoice ?? defaultInstallChoiceFor(initialMethod, requestedAgentId));
|
||||
// Route/service selection initializes the wizard once. Later renders must
|
||||
// preserve the user's current step in both hosts instead of snapping back
|
||||
// to Access after they continue.
|
||||
@@ -2004,7 +2015,7 @@ function StandardConnectionSetupFlow({
|
||||
capabilities={galleryQuery.data?.capabilities}
|
||||
githubIdentity={entry?.slug === "github"}
|
||||
identityLoading={Boolean(automaticOAuthEntry) && directOAuthLookupPending}
|
||||
preserveAgentAccess={Boolean(automaticOAuthEntry && (resumableOAuthConnection || reconnectConnection))}
|
||||
preserveAgentAccess={Boolean((automaticOAuthEntry && (resumableOAuthConnection || reconnectConnection)) || reconnectConnection?.connectionPurpose === "ai")}
|
||||
disabled={connectMutation.isPending || oauthStartMutation.isPending}
|
||||
/>
|
||||
{entry && instructionTemplate && instructionValue && <div className="mt-6"><ConnectionInstructionsEditor provider={entry.name} template={instructionTemplate} value={instructionValue} onChange={(value) => setInstructionDraft({ slug: entry.slug, value })} disabled={connectMutation.isPending || oauthStartMutation.isPending} /></div>}
|
||||
@@ -2294,7 +2305,7 @@ function StandardConnectionSetupFlow({
|
||||
zapierSource
|
||||
? { name: "Zapier", logoUrl: zapierEntry?.branding.logoUrl ?? null, darkLogoUrl: zapierEntry?.branding.darkLogoUrl ?? null }
|
||||
: entry && step !== "gallery"
|
||||
? { name: entry.name, logoUrl: entry.branding.logoUrl, darkLogoUrl: entry.branding.darkLogoUrl ?? null }
|
||||
? { name: aiMethod?.provider === "github" ? "GitHub Copilot" : entry.name, logoUrl: entry.branding.logoUrl, darkLogoUrl: entry.branding.darkLogoUrl ?? null }
|
||||
: undefined
|
||||
}
|
||||
unverifiedHost={!entry && !zapierSource && step !== "gallery" ? endpointHost(linkUrl) : null}
|
||||
|
||||
@@ -372,7 +372,7 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
|
||||
await renderPage();
|
||||
|
||||
expect(container.textContent).toContain("Paperclip Runner");
|
||||
expect(container.textContent).toContain("Onboarding continues to use legacy adapters");
|
||||
expect(container.textContent).toContain("First-run setup offers qualified GitHub Copilot");
|
||||
const toggle = container.querySelector<HTMLButtonElement>(
|
||||
PAPERCLIP_RUNNER_TOGGLE_SELECTOR,
|
||||
);
|
||||
|
||||
@@ -483,7 +483,7 @@ export function InstanceExperimentalSettings() {
|
||||
|
||||
<ExperimentalToggleCard
|
||||
title="Paperclip Runner"
|
||||
description="Allow new Codex agents to select the experimental Rust Paperclip Runner, including authenticated runner ingress when a sandbox requires it. Onboarding continues to use legacy adapters. Turning this off hides the choice without affecting existing native runs."
|
||||
description="Allow agents to select Paperclip Runner, including authenticated runner ingress when a sandbox requires it. First-run setup offers qualified GitHub Copilot. Turning this off hides the choice without affecting existing native runs."
|
||||
checked={enableNativeRunner}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleMutation.mutate({ enableNativeRunner: checked })
|
||||
|
||||
@@ -948,6 +948,49 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
expect(container.textContent).not.toContain("Connect for tool access instead");
|
||||
});
|
||||
|
||||
it.each([false, true])("opens Copilot with visible personal defaults and shares only by explicit choice (%s)", async (share) => {
|
||||
mockSearch.value = "source=github&method=copilot-token";
|
||||
listGalleryMock.mockResolvedValue({ apps: [GITHUB] });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
|
||||
client.setQueryData(queryKeys.environments.list("company-1"), [
|
||||
{ id: "local-env", name: "Local", driver: "local", status: "active", config: {} },
|
||||
]);
|
||||
client.setQueryData(queryKeys.instance.settings, { defaultEnvironmentId: "local-env" });
|
||||
const createAiAccount = vi.spyOn(aiConnectionsApi, "create").mockResolvedValue({
|
||||
connectionId: "copilot-ai-account", grantId: "copilot-ai-grant",
|
||||
});
|
||||
await render(client);
|
||||
await passAccessStep();
|
||||
expect(container.textContent).toContain("personal fine-grained token with Copilot Requests permission");
|
||||
expect(container.querySelector('input[placeholder="github_pat_..."]')).toBeTruthy();
|
||||
expect(container.querySelector('[aria-label="Copilot execution environment"]')).toBeTruthy();
|
||||
expect(container.textContent).not.toContain("You must connect this instance to Paperclip");
|
||||
expect(getCloudConnectorEnrollmentMock).not.toHaveBeenCalled();
|
||||
expect(startOAuthMock).not.toHaveBeenCalled();
|
||||
expect(connectAppMock).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("Connects as you");
|
||||
expect(container.textContent?.match(/Connects as you/g)).toHaveLength(1);
|
||||
expect(container.textContent).toContain("Connect GitHub Copilot");
|
||||
await openAccessAdvanced();
|
||||
expect(radioContaining("My GitHub account")?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(radioContaining("Only agents I choose")?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(radioContaining("Any agent")?.getAttribute("aria-checked")).toBe("false");
|
||||
if (share) {
|
||||
await act(async () => radioContaining("Shared organization GitHub account")!.click());
|
||||
await act(async () => radioContaining("Any agent")!.click());
|
||||
}
|
||||
const key = container.querySelector<HTMLInputElement>('input[type="password"]')!;
|
||||
await act(async () => setInputValue(key, "fixture-copilot-token"));
|
||||
await act(async () => buttonByText("Connect")!.click());
|
||||
await flushReact();
|
||||
expect(createAiAccount).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||||
provider: "github", method: "api_key", apiKey: "fixture-copilot-token",
|
||||
ownership: share ? "shared" : "personal", allAgents: share, agentIds: [],
|
||||
environmentId: "local-env",
|
||||
}));
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/copilot-ai-account/permissions");
|
||||
});
|
||||
|
||||
it("states the GitHub identity default and keeps its controls one click away", async () => {
|
||||
mockParams.appKey = "github";
|
||||
listGalleryMock.mockResolvedValue({ apps: [GITHUB_MANAGED] });
|
||||
|
||||
@@ -973,6 +973,10 @@ describe("Connectors landing page", () => {
|
||||
expect(container.textContent).not.toContain("Chat with agents");
|
||||
await act(() => void container.querySelector<HTMLButtonElement>('button[aria-label="Add key GitHub"]')!.click());
|
||||
expect(navigateMock).toHaveBeenLastCalledWith("/apps/connect?source=github");
|
||||
const copilot = [...container.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Connect Copilot")!;
|
||||
expect(copilot).toBeTruthy();
|
||||
await act(() => void copilot.click());
|
||||
expect(navigateMock).toHaveBeenLastCalledWith("/apps/connect?source=github&method=copilot-token");
|
||||
});
|
||||
|
||||
it("names what the card will actually ask for", async () => {
|
||||
|
||||
@@ -298,7 +298,7 @@ function connectorAction(
|
||||
// Tool connectors retain their capability-specific setup verbs.
|
||||
if (row.entry) {
|
||||
return {
|
||||
label: row.entry.methods?.some(method => method.purpose === "ai") ? "Connect" : connectionSetupVerbForApp(row.entry),
|
||||
label: row.entry.methods?.every(method => method.purpose === "ai") ? "Connect" : connectionSetupVerbForApp(row.entry),
|
||||
href: connectHrefFor(row.entry),
|
||||
};
|
||||
}
|
||||
@@ -1070,6 +1070,9 @@ export function ConnectorCard({
|
||||
chatConnectorsEnabled,
|
||||
preselectedAgentId,
|
||||
);
|
||||
const copilotMethod = row.slug === "github"
|
||||
? getAppStoreDefinition(row.slug)?.methods.find(method => method.purpose === "ai" && method.ai?.provider === "github")
|
||||
: undefined;
|
||||
const upstreamAccounts = (row.upstreamApps ?? []).flatMap(snapshot => {
|
||||
const connection = connectionById?.get(snapshot.connectionId);
|
||||
return connection ? snapshot.accounts.map((account, index) => ({ connection, account, snapshot,
|
||||
@@ -1113,6 +1116,14 @@ export function ConnectorCard({
|
||||
{row.aggregatorApp && row.upstreamApps?.some(app => app.accounts.length > 0) ? `Accounts managed in ${[...new Set(row.upstreamApps?.map(app => AGGREGATOR_NAMES[app.provider ?? "composio"]))].join(" and ")}.` : row.description}
|
||||
</p>
|
||||
</div>
|
||||
{copilotMethod && <Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onNavigate(`/apps/connect?source=github&method=${encodeURIComponent(copilotMethod.key)}`)}
|
||||
>
|
||||
Connect Copilot
|
||||
</Button>}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
||||
Reference in new issue
Block a user