Connect and configure GitHub Copilot through Apps (#15705)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Apps and AI Connections store credentials and govern access to agent
runtimes.
> - Users need a supported saved-token connection and config values that
survive save and reopen.
> - The release must preserve saved-token access and task lifecycle
guarantees.
> - This pull request will connect Copilot through the normal Apps and
AI Connections path.
> - The benefit is a reviewable part of the Copilot production release.

## Linked Issues or Issue Description

Refs #15701 and #15703. Supersedes setup work in #15562. Base: #15703.
Next: #15706.

## What Changed

- Require configuration update authority before Copilot
credential-bearing model discovery; default to the saved environment and
preserve explicit selection for editors.

- Keep Copilot discovery passive during reconnect reconciliation;
refresh on explicit opening, account/environment changes and reconnect.

- Add GitHub AI token connections with encrypted storage, ownership,
grants and audit logging.
- Keep repository access separate from Copilot runtime authentication.
- Probe the selected environment without sending a model prompt and
preserve authenticated model selection through save/reopen.
- Keep fresh execution admission consistent with normal Copilot setup;
persist task model choices without changing provider or permission
policy.
- Refresh authenticated models after reconnecting the same saved Copilot
account, including recovery from a previous authentication error.
- Apply the additive provider-default constraint migration through the
normal generated migration workflow.

## Verification

Runtime #15701 is merged at `d855e550ad8d2a267312983f26457aabf5393c3a`.
History #15703 is merged at `df80616d98119cbf5abd40549794d9980a5fabf6`.
Remaining source
[`42bcbd61299655f7e37aabe6bb5f19bba770ad1d`](https://github.com/paperclipai/paperclip/commit/42bcbd61299655f7e37aabe6bb5f19bba770ad1d)
contains the four-file authorization repair. The remaining PR trees
preserve six nonoverlapping files from the newer public mainline
managed-settings fix and the CI-owned lockfile. No unrelated provider is
enabled.

The security finding is repaired: existing-agent discovery requires
`agent_config:update` before connection selection or token resolution.
The saved environment is the default. Authorized editors can select a
prospective environment. Both denied board-viewer and agent-reader
regressions fail on the original source. All49 route tests and server
typecheck pass with the fix. Denied requests perform no connection
selection, credential resolution or probe. Current-head security scans
pass on both remaining PRs; the repaired thread is resolved.

Copilot CLI1.0.88 and profile47 retain digest
`sha256:55044aa235e88b3fe36ec8a556ee1e3382590870aab907e4462ef7d64af49cea`.
The [qualification
record](https://github.com/paperclipai/paperclip/blob/42bcbd61299655f7e37aabe6bb5f19bba770ad1d/doc/plans/2026-10-20-copilot-qualification-v47.json)
reports20/20 Product cells,154 assertions and7/7 protocol cases,46
assertions against frozen normal installed source
`c2616370800f5f6df45246b0890ba35a7cd2faab`. The exact qualification
model is `gpt-5.6-luna`. Every passing Product case confirms cleanup.
Original failures remain recorded; automatic behavior retries are
disabled. The route-only authorization repair changes no bound
runtime/profile bytes and does not relabel the frozen campaign.

The ordinary installed remote journey passed saved-token selection,
model discovery, normal UI save/reopen, metadata-only Test, completed
task, accepted receipt and exact19-byte browser download. An open model
catalog caused no additional discovery requests during117seconds. Actual
installed runtime smokes passed on macOS ARM64, macOS x64 under Rosetta
and native Linux x64 on Daytona. Raw token browser entry was not
exercised; encrypted connection creation used the normal API.

The first Daytona file attempt timed out during delayed semantic
dispatch; its precise stalled layer remains unresolved. Independent
ordinary remote delivery and the other19 Product cells passed before one
instrumented recheck passed8/8 with unchanged source,prompt,assertions
and deadlines. No causal latency repair is claimed.

Before the authorization repair, all four final PR heads passed required
verify/E2E and fresh Greptile5/5 reviews with zero unresolved threads.
Current-head CI/review is running for the two repaired heads. Local
focused checks retain129 connection,59 runner,72 shared-eval and88
recovery/workspace passes, plus migration safety and token gates. The
additive Copilot migration is361; released master history is preserved.

[Cloud normal-package
run39414239704](https://github.com/paperclipai/paperclip/actions/runs/39414239704)
builds the pre-security source4f3f. All four native producers and Linux
package assembly pass; the final clean installed consumers are
finishing. All18 retained archives match their hashes;37,863 regular
files contain zero selected credentials. That package does not contain
the later route repair. The actual final published package must include
it and pass the local/Daytona live canaries. The frozen Daytona image
remains published and independently verified. No separate full-image
scan is claimed. Docker builds run in the cloud.

**Remaining:** current-head CI/review, setup and qualification merges,
final publication, and ordinary local/Daytona canaries against the
actual shipped package. The user explicitly authorizes the maintainer
review override; CI remains a merge gate. No production deployment is
claimed. Provider USD remains unknown and GitHub-attributed.

## Risks

Bad credentials, missing entitlement, runtime installation and
unavailable models must remain distinct failures. A connection must not
adopt ambient credentials. The constraint migration changes accepted
provider values, with no credential-storage migration.

Current-master NewAgent fixture extraction is retained. Copilot cases
use that shared support and test both passive discovery and explicit
reconnect under the same setup lifecycle.

The onboarding retry regression shares the component suite’s
saved-connection, authenticated-model, draft, agent-list, hire and
render lifecycle fixtures. Keeping it beside the existing onboarding
failures makes adoption versus duplicate hire observable under the same
lifecycle; this is the concrete cohesion reason for the existing large
test file.

## Model Used

OpenAI Codex, based on GPT-6. The exact serving model ID and context
window are not exposed in this task. Used for code changes, review and
tool-assisted verification.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-20 07:02:22 -05:00
1 parent 0b266bd084
commit 92b238137b
94 files changed
+6813 -4907

No files matched your search

@@ -1,5 +1,6 @@
import { execFile, spawn } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import net from "node:net";
import os from "node:os";
import path from "node:path";
@@ -302,8 +303,8 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const output = { stdout: [] as string[], stderr: [] as string[] };
const child = spawn(
"pnpm",
["paperclipai", "run", "--config", configPath],
process.execPath,
["--import", createRequire(import.meta.url).resolve("tsx"), path.join(repoRoot, "cli/src/index.ts"), "run", "--config", configPath],
{
cwd: repoRoot,
env: createServerEnv(configPath, port, tempDb.connectionString, {
+72
View File
@@ -0,0 +1,72 @@
# GitHub Copilot runtime connection
GitHub Copilot uses a saved GitHub token and the packaged Copilot CLI **1.0.88**.
Runtime profile **v47** passed the local and Daytona qualification campaign.
The [qualification record](../plans/2026-10-20-copilot-qualification-v47.json)
identifies the tested build, retained failures, and remaining release gates.
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. Reconnecting
the same saved account refreshes its discovered models and clears the old selection.
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.
Model discovery uses the selected environment and can create a short-lived Daytona sandbox. Copilot model choices refresh when opening a task selector, changing an account or environment, reconnecting, or explicitly refreshing. Background live-data reconciliation does not repeat this probe.
The qualification record retains one Daytona tool-dispatch timeout whose exact
cause is unresolved. Its unchanged instrumented recheck passed after independent
ordinary remote delivery and the other19 Product cases passed. No timeout or
completion check was weakened. Qualification does not establish deployment: use
the release record to verify the shipped package and canary.
Discovering models for an existing agent requires permission to update that
agent configuration. The probe uses the saved environment unless an authorized
editor selects another environment. Read-only configuration access does not
resolve the token or start a discovery probe.
@@ -12,12 +12,11 @@ import {
} from "./paperclip-runner-permissions.js";
describe("Paperclip Runner permission defaults", () => {
it("admits Pi with provider credentials while keeping Copilot pending", () => {
it("marks Pi and Copilot qualified with their provider credential bindings", () => {
expect(PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === "pi"))
.toMatchObject({ qualified: true, credentialEnvironment: expect.arrayContaining(["OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY"]) });
for (const agent of ["copilot"]) {
expect(PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === agent)?.qualified).toBe(false);
}
expect(PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === "copilot"))
.toMatchObject({ qualified: true, credentialEnvironment: ["COPILOT_GITHUB_TOKEN"] });
});
it("defaults Codex to the only qualified non-interactive mode", () => {
expect(resolvePaperclipRunnerPermissionMode("codex", undefined)).toBe(
@@ -247,7 +247,7 @@ 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: true, credentialEnvironment: ["OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_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) => {
@@ -245,16 +256,7 @@ describe("buildPaperclipRunnerConfig", () => {
expect(config).not.toHaveProperty("acpxAgent");
});
it.each(["copilot"])("rejects unavailable ACPX %s without selecting another provider", (acpxAgent) => {
expect(() => buildPaperclipRunnerConfig(makeValues({
adapterType: "paperclip_runner",
model: "explicit-provider-model",
adapterSchemaValues: {
provider: "acpx",
acpxAgent,
},
}))).toThrow(/is not enabled for production/);
});
it.each(["agent", "plan", "ask"])("preserves Cursor's explicit model and %s mode", (mode) => {
expect(buildPaperclipRunnerConfig(makeValues({
@@ -123,7 +123,10 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
const configuredModel = typeof config.model === "string"
? config.model.trim()
: "";
if (provider === "acpx" && ["cursor", "pi", "copilot"].includes(acpxAgent) && !configuredModel && !schemaModel) {
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" && ["cursor", "pi"].includes(acpxAgent) && !configuredModel && !schemaModel) {
throw new Error(`${acpxAgent} requires an explicit provider model`);
}
const managedProfileId = typeof schemaValues.managedProfileId === "string"
@@ -85,10 +85,14 @@ function createFreshLeaseSandboxRunner(options: {
}) {
let counter = 0;
const installCommands: string[] = [];
const fixtureBin = path.join(options.homeDir, "fixture-system-bin");
const fixtureReady = (async () => {
await fs.mkdir(fixtureBin, { recursive: true });
await fs.symlink(process.execPath, path.join(fixtureBin, "node"));
await fs.writeFile(path.join(fixtureBin, "curl"), "#!/bin/sh\nexit 97\n", { mode: 0o755 });
})();
const systemPath = [
"/usr/local/bin",
"/opt/homebrew/bin",
"/usr/local/sbin",
fixtureBin,
"/usr/bin",
"/bin",
"/usr/sbin",
@@ -107,6 +111,7 @@ function createFreshLeaseSandboxRunner(options: {
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
}) => {
await fixtureReady;
counter += 1;
const args = [...(input.args ?? [])];
if (args[1] === SANDBOX_INSTALL_COMMAND) {
@@ -114,7 +119,8 @@ function createFreshLeaseSandboxRunner(options: {
args[1] = buildInstallSimulationCommand(options.installCommandPath, options.captureDir);
}
const inheritedPath = input.env?.PATH ?? systemPath;
// A fresh remote lease has its own PATH, independent of installed host agents.
const inheritedPath = systemPath;
const pathWithLocalBin = `${path.join(options.homeDir, ".local", "bin")}${path.delimiter}${inheritedPath}`;
const env = {
...(input.env ?? {}),
@@ -122,6 +128,9 @@ function createFreshLeaseSandboxRunner(options: {
PATH: pathWithLocalBin,
};
if (["agent", "cursor-agent"].includes(path.basename(input.command)) && input.command !== options.installCommandPath) {
throw new Error("Fresh sandbox fixture refused an agent outside its installation path");
}
return await runChildProcess(`cursor-fresh-lease-${counter}`, input.command, args, {
cwd: input.cwd ?? process.cwd(),
env,
@@ -0,0 +1,4 @@
ALTER TABLE "ai_connection_defaults" DROP CONSTRAINT "ai_connection_defaults_provider_check";--> statement-breakpoint
ALTER TABLE "ai_provider_defaults" DROP CONSTRAINT "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'));
File diff suppressed because it is too large. Load diff
+8 -1
View File
@@ -2514,6 +2514,13 @@
"when": 1792447934421,
"tag": "0360_retained_workspace_recovery_index",
"breakpoints": true
},
{
"idx": 361,
"version": "7",
"when": 1792494929768,
"tag": "0361_mighty_dracula",
"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')`),
]);
+7 -1
View File
@@ -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)
@@ -187,6 +192,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
+3 -2
View File
@@ -310,8 +310,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": {
@@ -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"
}
}
]
}
@@ -330,7 +330,7 @@ test('the verifier stages only workspace packages, so it skips the standalone bu
// staged, reusing the UI that `pnpm build` produced, and it is timed. The
// gated standalone build keeps release.sh's order: after the closure is
// known and before the UI copy and any staged source.
assert.deepEqual(JSON.parse(readFileSync(new URL('server/package.json', repo), 'utf8')).files, ['dist', 'ui-dist', 'skills']);
assert.deepEqual(JSON.parse(readFileSync(new URL('server/package.json', repo), 'utf8')).files, ['dist', 'ui-dist', 'skills', 'scripts/install-copilot-assets.mjs']);
assert.ok(source.includes("PAPERCLIP_RELEASE_REUSE_UI_DIST: '1'"));
const uiDist = position("run('bash', [join(repo, 'scripts/prepare-server-ui-dist.sh')], repo)");
assert.ok(position('publicInstallClosure(repo)') < gate && gate < uiDist && uiDist < position('timing.stage_ui_dist_ms =') && uiDist < position('const stageSource = '));
@@ -0,0 +1,118 @@
{
"schemaVersion": 1,
"slug": "github",
"name": "GitHub",
"description": "Connect GitHub repository access or authenticate GitHub Copilot agents.",
"categories": [
"developer",
"ai"
],
"featured": true,
"urlPatterns": [
"https://api.githubcopilot.com/mcp/*",
"https://github.com/*"
],
"methods": [
{
"key": "managed",
"transport": "mcp_remote",
"auth": "oauth",
"ownershipModes": [
"platform_shared"
],
"whenToUse": "Connect your GitHub account for durable MCP, shell Git, gh, and repository access.",
"defaults": {
"serverUrl": "https://api.githubcopilot.com/mcp/"
},
"guidanceMd": "Authorize Paperclip, then choose selected repositories in GitHub. You can edit repository access later from GitHub's installation settings.",
"riskTier": "S3",
"label": "Connect GitHub",
"purpose": "tool",
"oauthStrategy": "paperclip_cloud_connector",
"connectorProfile": "github.code",
"grantKinds": [
"user",
"agent"
],
"warnings": [
"Shell Git and gh receive this identity for the run and are not constrained by per-tool Ask-first controls."
],
"requiredResourceFilters": [
"organization",
"repository"
]
},
{
"key": "mcp-key",
"transport": "mcp_remote",
"auth": "api_key",
"ownershipModes": [
"customer"
],
"whenToUse": "Use the provider-hosted connection for the quickest setup.",
"defaults": {
"serverUrl": "https://api.githubcopilot.com/mcp/"
},
"guidanceMd": "Create a fine-grained token limited to the repositories agents should use.",
"riskTier": "S3",
"label": "Personal access token (advanced)",
"purpose": "tool",
"credentialFields": [
{
"key": "authorization",
"label": "GitHub token",
"type": "password",
"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\u2019s permissions."
}
],
"keyPlacement": {
"location": "header",
"name": "Authorization",
"prefix": "Bearer "
},
"requiredResourceFilters": [
"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"
}
}
]
}
+5 -3
View File
@@ -29,20 +29,22 @@
"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",
"build:runtime": "node scripts/bundle-runtime.mjs",
"start": "node dist/index.js",
"prepare:runner-vendor": "node scripts/prepare-runner-vendor.mjs",
"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": {
"@agentclientprotocol/claude-agent-acp": "0.73.0",
+27 -15
View File
@@ -1,4 +1,6 @@
import { probeAcpxClaudeInstallation, probeAcpxPiInstallation } from "../vendor/paperclip-runner/live/index.js";
import { probeCopilotExecutionTarget } from "../services/copilot-connection-probe.js";
import { HttpError } from "../errors.js";
import { probeAcpxClaudeInstallation, probeAcpxCursorInstallation, probeAcpxPiInstallation } from "../vendor/paperclip-runner/live/index.js";
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { buildSandboxNpmInstallCommand } from "@paperclipai/adapter-utils";
import type { ServerAdapterModule } from "../adapters/index.js";
@@ -22,6 +24,7 @@ const nativeSetup = vi.hoisted(() => ({ authenticate: vi.fn() }));
vi.mock("../services/native-runtime/setup-readiness.js", () => ({
testNativeRunnerAuthentication: nativeSetup.authenticate,
}));
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),
@@ -374,20 +377,29 @@ describe("server adapter registry", () => {
});
expect(probe).toHaveBeenLastCalledWith("openrouter/deepseek/deepseek-v4-flash-0731");
});
it("reports qualification-only readiness for an exact host-authorized candidate", async () => {
const key = "PAPERCLIP_RUNNER_ACPX_QUALIFICATION";
const previous = process.env[key];
process.env[key] = JSON.stringify([{ agent: "copilot", model: "exact-model" }]);
try {
const result = await requireServerAdapter("paperclip_runner").testEnvironment({
companyId: "company-1", adapterType: "paperclip_runner",
config: { provider: "acpx", acpxAgent: "copilot", model: "exact-model" },
});
expect(result).toMatchObject({ status: "warn", checks: [{ code: "acpx_candidate_qualification_only" }] });
} finally {
if (previous === undefined) delete process.env[key];
else process.env[key] = previous;
}
it("requires a saved Copilot token rather than ambient authentication",async()=>{
const prior=process.env.COPILOT_GITHUB_TOKEN;process.env.COPILOT_GITHUB_TOKEN="ambient-not-authority";
const probe=vi.mocked(probeCopilotExecutionTarget);probe.mockClear();
try{
const result=await requireServerAdapter("paperclip_runner").testEnvironment({companyId:"company-1",adapterType:"paperclip_runner",config:{provider:"acpx",acpxAgent:"copilot",model:"gpt-5.6-luna"}});
expect(result).toMatchObject({status:"fail",checks:[{code:"COPILOT_AUTH_REQUIRED"}]});expect(probe).not.toHaveBeenCalled();
}finally{if(prior===undefined)delete process.env.COPILOT_GITHUB_TOKEN;else process.env.COPILOT_GITHUB_TOKEN=prior;}
});
it.each([undefined,{kind:"remote",remoteCwd:"/workspace"}])("verifies the selected Copilot runtime and model without a prompt",async(executionTarget)=>{
const result=await requireServerAdapter("paperclip_runner").testEnvironment({companyId:"company-1",adapterType:"paperclip_runner",config:{provider:"acpx",acpxAgent:"copilot",model:"gpt-5.6-luna",env:{COPILOT_GITHUB_TOKEN:"fixture-token"}},executionTarget} as any);
expect(probeCopilotExecutionTarget).toHaveBeenLastCalledWith("fixture-token",executionTarget,"gpt-5.6-luna");
expect(result).toMatchObject({status:"pass",checks:[{code:"copilot_metadata_verified"}]});
});
it.each(["COPILOT_AUTH_REQUIRED","COPILOT_ENTITLEMENT_DENIED","COPILOT_MODEL_UNAVAILABLE","COPILOT_INSTALLATION_INVALID","COPILOT_REQUEST_FAILED"])("reports distinct Copilot failure %s",async code=>{
vi.mocked(probeCopilotExecutionTarget).mockRejectedValueOnce(new HttpError(422,"Fixture metadata failure",{code}));
const result=await requireServerAdapter("paperclip_runner").testEnvironment({companyId:"company-1",adapterType:"paperclip_runner",config:{provider:"acpx",acpxAgent:"copilot",model:"gpt-5.6-luna",env:{COPILOT_GITHUB_TOKEN:"fixture-token"}}});
expect(result).toMatchObject({status:"fail",checks:[{code}]});
});
it("sanitizes unexpected Copilot metadata failures",async()=>{
vi.mocked(probeCopilotExecutionTarget).mockRejectedValueOnce(new Error("fixture private implementation detail"));
const result=await requireServerAdapter("paperclip_runner").testEnvironment({companyId:"company-1",adapterType:"paperclip_runner",config:{provider:"acpx",acpxAgent:"copilot",model:"gpt-5.6-luna",env:{COPILOT_GITHUB_TOKEN:"fixture-token"}}});
expect(result).toMatchObject({status:"fail",checks:[{code:"COPILOT_REQUEST_FAILED",message:"Copilot metadata verification failed."}]});
expect(JSON.stringify(result)).not.toContain("private implementation detail");
});
it("wraps built-in npm runtime installs with the sandbox-aware install helper", () => {
const expectedClaudeInstall = `if ! command -v 'claude' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@anthropic-ai/claude-code")}; fi`;
@@ -435,6 +435,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");
@@ -30,6 +30,7 @@ describe("saved harness verification", () => {
["warn", ["claude_acp_anthropic_api_key_detected", "claude_acp_login_probe_unavailable"]],
["warn", ["cli_version_probe_mismatch"]], ["pass", [ADAPTER_AUTH_MISSING_CHECK_CODE]],
["warn", ["claude_hello_probe_passed", "claude_hello_probe_auth_required"]],
["pass", ["COPILOT_AUTH_REQUIRED"]], ["fail", ["COPILOT_MODEL_UNAVAILABLE"]],
] as const)("rejects %s with %j", (status, codes) => {
expect(() => assertHarnessTestPassed(result(status, [...codes]))).toThrow("The harness test failed");
});
@@ -37,6 +38,7 @@ describe("saved harness verification", () => {
["pass", ["process_command_available"]], ["warn", ["claude_hello_probe_passed", "optional_environment_warning"]],
["warn", ["claude_acp_anthropic_api_key_detected", "claude_hello_probe_passed"]],
["pass", ["codex_hello_probe_succeeded"]], ["warn", ["ai_connection_api_key_reverified"]],
["pass", ["copilot_metadata_verified"]], ["warn", ["copilot_metadata_verified"]],
] as const)("accepts %s with %j", (status, codes) => {
expect(() => assertHarnessTestPassed(result(status, [...codes]))).not.toThrow();
});
@@ -32,6 +32,7 @@ const mockEnvironmentService = vi.hoisted(() => ({
releaseLease: vi.fn(),
listBoundCompanyIds: vi.fn(async () => [] as string[]),
findManagedSandboxEnvironment: vi.fn(async () => null as Record<string, unknown> | null),
findKubernetesEnvironment: vi.fn(async () => null as Record<string, unknown> | null),
}));
const mockReleaseRunLease = vi.hoisted(() => vi.fn(async () => undefined));
@@ -46,7 +47,7 @@ const mockEnvironmentRuntime = vi.hoisted(() => ({
const mockResolveEnvironmentExecutionTarget = vi.hoisted(() => vi.fn());
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(async () => ({ defaultEnvironmentId: null as string | null })),
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })),
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false, executionMode: "local" })),
getExperimental: vi.fn(async (): Promise<{
enableManagedSandboxOnly: boolean;
enableOpenAiDot?: boolean;
@@ -125,9 +126,16 @@ vi.mock("../services/ai-connection-runtime.js", async (importOriginal) => ({
const mockValidateAiApiKey = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("../services/ai-api-key-test.js", () => ({ validateAiApiKey: mockValidateAiApiKey }));
const mockMarkAuthenticationFailed = vi.hoisted(() => vi.fn(async () => undefined));
const mockSelectAiConnection = vi.hoisted(() => vi.fn(async (_input: unknown) => ({ connectionId: "copilot-connection", grantId: "copilot-grant" })));
const mockAiCredential = vi.hoisted(() => vi.fn(async (_selection: unknown) => "synthetic-copilot-token"));
const mockCopilotModels = vi.hoisted(() => vi.fn(async () => ({ models: [{ id: "gpt-5.6-luna", label: "GPT-5.6 Luna" }], promptSent: false })));
const mockCopilotProbe = vi.hoisted(() => vi.fn(async () => ({ status: "verified", promptSent: false })));
vi.mock("../services/copilot-connection-probe.js", async importOriginal => ({
...(await importOriginal<object>()), probeCopilotExecutionTarget: mockCopilotProbe, probeCopilotConnection: mockCopilotModels,
}));
vi.mock("../services/ai-connections.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../services/ai-connections.js")>()),
aiConnectionService: () => ({ markAuthenticationFailed: mockMarkAuthenticationFailed }),
aiConnectionService: () => ({ markAuthenticationFailed: mockMarkAuthenticationFailed, select: mockSelectAiConnection, credential: mockAiCredential }),
}));
function mockManagedRuntime(method: "api_key" | "subscription") {
@@ -171,7 +179,7 @@ function loadAppModules() {
]);
}
async function createApp() {
async function createApp(actor: Record<string, unknown> = {}) {
const [{ agentRoutes }, { errorHandler }] = await loadAppModules();
const app = express();
app.use(express.json());
@@ -182,6 +190,7 @@ async function createApp() {
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
...actor,
};
next();
});
@@ -204,6 +213,8 @@ describe("agent test-environment route", () => {
beforeEach(async () => {
vi.clearAllMocks();
mockInstanceSettingsService.get.mockResolvedValue({ defaultEnvironmentId: null });
mockInstanceSettingsService.getGeneral.mockResolvedValue({ censorUsernameInLogs: false, executionMode: "local" });
mockEnvironmentService.findKubernetesEnvironment.mockResolvedValue(null);
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: false });
mockEnvironmentService.findManagedSandboxEnvironment.mockResolvedValue(null);
mockAccessService.decide.mockResolvedValue({
@@ -301,6 +312,74 @@ describe("agent test-environment route", () => {
} finally { vi.unstubAllEnvs(); }
});
it("lets an authorized agent editor discover Copilot models", async () => {
mockAgentService.getById.mockResolvedValue({ id: "agent-1", companyId: "company-1" });
const response = await request(await createApp()).get("/api/companies/company-1/adapters/paperclip_runner/models")
.query({ provider: "acpx", acpxAgent: "copilot", agentId: "agent-1" });
expect(response.status).toBe(200);
expect(response.body).toEqual([{ id: "gpt-5.6-luna", label: "GPT-5.6 Luna" }]);
expect(response.headers["cache-control"]).toBe("no-store");
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "agent_config:update" }));
expect(mockSelectAiConnection).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
companyId: "company-1", userId: "local-board", agentId: "agent-1", runnerProvider: "acpx", acpxAgent: "copilot",
binding: { provider: "github", method: "api_key", mode: "responsible_user" }, allowUninstalledShared: false,
}));
expect(mockCopilotModels).toHaveBeenCalledExactlyOnceWith(expect.anything(), "company-1", "synthetic-copilot-token", null, undefined, expect.anything());
});
it.each([
["board viewer", { type: "board" }],
["agent reader", { type: "agent", agentId: "reader-agent", companyId: "company-1", onBehalfOfUserId: "reader-user",
onBehalfOfMemberships: [{ companyId: "company-1", status: "active", membershipRole: "member" }] }],
])("refuses a %s discovery probe before resolving a shared token", async (_label, actor) => {
mockAccessService.decide.mockImplementation(async ({ action }) => ({
allowed: action === "agent_config:read", explanation: "No configuration update grant",
}));
mockAccessService.canUser.mockResolvedValue(false);
mockAgentService.getById.mockResolvedValue({ id: "agent-1", companyId: "company-1" });
const binding = { provider: "github", method: "api_key", mode: "shared",
connectionId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", grantId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" };
const response = await request(await createApp(actor)).get("/api/companies/company-1/adapters/paperclip_runner/models")
.query({ provider: "acpx", acpxAgent: "copilot", agentId: "agent-1",
environmentId: "33333333-3333-4333-8333-333333333333", aiConnection: JSON.stringify(binding) });
expect(response.status).toBe(403);
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "agent_config:update" }));
expect(mockSelectAiConnection).not.toHaveBeenCalled();
expect(mockAiCredential).not.toHaveBeenCalled();
expect(mockCopilotModels).not.toHaveBeenCalled();
});
it.each([undefined, "33333333-3333-4333-8333-333333333333"])("uses the saved or editor-selected Copilot environment (%s)", async environmentId => {
const savedEnvironmentId = "22222222-2222-4222-8222-222222222222";
mockAgentService.getById.mockResolvedValue({ id: "agent-1", companyId: "company-1", defaultEnvironmentId: savedEnvironmentId });
const response = await request(await createApp()).get("/api/companies/company-1/adapters/paperclip_runner/models")
.query({ provider: "acpx", acpxAgent: "copilot", agentId: "agent-1", ...(environmentId ? { environmentId } : {}) });
expect(response.status).toBe(200);
expect(mockCopilotModels).toHaveBeenCalledExactlyOnceWith(expect.anything(), "company-1", "synthetic-copilot-token",
environmentId ?? savedEnvironmentId, undefined, expect.anything());
});
it("refuses model discovery for a foreign agent before selecting credentials", async () => {
mockAgentService.getById.mockResolvedValue({ id: "agent-foreign", companyId: "company-2" });
const response = await request(await createApp()).get("/api/companies/company-1/adapters/paperclip_runner/models")
.query({ provider: "acpx", acpxAgent: "copilot", agentId: "agent-foreign" });
expect(response.status).toBe(404);
expect(mockSelectAiConnection).not.toHaveBeenCalled();
expect(mockAiCredential).not.toHaveBeenCalled();
expect(mockCopilotModels).not.toHaveBeenCalled();
});
it("keeps saved connection access enforced before Copilot model probing", async () => {
const { forbidden } = await import("../errors.js");
mockAgentService.getById.mockResolvedValue({ id: "agent-1", companyId: "company-1" });
mockSelectAiConnection.mockRejectedValueOnce(forbidden("Saved connection access denied"));
const response = await request(await createApp()).get("/api/companies/company-1/adapters/paperclip_runner/models")
.query({ provider: "acpx", acpxAgent: "copilot", agentId: "agent-1" });
expect(response.status).toBe(403);
expect(mockAiCredential).not.toHaveBeenCalled();
expect(mockCopilotModels).not.toHaveBeenCalled();
});
it("tests the instance default sandbox when the agent inherits its environment", async () => {
const environmentId = "11111111-1111-4111-8111-111111111111";
mockInstanceSettingsService.get.mockResolvedValue({ defaultEnvironmentId: environmentId });
@@ -376,6 +455,41 @@ describe("agent test-environment route", () => {
expect(testEnvironmentSpy).toHaveBeenCalledWith(expect.objectContaining({ executionTarget: target }));
});
it.each([null, "33333333-3333-4333-8333-333333333333"])("verifies Copilot on forced Kubernetes before the host selection (%s)", async environmentId => {
const kubernetesId = "11111111-1111-4111-8111-111111111111";
mockInstanceSettingsService.getGeneral.mockResolvedValue({ censorUsernameInLogs: false, executionMode: "kubernetes" });
mockEnvironmentService.findKubernetesEnvironment.mockResolvedValue({ id: kubernetesId });
const target = { kind: "remote", transport: "sandbox", remoteCwd: "/workspace", providerKey: "kubernetes", runner: { execute: vi.fn() } };
mockResolveEnvironmentExecutionTarget.mockResolvedValue(target);
mockPrepareManagedAiRuntime.mockImplementation(async (_db, input) => ({
config: { ...input.config, env: { COPILOT_GITHUB_TOKEN: "private-fixture" } },
attribution: { provider: "github", method: "api_key" }, cleanup: vi.fn(),
}));
const app = await createApp();
const response = await request(app).post("/api/companies/company-1/adapters/paperclip_runner/test-environment").send({
environmentId, adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" },
aiConnection: { provider: "github", method: "api_key", mode: "responsible_user" },
});
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(response.body.status).toBe("pass");
expect(mockEnvironmentRuntime.acquireRunLease).toHaveBeenCalledWith(expect.objectContaining({ environment: expect.objectContaining({ id: kubernetesId }) }));
expect(mockCopilotProbe).toHaveBeenCalledExactlyOnceWith("private-fixture", target, "gpt-5.6-luna");
expect(mockReleaseRunLease).toHaveBeenCalledOnce();
});
it("refuses a Copilot test before credentials when forced Kubernetes is unavailable", async () => {
mockInstanceSettingsService.getGeneral.mockResolvedValue({ censorUsernameInLogs: false, executionMode: "kubernetes" });
const app = await createApp();
const response = await request(app).post("/api/companies/company-1/adapters/paperclip_runner/test-environment").send({
adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" },
aiConnection: { provider: "github", method: "api_key", mode: "responsible_user" },
});
expect(response.status).toBe(422);
expect(response.body.details.code).toBe("copilot_environment_unavailable");
expect(mockPrepareManagedAiRuntime).not.toHaveBeenCalled();
expect(mockCopilotProbe).not.toHaveBeenCalled();
});
it("checks the inherited environment's company before resolving credentials", async () => {
mockInstanceSettingsService.get.mockResolvedValue({ defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" });
mockEnvironmentService.listBoundCompanyIds.mockResolvedValue(["company-2"]);
+231 -2
View File
@@ -39,6 +39,10 @@ import express from "express";
import request from "supertest";
import { aiConnectionRoutes, canInstallSharedAiConnectionForNewAgent, responsibleUserForAiRequest } from "../routes/ai-connections.js";
import { validateAiApiKey } from "../routes/ai-connections.js";
import * as copilotProbe from "../services/copilot-connection-probe.js";
import * as settingsModule from "../services/instance-settings.js";
import { agentRoutes } from "../routes/agents.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
vi.mock("../services/local-ai-browser-login.js", () => ({
startLocalBrowserLogin: () => ({ authorizationUrl: "https://auth.openai.com/codex/device", code: "ABCD-EFGHJ", abort: () => {} }),
}));
@@ -68,6 +72,219 @@ beforeAll(async () => {
afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); if (home) await rm(home, { recursive: true, force: true }); });
describe("managed AI connections", () => {
it.each(["create", "hire", "edit"])("refuses Copilot %s before probing or persisting when forced Kubernetes is missing", async action => {
const { errorHandler } = await import("../middleware/index.js");
const owner = `copilot-kubernetes-${action}`, id = randomUUID();
const config = { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" };
const aiConnection = { provider: "github", method: "api_key", mode: "responsible_user" } as const;
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
await db.insert(agents).values({ id, companyId, name: "Kubernetes fixture", adapterType: "paperclip_runner", adapterConfig: config, runtimeConfig: { aiConnection } });
const account = await service.save(companyId, owner, { provider: "github", method: "api_key", ownership: "personal", name: "Kubernetes fixture", apiKey: "github_pat_fixture_kubernetes", agentIds: [id], allAgents: false }, "github_pat_fixture_kubernetes");
await service.setDefault(companyId, owner, account.grantId);
const [company] = await db.select().from(companies).where(eq(companies.id, companyId));
await db.update(companies).set({ requireBoardApprovalForNewAgents: false }).where(eq(companies.id, companyId));
const before = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, companyId));
const originalSettings = settingsModule.instanceSettingsService;
const settings = vi.spyOn(settingsModule, "instanceSettingsService").mockImplementation(database => {
const service = originalSettings(database);
return { ...service, getGeneral: async () => ({ ...await service.getGeneral(), executionMode: "kubernetes" as const }) };
});
const probe = vi.spyOn(copilotProbe, "probeCopilotExecutionTarget");
const app = express(); app.use(express.json());
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: owner, companyIds: [companyId], isInstanceAdmin: true }; next(); });
app.use("/api", agentRoutes(db)); app.use(errorHandler);
try {
const changedConfig = { ...config, model: "prospective-model" };
const response = action === "edit"
? await request(app).patch(`/api/agents/${id}`).send({ adapterConfig: changedConfig })
: await request(app).post(`/api/companies/${companyId}/${action === "hire" ? "agent-hires" : "agents"}`).send({ name: "Forced Kubernetes", role: "general", adapterType: "paperclip_runner", adapterConfig: changedConfig, runtimeConfig: { aiConnection } });
expect(response.status, JSON.stringify(response.body)).toBe(422);
expect(response.body.details.code).toBe("copilot_environment_unavailable");
expect(probe).not.toHaveBeenCalled();
expect((await db.select().from(agents).where(eq(agents.id, id)))[0].adapterConfig).toEqual(config);
expect(await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, companyId))).toHaveLength(before.length);
} finally {
probe.mockRestore(); settings.mockRestore();
await db.update(companies).set({ requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents }).where(eq(companies.id, companyId));
}
});
it.each(["create", "hire", "edit"].flatMap(action => [true, false].map(connectionProvided => ({ action, connectionProvided }))))(
"refuses unavailable Copilot models before $action persists configuration (connection binding: $connectionProvided)", async ({ action, connectionProvided }) => {
const { unprocessable } = await import("../errors.js");
const { errorHandler } = await import("../middleware/index.js");
const owner = `copilot-model-${action}-${connectionProvided}`, id = randomUUID();
const savedConfig = { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" };
const aiConnection = { provider: "github", method: "api_key", mode: "responsible_user" } as const;
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
await db.insert(agents).values({ id, companyId, name: `Copilot ${action} fixture`, adapterType: "paperclip_runner", adapterConfig: savedConfig, runtimeConfig: connectionProvided ? { aiConnection } : {} });
const token = `github_pat_fixture_model_${action}`;
const account = await service.save(companyId, owner, { provider: "github", method: "api_key", ownership: "personal", name: `Model ${action}`, apiKey: token, agentIds: [id], allAgents: false }, token);
await service.setDefault(companyId, owner, account.grantId);
const [company] = await db.select().from(companies).where(eq(companies.id, companyId));
await db.update(companies).set({ requireBoardApprovalForNewAgents: false }).where(eq(companies.id, companyId));
const before = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, companyId));
const probe = vi.spyOn(copilotProbe, "probeCopilotExecutionTarget").mockRejectedValue(unprocessable("The selected Copilot model is unavailable for this account.", { code: "COPILOT_MODEL_UNAVAILABLE" }));
const app = express(); app.use(express.json());
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: owner, companyIds: [companyId], isInstanceAdmin: true }; next(); });
app.use("/api", agentRoutes(db)); app.use(errorHandler);
const config = { ...savedConfig, model: "unavailable-copilot-model" };
try {
const response = action === "edit"
? await request(app).patch(`/api/agents/${id}`).send({ adapterConfig: config })
: await request(app).post(`/api/companies/${companyId}/${action === "hire" ? "agent-hires" : "agents"}`).send({ name: `Unavailable ${action}`, role: "general", adapterType: "paperclip_runner", adapterConfig: config, ...(connectionProvided ? { runtimeConfig: { aiConnection } } : {}) });
expect(response.status, JSON.stringify(response.body)).toBe(422);
expect(response.body).toMatchObject({ error: "The selected Copilot model is unavailable for this account." });
expect(probe).toHaveBeenCalledExactlyOnceWith(token, null, "unavailable-copilot-model");
expect((await db.select().from(agents).where(eq(agents.id, id)))[0].adapterConfig).toEqual(savedConfig);
expect(await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, companyId))).toHaveLength(before.length);
expect(JSON.stringify(response.body)).not.toContain(token);
} finally {
probe.mockRestore();
await db.update(companies).set({ requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents }).where(eq(companies.id, companyId));
}
});
it("verifies the exact available Copilot model before a model-only edit with an unchanged connection", async () => {
const { errorHandler } = await import("../middleware/index.js");
const owner = "copilot-model-valid", id = randomUUID();
const aiConnection = { provider: "github", method: "api_key", mode: "responsible_user" } as const;
const config = { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" };
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
await db.insert(agents).values({ id, companyId, name: "Copilot available model", adapterType: "paperclip_runner", adapterConfig: { ...config, model: "previous-model" }, runtimeConfig: { aiConnection } });
const token = "github_pat_fixture_model_valid";
const account = await service.save(companyId, owner, { provider: "github", method: "api_key", ownership: "personal", name: "Valid model", apiKey: token, agentIds: [id], allAgents: false }, token);
await service.setDefault(companyId, owner, account.grantId);
const probe = vi.spyOn(copilotProbe, "probeCopilotExecutionTarget").mockResolvedValue({ status: "verified", version: "1.0.88", profileDigest: "fixture-profile", promptSent: false, models: [{ id: config.model, label: "GPT" }] });
const app = express(); app.use(express.json());
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: owner, companyIds: [companyId], isInstanceAdmin: true }; next(); });
app.use("/api", agentRoutes(db)); app.use(errorHandler);
try {
const response = await request(app).patch(`/api/agents/${id}`).send({ adapterConfig: config });
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(probe).toHaveBeenCalledExactlyOnceWith(token, null, config.model);
expect((await db.select().from(agents).where(eq(agents.id, id)))[0].adapterConfig).toMatchObject(config);
expect(response.body.runtimeConfig.aiConnection).toEqual(aiConnection);
} finally { probe.mockRestore(); }
});
it("passes the live sandbox worker manager to Copilot connection verification", async () => {
const pluginWorkerManager = {} as PluginWorkerManager;
const probe = vi.spyOn(copilotProbe, "probeCopilotConnection").mockResolvedValue({
status: "verified", version: "1.0.88", profileDigest: "fixture-profile",
promptSent: false, models: [{ id: "gpt-5.6-luna", label: "GPT" }],
});
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = { type: "board", source: "local_implicit", userId: "alice", companyIds: [companyId] };
next();
});
app.use("/api", aiConnectionRoutes(db, { pluginWorkerManager }));
try {
const response = await request(app).post(`/api/companies/${companyId}/ai-connections`).send({
provider: "github", method: "api_key", name: "Copilot remote route", ownership: "personal",
apiKey: "github_pat_fixture_remote", agentIds: [], allAgents: false,
});
expect(response.status).toBe(201);
expect(probe).toHaveBeenCalledWith(db, companyId, "github_pat_fixture_remote", undefined, undefined, { pluginWorkerManager });
} finally { probe.mockRestore(); }
});
it("discovers Copilot models in the selected environment with its sandbox worker", async () => {
const pluginWorkerManager = {} as PluginWorkerManager;
const [environment] = await db.insert(environments).values({ name: "Copilot model discovery", driver: "sandbox", config: { provider: "daytona" } }).returning();
const token = "github_pat_fixture_model_discovery";
const account = await service.save(companyId, "alice", { provider: "github", method: "api_key", ownership: "personal", name: "Model discovery account", apiKey: token, agentIds: [agentId], allAgents: false }, token);
await service.setDefault(companyId, "alice", account.grantId);
const probe = vi.spyOn(copilotProbe, "probeCopilotConnection").mockResolvedValue({ status: "verified", version: "1.0.88", profileDigest: "fixture-profile", promptSent: false, models: [{ id: "gpt-5.6-luna", label: "GPT" }] });
const app = express(); app.use(express.json());
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: "alice", companyIds: [companyId] }; next(); });
app.use("/api", agentRoutes(db, { pluginWorkerManager }));
try {
const response = await request(app).get(`/api/companies/${companyId}/adapters/paperclip_runner/models`).query({ provider: "acpx", acpxAgent: "copilot", environmentId: environment.id, agentId, aiConnection: JSON.stringify({ provider: "github", method: "api_key", mode: "responsible_user" }) });
expect(response.status).toBe(200);
expect(response.headers["cache-control"]).toBe("no-store");
expect(response.body).toEqual([{ id: "gpt-5.6-luna", label: "GPT" }]);
expect(probe).toHaveBeenCalledWith(db, companyId, token, environment.id, undefined, { pluginWorkerManager });
expect(JSON.stringify(response.body)).not.toContain(token);
} finally { probe.mockRestore(); }
});
it("keeps Copilot model discovery behind shared installation and agent configuration authority", async () => {
const { errorHandler } = await import("../middleware/index.js");
const owner = "copilot-discovery-owner", audience = "copilot-discovery-audience";
await db.insert(companyMemberships).values([owner, audience].map(principalId => ({ companyId, principalId, principalType: "user", status: "active", membershipRole: "member" })));
await db.insert(principalPermissionGrants).values([owner, audience].map(principalId => ({ companyId, principalId, principalType: "user", permissionKey: "agents:create" })));
const token = "github_pat_fixture_shared_discovery";
const account = await service.save(companyId, owner, { provider: "github", method: "api_key", ownership: "shared", name: "Shared discovery", apiKey: token, agentIds: [], allAgents: false }, token);
await db.insert(connectionGrantMembers).values([owner, audience].map(subjectId => ({ companyId, grantId: account.grantId, subjectType: "user", subjectId })));
const binding = { provider: "github", method: "api_key", mode: "shared", ...account };
const probe = vi.spyOn(copilotProbe, "probeCopilotConnection").mockResolvedValue({ status: "verified", version: "1.0.88", profileDigest: "fixture-profile", promptSent: false, models: [{ id: "gpt-5.6-luna", label: "GPT" }] });
const app = express(); app.use(express.json());
app.use((req, _res, next) => { req.actor = { type: "board", source: "session", userId: String(req.headers["x-test-user"] ?? audience), companyIds: [companyId], memberships: [{ companyId, membershipRole: "member", status: "active" }] }; next(); });
app.use("/api", agentRoutes(db)); app.use(errorHandler);
const url = `/api/companies/${companyId}/adapters/paperclip_runner/models`;
const query = { provider: "acpx", acpxAgent: "copilot", aiConnection: JSON.stringify(binding) };
try {
const denied = await request(app).get(url).query(query);
expect(denied.status, JSON.stringify(denied.body)).toBe(403);
expect(denied.body.error).toContain("not permitted for this agent");
expect(probe).not.toHaveBeenCalled();
const allowed = await request(app).get(url).set("x-test-user", owner).query(query);
expect(allowed.status, JSON.stringify(allowed.body)).toBe(200);
expect(probe).toHaveBeenCalledExactlyOnceWith(db, companyId, token, null, undefined, { pluginWorkerManager: undefined });
probe.mockClear();
const foreign = await request(app).get(url).set("x-test-user", owner).query({ ...query, agentId: randomUUID() });
expect(foreign.status).toBe(404);
expect(probe).not.toHaveBeenCalled();
expect(JSON.stringify([denied.body, allowed.body, foreign.body])).not.toContain(token);
} finally { probe.mockRestore(); }
});
it("preserves repository GitHub credentials for managed non-Copilot execution", async () => {
const owner = "repo-github-owner";
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
await create(owner, owner);
for (const configured of [{ GH_TOKEN: "repo-gh-fixture", GITHUB_TOKEN: "repo-github-fixture" }, {}]) {
const runtime = await prepareManagedAiRuntime(db, { ...input, responsibleUserId: owner, config: { env: configured } });
try {
expect(runtime.config.env).toMatchObject({ COPILOT_GITHUB_TOKEN: "", ANTHROPIC_API_KEY: `fixture-${owner}` });
for (const key of ["GH_TOKEN", "GITHUB_TOKEN"] as const) {
if (key in configured) expect(runtime.config.env?.[key]).toBe(configured[key as keyof typeof configured]);
else expect(runtime.config.env).not.toHaveProperty(key);
}
} finally { await runtime.cleanup(); }
}
});
it("uses a vaulted Copilot token with GitHub attribution and refuses revoked or foreign grants", async () => {
const owner = "copilot-owner";
const token = "github_pat_fixture_copilot";
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
const account = await service.save(companyId, owner, { provider: "github", method: "api_key", ownership: "personal", name: "Copilot", apiKey: token, agentIds: [agentId], allAgents: false }, token);
const selectedBinding = { provider: "github", method: "api_key", mode: "responsible_user" } as const;
const config = { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna", env: { COPILOT_GITHUB_TOKEN: "ambient-never-use", GH_TOKEN: "project-never-use" } };
const runInput = { companyId, agentId, responsibleUserId: owner, adapterType: "paperclip_runner", binding: selectedBinding, config };
const selected = await service.select({ ...runInput, userId: owner, runnerProvider: "acpx", acpxAgent: "copilot", model: config.model });
expect(await service.credential(selected)).toBe(token);
expect(JSON.stringify(await service.list(companyId, owner))).not.toContain(token);
const runtime = await prepareManagedAiRuntime(db, runInput);
try {
expect(runtime.config).toMatchObject({ provider: "acpx", acpxAgent: "copilot", model: config.model, env: { COPILOT_GITHUB_TOKEN: token, GH_TOKEN: "", GITHUB_TOKEN: "", OPENAI_API_KEY: "", ANTHROPIC_API_KEY: "" } });
expect(runtime.attribution).toMatchObject({ provider: "github", method: "api_key", connectionId: account.connectionId, grantId: account.grantId });
expect(config.env.COPILOT_GITHUB_TOKEN).toBe("ambient-never-use");
} finally { await runtime.cleanup(); }
await expect(access(runtime.home!)).rejects.toThrow();
await expect(service.select({ ...runInput, userId: "bob", runnerProvider: "acpx", acpxAgent: "copilot" })).rejects.toThrow();
await expect(service.select({ ...runInput, companyId: otherCompanyId, userId: owner, binding: { ...selectedBinding, mode: "shared", ...account }, runnerProvider: "acpx", acpxAgent: "copilot" })).rejects.toThrow();
const network = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("usage must not send credentials"));
try {
expect(await service.probeUsage(companyId, owner, account.connectionId, account.grantId)).toMatchObject({ provider: "github", status: "unsupported", limits: [] });
expect(network).not.toHaveBeenCalled();
} finally { network.mockRestore(); }
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, account.grantId));
await expect(prepareManagedAiRuntime(db, runInput)).rejects.toThrow();
});
it("marks explicit AI selection rejections without changing their HTTP contract", async () => {
const owner = "selection-blocker-owner";
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
@@ -850,12 +1067,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/0361_mighty_dracula.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")) {
@@ -868,6 +1091,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) => {
@@ -398,7 +398,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");
@@ -409,6 +409,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"),
}),
],
}),
@@ -433,6 +439,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,125 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { environmentRuntimeService } from "../services/environment-runtime.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
const mocks = vi.hoisted(() => ({ metadata: vi.fn(), command: vi.fn(), environment: vi.fn(), bindings: vi.fn(), acquire: vi.fn(), realize: vi.fn(), release: vi.fn(), resolve: vi.fn(), general: vi.fn(), experimental: vi.fn(), defaults: vi.fn(), kubernetes: vi.fn(), managed: vi.fn() }));
vi.mock("../vendor/paperclip-runner/live/index.js", async importOriginal => ({ ...await importOriginal<object>(), probeCopilotMetadata: mocks.metadata }));
vi.mock("@paperclipai/adapter-utils/execution-target", () => ({ runAdapterExecutionTargetShellCommand: mocks.command }));
vi.mock("../middleware/logger.js", () => ({ logger: { warn: vi.fn() } }));
vi.mock("../services/environments.js", () => ({ environmentService: () => ({ getById: mocks.environment, listBoundCompanyIds: mocks.bindings, findKubernetesEnvironment: mocks.kubernetes, findManagedSandboxEnvironment: mocks.managed }) }));
vi.mock("../services/instance-settings.js", () => ({ instanceSettingsService: () => ({ get: mocks.defaults, getGeneral: mocks.general, getExperimental: mocks.experimental }) }));
vi.mock("../services/environment-runtime.js", () => ({ environmentRuntimeService: vi.fn(() => ({ acquireRunLease: mocks.acquire, realizeWorkspace: mocks.realize, getDriver: () => ({ releaseRunLease: mocks.release }) })) }));
vi.mock("../services/environment-execution-target.js", () => ({ resolveEnvironmentExecutionTarget: mocks.resolve }));
import { logger } from "../middleware/logger.js";
import { probeCopilotConnection, probeCopilotExecutionTarget } from "../services/copilot-connection-probe.js";
import { QUALIFIED_ACPX_PROFILES } from "../../../packages/paperclip-runner/src/drivers/acpx/qualified-profiles.js";
import type { Db } from "@paperclipai/db";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
const verified = { status: "verified", version: "1.0.88", profileDigest: QUALIFIED_ACPX_PROFILES.copilot.commandDigest, promptSent: false, models: [{ id: "gpt-5.6-luna", label: "GPT" }] };
const remote = { kind: "remote", remoteCwd: "/workspace" } as AdapterExecutionTarget;
beforeEach(() => {
vi.clearAllMocks(); mocks.command.mockReset(); mocks.general.mockResolvedValue({ executionMode: "local" }); mocks.experimental.mockResolvedValue({}); mocks.defaults.mockResolvedValue({ defaultEnvironmentId: null });
mocks.environment.mockResolvedValue({ id: "selected", driver: "sandbox", status: "active", config: { provider: "daytona" } }); mocks.bindings.mockResolvedValue(["company"]);
mocks.acquire.mockResolvedValue({ lease: { id: "lease", metadata: {} } }); mocks.realize.mockResolvedValue({}); mocks.release.mockResolvedValue(undefined); mocks.resolve.mockResolvedValue(remote);
mocks.command.mockResolvedValue({ timedOut: false, stdout: JSON.stringify(verified), exitCode: 0 }); mocks.metadata.mockResolvedValue(verified);
});
describe("Copilot connection verification", () => {
it.each(["ssh", "sandbox", "plugin"])("keeps an explicitly selected %s environment under managed-only execution", async driver => {
mocks.experimental.mockResolvedValue({ enableManagedSandboxOnly: true });
mocks.environment.mockResolvedValue({ id: "selected", driver, status: "active", config: { provider: "daytona" } });
expect(await probeCopilotConnection({} as Db, "company", "private-token", "selected")).toEqual(verified);
expect(mocks.managed).not.toHaveBeenCalled();
expect(mocks.resolve).toHaveBeenCalledWith(expect.objectContaining({ environment: expect.objectContaining({ id: "selected", driver }) }));
expect(mocks.metadata).not.toHaveBeenCalled();
expect(mocks.command).toHaveBeenCalledOnce();
});
it.each([null, "local"])("redirects only a local selection (%s) onto the managed environment", async selection => {
mocks.experimental.mockResolvedValue({ enableManagedSandboxOnly: true });
mocks.managed.mockResolvedValue({ id: "managed" });
mocks.environment.mockImplementation(async id => ({ id, driver: id === "local" ? "local" : "sandbox", status: "active", config: { provider: "daytona" } }));
expect(await probeCopilotConnection({} as Db, "company", "private-token", selection)).toEqual(verified);
expect(mocks.resolve).toHaveBeenCalledWith(expect.objectContaining({ environment: expect.objectContaining({ id: "managed" }) }));
expect(mocks.metadata).not.toHaveBeenCalled();
});
it("gives forced Kubernetes precedence over an explicitly selected remote and managed-only policy", async () => {
mocks.general.mockResolvedValue({ executionMode: "kubernetes" });
mocks.experimental.mockResolvedValue({ enableManagedSandboxOnly: true });
mocks.kubernetes.mockResolvedValue({ id: "kubernetes" });
mocks.environment.mockImplementation(async id => ({ id, driver: "sandbox", status: "active", config: { provider: "kubernetes" } }));
expect(await probeCopilotConnection({} as Db, "company", "private-token", "selected")).toEqual(verified);
expect(mocks.resolve).toHaveBeenCalledWith(expect.objectContaining({ environment: expect.objectContaining({ id: "kubernetes" }) }));
expect(mocks.managed).not.toHaveBeenCalled();
expect(mocks.metadata).not.toHaveBeenCalled();
});
it("probes the selected environment and releases its owned lease", async () => {
const db = {} as Db;
const pluginWorkerManager = {} as PluginWorkerManager;
expect(await probeCopilotConnection(db, "company", "private-token", "selected", "gpt-5.6-luna", { pluginWorkerManager })).toEqual(verified);
expect(environmentRuntimeService).toHaveBeenCalledWith(db, { pluginWorkerManager });
expect(mocks.metadata).not.toHaveBeenCalled(); expect(mocks.acquire).toHaveBeenCalledOnce();
expect(mocks.command.mock.calls[0]![3]).toMatchObject({ env: { COPILOT_GITHUB_TOKEN: "private-token", PAPERCLIP_COPILOT_PROBE_MODEL: "gpt-5.6-luna" }, timeoutSec: 35 });
expect(mocks.command.mock.calls[0]![2]).not.toContain("private-token");
expect(mocks.release).toHaveBeenCalledWith(expect.objectContaining({ status: "released" }));
});
it("rejects a foreign company before creating a lease or transmitting the token", async () => {
mocks.bindings.mockResolvedValue(["other-company"]);
await expect(probeCopilotConnection({} as Db, "company", "private-token", "selected")).rejects.toMatchObject({ status: 403 });
expect(mocks.command).not.toHaveBeenCalled(); expect(mocks.metadata).not.toHaveBeenCalled(); expect(mocks.acquire).not.toHaveBeenCalled();
});
it("does not fall back to host credentials when the remote runtime is missing", async () => {
mocks.command.mockResolvedValue({ timedOut: false, stdout: "", exitCode: 127 });
await expect(probeCopilotConnection({} as Db, "company", "private-token", "selected")).rejects.toMatchObject({ details: { code: "COPILOT_INSTALLATION_INVALID" } });
expect(mocks.metadata).not.toHaveBeenCalled(); expect(mocks.release).toHaveBeenCalledWith(expect.objectContaining({ status: "failed" }));
});
it("sanitizes remote errors and releases the lease on authentication failure", async () => {
mocks.command.mockResolvedValue({ timedOut: false, stdout: JSON.stringify({ status: "failed", code: "COPILOT_AUTH_REQUIRED", promptSent: false, message: "private-token" }), exitCode: 1 });
const error = await probeCopilotConnection({} as Db, "company", "private-token", "selected").catch(e => e);
expect(error.details.code).toBe("COPILOT_AUTH_REQUIRED"); expect(error.message).not.toContain("private-token"); expect(mocks.release).toHaveBeenCalledOnce();
});
it("preserves authentication classification when lease cleanup also fails", async () => {
mocks.command.mockResolvedValue({ timedOut: false, stdout: JSON.stringify({ status: "failed", code: "COPILOT_AUTH_REQUIRED", promptSent: false, message: "private-token" }), exitCode: 1 });
mocks.release.mockRejectedValue(new Error("cleanup private-token"));
const error = await probeCopilotConnection({} as Db, "company", "private-token", "selected").catch(e => e);
expect(error).toMatchObject({ status: 422, details: { code: "COPILOT_AUTH_REQUIRED" } });
expect(error.message).not.toContain("private-token");
expect(logger.warn).toHaveBeenCalledWith({ companyId: "company", environmentId: "selected", leaseId: "lease", code: "COPILOT_PROBE_CLEANUP_FAILED" }, expect.any(String));
expect(JSON.stringify(vi.mocked(logger.warn).mock.calls)).not.toContain("private-token");
expect(mocks.release).toHaveBeenCalledOnce();
});
it("does not verify a connection when its successful probe cannot clean up", async () => {
mocks.release.mockRejectedValue(new Error("cleanup private-token"));
const error = await probeCopilotConnection({} as Db, "company", "private-token", "selected").catch(e => e);
expect(error).toMatchObject({ status: 422, details: { code: "COPILOT_REQUEST_FAILED", cleanupCode: "COPILOT_PROBE_CLEANUP_FAILED" } });
expect(error.message).not.toContain("private-token");
expect(mocks.release).toHaveBeenCalledOnce();
});
it("rejects stale profile results and unavailable model selections", async () => {
mocks.command.mockResolvedValue({ timedOut: false, stdout: JSON.stringify({ ...verified, profileDigest: "stale" }), exitCode: 0 });
await expect(probeCopilotExecutionTarget("private-token", remote)).rejects.toMatchObject({ details: { code: "COPILOT_REQUEST_FAILED" } });
mocks.command.mockResolvedValue({ timedOut: false, stdout: JSON.stringify(verified), exitCode: 0 });
await expect(probeCopilotExecutionTarget("private-token", remote, "missing")).rejects.toMatchObject({ details: { code: "COPILOT_MODEL_UNAVAILABLE" } });
});
});
describe("remote Copilot installation fallback", () => {
it.each(["missing", "incomplete", "stale-profile"])("uses the valid home pack after rejecting a %s opt pack", async variant => {
const rejected = variant === "missing" ? { timedOut: false, stdout: "", exitCode: 127 }
: { timedOut: false, exitCode: variant === "incomplete" ? 1 : 0, stdout: JSON.stringify(variant === "incomplete"
? { status: "failed", code: "COPILOT_INSTALLATION_INVALID", promptSent: false }
: { ...verified, profileDigest: `sha256:${"0".repeat(64)}` }) };
mocks.command.mockResolvedValueOnce(rejected).mockResolvedValueOnce({ timedOut: false, stdout: JSON.stringify(verified), exitCode: 0 });
await expect(probeCopilotExecutionTarget("private-token", remote, "gpt-5.6-luna")).resolves.toEqual(verified);
expect(mocks.command).toHaveBeenCalledTimes(2);
expect(mocks.command.mock.calls[0]![2]).toContain("/opt/paperclip-runner/provider-pack");
expect(mocks.command.mock.calls[1]![2]).toContain("$HOME/.local/share/paperclip-runner/provider-pack");
for (const call of mocks.command.mock.calls) {
expect(call[2]).not.toContain("private-token");
expect(call[3].env).toMatchObject({ COPILOT_GITHUB_TOKEN: "private-token", PAPERCLIP_COPILOT_PROBE_MODEL: "gpt-5.6-luna" });
expect(call[3].timeoutSec).toBeLessThanOrEqual(35);
}
});
it.each(["COPILOT_AUTH_REQUIRED", "COPILOT_ENTITLEMENT_DENIED", "COPILOT_MODEL_UNAVAILABLE"])("keeps %s and does not try another installation", async code => {
mocks.command.mockResolvedValueOnce({ timedOut: false, exitCode: 1, stdout: JSON.stringify({ status: "failed", code, promptSent: false }) });
await expect(probeCopilotExecutionTarget("private-token", remote)).rejects.toMatchObject({ details: { code } });
expect(mocks.command).toHaveBeenCalledOnce();
});
});
@@ -1,6 +1,7 @@
import express from "express";
import request from "supertest";
import { request as httpRequest } from "node:http";
import { createServer as createViteServer } from "vite";
import { afterEach, describe, expect, it } from "vitest";
import { idleAdmissionMiddleware, trackIdleRequestHandlers } from "../middleware/idle-admission.js";
import { idleWorkSnapshot, startTaskDrain, stopTaskDrain, readTaskDrain, beginIdleTrackedWork } from "../services/task-admission.js";
@@ -49,6 +50,36 @@ describe("idle admission", () => {
expect(idleWorkSnapshot().active).toBe(0);
});
it("tracks ordinary middleware with non-router stack metadata", async () => {
const server = app(), done = deferred();
const middleware = Object.assign(async (_req: express.Request, res: express.Response) => {
res.sendStatus(202); await done.promise;
}, { stack: { name: "middleware metadata" } });
server.use(middleware);
expect(() => trackIdleRequestHandlers(server)).not.toThrow();
await request(server).get("/work").expect(202);
expect(idleWorkSnapshot().active).toBe(1);
done.resolve();
await new Promise(resolve => setImmediate(resolve));
expect(idleWorkSnapshot().active).toBe(0);
});
it("supports real Vite Connect route metadata without losing async work", async () => {
const vite = await createViteServer({ configFile: false, appType: "custom",
server: { middlewareMode: true, watch: null }, optimizeDeps: { noDiscovery: true, include: [] } });
const server = app(), done = deferred();
try {
server.use(vite.middlewares);
server.post("/work", async (_req, res) => { res.sendStatus(202); await done.promise; });
expect(() => trackIdleRequestHandlers(server)).not.toThrow();
await request(server).post("/work").expect(202);
expect(idleWorkSnapshot().active).toBe(1);
done.resolve();
await new Promise(resolve => setImmediate(resolve));
expect(idleWorkSnapshot().active).toBe(0);
} finally { await vite.close(); }
});
it("tracks real Vite middleware without confusing Connect mount paths with Express routes", async () => {
const { createServer } = await import("vite");
const vite = await createServer({
@@ -7480,7 +7480,7 @@ describeEmbeddedPostgres("workspace runtime service control persistence", () =>
const blockerScript = [
"const net=require('node:net');",
"const port=Number(process.argv[1]);",
"net.createServer((socket)=>socket.destroy()).listen(port,'127.0.0.1');",
"net.createServer((socket)=>socket.destroy()).listen(port,'127.0.0.1',()=>process.send({ready:true}));",
].join("");
const raceScript = [
"const fs=require('node:fs');",
@@ -7493,8 +7493,10 @@ describeEmbeddedPostgres("workspace runtime service control persistence", () =>
"const start=()=>http.createServer((_req,res)=>res.end('ok')).listen(port,'127.0.0.1');",
"if(!fs.existsSync(marker)){",
"fs.writeFileSync(marker,String(port));",
"const blocker=spawn(process.execPath,['-e',blockerScript,String(port)],{detached:true,stdio:'ignore'});",
"fs.writeFileSync(pidFile,String(blocker.pid));blocker.unref();setTimeout(start,200);",
"const blocker=spawn(process.execPath,['-e',blockerScript,String(port)],{detached:true,stdio:['ignore','ignore','ignore','ipc']});",
"fs.writeFileSync(pidFile,String(blocker.pid));",
"const readyDeadline=setTimeout(()=>{throw new Error('Fixture blocker did not become ready');},5000);",
"blocker.once('message',(message)=>{if(message?.ready!==true)throw new Error('Invalid fixture readiness message');clearTimeout(readyDeadline);blocker.disconnect();blocker.unref();start();});",
"}else{start();}",
"setInterval(()=>{},1000);",
].join("");
+25 -11
View File
@@ -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 { validateAiApiKey } from "../services/ai-api-key-test.js";
@@ -443,16 +444,21 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
checks: [{ code: "dot_event_test_required", level: "warn" as const, message: "Dot manages its model and billing. Validate the dedicated agent binding and event round trip in Paperclip; this read-only check does not wake the Dot." }] };
}
if (profile.provider === "acpx") {
if (["copilot"].includes(profile.acpxAgent)) {
// The profile resolver already validated the isolated host's exact
// qualification pair. Do not report a production readiness pass.
return {
adapterType: "paperclip_runner", status: "warn" as const, testedAt: new Date().toISOString(),
checks: [{ code: "acpx_candidate_qualification_only", level: "warn" as const,
message: "This exact candidate and model are admitted for operator-controlled qualification only. Verified runtime installation, bound credentials, and model access are checked before execution; production support remains pending." }],
};
}
try {
if (profile.acpxAgent === "copilot") {
const configured = context.config.env;
const token = configured && typeof configured === "object" && !Array.isArray(configured)
? (configured as Record<string, unknown>).COPILOT_GITHUB_TOKEN : undefined;
if (typeof token !== "string" || !token) {
return { adapterType: "paperclip_runner", status: "fail" as const, testedAt: new Date().toISOString(),
checks: [{ code: "COPILOT_AUTH_REQUIRED", level: "error" as const, message: "Select a saved Copilot token connection." }] };
}
const { probeCopilotExecutionTarget } = await import("../services/copilot-connection-probe.js");
await probeCopilotExecutionTarget(token, context.executionTarget, profile.model);
return { adapterType: "paperclip_runner", status: "pass" as const, testedAt: new Date().toISOString(),
checks: [{ code: "copilot_metadata_verified", level: "info" as const,
message: "The verified Copilot runtime authenticated this account and accepted the selected model. No model prompt was sent." }] };
}
if (profile.acpxAgent !== "claude" && profile.acpxAgent !== "grok" && profile.acpxAgent !== "cursor" && profile.acpxAgent !== "pi") throw new Error("Select Codex to use the native Codex runner.");
const target = context.executionTarget;
if (target?.kind === "remote") {
@@ -478,6 +484,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: [{
@@ -565,7 +579,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
)
: buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex@0.160.0"),
agentConfigurationDoc:
"# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex, OpenCode, Claude Managed, AWS AgentCore, or ACPX Claude/Grok Build/Cursor/Pi through the Rust Paperclip runner and authenticated PRP transport. Pi accepts an explicit provider/model ID and uses the company credentials bound to the agent environment. GitHub Copilot awaits local and Daytona qualification. Managed providers use company-scoped qualified profiles, explicit retention acknowledgement, and spend limits.\n",
"# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex, OpenCode, Claude Managed, AWS AgentCore, or ACPX Claude/Grok Build/Cursor/Pi through the Rust Paperclip runner and authenticated PRP transport. Pi accepts an explicit provider/model ID and uses the company credentials bound to the agent environment. GitHub Copilot requires a saved GitHub token and an explicitly selected available model. Managed providers use company-scoped qualified profiles, explicit retention acknowledgement, and spend limits.\n",
getConfigSchema: () => ({
fields: [
{
@@ -584,7 +598,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
View File
@@ -954,7 +954,7 @@ export async function createApp(
api.use(decisionModelRoutes(db));
api.use(agentObservabilityRoutes(db, { deploymentMode: opts.deploymentMode, deploymentExposure: opts.deploymentExposure }));
api.use(fastResponseRoutes(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,
+3 -4
View File
@@ -74,10 +74,9 @@ export function trackIdleRequestHandlers(app: Application): void {
for (const layer of stack) {
if (seen.has(layer)) continue;
seen.add(layer);
// Connect/Vite layers use a mount-path string for route; only Express
// Route objects contain a nested handler stack.
if (layer.route && typeof layer.route !== "string") { visit(layer.route.stack, true); continue; }
if (layer.handle.stack) { visit(layer.handle.stack); continue; }
// Connect/Vite mount paths are strings; Express Route stacks are arrays.
if (layer.route && typeof layer.route !== "string" && Array.isArray(layer.route.stack)) { visit(layer.route.stack, true); continue; }
if (Array.isArray(layer.handle.stack)) { visit(layer.handle.stack); continue; }
const original = layer.handle;
if (original === idleAdmissionMiddleware) continue;
const invoke = (req: Request, args: unknown[]) => {
+68 -10
View File
@@ -22,10 +22,12 @@ import {
ADAPTER_AUTH_MISSING_CHECK_CODE,
aiConnectionBindingSchema,
aiRuntimeConnectionBindingSchema,
type AiConnectionBinding,
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 } from "./ai-connections.js";
import { isAiConnectionCompatible } from "@paperclipai/shared";
@@ -1849,7 +1851,10 @@ export function agentRoutes(
if (experimental.enableMuse !== true || experimental.enableNativeRunner !== true) throw unprocessable("Muse is experimental and disabled on this instance.", { code: "paperclip_runner_muse_disabled" });
} else if (asRecord(config)?.provider === "grok_bot" && (experimental.enableGrokBot !== true || experimental.enablePublicMcp !== true || experimental.enableNativeRunner !== true)) {
throw unprocessable("Enable Grok Bot, Assistant connections (MCP), and Paperclip Runner before connecting this agent.", { code: "paperclip_runner_grok_disabled" });
} else if (!([undefined, "codex"].includes(asRecord(config)?.provider as string | undefined)) && agentHarnessType("paperclip_runner", asRecord(config) ?? {}) !== "claude_local" && experimental.enableNativeRunner !== true) {
} else if (!([undefined, "codex"].includes(asRecord(config)?.provider as string | undefined)) &&
!(asRecord(config)?.provider === "acpx" && asRecord(config)?.acpxAgent === "copilot") &&
agentHarnessType("paperclip_runner", asRecord(config) ?? {}) !== "claude_local" &&
experimental.enableNativeRunner !== true) {
throw unprocessable(
"Paperclip Runner is experimental and disabled on this instance.",
{ code: "paperclip_runner_rollout_disabled" },
@@ -2126,11 +2131,20 @@ export function agentRoutes(
runtimeConfig: unknown,
) {
const normalized = normalizeNewAgentRuntimeConfig(runtimeConfig);
if (req.actor.type !== "agent" || normalized.aiConnection) return normalized;
const manager = req.actor.agentId ? await svc.getById(req.actor.agentId) : null;
if (!manager || manager.companyId !== companyId) throw forbidden("Hiring agent is unavailable");
const binding = defaultAiConnectionForHire(adapterType, adapterConfig, manager.runtimeConfig?.aiConnection);
if (binding) normalized.aiConnection = binding;
if (normalized.aiConnection) return normalized;
if (req.actor.type === "agent") {
const manager = req.actor.agentId ? await svc.getById(req.actor.agentId) : null;
if (!manager || manager.companyId !== companyId) throw forbidden("Hiring agent is unavailable");
const binding = defaultAiConnectionForHire(adapterType, adapterConfig, manager.runtimeConfig?.aiConnection);
if (binding) normalized.aiConnection = binding;
}
// Copilot has no ambient-auth setup path. Omitting a binding still selects
// the responsible user's saved account and must pass the same metadata
// admission as an explicit connection before configuration is persisted.
if (!normalized.aiConnection && adapterType === "paperclip_runner"
&& adapterConfig.provider === "acpx" && adapterConfig.acpxAgent === "copilot") {
normalized.aiConnection = { provider: "github", method: "api_key", mode: "responsible_user" };
}
return normalized;
}
@@ -3011,6 +3025,32 @@ export function agentRoutes(
return;
}
const provider = asNonEmptyString(req.query.provider);
if (type === "paperclip_runner" && provider === "acpx" && req.query.acpxAgent === "copilot") {
const userId = responsibleUserForAiRequest(req);
let binding: AiConnectionBinding = { provider: "github", method: "api_key", mode: "responsible_user" };
if (typeof req.query.aiConnection === "string") {
if (req.query.aiConnection.length > 4096) throw unprocessable("Invalid AI connection binding");
try { binding = aiConnectionBindingSchema.parse(JSON.parse(req.query.aiConnection)); }
catch { throw unprocessable("Invalid AI connection binding"); }
}
if (binding.provider !== "github") throw unprocessable("Select a GitHub Copilot AI connection");
const modelAgentId = asNonEmptyString(req.query.agentId);
let modelEnvironmentId = environmentId;
if (modelAgentId) {
const modelAgent = await svc.getById(modelAgentId);
if (!modelAgent || modelAgent.companyId !== companyId) throw notFound("Agent not found");
await assertCanUpdateAgent(req, modelAgent);
modelEnvironmentId ??= modelAgent.defaultEnvironmentId ?? null;
} else await assertCanCreateAgentsForCompany(req, companyId);
const allowUninstalledShared = !modelAgentId && await canInstallSharedAiConnectionForNewAgent(db, req, companyId, binding);
const service = aiConnectionService(db);
const selection = await service.select({ companyId, userId, agentId: modelAgentId ?? "00000000-0000-0000-0000-000000000000",
adapterType: type, runnerProvider: "acpx", acpxAgent: "copilot", binding, allowUninstalledPersonal: true, allowUninstalledShared });
const metadata = await probeCopilotConnection(db, companyId, await service.credential(selection), modelEnvironmentId, 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;
@@ -3075,15 +3115,18 @@ export function agentRoutes(
// Hiring is allowed before the responsible user has connected this
// provider. Execution still resolves credentials and creates the normal
// task connection request; compatibility and access denials stay errors.
if (newAgent && !test && binding.mode === "responsible_user" && error instanceof HttpError
if (newAgent && !test && binding.provider !== "github" && binding.mode === "responsible_user" && error instanceof HttpError
&& ["ai_connection_default_missing", "ai_connection_missing", "ai_connection_unavailable", "ai_connection_responsible_user_missing"].includes(String(asRecord(error.details)?.code))) {
return null;
}
throw error;
});
if (!selection) return undefined;
if (test) {
const testEnvironmentId = await resolveAdapterTestEnvironmentId(companyId, environmentId);
// Copilot model availability is account-specific. Verify the selected
// packaged runtime before creation/adoption even when a token is saved.
// This metadata-only path sends no model prompt.
if (test || binding.provider === "github") {
const testEnvironmentId = await resolveAdapterTestEnvironmentId(companyId, environmentId, binding.provider === "github");
if (testEnvironmentId) await assertAdapterTestEnvironmentForCompany(companyId, testEnvironmentId);
const target = await resolveAdapterTestExecutionContext({ companyId, adapterType, environmentId: testEnvironmentId });
try {
@@ -3092,6 +3135,10 @@ export function agentRoutes(
const managedAiCredentialHome = adapterType === "paperclip_runner" && asRecord(managed.config)?.provider === "codex" && managed.home
? path.join(managed.home, "provider") : undefined;
const result = await testManagedEnvironment(adapterType, { companyId, adapterType, config: managed.config, managedAiCredentialHome, executionTarget: target.executionTarget, environmentName: target.environmentName }, binding, managed, agentId);
if (binding.provider === "github" && result.status === "fail") {
const failure = result.checks.find(check => check.level === "error");
throw unprocessable(failure?.message ?? "Copilot metadata verification failed.", { code: failure?.code ?? "COPILOT_REQUEST_FAILED" });
}
if (result.status === "fail" || result.checks.some(check => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)) throw unprocessable("The selected AI connection failed validation in this agent’s environment. Run the agent test to see the failing checks.", {
code: "ai_connection_validation_failed",
checks: result.checks.filter(check => check.level === "error" || check.code === ADAPTER_AUTH_MISSING_CHECK_CODE).map(check => ({ code: check.code, level: check.level })),
@@ -3132,6 +3179,7 @@ export function agentRoutes(
req.body.environmentId === undefined
? savedAgent?.defaultEnvironmentId
: asNonEmptyString(req.body.environmentId),
aiBinding?.provider === "github",
);
// Fail closed on a foreign environment before any secret resolution, env
// merge, target resolution, sandbox lease, or adapter test runs.
@@ -5390,13 +5438,23 @@ export function agentRoutes(
});
}
if (existing.runtimeConfig.aiConnection && requestedRuntimeConfig && !requestedRuntimeConfig.aiConnection) requestedRuntimeConfig.aiConnection = existing.runtimeConfig.aiConnection;
const nextAiConfig = (patchData.adapterConfig ?? existing.adapterConfig) as Record<string, unknown>;
if (!requestedRuntimeConfig?.aiConnection && !existing.runtimeConfig.aiConnection
&& requestedAdapterType === "paperclip_runner" && nextAiConfig.provider === "acpx"
&& nextAiConfig.acpxAgent === "copilot"
&& (touchesAdapterConfiguration || Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId"))) {
requestedRuntimeConfig = { ...(requestedRuntimeConfig ?? existing.runtimeConfig),
aiConnection: { provider: "github", method: "api_key", mode: "responsible_user" } };
}
const nextAiBinding = aiRuntimeConnectionBindingSchema.safeParse(requestedRuntimeConfig?.aiConnection ?? existing.runtimeConfig.aiConnection).data;
if (nextAiBinding) {
await assertCanUpdateAgent(req, existing);
const changed = JSON.stringify(nextAiBinding) !== JSON.stringify(existing.runtimeConfig.aiConnection);
const aiConfig = (patchData.adapterConfig ?? existing.adapterConfig) as Record<string, unknown>;
if (nextAiBinding.mode !== "router" && !isAiConnectionCompatible(nextAiBinding, requestedAdapterType, aiConfig.model, aiConfig.provider, aiConfig.acpxAgent)) throw unprocessable("Select an AI connection compatible with the new harness and model");
if (changed || (nextAiBinding.mode === "router" && requestedAdapterType !== existing.adapterType)) await validateManagedAgentBinding(req, existing.companyId, existing.id, requestedAdapterType, aiConfig, nextAiBinding, (patchData.defaultEnvironmentId !== undefined ? patchData.defaultEnvironmentId : existing.defaultEnvironmentId) as string | null, true);
const copilotConfigurationChanged = requestedAdapterType === "paperclip_runner" && aiConfig.provider === "acpx" && aiConfig.acpxAgent === "copilot"
&& (touchesAdapterConfiguration || Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId"));
if (changed || copilotConfigurationChanged || (nextAiBinding.mode === "router" && requestedAdapterType !== existing.adapterType)) await validateManagedAgentBinding(req, existing.companyId, existing.id, requestedAdapterType, aiConfig, nextAiBinding, (patchData.defaultEnvironmentId !== undefined ? patchData.defaultEnvironmentId : existing.defaultEnvironmentId) as string | null, true);
}
if (requestedRuntimeConfig) patchData.runtimeConfig = requestedRuntimeConfig;
if (touchesAdapterConfiguration || Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId")) {
+7 -4
View File
@@ -33,6 +33,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 {
@@ -136,7 +138,7 @@ export async function canInstallSharedAiConnectionForNewAgent(
}
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.");
}
@@ -303,9 +305,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"],
+20 -2
View File
@@ -12,6 +12,7 @@ import { resolveEnvironmentExecutionTarget } from "./environment-execution-targe
import { prepareManagedAiRuntime, assertManagedAiProjectAuth } from "./ai-connection-runtime.js";
import { aiConnectionService } from "./ai-connections.js";
import { validateAiApiKey } from "./ai-api-key-test.js";
import { probeCopilotExecutionTarget } from "./copilot-connection-probe.js";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
import { assertEnvironmentSelectionForCompany } from "./environment-selection.js";
const asRecord = (value: unknown): Record<string, unknown> | null => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
@@ -22,7 +23,12 @@ export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: Plugin
// A null agent override inherits the instance default, just like dispatch.
// Resolve this before secrets or probes so a default remote environment can
// never accidentally validate the account on the control-plane host.
async function resolveAdapterTestEnvironmentId(companyId: string, environmentId: string | null | undefined) {
async function resolveAdapterTestEnvironmentId(companyId: string, environmentId: string | null | undefined, copilot = false) {
if (copilot && (await instanceSettings.getGeneral()).executionMode === "kubernetes") {
const kubernetes = await environmentsSvc.findKubernetesEnvironment(companyId);
if (!kubernetes) throw unprocessable("The Kubernetes execution environment is unavailable.", { code: "copilot_environment_unavailable" });
return kubernetes.id;
}
if (environmentId) return environmentId;
const settings = await instanceSettings.get();
if (settings.defaultEnvironmentId) return settings.defaultEnvironmentId;
@@ -471,7 +477,7 @@ export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: Plugin
// 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,
@@ -482,6 +488,18 @@ export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: Plugin
}
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 = typeof context.config.model === "string" ? context.config.model.trim() : null;
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;
@@ -16,9 +16,9 @@ import type { LifecycleAgent } from "../modules/agent-lifecycle/index.js";
export function assertHarnessTestPassed(result: AdapterEnvironmentTestResult) {
const verified = result.checks.some(check => check.level === "info" &&
(/hello_probe_(passed|succeeded)$/.test(check.code) || check.code === "ai_connection_api_key_reverified"));
(/hello_probe_(passed|succeeded)$/.test(check.code) || check.code === "ai_connection_api_key_reverified" || check.code === "copilot_metadata_verified"));
const authenticationFailed = result.checks.some(check =>
check.code === ADAPTER_AUTH_MISSING_CHECK_CODE || /_hello_probe_auth_required$/.test(check.code));
check.code === ADAPTER_AUTH_MISSING_CHECK_CODE || check.code === "COPILOT_AUTH_REQUIRED" || /_hello_probe_auth_required$/.test(check.code));
const incomplete = !verified && (result.status === "warn" || result.checks.some(check => check.code.includes("hello_probe")));
if (result.status === "fail" || authenticationFailed || incomplete) {
const has = (suffix: string) => result.checks.some(check => check.code.endsWith(suffix));
@@ -63,7 +63,7 @@ export function agentHarnessVerificationService(db: Db, manager: PluginWorkerMan
}
if (binding) config = { ...config, env: stripAiAuthBindings(config.env) };
config = (await secrets.resolveAdapterConfigForRuntime(agent.companyId, config, context, { adapterType: agent.adapterType })).config;
const environmentId = await tests.resolveAdapterTestEnvironmentId(agent.companyId, agent.defaultEnvironmentId);
const environmentId = await tests.resolveAdapterTestEnvironmentId(agent.companyId, agent.defaultEnvironmentId, agent.adapterType === "paperclip_runner" && config.provider === "acpx" && config.acpxAgent === "copilot");
if (environmentId) {
await tests.assertAdapterTestEnvironmentForCompany(agent.companyId, environmentId);
const environment = await environments.getById(environmentId);
+1
View File
@@ -7,6 +7,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",
+1 -1
View File
@@ -46,7 +46,7 @@ export function isAiAuthenticationRepairable(run: AuthenticationFailure): boolea
/** Provider authentication signals only. Tool authorization and quotas need different repairs. */
export function isAiAuthenticationFailure(code: string | null | undefined): boolean {
return Boolean(code && (
/^(acpx|claude|codex|grok|opencode|gemini|kimi|pi|cursor)_auth_required$/.test(code)
/^(acpx|claude|codex|grok|opencode|gemini|kimi|pi|cursor|copilot)_auth_required$/i.test(code)
|| ["adapter_auth_missing", "authentication_required", "auth_required", "refresh_token_reused", "refresh_token_expired", "refresh_token_invalidated"].includes(code)
));
}
@@ -29,6 +29,7 @@ export function isAiConnectionBusy(error: unknown): error is HttpError {
// Blank values intentionally override inherited credentials in CLI child environments.
export const AI_AUTH_ENV_KEYS = [
"COPILOT_GITHUB_TOKEN",
"PAPERCLIP_AI_PROVIDER_KEY", "PAPERCLIP_AI_PROVIDER_URL", "PAPERCLIP_CODEX_PROVIDERS",
"GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GEMINI_BASE_URL", "GOOGLE_GENAI_USE_VERTEXAI",
"HERMES_HOME", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_BEARER_TOKEN_BEDROCK", "AWS_PROFILE", "AWS_DEFAULT_PROFILE", "AWS_SHARED_CREDENTIALS_FILE", "AWS_CONFIG_FILE", "AWS_WEB_IDENTITY_TOKEN_FILE", "AWS_ROLE_ARN", "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "AWS_CONTAINER_CREDENTIALS_FULL_URI",
@@ -312,6 +313,7 @@ export async function prepareManagedAiRuntime(
...stripAiAuthBindings(input.config.env),
...Object.fromEntries(AI_AUTH_ENV_KEYS.map((key) => [key, ""])),
...managedAiHomeEnvironment(home),
...(input.binding.provider === "github" ? { GH_TOKEN: "", GITHUB_TOKEN: "" } : {}),
};
const capability =
AI_CONNECTION_CAPABILITIES[input.binding.provider].methods[
+1 -1
View File
@@ -332,7 +332,7 @@ export async function probeAiConnectionUsage(
options: { request?: typeof fetch } = {},
): Promise<Omit<AiConnectionUsage, "connectionId" | "grantId">> {
const base = { ...metadata, checkedAt: new Date().toISOString(), source: null, planType: null, limits: [], overage: null };
if (metadata.provider === "google" || !supportsAiConnectionUsage(metadata.provider, metadata.method)) {
if (metadata.provider === "google" || metadata.provider === "github" || !supportsAiConnectionUsage(metadata.provider, metadata.method)) {
return { ...base, status: "unsupported", errorCode: "unsupported", message: messages.unsupported };
}
try {
@@ -0,0 +1,106 @@
import { randomUUID } from "node:crypto";
import type { Db } from "@paperclipai/db";
import { probeCopilotMetadata, validateCopilotMetadata } from "../vendor/paperclip-runner/live/index.js";
import { runAdapterExecutionTargetShellCommand, type AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import { environmentService } from "./environments.js";
import { environmentRuntimeService } from "./environment-runtime.js";
import { instanceSettingsService } from "./instance-settings.js";
import { resolveEnvironmentExecutionTarget } from "./environment-execution-target.js";
import { assertEnvironmentSelectionForCompany } from "../routes/environment-selection.js";
import { buildLoginLeaseAcquireArgs } from "./adapter-login-lease.js";
import { logger } from "../middleware/logger.js";
import { forbidden, unprocessable } from "../errors.js";
export async function probeCopilotConnection(db: Db, companyId: string, token: string, suppliedEnvironmentId?: string | null, model?: string, runtimeOptions: Parameters<typeof environmentRuntimeService>[1] = {}) {
const environments = environmentService(db), settings = instanceSettingsService(db);
const experimental = await settings.getExperimental();
let environmentId = suppliedEnvironmentId ?? (await settings.get()).defaultEnvironmentId;
if ((await settings.getGeneral()).executionMode === "kubernetes") {
const kubernetes = await environments.findKubernetesEnvironment(companyId);
if (!kubernetes) throw unprocessable("The Kubernetes execution environment is unavailable.", { code: "copilot_environment_unavailable" });
environmentId = kubernetes.id;
} else if (experimental.enableManagedSandboxOnly && (!environmentId || (await environments.getById(environmentId))?.driver === "local")) {
const managed = await environments.findManagedSandboxEnvironment(companyId);
if (!managed) throw unprocessable("The managed environment is unavailable.", { code: "copilot_environment_unavailable" });
environmentId = managed.id;
}
if (!environmentId) return requireVerified(await probeCopilotMetadata(token, model));
await assertEnvironmentSelectionForCompany(environments, companyId, environmentId, { allowedDrivers: ["local", "ssh", "sandbox", "plugin"] });
const boundCompanyIds = await environments.listBoundCompanyIds(environmentId);
if (boundCompanyIds.length > 0 && !boundCompanyIds.includes(companyId)) throw forbidden("The selected environment belongs to another company.", { code: "environment_company_mismatch" });
const environment = await environments.getById(environmentId);
if (!environment || environment.status !== "active") throw unprocessable("Choose an active Copilot execution environment.", { code: "copilot_environment_unavailable" });
if (environment.driver === "local") return requireVerified(await probeCopilotMetadata(token, model));
const runtime = environmentRuntimeService(db, runtimeOptions);
let lease: Awaited<ReturnType<typeof runtime.acquireRunLease>> | undefined;
let succeeded = false;
let probeFailed = false;
try {
if (environment.driver !== "ssh") {
lease = await runtime.acquireRunLease(buildLoginLeaseAcquireArgs({ metadata: { companyId, environment, adapterType: "paperclip_runner" }, assertCompanyBinding: true, requestedExpiresAt: new Date(Date.now() + 90_000) }));
await runtime.realizeWorkspace({ environment, lease: lease.lease, workspace: {} });
}
const target = await resolveEnvironmentExecutionTarget({ db, companyId, adapterType: "paperclip_runner", environment,
leaseId: lease?.lease.id, lease: lease?.lease, leaseMetadata: lease?.lease.metadata ?? null, environmentRuntime: runtime });
if (!target || target.kind !== "remote") throw unprocessable("The selected environment cannot run the Copilot metadata probe.", { code: "copilot_environment_unavailable" });
const verified = await probeCopilotExecutionTarget(token, target, model);
succeeded = true;
return verified;
} catch (error) {
probeFailed = true;
throw error;
} finally {
if (lease) {
try {
const driver = runtime.getDriver(environment.driver);
if (!driver) throw new Error("Copilot verification lease cleanup driver is unavailable");
await driver.releaseRunLease({ environment, lease: lease.lease, status: succeeded ? "released" : "failed" });
} catch {
// Report only owned identifiers; driver errors may contain credentials.
logger.warn({ companyId, environmentId: environment.id, leaseId: lease.lease.id, code: "COPILOT_PROBE_CLEANUP_FAILED" }, "Copilot verification lease cleanup failed");
if (!probeFailed) throw unprocessable("Copilot verification could not release its execution environment.", { code: "COPILOT_REQUEST_FAILED", cleanupCode: "COPILOT_PROBE_CLEANUP_FAILED" });
}
}
}
}
function requireVerified(value: unknown) {
const result = validateCopilotMetadata(value);
if (result.status !== "verified") throw unprocessable(result.message, { code: result.code });
return result;
}
export async function probeCopilotExecutionTarget(token: string, target: AdapterExecutionTarget | null | undefined, model?: string) {
if (!target || target.kind === "local") return requireVerified(await probeCopilotMetadata(token, model));
// Try the same two installed locations as native admission. An old /opt
// pack must not hide a valid home pack; account/model failures remain final.
const deadlineAt = Date.now() + 35_000;
let incompatibleMetadata: unknown;
for (const location of ["/opt/paperclip-runner/provider-pack", '"$HOME/.local/share/paperclip-runner/provider-pack"']) {
const remainingMs = deadlineAt - Date.now();
if (remainingMs <= 0) throw unprocessable("Copilot metadata verification timed out.", { code: "COPILOT_REQUEST_FAILED" });
const command = `pack=${location}; if [ -f "$pack/provider-pack.json" ] && [ -f "$pack/dist/cli/copilot-metadata-probe.js" ] && [ -x "$pack/node_modules/node/bin/node" ]; 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; exit 127`;
// The selected token and exact model are environment values, never 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: Math.ceil(remainingMs / 1000), 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 { continue; }
const verified = validateCopilotMetadata(result);
if (verified.status === "failed") {
if (verified.code === "COPILOT_INSTALLATION_INVALID") continue;
if (result !== null && typeof result === "object" && (result as Record<string, unknown>).status === "verified") {
incompatibleMetadata = verified;
continue;
}
requireVerified(verified);
continue;
}
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;
}
if (incompatibleMetadata) requireVerified(incompatibleMetadata);
throw unprocessable("Install the verified Copilot runtime in this environment.", { code: "COPILOT_INSTALLATION_INVALID" });
}
+1 -1
View File
@@ -7,5 +7,5 @@ import type { AiConnectionMetadata } from "@paperclipai/shared";
/** Catalogs follow the saved API connection, never the host CLI's credentials or environment. */
export function fastResponseCatalogModels(connection: Pick<AiConnectionMetadata, "provider" | "routing">) {
if (connection.routing) return connection.routing.models ?? [];
return { anthropic, openai, google, xai, openrouter: [] }[connection.provider];
return { anthropic, openai, google, xai, openrouter: [], github: [] }[connection.provider];
}
@@ -55,6 +55,22 @@ describe("Paperclip Runner native provider configuration", () => {
})).toThrow("Preconfigured home supports");
}
});
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",
@@ -14,7 +14,7 @@ type Input = Parameters<typeof createHeartbeatInstructionWorkingCopy>[0];
type Prepare = Parameters<ReturnType<typeof createHeartbeatInstructionWorkingCopy>["prepare"]>[0];
type Copy = NonNullable<Awaited<ReturnType<Input["instructionCopies"]["prepare"]>>>;
function fixture(executionTarget: Input["workspace"]["executionTarget"] = null) {
function fixture(executionTarget: Input["workspace"]["executionTarget"] = null, acpxAgent?: string) {
let run = { id: "current-run", companyId: "company", agentId: "agent", runnerProfileJson: null, status: "queued" } as ReturnType<Input["readRun"]>;
const copy = { runId: run.id, companyId: run.companyId, agentId: run.agentId,
localRoot: "/workspace/copy", executionRoot: "/workspace/copy", entryFile: "AGENTS.md",
@@ -32,14 +32,14 @@ function fixture(executionTarget: Input["workspace"]["executionTarget"] = null)
const controller = createHeartbeatInstructionWorkingCopy({
db: {} as Input["db"], instructionCopies: copies as unknown as Input["instructionCopies"],
readRun: () => run,
agent: { id: "agent", companyId: "company", adapterType: "codex_local", adapterConfig: { lifecycleMode: "warm" }, runtimeConfig: {} } as unknown as Input["agent"],
agent: { id: "agent", companyId: "company", adapterType: acpxAgent ? "paperclip_runner" : "codex_local", adapterConfig: { lifecycleMode: "warm", ...(acpxAgent ? { provider: "acpx", acpxAgent } : {}) }, runtimeConfig: {} } as unknown as Input["agent"],
context,
session: { taskSession: null, taskSessionForRun: { lastRunId: "previous-run" }, sessionConfigMetadata: {} as Input["session"]["sessionConfigMetadata"] },
workspace: { executionTarget, executionWorkspace: { cwd: "/workspace" } as Input["workspace"]["executionWorkspace"], persistedExecutionWorkspace: null },
appendRunEvent,
});
const prepare: Prepare = { runtimeConfig: { retained: true }, hasInstructionFilesystem: true,
nativeRuntimeResolution: { kind: "native", profile: { backend: "codex_app_server" } } as Prepare["nativeRuntimeResolution"] };
nativeRuntimeResolution: { kind: "native", profile: { backend: acpxAgent ? "acpx_runtime" : "codex_app_server" } } as Prepare["nativeRuntimeResolution"] };
const reservation = { reuseRunId: "previous-run", adopt: vi.fn(), release: vi.fn().mockResolvedValue(undefined) };
native.reserve.mockResolvedValue(reservation);
return { controller, copies, copy, context, prepare, reservation, appendRunEvent,
@@ -49,6 +49,21 @@ function fixture(executionTarget: Input["workspace"]["executionTarget"] = null)
beforeEach(() => { vi.clearAllMocks(); native.claim.mockResolvedValue(null); });
describe("instruction working-copy extraction boundary", () => {
it.each([
["copilot", false, true], ["copilot", true, true],
["cursor", false, true], ["cursor", true, true],
["claude", false, false], ["claude", true, false],
] as const)("prepares %s instructions with the expected warm ownership on remote=%s", async (agent, remote, warm) => {
const target: Input["workspace"]["executionTarget"] = remote
? { kind: "remote", transport: "sandbox", remoteCwd: "/workspace", runnerLifecyclePolicy: { mode: "warm", idleTimeoutMs: 300_000 } }
: null;
const f = fixture(target, agent);
await f.controller.prepare(f.prepare);
expect(f.copies.prepare).toHaveBeenCalledWith(expect.objectContaining({ warm, reuseRunId: warm ? "previous-run" : undefined }));
if (warm) expect(native.reserve).toHaveBeenCalledWith(expect.objectContaining({ previousRunId: "previous-run", runId: "current-run", target }));
else expect(native.reserve).not.toHaveBeenCalled();
});
it.each(["openai_dot_mcp", "muse_external"])("does not prepare an instruction filesystem for %s", async backend => {
const f = fixture();
const result = await f.controller.prepare({ ...f.prepare, hasInstructionFilesystem: false,
@@ -9,6 +9,7 @@ import { AgentDirectoryReuseInvalidatedError, isAgentDirectoryCopy } from "../ag
import { collectStoppedInstructionCopyWithRetries, instructionWorkingCopyGuidance, type agentInstructionWorkingCopyService } from "../agent-instruction-working-copies.js";
import { claimWarmNativeInstructionCopy, nativeSessionWorkspaceScope, reserveWarmNativeInstructionDirectory } from "../native-runtime/index.js";
import { parseNativeExecutionInput } from "../../vendor/paperclip-runner/index.js";
import { supportsNativeWarmFiles } from "../native-runtime/native-warm-capability.js";
import type { HeartbeatRuntimeSelectionInput } from "./runtime-selection.js";
import type { createHeartbeatLifecycle } from "./run-lifecycle.js";
import type { EffectiveRunSessionConfigMetadata } from "./workspaces.js";
@@ -137,7 +138,7 @@ export function createHeartbeatInstructionWorkingCopy(input: InstructionWorkingC
const savedFileInput = parseObject(parseObject(readRun().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" ||
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"
@@ -98,6 +98,25 @@ describe.skipIf(!support.supported)("heartbeat runtime selection boundary", () =
expect(input.onNativeOwnershipHeld).not.toHaveBeenCalled();
});
it("persists the Copilot task model without accepting task provider or permission changes", async () => {
const { input, run, issue } = await fixture();
const adapterConfig = { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna", acpxPermissionMode: "approve-reads" };
input.agent = { ...input.agent, adapterConfig };
await db.update(agents).set({ adapterConfig }).where(eq(agents.id, input.agent.id));
input.nativeRuntimeResolution = resolveHeartbeatNativeRuntimeMode({
persisted: input.run, enabled: true, runtimeConfig: {}, adapterConfig,
agent: input.agent, issue, target: null, workspaceId: null,
});
input.config.issueAssigneeOverrides = { adapterConfig: {
model: "gpt-6-luna", provider: "codex", acpxAgent: "pi", acpxPermissionMode: "approve-all",
} };
const selected = await selectHeartbeatRuntime(db, input);
if (!selected.selected || !selected.nativeExecution) throw new Error("Expected native Copilot selection");
const expected = { kind: "acpx", agent: "copilot", model: "gpt-6-luna", permissionMode: "approve-reads" };
expect(selected.nativeExecution.provider).toMatchObject(expected);
expect((await read(run.id)).runnerProfileJson).toMatchObject({ nativeExecutionInput: { provider: expected } });
});
it("persists configured GitHub instruction skills without admitting provider-mentioned skills", async () => {
const { input, issue, run } = await fixture();
input.config.runScopedMentionedSkillKeys = ["company/test/configured"];
@@ -259,6 +278,39 @@ describe.skipIf(!support.supported)("heartbeat runtime selection boundary", () =
expect(await coordinators(run.id)).toHaveLength(0);
});
it.each([
{ provider: "github" as const, method: "api_key" as const, expected: "warm" },
{ provider: "github" as const, method: "subscription" as const, expected: "per_turn" },
{ provider: "openai" as const, method: "api_key" as const, expected: "per_turn" },
])("persists $expected Copilot lifecycle for a managed $provider/$method account", async ({ provider, method, expected }) => {
const { input, run, issue } = await fixture();
const adapterConfig = { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna", lifecycleMode: "warm", idleTimeoutMs: 300_000 };
input.agent = { ...input.agent, adapterConfig };
await db.update(agents).set({ adapterConfig }).where(eq(agents.id, input.agent.id));
input.nativeRuntimeResolution = resolveHeartbeatNativeRuntimeMode({
persisted: input.run, enabled: true, runtimeConfig: {}, adapterConfig,
agent: input.agent, issue, target: null, workspaceId: null,
});
const attribution = { connectionId: randomUUID(), grantId: randomUUID(), provider, method, mode: "responsible_user" as const, responsibleUserId: "task-owner" };
input.config.managedAiRuntime = {
sessionIdentity: "fixture-grant:task-owner:epoch-1",
config: { env: {}, managedAiConnection: { ...attribution, identity: "fixture-grant:task-owner:generation-1", sessionIdentity: "fixture-grant:task-owner:epoch-1" } },
attribution,
accountName: "Saved account",
accountOwnerUserId: "task-owner",
identity: "fixture-grant:task-owner:generation-1",
home: instructionRoot,
cleanup: async () => {},
};
const selected = await selectHeartbeatRuntime(db, input);
if (!selected.selected || !selected.nativeExecution) throw new Error("Expected native Copilot selection");
expect(selected.nativeExecution.session.lifecyclePolicy.mode).toBe(expected);
expect((await read(run.id)).runnerProfileJson).toMatchObject({
nativeExecutionInput: { session: { lifecyclePolicy: { mode: expected } } },
});
expect(await coordinators(run.id)).toHaveLength(1);
});
it("preserves only server-owned admission and dispatch evidence when recording legacy selection", async () => {
const { input, run } = await fixture("process");
const admission = { version: 1, kind: "fixture" }, dispatch = { entered: true };
@@ -11,6 +11,7 @@ import { isAgentDirectoryCopy } from "../agent-directory-working-copies.js";
import { isConversation } from "../agent-conversations.js";
import { renewLegacyControllerLease } from "../legacy-controller-lease.js";
import type { prepareManagedAiRuntime } from "../ai-connection-runtime.js";
import { supportsManagedNativeWarmSession } from "../native-runtime/native-warm-capability.js";
import {
getNativeReviewAssignment,
readNativeReviewAssignmentContext,
@@ -504,11 +505,11 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele
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 ??
(["openai_dot_mcp", "grok_bot_mcp", "muse_external"].includes(nativeRuntimeResolution.profile.backend) || managedAiRuntime && !supportsManagedWarmSession
? { mode: "per_turn" as const, idleTimeoutMs: null }
@@ -718,6 +719,8 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele
const nativeProviderConfig = nativeRuntimeResolution.profile.backend === "codex_app_server"
|| (nativeRuntimeResolution.profile.backend === "acpx_runtime" && agentHarnessType(agent.adapterType, parseObject(agent.adapterConfig)) === "claude_local")
|| nativeRuntimeResolution.profile.backend === "opencode_server"
|| (nativeRuntimeResolution.profile.backend === "acpx_runtime"
&& parseObject(agent.adapterConfig).acpxAgent === "copilot")
? projectPaperclipRunnerTaskConfig(
nativeRuntimeResolution.profile.backend,
agent.adapterConfig,
@@ -4,21 +4,11 @@ import { ACPX_QUALIFICATION_ENV, resolveAcpxQualification } from "./acpx-qualifi
import type { NativeExecutionInput } from "../../vendor/paperclip-runner/index.js";
import { resolvePaperclipRunnerProviderProfile, resolvePaperclipRunnerNativeProviderInput } from "./provider-profile.js";
const provider = { kind: "acpx", agent: "copilot", model: "exact-model", permissionMode: "approve-all" } as NativeExecutionInput["provider"];
const provider = { kind: "acpx", agent: "copilot", model: "exact-model", permissionMode: "approve-all" } as Extract<NativeExecutionInput["provider"], { kind: "acpx" }>;
const authorize = (value: unknown) => ({ [ACPX_QUALIFICATION_ENV]: JSON.stringify(value) });
describe("host ACPX qualification admission", () => {
afterEach(() => vi.unstubAllEnvs());
it.each(["copilot"])("admits %s through agent validation and native input only for the exact host pair", (agent) => {
const config = { provider: "acpx", acpxAgent: agent, model: "exact-model" };
vi.stubEnv(ACPX_QUALIFICATION_ENV, undefined);
expect(() => resolvePaperclipRunnerProviderProfile(config)).toThrow(expect.objectContaining({ code: "paperclip_runner_acpx_agent_unavailable" }));
expect(() => resolvePaperclipRunnerProviderProfile({ ...config, env: authorize([{ agent, model: config.model }]), [ACPX_QUALIFICATION_ENV]: JSON.stringify([{ agent, model: config.model }]) })).toThrow(expect.objectContaining({ code: "paperclip_runner_acpx_agent_unavailable" }));
vi.stubEnv(ACPX_QUALIFICATION_ENV, JSON.stringify([{ agent, model: config.model }]));
expect(resolvePaperclipRunnerProviderProfile(config)).toMatchObject({ acpxAgent: agent, model: config.model });
expect(resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: config })).toMatchObject({ acpxAgent: agent, model: config.model });
expect(() => resolvePaperclipRunnerProviderProfile({ ...config, model: "other-model" })).toThrow(expect.objectContaining({ code: "paperclip_runner_acpx_qualification_invalid" }));
expect(() => resolvePaperclipRunnerProviderProfile({ ...config, model: "" })).toThrow(expect.objectContaining({ code: "paperclip_runner_acpx_model_required" }));
});
it("admits only the exact Pi model without host authorization and preserves permission modes", () => {
const config = { provider: "acpx", acpxAgent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731" };
for (const authorization of [undefined, "malformed unrelated candidate authorization"]) {
@@ -34,18 +24,12 @@ describe("host ACPX qualification admission", () => {
}
}
});
it("keeps normal candidate execution closed and admits only the exact operator pair", () => {
expect(resolveAcpxQualification(provider, {})).toBeUndefined();
expect(resolveAcpxQualification(provider, authorize([{ agent: "copilot", model: "exact-model" }]))).toBe("copilot");
for (const entries of [[{ agent: "pi", model: "exact-model" }], [{ agent: "copilot", model: "other-model" }]]) {
expect(() => resolveAcpxQualification(provider, authorize(entries))).toThrow("exact model");
}
});
it.each([[], {}, [{ agent: "cursor", model: "" }], [{ agent: "cursor", model: " exact-model" }],
[{ agent: "cursor", model: "exact-model", allowAll: true }], [{ agent: "claude", model: "exact-model" }],
[{ agent: "cursor", model: "exact-model" }, { agent: "cursor", model: "exact-model" }],
])("rejects malformed or broad authorization %j", entries => {
expect(() => resolveAcpxQualification(provider, authorize(entries))).toThrow("Invalid ACPX");
expect(() => resolveAcpxQualification({ ...provider, agent: "pi" }, authorize(entries))).toThrow("Invalid ACPX");
});
it("admits Cursor through ordinary configuration and ignores unrelated qualification authority", () => {
const config = { provider: "acpx", acpxAgent: "cursor", model: "exact-cursor-model" };
@@ -55,6 +39,17 @@ describe("host ACPX qualification admission", () => {
expect(resolveAcpxQualification({ ...provider, agent: "cursor" } as typeof provider, authorize([]))).toBeUndefined();
expect(() => resolvePaperclipRunnerProviderProfile({ ...config, model: "" })).toThrow(expect.objectContaining({ code: "paperclip_runner_acpx_model_required" }));
});
it.each([undefined, "malformed", JSON.stringify([{agent:"pi",model:"pi-model"}])])("admits ordinary Copilot without host overrides (%s)", host => {
vi.stubEnv(ACPX_QUALIFICATION_ENV,host);
const config={provider:"acpx",acpxAgent:"copilot",model:"gpt-5.6-luna"};
expect(resolvePaperclipRunnerProviderProfile(config)).toMatchObject({acpxAgent:"copilot",model:config.model});
expect(resolvePaperclipRunnerNativeProviderInput({backend:"acpx_runtime",adapterConfig:config})).toMatchObject({acpxAgent:"copilot",model:config.model});
expect(resolveAcpxQualification({...provider,model:config.model},{})).toBe("copilot");
});
it.each(["", " ", "auto", "default"])("refuses implicit Copilot model %s",model=>{
expect(()=>resolvePaperclipRunnerProviderProfile({provider:"acpx",acpxAgent:"copilot",model})).toThrow(expect.objectContaining({code:"paperclip_runner_acpx_model_required"}));
expect(()=>resolveAcpxQualification({...provider,model},{})).toThrow("explicit available model");
});
it("does not alter existing qualified providers", () => {
expect(resolveAcpxQualification({ ...provider, agent: "codex" } as typeof provider, authorize([]))).toBeUndefined();
});
@@ -1,3 +1,4 @@
import { PAPERCLIP_RUNNER_ACPX_PROFILES } from "@paperclipai/adapter-utils";
import type { NativeExecutionInput } from "../../vendor/paperclip-runner/index.js";
/** Operator-only admission for an exact candidate/model during qualification. */
@@ -9,6 +10,15 @@ export function resolveAcpxQualification(
hostEnvironment: NodeJS.ProcessEnv,
): AcpxQualificationCandidate | undefined {
if (provider.kind !== "acpx" || !["copilot", "pi"].includes(provider.agent)) return undefined;
// The server release declaration admits qualified Copilot into the frozen
// SDK's explicit native transport slot. No operator environment is required.
// Pi retains the mainline transport and qualification behavior below.
if (provider.agent === "copilot" && PAPERCLIP_RUNNER_ACPX_PROFILES.some(profile => profile.value === "copilot" && profile.qualified)) {
if (!provider.model || provider.model !== provider.model.trim() || ["auto", "default"].includes(provider.model.toLowerCase())) {
throw new Error("GitHub Copilot requires an explicit available model ID");
}
return "copilot";
}
const encoded = hostEnvironment[ACPX_QUALIFICATION_ENV];
if (!encoded) return undefined;
let entries: unknown;
@@ -245,7 +245,13 @@ async function openedFilePath(fd: number): Promise<string> {
execFile(
"/usr/sbin/lsof",
["-a", "-p", String(process.pid), "-d", String(fd), "-F0n"],
{ encoding: "buffer", maxBuffer: 16_384, timeout: 1_000 },
{
encoding: "buffer",
maxBuffer: 16_384,
timeout: 1_000,
// lsof escapes non-ASCII names under the C locale, breaking descriptor verification.
env: { ...process.env, LC_ALL: "en_US.UTF-8" },
},
(error, stdout) => {
if (error) reject(error);
else resolve(Buffer.from(stdout));
@@ -10063,6 +10063,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",
{
@@ -10116,24 +10126,7 @@ describe("native process ownership", () => {
},
);
it.each(["copilot"])("rejects ACPX candidate %s without host authorization before constructing a backend", async (agent) => {
const piExecution = {
...execution,
binding: { ...execution.binding, runId: "run-acpx-pi-rejected" },
provider: { kind: "acpx", agent, model: "pi-model" },
session: { ...execution.session, driverKind: "acpx_runtime" },
} as unknown as NativeExecutionInputV1;
state.createBackend.mockClear();
await expect(
executePaperclipNativeSession({
db: leaseDb(piExecution),
execution: piExecution,
runnerInstanceId: "runner",
}),
).rejects.toThrow("exact host qualification authorization");
expect(state.createBackend).not.toHaveBeenCalled();
});
});
describe("runnerd provider runtime wiring", () => {
@@ -34,7 +34,7 @@ import { copyBackCodexAuth } from "@paperclipai/adapter-codex-local/server";
import { nativeCompletionFeedback } from "./native-completion-feedback.js";
import { hasAcknowledgedNativeReassignmentStopIntent, hasAcknowledgedNativeStopIntent } from "../acknowledged-native-stop.js";
import { stoppedCodexTurnIsTextOnly } from "./stopped-codex-turn.js";
import { prepareVerifiedRemoteProviderPack, useVerifiedPreinstalledRemoteProviderPack, runRemoteProviderVerification } from "./remote-provider-pack.js";
import { prepareVerifiedRemoteProviderPack, useVerifiedPreinstalledRemoteProviderPack, runRemoteProviderVerification, shouldUseBundledAcpxImageAssets } from "./remote-provider-pack.js";
import { readProviderPackArchive, stageProviderPackArchive } from "./provider-pack-archive.js";
import { selectRemotePiCompanion } from "./remote-pi-companion.js";
import { readNativeLocalProcessStop, PROCESS_START_REQUESTED } from "../native-local-process-stop.js";
@@ -10092,7 +10092,7 @@ type RemoteProviderPackManifest = {
distDigest: string;
bridgeDigest: string;
acpxProfileDigests: typeof REMOTE_PROVIDER_PACK_PROFILE_DIGESTS;
providers?: Partial<Record<"pi" | "cursor", {
providers?: Partial<Record<"pi" | "cursor" | "copilot", {
version: string; profileDigest: string; closureDigest: string; qualification: "qualified" | "pending";
path: string; sha256: string;
}>>;
@@ -10281,10 +10281,9 @@ function readRemoteProviderPackIdentity(packRoot: string, verifyControllerFiles:
}
for (const [provider, candidate] of Object.entries(candidates)) {
const expectedPath = `provider-assets/${provider}/${payload.target.platform}-${payload.target.architecture}`;
if (!(inventory === "providers" ? ["pi", "cursor"] : ["cursor", "copilot", "pi"]).includes(provider) || !candidate
if (!(inventory === "providers" ? ["pi", "cursor", "copilot"] : ["cursor", "copilot", "pi"]).includes(provider) || !candidate
|| Object.keys(candidate).some(key => !["version", "profileDigest", "closureDigest", "qualification", "path", "sha256"].includes(key))
|| candidate.qualification !== (["pi", "cursor"].includes(provider) ? "qualified" : "pending")
|| candidate.path !== expectedPath
|| candidate.qualification !== (["pi", "cursor"].includes(provider) || inventory === "providers" && provider === "copilot" ? "qualified" : "pending") || candidate.path !== expectedPath
|| typeof candidate.version !== "string" || !candidate.version || candidate.version.length > 120
|| !/^sha256:[a-f0-9]{64}$/.test(candidate.profileDigest)
|| !/^sha256:[a-f0-9]{64}$/.test(candidate.closureDigest)
@@ -10302,6 +10301,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");
}
@@ -11746,9 +11760,13 @@ async function createRunnerdBackendWithinSessionClaim(
const configuredProviderPackRoot =
input.runnerRemoteProviderPackPath?.trim() || remotePiCompanion?.providerPack || bundledClaudeRoot || null;
let expectedProviderPackManifest: RemoteProviderPackManifest | null = null;
const useBundledRemoteImageAssets = requiresRemoteProviderPack && !configuredProviderPackRoot &&
(input.execution.provider.kind === "opencode" ||
(input.execution.provider.kind === "acpx" && (input.execution.provider.agent === "cursor" || remoteClaude)));
const useBundledRemoteImageAssets =
(requiresRemoteProviderPack && !configuredProviderPackRoot &&
(input.execution.provider.kind === "opencode" || remoteClaude)) ||
shouldUseBundledAcpxImageAssets({
requiresRemoteProviderPack, configuredProviderPackRoot,
provider: input.execution.provider,
});
if (useBundledRemoteImageAssets) {
expectedProviderPackManifest = bundledClaudeManifest && (remoteClaudeTarget === "linux-x64" || remoteClaudeTarget === "linux-arm64")
? readBundledRemoteProviderPackManifest(bundledClaudeManifest, remoteClaudeTarget)
@@ -859,6 +859,11 @@ const recoveryFakeCodex = resolve(
async onContinuityBreak(value) {
continuity = value;
},
}).then((result) => {
if (result.terminal.runTerminalState !== "succeeded") {
throw new Error(`Disposable recovery fixture failed: ${JSON.stringify(result)}`);
}
return result;
}).catch(async (error: unknown) => {
const eventRows = await db
.select({
@@ -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");
}
@@ -31,6 +31,7 @@ export const DEFAULT_ACPX_RUNNER_MODELS = {
// These profiles require explicit configuration. Admission belongs to the runner.
codex: null,
cursor: null,
copilot: null,
pi: null,
} as const;
@@ -535,6 +536,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.`);
@@ -554,7 +558,7 @@ export function resolvePaperclipRunnerProviderProfile(
if ((acpxAgent === "cursor" || acpxAgent === "pi") && !model) {
throw new PaperclipRunnerProviderProfileError("paperclip_runner_acpx_model_required", "This ACPX agent requires an explicit model ID; there is no default model.");
}
if (acpxAgent !== "claude" && acpxAgent !== "codex" && acpxAgent !== "grok" && acpxAgent !== "cursor" && acpxAgent !== "pi") {
if (acpxAgent !== "claude" && acpxAgent !== "codex" && acpxAgent !== "grok" && acpxAgent !== "cursor" && acpxAgent !== "copilot" && acpxAgent !== "pi") {
throw new PaperclipRunnerProviderProfileError(
"paperclip_runner_acpx_agent_unavailable",
"Paperclip Runner ACPX requires a qualified agent profile.",
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { prepareVerifiedRemoteProviderPack, useVerifiedPreinstalledRemoteProviderPack, runRemoteProviderVerification } from "./remote-provider-pack.js";
import { prepareVerifiedRemoteProviderPack, useVerifiedPreinstalledRemoteProviderPack, runRemoteProviderVerification, shouldUseBundledAcpxImageAssets } from "./remote-provider-pack.js";
describe("remote provider verification", () => {
const ok = { exitCode: 0, timedOut: false, stdout: "verified", stderr: "", signal: null };
@@ -91,6 +91,21 @@ describe("remote provider pack reuse", () => {
});
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);
});
});
describe("standard remote provider pack discovery", () => {
const roots = ["/opt/paperclip-runner/provider-pack", "/home/operator/.local/share/paperclip-runner/provider-pack"];
it.each(["old manifest", "modified artifact", "failed link"])("tries the matching home pack after %s rejects /opt", async reason => {
@@ -1,4 +1,16 @@
import type { NativeRunTrace } from "./native-run-trace.js";
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);
}
type VerificationCommandResult = {
exitCode: number | null;
@@ -50,6 +50,17 @@ describe("resolveNativeRuntimeMode", () => {
})).toMatchObject({ kind: "native", profile: { backend: "codex_app_server" } });
});
it("admits saved-token Copilot with the generic Runner rollout disabled", () => {
expect(resolveNativeRuntimeMode({ ...eligible, enabled: false,
adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" },
})).toMatchObject({ kind: "native", profile: { backend: "acpx_runtime" } });
for (const acpxAgent of ["pi", "cursor", "codex"]) {
expect(() => resolveNativeRuntimeMode({ ...eligible, enabled: false,
adapterConfig: { provider: "acpx", acpxAgent, model: "gpt-5.6-luna" },
})).toThrow(expect.objectContaining({ code: "paperclip_runner_rollout_disabled" }));
}
});
it.each([
[false, false],
[false, true],
@@ -144,16 +155,23 @@ describe("resolveNativeRuntimeMode", () => {
kind: "native",
profile: { backend: "acpx_runtime" },
});
expect(resolveNativeRuntimeMode({
...eligible,
adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" },
})).toMatchObject({
kind: "native",
profile: { backend: "acpx_runtime" },
});
});
it("rejects malformed OpenCode and unqualified ACPX profiles", () => {
it("rejects malformed OpenCode and unregistered ACPX profiles", () => {
expect(() => resolveNativeRuntimeMode({
...eligible,
adapterConfig: { provider: "opencode", model: "gpt-5.6-sol" },
})).toThrow(expect.objectContaining({
code: "paperclip_runner_opencode_model_invalid",
}));
for (const acpxAgent of ["copilot"]) {
for (const acpxAgent of ["unregistered-acpx-agent"]) {
expect(() => resolveNativeRuntimeMode({
...eligible,
adapterConfig: { provider: "acpx", acpxAgent, model: "explicit-provider-model" },
@@ -138,7 +138,10 @@ export function resolveNativeRuntimeMode(input: {
if (input.dotEnabled !== true) throw ineligible("paperclip_runner_dot_disabled", "Enable OpenAI Dot and Assistant connections (MCP) in experimental settings before assigning new work.");
} else if (runnerProfile.provider === "grok_bot" && (input.grokEnabled !== true || !input.enabled)) {
throw ineligible("paperclip_runner_grok_disabled", "Enable Grok Bot, Paperclip Runner, and Assistant connections (MCP) before assigning new work.");
} else if (runnerProfile.provider !== "codex" && !(runnerProfile.provider === "acpx" && runnerProfile.acpxAgent === "claude") && !input.enabled) {
} else if (runnerProfile.provider !== "codex"
&& !(runnerProfile.provider === "acpx"
&& (runnerProfile.acpxAgent === "claude" || runnerProfile.acpxAgent === "copilot"))
&& !input.enabled) {
throw ineligible(
"paperclip_runner_rollout_disabled",
"Paperclip Runner is experimental and disabled on this instance.",
+4 -2
View File
@@ -1046,7 +1046,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
@@ -12579,7 +12580,8 @@ export function toolAccessService(
galleryEntry &&
getAvailableConnectionMethods(galleryEntry).filter(
(candidate) =>
candidate.purpose !== "channel" && candidate.transport !== "chat_sdk",
candidate.purpose !== "channel" && candidate.purpose !== "ai"
&& candidate.transport !== "chat_sdk" && candidate.transport !== "runtime_auth",
).length > 1 &&
!inferredMethodKey
) {
+14 -7
View File
@@ -1,7 +1,14 @@
/** Development boundary; the server build replaces this with Runner dist/live. */
export {
probeAcpxClaudeInstallation,
probeAcpxGrokInstallation,
probeAcpxCursorInstallation,
probeAcpxPiInstallation,
} from "@paperclipai/paperclip-runner/live";
/** Source shim; server build replaces it with the runner's compiled live tree. */
type RunnerLiveModule = typeof import("@paperclipai/paperclip-runner/live");
const sourceUrl = new URL(
"../../../../../packages/paperclip-runner/src/live/index.ts",
import.meta.url,
);
const runner = (await import(sourceUrl.href)) as RunnerLiveModule;
export const probeCopilotMetadata = runner.probeCopilotMetadata;
export const validateCopilotMetadata = runner.validateCopilotMetadata;
export const probeAcpxClaudeInstallation = runner.probeAcpxClaudeInstallation;
export const probeAcpxGrokInstallation = runner.probeAcpxGrokInstallation;
export const probeAcpxCursorInstallation = runner.probeAcpxCursorInstallation;
export const probeAcpxPiInstallation = runner.probeAcpxPiInstallation;
@@ -61,6 +61,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",
@@ -276,7 +276,7 @@ describe("Paperclip Runner Codex configuration", () => {
expect(html).not.toContain("Ask for untrusted operations");
});
it("offers qualified Claude, Cursor and Pi while keeping Copilot disabled", async () => {
it("offers qualified Claude, Cursor, Pi and Copilot", async () => {
const html = await renderRunner({
provider: "acpx",
acpxAgent: "claude",
@@ -289,7 +289,7 @@ describe("Paperclip Runner Codex configuration", () => {
expect(Array.from(options, option => ({ label: option.textContent?.trim(), disabled: option.hasAttribute("data-disabled") })))
.toEqual(expect.arrayContaining([
{ label: "Claude", disabled: false }, { label: "Cursor", disabled: false },
{ label: "Pi", disabled: false }, { label: "GitHub Copilot — qualification pending", disabled: true },
{ label: "Pi", disabled: false }, { label: "GitHub Copilot", disabled: false },
]));
expect(html).not.toContain("Codex via ACPX");
expect(html).not.toContain("ACPX Codex");
+1 -1
View File
@@ -100,7 +100,7 @@ describe("adapter configuration sections", () => {
expect(Array.from(options, option => ({ label: option.textContent?.trim(), disabled: option.hasAttribute("data-disabled") })))
.toEqual(expect.arrayContaining([
{ label: "Claude", disabled: false }, { label: "Pi", disabled: false },
{ label: "Cursor", disabled: false }, { label: "GitHub Copilot — qualification pending", disabled: true },
{ label: "Cursor", disabled: false }, { label: "GitHub Copilot", disabled: false },
]));
expect(adapter).not.toContain("Runner lifecycle");
expect(policy).toContain("Runner lifecycle");
+4 -1
View File
@@ -232,11 +232,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()}` : "";
@@ -1042,6 +1042,33 @@ describe("AgentConfigForm environment selector", () => {
expect(result.container.querySelector('[aria-label="Environment override"]')).toBeNull();
});
it("refreshes Copilot models after reconnecting the same responsible-user account", async () => {
const binding = { mode: "responsible_user", provider: "github", method: "api_key" } as const;
const account = { id: "copilot-account", grantId: "copilot-grant", companyId: "company-1", provider: "github", method: "api_key", name: "My Copilot", ownership: "personal", ownerUserId: "you", isDefault: true, status: "needs_attention" } as const;
const accountList = vi.spyOn(aiConnectionsApi, "list").mockResolvedValue({ currentUserId: "you", canManageConnections: true, connections: [account] });
const reconnect = vi.spyOn(aiConnectionsApi, "create").mockResolvedValue({ connectionId: account.id, grantId: account.grantId });
mockAgentsApi.adapterModels.mockResolvedValueOnce([{ id: "old-model", label: "Old token model" }])
.mockResolvedValue([{ id: "gpt-5.6-luna", label: "New token model" }]);
try {
const result = await renderForm([makeEnvironment({ id: "local-1", name: "Local", driver: "local" })], {
adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" }, runtimeConfig: { aiConnection: binding },
});
roots.push(result.root);
await vi.waitFor(() => expect(mockAgentsApi.adapterModels).toHaveBeenCalledTimes(1));
await clickByText(result.container, "Reconnect account");
const token = document.querySelector<HTMLInputElement>('input[placeholder="github_pat_..."]');
expect(token).not.toBeNull();
await act(async () => setInputValue(token!, "github_pat_test_reconnect"));
await flushReact();
await clickByText(document.body, "Connect");
await vi.waitFor(() => expect(reconnect).toHaveBeenCalledWith("company-1", expect.objectContaining({ provider: "github", connectionId: account.id, apiKey: "github_pat_test_reconnect" })));
await vi.waitFor(() => expect(mockAgentsApi.adapterModels).toHaveBeenCalledTimes(2));
expect(mockAgentsApi.adapterModels).toHaveBeenLastCalledWith("company-1", "paperclip_runner", expect.objectContaining({ provider: "acpx", acpxAgent: "copilot", aiConnection: binding }));
await flushReact();
expect(result.container.textContent).toContain("New token model");
} finally { accountList.mockRestore(); reconnect.mockRestore(); }
});
it("renders GPT-6 Astra and its model-specific reasoning efforts", async () => {
mockAgentsApi.adapterModels.mockResolvedValue([
{ id: "gpt-5.6-sol", label: "gpt-5.6-sol" },
+23 -5
View File
@@ -1,3 +1,4 @@
import { COPILOT_MODEL_DISCOVERY_QUERY_OPTIONS } from "@/lib/copilot-task-model-catalog";
import { useConnectionModels } from "./ai-connections/useConnectionModels";
import { agentHarnessType, agentRunner, type AgentRunnerChoice } from "@paperclipai/shared";
import { adaptersApi } from "../api/adapters";
@@ -12,7 +13,7 @@ import { useState, useEffect, useRef, useMemo, useCallback, Children, isValidEle
import type { AdapterConfigSection } from "../adapters/types";
import { useConfigSchema } from "../adapters/schema-config-fields";
import { schemaFieldSection } from "../adapters/config-sections";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { hashKey, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
Agent,
AdapterAuthSessionPrompt,
@@ -926,24 +927,37 @@ 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 connectionModels = useConnectionModels(isExternalRunner ? null : 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)));
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}:${isCreate ? "new" : props.agent.id}:${JSON.stringify(modelAiBinding ?? null)}` : modelProvider;
const connectionModels = useConnectionModels(isExternalRunner ? null : selectedCompanyId, isCreate ? undefined : modelAiBinding, aiRoutingHarness(adapterType, runnerProvider, runnerAcpxAgent));
// Fetch adapter models for the effective provider, including unsaved changes.
const modelAdapterType = isRunnerHarness ? harnessType : adapterType;
const modelQueryKey = selectedCompanyId
? queryKeys.agents.adapterModels(selectedCompanyId, modelAdapterType, currentDefaultEnvironmentId || null, modelProvider)
? queryKeys.agents.adapterModels(selectedCompanyId, modelAdapterType, currentDefaultEnvironmentId || null, modelCacheIdentity)
: ["agents", "none", "adapter-models", adapterType];
const {
data: fetchedModels,
error: fetchedModelsError,
isLoading: fetchingModels,
refetch: refreshConnectionModels,
} = useQuery({
queryKey: modelQueryKey,
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, modelAdapterType, {
environmentId: currentDefaultEnvironmentId || null,
provider: modelProvider,
acpxAgent: runnerAcpxAgent,
aiConnection: modelAiBinding,
agentId: isCreate ? undefined : props.agent.id,
}),
...(runnerAcpxAgent === "copilot" ? COPILOT_MODEL_DISCOVERY_QUERY_OPTIONS : {}),
enabled: Boolean(selectedCompanyId) && !isExternalRunner && !connectionModels,
});
const copilotModelScope = runnerAcpxAgent === "copilot" ? JSON.stringify(modelQueryKey) : null;
useEffect(() => {
if (copilotModelScope && selectedCompanyId && !isExternalRunner && !connectionModels) void refreshConnectionModels();
}, [copilotModelScope, selectedCompanyId, isExternalRunner, connectionModels, refreshConnectionModels]);
const [refreshModelsError, setRefreshModelsError] = useState<string | null>(null);
const [refreshingModels, setRefreshingModels] = useState(false);
const models = connectionModels?.models ?? fetchedModels ?? externalModels ?? [];
@@ -1298,7 +1312,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
setRefreshingModels(true);
setRefreshModelsError(null);
try {
const refreshed = await agentsApi.adapterModels(selectedCompanyId, modelAdapterType, { refresh: true, environmentId: currentDefaultEnvironmentId || null, provider: modelProvider });
const refreshed = await agentsApi.adapterModels(selectedCompanyId, modelAdapterType, { 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.");
@@ -1723,7 +1737,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
{!isExternalRunner && !isCreate && selectedCompanyId && <AiConnectionField companyId={selectedCompanyId} agentId={props.agent.id} agentName={props.agent.name} adapterType={aiRoutingHarness(adapterType, eff("adapterConfig", "provider", config.provider), eff("adapterConfig", "acpxAgent", config.acpxAgent))}
routerAdapterType={adapterType} value={aiRuntimeConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data}
model={String(eff("adapterConfig", "model", config.model) ?? "")} environmentId={currentDefaultEnvironmentId || undefined} legacy
onChange={binding => mark("runtime", "runtimeConfig", { ...runtimeConfig, aiConnection: binding })} />}
onChange={binding => {
mark("runtime", "runtimeConfig", { ...runtimeConfig, aiConnection: binding });
if (runnerAcpxAgent === "copilot" && hashKey([modelAiBinding ?? null]) === hashKey([binding ?? null])) void refreshConnectionModels();
}} />}
{showInlineAdapterTestEnvironmentFeedback && !props.compactTestFeedback && (testActionError || testEnvironment.error) && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
@@ -2213,6 +2230,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
{props.compactTestFeedback && showInlineAdapterTestEnvironmentFeedback && showAdapterTestEnvironmentButton && (
<RuntimeTestCard
variant={isExternalRunner ? "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)}
@@ -2286,6 +2286,34 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("discovers Copilot overrides with the assignee connection and environment", async () => {
const binding = { provider: "github", method: "api_key", mode: "shared", connectionId: "11111111-1111-4111-8111-111111111111", grantId: "33333333-3333-4333-8333-333333333333" };
mockAgentsApi.list.mockResolvedValue([{ id: "agent-1", name: "Copilot", role: "engineer", status: "active",
adapterType: "paperclip_runner", defaultEnvironmentId: "daytona-1",
adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" },
runtimeConfig: { aiConnection: binding } }]);
mockAgentsApi.adapterModels.mockResolvedValue([{ id: "gpt-5.6-luna", label: "Copilot Luna" }]);
const { root, queryClient } = renderPropertiesWithQueryClient(container, {
issue: createIssue({ assigneeAgentId: "agent-1", assigneeAdapterOverrides: { adapterConfig: { model: "gpt-5.6-luna" } } }),
childIssues: [], onUpdate: vi.fn(), inline: true,
});
await flush();
await flush();
expect(mockAgentsApi.adapterModels).not.toHaveBeenCalled();
const modelTrigger = findRowTrigger(container, "Model");
expect(modelTrigger).toBeDefined();
await act(async () => modelTrigger!.click());
await waitForAssertion(() => expect(mockAgentsApi.adapterModels).toHaveBeenCalledWith("company-1", "paperclip_runner", {
environmentId: "daytona-1", provider: "acpx", acpxAgent: "copilot", agentId: "agent-1", aiConnection: binding,
}));
await act(async () => { await queryClient.invalidateQueries({ type: "active" }, { cancelRefetch: false }); });
expect(mockAgentsApi.adapterModels).toHaveBeenCalledTimes(1);
await act(async () => modelTrigger!.click());
await act(async () => modelTrigger!.click());
await waitForAssertion(() => expect(mockAgentsApi.adapterModels).toHaveBeenCalledTimes(2));
act(() => root.unmount());
});
it("edits existing custom assignee model options from the properties pane", async () => {
const onUpdate = vi.fn();
mockAgentsApi.list.mockResolvedValue([
+14
View File
@@ -107,6 +107,20 @@ it("offers one Codex harness without a runner/provider tile", async () => {
expect(document.querySelectorAll('input[value="codex_local"]')).toHaveLength(1);
expect(document.querySelector("select")).toBeNull();
});
it.each([true, false, undefined])("opens saved-token Copilot setup without an experimental override (%s)", async enableNativeRunner => {
await act(async () => cache.setQueryData(queryKeys.instance.experimentalSettings, { enableNativeRunner }));
await name();
await act(async () => document.querySelector<HTMLInputElement>('input[value="copilot_runtime"]')!.click());
await click("Configure agent");
const query = new URL(state.navigate.mock.calls[0][0], "http://local").searchParams;
expect(query.get("adapterType")).toBe("paperclip_runner");
expect(query.get("runnerProvider")).toBe("copilot");
});
it("hides Copilot when its runtime adapter is disabled", async () => {
await act(async () => cache.setQueryData(queryKeys.adapters.all, state.adapters.map((entry: any) => entry.type === "paperclip_runner" ? { ...entry, disabled: true } : entry)));
await name();
expect(document.querySelector('input[value="copilot_runtime"]')).toBeNull();
});
it.each(["grok", "opencode"])("preserves explicit experimental %s creation in Advanced", async provider => {
await name();
await act(async () => (document.querySelector("summary") as HTMLElement).click());
@@ -111,7 +111,7 @@ async function flushReact() {
});
}
async function mount() {
async function mount(nativeRunnerEnabled?: boolean) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
@@ -124,6 +124,7 @@ async function mount() {
session: { id: "session-1", userId: "user-1" },
user: { id: "user-1", name: "Example", email: "user-1@example.com", image: null },
});
if (nativeRunnerEnabled !== undefined) queryClient.setQueryData(queryKeys.instance.experimentalSettings, { enableNativeRunner: nativeRunnerEnabled, enableOpenAiDot: true });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
@@ -289,4 +290,20 @@ describe("OnboardingWizard adapter selection", () => {
root.unmount();
});
});
it("retains an explicit Copilot draft with the native runner experiment disabled", async () => {
mockAdapterRegistry.list = [{ type: "paperclip_runner" }, { type: "codex_local" }];
window.localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify({ step: 0, adapterType: "paperclip_runner", onboardingCopilot: true, model: "gpt-5.6-luna" }));
const { root } = await mount(false);
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());
});
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(true);
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());
});
});
+123 -2
View File
@@ -36,6 +36,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 } };
@@ -83,7 +92,7 @@ const mockAgentsApi = vi.hoisted(() => ({
// The hire step lists the company's agents first and adopts one that already
// carries the typed name on the same source, so a wizard that reopens on the
// agent step cannot hire "Ada 2". Empty by default: the company is new.
list: vi.fn(async () => [] as Array<{ id: string; name: string; adapterType: string }>),
list: vi.fn(async () => [] as Array<{ id: string; name: string; adapterType: string; adapterConfig?: Record<string, unknown> }>),
instructionsBundle: vi.fn(async () => ({ entryFile: "AGENTS.md" })),
saveInstructionsFile: vi.fn(async () => ({})),
// No default implementation: the top-level `beforeEach` sets the "no
@@ -150,7 +159,7 @@ const mockEnvironmentsApi = vi.hoisted(() => ({
}));
const mockInstanceSettingsApi = vi.hoisted(() => ({
get: vi.fn(async () => ({ defaultEnvironmentId: null as string | null })),
getExperimental: vi.fn(async () => ({ enableManagedSandboxOnly: false })),
getExperimental: vi.fn(async (): Promise<{ enableManagedSandboxOnly: boolean; enableNativeRunner?: boolean }> => ({ enableManagedSandboxOnly: false })),
}));
const mockApprovalsApi = vi.hoisted(() => ({
create: vi.fn(),
@@ -351,6 +360,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
beforeEach(() => {
mockAdaptersApi.list.mockReset().mockResolvedValue([]);
mockEnvironmentsApi.probe.mockReset().mockResolvedValue({ ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "platform-lease" } });
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: false });
localHealth.get.mockResolvedValue({ deploymentMode: "authenticated" });
managedApi.checkLocalLogin.mockReset().mockResolvedValue({
status: "sign_in_required",
@@ -402,6 +412,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,
@@ -429,6 +440,116 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
vi.clearAllMocks();
});
describe("qualified Copilot onboarding", () => {
async function openCopilot() {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: false, enableNativeRunner: true });
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();
queryClient.setQueryData(queryKeys.instance.experimentalSettings, { enableManagedSandboxOnly: false, enableNativeRunner: true });
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(["choices", "authentication error"])("refreshes Copilot onboarding after reconnecting the same binding with cached %s", async (cachedState) => {
const { root, click } = await openCopilot();
try {
mockAgentsApi.adapterModels.mockReset();
if (cachedState === "authentication error") mockAgentsApi.adapterModels.mockRejectedValueOnce(new Error("Expired token"));
else mockAgentsApi.adapterModels.mockResolvedValueOnce([{ id: "old-model", label: "Old account model" }]);
await click("Use saved Copilot connection");
await vi.waitFor(() => expect(mockAgentsApi.adapterModels).toHaveBeenCalledTimes(1));
mockAgentsApi.adapterModels.mockResolvedValue([{ id: "new-model", label: "New account model" }]);
await click("Use saved Copilot connection");
await vi.waitFor(() => expect(mockAgentsApi.adapterModels).toHaveBeenCalledTimes(2));
await vi.waitFor(() => expect(document.body.querySelector('[aria-label="Copilot model"]')?.textContent).toContain("New account model"));
expect(document.body.textContent).not.toContain("Old account model");
expect(document.body.textContent).not.toContain("Expired token");
expect((document.body.querySelector('[aria-label="Copilot model"]') as HTMLSelectElement).value).toBe("");
} finally { await act(async () => root.unmount()); }
});
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("reuses an existing Copilot hire after losing its draft id without adopting another Runner", async () => {
const { root, click } = await openCopilot();
try {
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.list.mockResolvedValue([
{ id: "codex-other", name: "Copilot canary", adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } },
{ id: "copilot-existing", name: "Copilot canary", adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" } },
]);
await click("Connect");
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject({ createdAgentId: "copilot-existing" });
expect(document.body.textContent).toContain("Copilot canary is ready to work!");
} finally {
await act(async () => root.unmount());
}
});
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,
+113 -54
View File
@@ -1,3 +1,4 @@
import { COPILOT_MODEL_DISCOVERY_QUERY_OPTIONS } from "@/lib/copilot-task-model-catalog";
import type { AgentRunner, AgentRunnerChoice } from "@paperclipai/shared";
import { needsClaudeSetupQualification, qualifyClaudeSetup } from "../lib/qualify-claude-setup";
import { agentHarnessType } from "@paperclipai/shared";
@@ -7,7 +8,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";
@@ -152,15 +155,21 @@ type Step = 0 | 1 | 2 | 3 | 4 | 5;
// wizard's registry-driven approach rather than a fixed union.
type AdapterType = string;
// 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, provider?: unknown): AdapterType {
if (savedAdapterType === "paperclip_runner" && (provider === undefined || provider === "codex")) return "codex_local";
return typeof savedAdapterType === "string" && savedAdapterType !== "paperclip_runner"
const copilotOnboardingQualified = PAPERCLIP_RUNNER_ACPX_PROFILES.some(
(profile) => profile.value === "copilot" && profile.qualified,
);
function restoreOnboardingAdapterType(savedAdapterType: unknown, provider?: unknown, savedCopilot?: unknown): AdapterType {
if (savedAdapterType === "paperclip_runner" && savedCopilot !== true && (provider === undefined || provider === "codex")) return "codex_local";
return typeof savedAdapterType === "string" && (savedAdapterType !== "paperclip_runner" || (savedCopilot === true && copilotOnboardingQualified))
? savedAdapterType
: "claude_local";
}
@@ -254,6 +263,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 {
@@ -597,7 +607,7 @@ function OnboardingWizardInner({
(saved?.agentRole as AgentRole) || DEFAULT_AGENT_ROLE,
);
const [adapterType, setAdapterType] = useState<AdapterType>(() =>
restoreOnboardingAdapterType(saved?.adapterType, saved?.runnerProvider),
restoreOnboardingAdapterType(saved?.adapterType, saved?.runnerProvider, saved?.onboardingCopilot),
);
const [runner, setRunner] = useState<AgentRunnerChoice>(() => saved?.runner === "legacy" || saved?.runner === "paperclip"
? saved.runner : saved?.adapterType === "paperclip_runner" && (saved?.runnerProvider === undefined || ["codex", "claude", "acpx"].includes(String(saved?.runnerProvider))) ? "paperclip" : "auto");
@@ -624,19 +634,21 @@ 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) ?? "");
const [model, setModel] = useState(
savedNativeRunnerDraft && saved?.runnerProvider !== undefined && !["codex", "claude", "acpx"].includes(String(saved.runnerProvider)) ? "" : (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);
@@ -710,7 +722,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"
);
@@ -763,9 +775,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);
@@ -798,6 +811,7 @@ function OnboardingWizardInner({
* hand rather than the one before it.
*/
function clearCompanyScopedState() {
setCopilotConnection(undefined);
setAgentAppearance(randomAgentAppearance());
setCreatedCompanyPrefix(null);
setCompanyName("");
@@ -912,6 +926,7 @@ function OnboardingWizardInner({
const state = {
step, companyName,
agentName, agentAppearance, agentRole, adapterType, runner, cwd, model, command, args, url,
onboardingCopilot: isCopilot,
// The mode, never the key: this blob is localStorage.
credentialMode, credentialModeChoice,
createdCompanyId, createdCompanyPrefix, createdAgentId,
@@ -926,21 +941,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);
@@ -970,24 +970,26 @@ function OnboardingWizardInner({
queryFn: () => instanceSettingsApi.getExperimental(),
enabled: effectiveOnboardingOpen && step >= 3 && step <= 5,
});
const resolvedLoginEnvironmentId = useMemo(() => {
const copilotOnboardingEnabled = copilotOnboardingQualified;
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 loginEnvironmentSettingsReady = loginEnvironmentsReady && loginSettingsReady && loginPolicyReady
&& !loginEnvironmentsFetching && !loginSettingsFetching && !loginPolicyFetching;
const runnerDiscovery = useQuery({
@@ -996,6 +998,31 @@ function OnboardingWizardInner({
enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4
&& loginEnvironmentSettingsReady,
});
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 } : {}),
}),
...(isCopilot ? COPILOT_MODEL_DISCOVERY_QUERY_OPTIONS : {}),
enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4 && (!isCopilot || Boolean(copilotConnection && copilotEnvironmentReady)),
retry: false,
});
const requestCopilotModels = Boolean(isCopilot && createdCompanyId && effectiveOnboardingOpen && step === 4 && copilotConnection && copilotEnvironmentReady);
const copilotModelScope = JSON.stringify([createdCompanyId, resolvedLoginEnvironmentId, copilotConnection]);
useEffect(() => {
if (requestCopilotModels) void refreshAdapterModels();
}, [requestCopilotModels, copilotModelScope, refreshAdapterModels]);
const copilotModelReady = Boolean(copilotConnection && copilotEnvironmentReady && !adapterModelsError && !adapterModelsFetching && adapterModels?.some((entry) => entry.id === model.trim()));
const needsRunnerDiscovery = ["codex_local", "claude_local"].includes(adapterType) && runner === "auto";
const resolvedRunner = runner === "auto"
? runnerDiscovery.data?.find(item => item.type === adapterType)?.defaultRunner
@@ -1182,7 +1209,7 @@ function OnboardingWizardInner({
adapterCaps.supportsSkills ||
adapterCaps.supportsLocalAgentJwt;
const isLocalAdapter =
isLocalAdapterCaps ||
isLocalAdapterCaps || isCopilot ||
adapterType === "claude_local" ||
adapterType === "codex_local" ||
adapterType === "gemini_local" ||
@@ -1202,11 +1229,14 @@ function OnboardingWizardInner({
)
.map((a) => ({ ...getAdapterDisplay(a.type), type: a.type }));
const copilot = copilotOnboardingEnabled && !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]);
}, [disabledTypes, copilotOnboardingEnabled]);
/**
* A source chosen from the visible row. Read off the row rather than off
@@ -1233,7 +1263,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 && runnerDiscoveryReady;
const connectStepReady = sourceSelected && !adapterEnvLoading && !savedKeys.loading && runnerDiscoveryReady && (!isCopilot || copilotModelReady);
/**
* Whether this step has a sign-in to do before it can hire.
@@ -1425,7 +1455,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 }
@@ -1444,7 +1474,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.
@@ -1541,14 +1571,18 @@ function OnboardingWizardInner({
// registered once the adapters query resolves, so before that a saved
// external adapter is indistinguishable from a disabled one - and snapping
// would replace the customer's choice with a built-in and persist it.
if (!adapterRegistryLoaded) return;
if (!adapterRegistryLoaded || (adapterType === "paperclip_runner" && !loginPolicyReady)) return;
const visible = [...recommendedAdapters, ...moreAdapters].filter(
(a) => !a.comingSoon,
);
if (visible.length === 0) return;
if (visible.length === 0) {
if (adapterType === "paperclip_runner" && !copilotOnboardingEnabled) { setAdapterType("claude_local"); setModel(""); setCopilotConnection(undefined); setSourcePicked(false); }
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);
if (adapterType === "paperclip_runner") { setModel(""); setCopilotConnection(undefined); }
// 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
// holding it *as chosen* would put a filled tile and an open sign-in panel
@@ -1570,7 +1604,7 @@ function OnboardingWizardInner({
return;
}
setModel("");
}, [adapterRegistryLoaded, recommendedAdapters, moreAdapters, adapterType]);
}, [adapterRegistryLoaded, recommendedAdapters, moreAdapters, adapterType, loginPolicyReady, copilotOnboardingEnabled]);
const COMMAND_PLACEHOLDERS: Record<string, string> = {
claude_local: "claude",
@@ -1600,7 +1634,7 @@ function OnboardingWizardInner({
setAdapterEnvResult(null);
adapterEnvResultAppliedStoredLoginRef.current = false;
if (adapterEnvErrorSelectionRef.current !== adapterSetupSelectionRef.current) setAdapterEnvError(null);
}, [step, adapterType, runner, model, command, args, url, credentialMode, apiKey, selectedSavedKey, selectedApiKey?.id, savedSubscription?.id, createdCompanyId, resolvedLoginEnvironmentId, runner === "auto" ? runnerDiscovery.data?.find(item => item.type === adapterType)?.defaultRunner : undefined]);
}, [step, adapterType, runner, model, command, args, url, credentialMode, apiKey, selectedSavedKey, selectedApiKey?.id, savedSubscription?.id, createdCompanyId, copilotConnection, resolvedLoginEnvironmentId, runner === "auto" ? runnerDiscovery.data?.find(item => item.type === adapterType)?.defaultRunner : undefined]);
/**
* Leaving the step puts the row back to a question.
@@ -1699,6 +1733,7 @@ function OnboardingWizardInner({
setForceUnsetAnthropicApiKey(false);
setUnsetAnthropicLoading(false);
setClaudeOAuthStatus(null);
setCopilotConnection(undefined);
setCreatedCompanyId(null);
setCreatedCompanyPrefix(null);
setCreatedAgentId(null);
@@ -1882,6 +1917,7 @@ function OnboardingWizardInner({
...defaultCreateValues,
adapterType,
runner,
...(isCopilot ? { adapterSchemaValues: { provider: "acpx", acpxAgent: "copilot" } } : {}),
resolvedRunner: setupRunner ?? resolvedRunner,
model:
adapterType === "gemini_local"
@@ -2141,10 +2177,8 @@ function OnboardingWizardInner({
// doesn't hire a second agent.
async function handleGiveHeartbeat() {
if (!createdCompanyId) return;
if (adapterType === "paperclip_runner") {
setAdapterType("claude_local");
setModel("");
setError("Choose a harness to continue onboarding.");
if (isCopilot && (!copilotOnboardingEnabled || !sourceSelected || !copilotModelReady)) {
setError("Connect GitHub Copilot and select an available model before creating this agent.");
return;
}
if (createdAgentId) {
@@ -2216,7 +2250,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;
}
@@ -2307,7 +2341,7 @@ function OnboardingWizardInner({
const existing = existingAgents?.find(
(agent) =>
agent.name.trim().toLowerCase() === hireName.toLowerCase() &&
agentHarnessType(agent.adapterType, agent.adapterConfig) === adapterType,
agentHarnessType(agent.adapterType, agent.adapterConfig) === agentHarnessType(adapterType, hireAdapterConfig),
);
if (existing) {
if (!isCurrent()) return;
@@ -2811,8 +2845,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={
@@ -2906,6 +2941,33 @@ 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("");
if (JSON.stringify(copilotConnection) === JSON.stringify(parsed.data)) void refreshAdapterModels();
}} />
{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 ${
@@ -3043,11 +3105,8 @@ function OnboardingWizardInner({
</details>}
{/* 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
+8 -1
View File
@@ -53,6 +53,7 @@ export function RuntimeTestCard({
onTest,
disabled = false,
variant = "connection",
metadataOnly = false,
}: {
state: TestState | "warn";
result: AdapterEnvironmentTestResult | null;
@@ -60,6 +61,7 @@ export function RuntimeTestCard({
onTest: () => void;
disabled?: boolean;
variant?: "connection" | "prerequisites";
metadataOnly?: boolean;
}) {
const missingRuntime = state === "fail"
? result?.checks.find(check => check.code === "acpx_runtime_package_missing")
@@ -67,6 +69,11 @@ export function RuntimeTestCard({
const content = missingRuntime
? { ...copy.fail, title: "Runtime missing", description: missingRuntime.message }
: (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
@@ -107,7 +114,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"
+2 -1
View File
@@ -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(
@@ -34,6 +34,7 @@ import { projectsApi } from "../../api/projects";
import { useCompany } from "../../context/CompanyContext";
import { useSidebar } from "../../context/SidebarContext";
import { queryKeys } from "../../lib/queryKeys";
import { COPILOT_MODEL_DISCOVERY_QUERY_OPTIONS, copilotTaskModelCatalogOptions } from "../../lib/copilot-task-model-catalog";
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildCompanyUserProfileMap, isAgentTaskTarget } from "../../lib/company-members";
import { ISSUE_OVERRIDE_ADAPTER_TYPES, type IssueModelLane } from "../../lib/issue-assignee-overrides";
import { useProjectOrder } from "../../hooks/useProjectOrder";
@@ -731,11 +732,12 @@ export function IssueProperties({
const assignee = issue.assigneeAgentId
? agents?.find((a) => a.id === issue.assigneeAgentId)
: null;
const copilotCatalog = copilotTaskModelCatalogOptions(assignee);
const assigneeAdapterType = assignee?.adapterType ?? null;
const assigneeAdapterOverrides = issue.assigneeAdapterOverrides ?? null;
const showAssigneeAdapterOptions = assigneeAdapterOverrides !== null;
const supportsAssigneeOverrides = Boolean(
assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType),
assigneeAdapterType && (ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType) || copilotCatalog),
);
const assigneeOverrideLane = overrideLane(assigneeAdapterOverrides);
const assigneeOverrideAdapterConfig = asRecord(assigneeAdapterOverrides?.adapterConfig);
@@ -752,14 +754,24 @@ export function IssueProperties({
const assigneeOverrideChrome = assigneeAdapterType === "claude_local"
&& assigneeOverrideAdapterConfig.chrome === true;
const catalogProvider = assigneeAdapterType === "paperclip_runner" ? String(normalizeLegacyRunnerProvider(assigneePrimaryAdapterConfig).provider ?? "codex") : undefined;
const { data: assigneeAdapterModels } = useQuery({
const catalogEnvironmentId = copilotCatalog ? assignee?.defaultEnvironmentId ?? null : null;
const copilotCatalogScope = JSON.stringify(copilotCatalog ?? null);
const requestCopilotModels = Boolean(copilotCatalog && assigneeOptionsOpen && showAssigneeAdapterOptions && supportsAssigneeOverrides);
const { data: assigneeAdapterModels, refetch: refreshAssigneeModels } = useQuery({
queryKey:
companyId && assigneeAdapterType
? queryKeys.agents.adapterModels(companyId, assigneeAdapterType, null, catalogProvider)
? [...queryKeys.agents.adapterModels(companyId, assigneeAdapterType, catalogEnvironmentId, catalogProvider), copilotCatalog ?? null]
: ["agents", "none", "adapter-models", assigneeAdapterType ?? "none"],
queryFn: () => agentsApi.adapterModels(companyId!, assigneeAdapterType!, { provider: catalogProvider }),
enabled: Boolean(companyId) && showAssigneeAdapterOptions && supportsAssigneeOverrides,
queryFn: () => agentsApi.adapterModels(companyId!, assigneeAdapterType!, {
provider: catalogProvider,
...(copilotCatalog ? { environmentId: catalogEnvironmentId, ...copilotCatalog } : {}),
}),
...(copilotCatalog ? COPILOT_MODEL_DISCOVERY_QUERY_OPTIONS : {}),
enabled: Boolean(companyId) && showAssigneeAdapterOptions && supportsAssigneeOverrides && (!copilotCatalog || requestCopilotModels),
});
useEffect(() => {
if (requestCopilotModels) void refreshAssigneeModels();
}, [requestCopilotModels, copilotCatalogScope, catalogEnvironmentId, companyId, refreshAssigneeModels]);
const modelOverrideOptions = useMemo<InlineEntityOption[]>(() => {
const models = sortAdapterModels(assigneeAdapterModels ?? []);
const options = models.map((model) => ({
@@ -1,4 +1,5 @@
import { useCompany } from "@/context/CompanyContext";
import { PAPERCLIP_RUNNER_ACPX_PROFILES } from "@paperclipai/adapter-utils";
import { useAgentAppearanceDraft } from "@/hooks/useAgentAppearanceDraft";
import { AdapterMark } from "../AdapterMark";
import { AgentCharacter } from "../AgentCharacter";
@@ -69,7 +70,9 @@ export function AgentBasicsDialog({
queryFn: () => adaptersApi.list(),
enabled: open,
});
const choices = (adapters ?? []).filter(
const nativeRunner = adapters?.find(adapter => adapter.type === "paperclip_runner" && adapter.loaded && !adapter.disabled);
const copilotAvailable = Boolean(nativeRunner) && PAPERCLIP_RUNNER_ACPX_PROFILES.some(profile => profile.value === "copilot" && profile.qualified);
const choices = [...(adapters ?? []).filter(
(adapter) =>
adapter.loaded &&
!adapter.disabled &&
@@ -80,7 +83,7 @@ export function AgentBasicsDialog({
}) &&
!["process", "http"].includes(adapter.type) &&
!getAdapterDisplay(adapter.type).comingSoon,
);
), ...(copilotAvailable ? [{ ...nativeRunner!, type: "copilot_runtime" }] : [])];
const experimentalRunnerAvailable = experimental.data?.enableNativeRunner === true
&& adapters?.some(adapter => adapter.type === "paperclip_runner" && adapter.loaded && !adapter.disabled);
const validAdapter = choices.some((adapter) => adapter.type === adapterType)
@@ -122,8 +125,8 @@ export function AgentBasicsDialog({
if (step === "name") setStep("adapter");
else if (validAdapter)
onContinue({ name: name.trim(),
adapterType: adapterType === "openai_dot" ? "paperclip_runner" : adapterType,
runnerProvider: adapterType === "openai_dot" ? "openai_dot" : adapterType === "paperclip_runner" ? runnerProvider : "codex" });
adapterType: ["openai_dot", "copilot_runtime"].includes(adapterType) ? "paperclip_runner" : adapterType,
runnerProvider: adapterType === "copilot_runtime" ? "copilot" : adapterType === "openai_dot" ? "openai_dot" : adapterType === "paperclip_runner" ? runnerProvider : "codex" });
}}
>
<div className="flex min-h-0 flex-col gap-7 overflow-y-auto px-6 pb-8 sm:px-10">
+39 -19
View File
@@ -1,3 +1,4 @@
import { COPILOT_MODEL_DISCOVERY_QUERY_OPTIONS } from "@/lib/copilot-task-model-catalog";
import { ExternalAgentInviteDialog } from "./ExternalAgentInviteDialog";
import { useConnectionModels } from "../ai-connections/useConnectionModels";
import { AgentCharacter } from "../AgentCharacter";
@@ -19,7 +20,7 @@ import { needsClaudeSetupQualification, qualifyClaudeSetup } from "@/lib/qualify
import { useCloudInstance } from "@/hooks/useCloudInstance";
import { isNewAgentAdapterAllowed } from "@/lib/new-agent-adapters";
import { useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { hashKey, useQuery, useQueryClient } from "@tanstack/react-query";
import { AnimatePresence, motion, MotionConfig } from "motion/react";
import { ArrowLeft, ArrowRight, Check, Settings2 } from "lucide-react";
import type {
@@ -129,7 +130,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"
@@ -167,7 +170,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,
);
@@ -242,12 +247,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),
@@ -288,6 +287,22 @@ function Setup({
? cause.message
: "Could not resolve the environment.";
}
const requestCopilotModels = brandType === "copilot_runtime" && showModel && !connectionModels && Boolean(aiBinding)
&& envs.isSuccess && settings.isSuccess && experimental.isSuccess && general.isSuccess && !environmentError;
const copilotModelScope = JSON.stringify([companyId, environmentId, aiBinding]);
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 }),
...(brandType === "copilot_runtime" ? COPILOT_MODEL_DISCOVERY_QUERY_OPTIONS : {}),
enabled: Boolean(brandType) && showModel && !connectionModels && (brandType !== "copilot_runtime" || requestCopilotModels),
retry: false,
});
const refreshCopilotModels = models.refetch;
useEffect(() => {
if (requestCopilotModels) void refreshCopilotModels();
}, [requestCopilotModels, copilotModelScope, refreshCopilotModels]);
const environment = envs.data?.find((env) => env.id === environmentId);
const setupEnvironmentKey = JSON.stringify([companyId, environmentId, environment?.config]);
const setupEnvironmentRef = useRef("");
@@ -399,9 +414,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 } : {}),
},
}
: {}),
@@ -412,8 +427,8 @@ function Setup({
}
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) {
@@ -444,6 +459,8 @@ function Setup({
return config;
}
function preparedConfig(nextConnection = connection, setupRunner?: AgentRunner) {
if (brandType === "copilot_runtime" && !models.data?.some(candidate => candidate.id === model))
throw new Error("Refresh Copilot models and select an available model before creating this agent.");
if (isDot && !allowUnmeteredProvider) throw new Error("Acknowledge external provider billing before creating your Dot agent.");
if (multiProvider && (!model.trim() || !model.includes("/")))
throw new Error("Choose or enter a model in provider/model format.");
@@ -930,7 +947,9 @@ function Setup({
</>}
{!connectionAdapter && aiProviderForAdapter(brandType) && (
<AiConnectionField companyId={companyId} agentName={name} adapterType={brandType} model={model} environmentId={environmentId ?? undefined} value={aiBinding}
onChange={binding => { binding.mode !== "router" && setRuntimeAiBinding(binding); resetTest(); }} />
onChange={binding => { binding.mode !== "router" && setRuntimeAiBinding(binding); resetTest();
if (brandType === "copilot_runtime" && hashKey([aiBinding ?? null]) === hashKey([binding ?? null])) void refreshCopilotModels();
}} />
)}
{(connectionModels ? connectionModels.error : models.error) && <p role="alert" className="text-sm text-destructive">Could not load models. Retry or enter a model ID manually.</p>}
{((showModel && !usingKimiApi) ||
@@ -941,8 +960,8 @@ function Setup({
presentation={brandType === "codex_local" ? "searchable" : "native"}
models={connectionModels?.models ?? models.data ?? []}
loadingModels={connectionModels?.isLoading ?? models.isLoading}
onRefreshModels={connectionModels?.refreshModels}
refreshingModels={connectionModels?.refreshing}
onRefreshModels={connectionModels?.refreshModels ?? (brandType === "copilot_runtime" ? async () => { await models.refetch(); } : undefined)}
refreshingModels={connectionModels?.refreshing ?? (brandType === "copilot_runtime" ? models.isFetching : undefined)}
value={model}
onChange={(value) => {
setModel(connectionModels?.resolveModel(value) ?? value);
@@ -967,9 +986,9 @@ function Setup({
}}
open={modelOpen}
onOpenChange={setModelOpen}
allowDefault={!multiProvider}
required={multiProvider}
creatable
allowDefault={!multiProvider && brandType !== "copilot_runtime"}
required={multiProvider || brandType === "copilot_runtime"}
creatable={brandType !== "copilot_runtime"}
groupByProvider={multiProvider && !connectionModels}
preserveOrder={Boolean(connectionModels) || adapterCuratesModelOrder(brandType)}
/>
@@ -1244,6 +1263,7 @@ function Setup({
</fieldset>
<RuntimeTestCard
variant={isDot ? "prerequisites" : "connection"}
metadataOnly={brandType === "copilot_runtime"}
state={testState}
result={result}
error={error}
@@ -48,6 +48,7 @@ function render(onAssigneeChange: (value: string) => void, onSettingsChange: ()
modelOptionsOverride={useCatalog ? undefined : []} {...props} />
</QueryClientProvider>;
flushSync(() => root!.render(insideDialog ? <Dialog defaultOpen><DialogContent aria-describedby={undefined}><DialogTitle>New task</DialogTitle>{picker}</DialogContent></Dialog> : picker));
return queryClient;
}
beforeEach(() => localStorage.clear());
@@ -260,6 +261,52 @@ describe("composer assignee picker", () => {
expect(choices.some((item) => item.includes("GPT-6 Sol"))).toBe(false);
});
it("keeps Copilot discovery passive during reconnect reconciliation and refreshes on opening", async () => {
const copilot = { ...agent, adapterType: "paperclip_runner", defaultEnvironmentId: "daytona-1",
adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" } } as Agent;
const loadModels = vi.spyOn(agentsApi, "adapterModels")
.mockResolvedValueOnce([{ id: "gpt-5.6-luna", label: "Copilot Luna" }])
.mockResolvedValue([{ id: "gpt-5.6-luna", label: "Updated Copilot Luna" }]);
const client = render(vi.fn(), vi.fn(), true, { agents: new Map([[agent.id, copilot]]), settings: null });
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
expect(loadModels).not.toHaveBeenCalled();
await act(async () => { await client.invalidateQueries({ type: "active" }, { cancelRefetch: false }); });
expect(loadModels).not.toHaveBeenCalled();
await click("Select model and effort");
expect(loadModels).toHaveBeenCalledTimes(1);
await act(async () => { await client.invalidateQueries({ type: "active" }, { cancelRefetch: false }); });
expect(loadModels).toHaveBeenCalledTimes(1);
await act(async () => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
});
await click("Select model and effort");
expect(loadModels).toHaveBeenCalledTimes(2);
await click("Choose exact model");
expect(document.body.textContent).toContain("Updated Copilot Luna");
});
it("discovers Copilot task models with the assignee connection and environment", async () => {
const binding = { provider: "github", method: "api_key", mode: "shared", connectionId: "11111111-1111-4111-8111-111111111111", grantId: "33333333-3333-4333-8333-333333333333" } as const;
const copilot = { ...agent, adapterType: "paperclip_runner", defaultEnvironmentId: "daytona-1",
adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" },
runtimeConfig: { aiConnection: binding } } as Agent;
const loadModels = vi.spyOn(agentsApi, "adapterModels").mockResolvedValueOnce([
{ id: "gpt-5.6-luna", label: "Copilot Luna" },
]);
render(vi.fn(), vi.fn(), true, { agents: new Map([[agent.id, copilot]]), settings: null });
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
expect(loadModels).not.toHaveBeenCalled();
await click("Select model and effort");
expect(loadModels).toHaveBeenCalledWith("company-1", "paperclip_runner", {
environmentId: "daytona-1", provider: "acpx", poolId: undefined,
acpxAgent: "copilot", agentId: agent.id, aiConnection: binding,
});
await click("Choose exact model");
expect(document.body.textContent).toContain("Copilot Luna");
expect(document.body.textContent).not.toContain("Claude Sonnet");
});
it("preserves settings when the selected assignee is chosen again", async () => {
const onAssigneeChange = vi.fn();
const onSettingsChange = vi.fn();
@@ -4,6 +4,7 @@ import { ArrowLeft, Check, ChevronDown, Plus, RotateCcw, Search, X, Zap } from "
import { aiRuntimeConnectionBindingSchema, agentHarnessType, type Agent, type IssueAssigneeAdapterOverrides } from "@paperclipai/shared";
import { agentsApi, type AdapterModel } from "@/api/agents";
import { queryKeys } from "@/lib/queryKeys";
import { COPILOT_MODEL_DISCOVERY_QUERY_OPTIONS, copilotTaskModelCatalogOptions } from "@/lib/copilot-task-model-catalog";
import { cn } from "@/lib/utils";
import { useMobileEntityPickerViewportStyle } from "@/hooks/useMobileEntityPickerViewportStyle";
import { getLastComposerEffort, rememberComposerEffort } from "@/lib/recent-composer-effort";
@@ -112,15 +113,22 @@ export function ComposerRunSettingsPicker({
}, [isMuseRunner, settings, onSettingsChange]);
const binding = aiRuntimeConnectionBindingSchema.safeParse(agent?.runtimeConfig?.aiConnection).data;
const poolId = binding?.mode === "router" ? binding.connectionId : undefined;
const { data: fetchedModels = [], isPending: modelsPending } = useQuery({
const copilotCatalog = copilotTaskModelCatalogOptions(agent);
const copilotCatalogScope = JSON.stringify(copilotCatalog ?? null);
const requestCopilotModels = Boolean(copilotCatalog && open && view !== "agents" && !modelOptionsOverride);
const { data: fetchedModels = [], isPending: modelsPending, refetch: refreshModels } = useQuery({
queryKey: agent && modelSupported
? [...queryKeys.agents.adapterModels(companyId, agent.adapterType, agent.defaultEnvironmentId ?? null, provider), poolId ?? null]
? [...queryKeys.agents.adapterModels(companyId, agent.adapterType, agent.defaultEnvironmentId ?? null, provider), poolId ?? null, copilotCatalog ?? null]
: ["agents", "composer-models", "none"],
queryFn: () => agentsApi.adapterModels(companyId, agent!.adapterType, {
environmentId: agent!.defaultEnvironmentId ?? null, provider, poolId,
environmentId: agent!.defaultEnvironmentId ?? null, provider, poolId, ...copilotCatalog,
}),
enabled: Boolean(agent && modelSupported && !modelOptionsOverride),
...(copilotCatalog ? COPILOT_MODEL_DISCOVERY_QUERY_OPTIONS : {}),
enabled: Boolean(agent && modelSupported && !modelOptionsOverride && (!copilotCatalog || requestCopilotModels)),
});
useEffect(() => {
if (requestCopilotModels) void refreshModels();
}, [requestCopilotModels, copilotCatalogScope, companyId, agent?.defaultEnvironmentId, refreshModels]);
// The server resolves instance-declared models first and otherwise returns the
// adapter's curated catalog. Do not replace a declared Codex list locally.
const models: readonly (AdapterModel & { detail?: string })[] = modelOptionsOverride
@@ -366,12 +366,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;
@@ -984,7 +991,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(
@@ -1046,7 +1053,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"
@@ -1693,7 +1704,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.
@@ -2143,7 +2154,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>}
@@ -2446,7 +2457,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}
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import type { Agent } from "@paperclipai/shared";
import { copilotTaskModelCatalogOptions } from "./copilot-task-model-catalog";
describe("Copilot task model catalog identity", () => {
const agent = { id: "copilot", adapterType: "paperclip_runner", adapterConfig: {
provider: "acpx", acpxAgent: "copilot",
} } as unknown as Agent;
it("changes the catalog identity when a saved connection changes", () => {
const a = copilotTaskModelCatalogOptions({ ...agent, runtimeConfig: { aiConnection: {
provider: "github", method: "api_key", mode: "shared", connectionId: "11111111-1111-4111-8111-111111111111", grantId: "33333333-3333-4333-8333-333333333333",
} } });
const b = copilotTaskModelCatalogOptions({ ...agent, runtimeConfig: { aiConnection: {
provider: "github", method: "api_key", mode: "shared", connectionId: "22222222-2222-4222-8222-222222222222", grantId: "33333333-3333-4333-8333-333333333333",
} } });
expect(a).not.toEqual(b);
});
it("allows normal responsible-user discovery when no explicit binding is saved", () => {
expect(copilotTaskModelCatalogOptions(agent)).toEqual({ acpxAgent: "copilot", agentId: "copilot", aiConnection: undefined });
});
it("does not change catalogs for other runners", () => {
expect(copilotTaskModelCatalogOptions({ ...agent, adapterConfig: { provider: "acpx", acpxAgent: "claude" } })).toBeUndefined();
});
});
+19
View File
@@ -0,0 +1,19 @@
import { aiConnectionBindingSchema, type Agent } from "@paperclipai/shared";
/** Task overrides must discover models through the assignee's saved connection. */
export function copilotTaskModelCatalogOptions(agent: Agent | null | undefined) {
if (agent?.adapterType !== "paperclip_runner"
|| agent.adapterConfig.provider !== "acpx"
|| agent.adapterConfig.acpxAgent !== "copilot") return undefined;
return {
acpxAgent: "copilot",
agentId: agent.id,
aiConnection: aiConnectionBindingSchema.safeParse(agent.runtimeConfig?.aiConnection).data,
};
}
/** Remote catalogs may provision a sandbox; live-data reconciliation must not repeat discovery. */
export const COPILOT_MODEL_DISCOVERY_QUERY_OPTIONS = {
staleTime: "static" as const,
retry: false as const,
};
+1 -1
View File
@@ -20,7 +20,7 @@ export function isNewAgentAdapterAllowed(
return openAiDotEnabled;
}
if (type === "muse" || (type === "paperclip_runner" && runnerProvider === "muse")) return museEnabled && nativeRunnerEnabled;
if (type === "paperclip_runner") return nativeRunnerEnabled;
if (type === "paperclip_runner") return runnerProvider === "copilot" || nativeRunnerEnabled;
if (cloud) return CLOUD_ADAPTERS.has(type);
return true;
}
@@ -393,7 +393,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,
);
@@ -531,7 +531,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 })
+64
View File
@@ -2,6 +2,7 @@
import { act } from "react";
import { describe, expect, it, vi } from "vitest";
import { queryKeys } from "@/lib/queryKeys";
import { ApiError } from "@/api/client";
import {
api, envApi, settings, secrets, state, managedApi, adapterApi,
root, container, cache, settle, click, fill, render, connect, pass,
@@ -317,6 +318,69 @@ describe("New agent setup", () => {
expect(api.testEnvironment).toHaveBeenLastCalledWith("company-1", "codex_local", expect.objectContaining({ runner: "paperclip" }));
expect(api.hire.mock.calls[0][1]).toMatchObject({ adapterType: "codex_local", runner: "paperclip" });
});
it("keeps saved-token Copilot discovery passive while new-agent setup is idle", async () => {
api.adapterModels.mockResolvedValue([{ id: "gpt-5.6-luna", label: "Copilot Luna" }]);
await render("paperclip_runner", "copilot");
await vi.waitFor(() => expect(api.adapterModels).toHaveBeenLastCalledWith("company-1", "paperclip_runner", expect.objectContaining({ environmentId: "local-1", acpxAgent: "copilot" })));
await settle();
const initialDiscoveries = api.adapterModels.mock.calls.length;
await act(async () => { await cache.invalidateQueries({ type: "active" }, { cancelRefetch: false }); });
await settle();
expect(api.adapterModels).toHaveBeenCalledTimes(initialDiscoveries);
});
it("refuses an undiscovered Copilot model before hiring with a saved connection", async () => {
api.adapterModels.mockResolvedValue([{ id: "gpt-5.6-luna", label: "GPT" }]);
await render("paperclip_runner", "copilot");
await fill("Model", "unavailable-copilot-model");
await click("Finish setup");
expect(api.hire).not.toHaveBeenCalled();
expect(container.textContent).toContain("select an available model");
});
it("preserves the authenticated Copilot model when hiring with a saved connection", async () => {
api.adapterModels.mockResolvedValue([{ id: "gpt-5.6-luna", label: "GPT" }]);
await render("paperclip_runner", "copilot");
await fill("Model", "gpt-5.6-luna");
await click("Finish setup");
expect(api.hire).toHaveBeenCalledWith("company-1", expect.objectContaining({
adapterType: "paperclip_runner", adapterConfig: expect.objectContaining({ provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" }),
runtimeConfig: expect.objectContaining({ aiConnection: { provider: "github", method: "api_key", mode: "responsible_user" } }),
}));
});
it("discovers models after the first Copilot token becomes the responsible user's default", async () => {
let defaultSaved = false;
managedApi.setDefault.mockImplementation(async () => { defaultSaved = true; return {}; });
api.adapterModels.mockImplementation(async () => {
if (!defaultSaved) throw new ApiError("Connect Copilot first", 422, null);
return [{ id: "gpt-5.6-luna", label: "GPT" }];
});
await render("paperclip_runner", "copilot");
const beforeConnection = api.adapterModels.mock.calls.length;
expect(beforeConnection).toBeGreaterThan(0);
const trigger = document.querySelector('[role="combobox"][aria-label="Connection"]')!;
await act(async () => trigger.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })));
await settle();
const option = [...document.querySelectorAll<HTMLElement>('[role="option"]')].find(item => item.textContent?.includes("Connect an account"));
expect(option).toBeDefined();
await act(async () => option!.click());
await settle();
const input = document.querySelector<HTMLInputElement>('[aria-label="API key"]')!;
expect(input).toBeTruthy();
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "github_pat_fixture_first");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
const save = [...document.querySelectorAll<HTMLButtonElement>('[role="dialog"] button')].find(button => button.textContent?.trim() === "Connect")!;
expect(save).toBeTruthy();
await act(async () => save.click());
await settle();
expect(managedApi.setDefault).toHaveBeenCalledWith("company-1", "managed-grant");
expect(api.adapterModels).toHaveBeenCalledTimes(beforeConnection + 1);
expect(api.adapterModels.mock.invocationCallOrder[beforeConnection]).toBeGreaterThan(managedApi.setDefault.mock.invocationCallOrder[0]);
expect(api.adapterModels).toHaveBeenLastCalledWith("company-1", "paperclip_runner", expect.objectContaining({
provider: "acpx", acpxAgent: "copilot", aiConnection: { provider: "github", method: "api_key", mode: "responsible_user" },
}));
});
it("creates Dot through its independent choice without CLI or model setup, then routes to pairing", async () => {
settings.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true, enablePublicMcp: true });
await render("paperclip_runner", "openai_dot");
+43
View File
@@ -996,6 +996,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] });
+4
View File
@@ -983,6 +983,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 () => {
+12 -1
View File
@@ -318,7 +318,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),
};
}
@@ -1100,6 +1100,9 @@ export function ConnectorCard({
chatProviderVisible(row.slug, chatConnectorsEnabled, githubReviewBotsEnabled),
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,
@@ -1153,6 +1156,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"