From 304af990400f832500bc03c6476de5f6c18872ad Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 2 Oct 2026 15:06:36 -0500 Subject: [PATCH] Add connection-owned model provider routing Co-Authored-By: Paperclip --- doc/connections/AI-CONNECTIONS.md | 88 ++- .../adapters/claude-local/src/server/test.ts | 2 + .../adapters/codex-local/src/server/test.ts | 19 +- .../src/server/command-resolution.test.ts | 19 + packages/adapters/hermes/src/server/skills.ts | 7 +- packages/adapters/hermes/src/server/test.ts | 28 + .../runner-core/src/acpx_sidecar_transport.rs | 31 +- .../crates/runner-core/src/codex_provider.rs | 3 + .../durable-prp-control-plane.test.ts | 6 + .../durable-prp-control-plane.ts | 11 +- .../src/drivers/acpx/environment.test.ts | 17 + .../src/drivers/acpx/environment.ts | 6 +- .../codex/app-server-transport.test.ts | 9 + .../src/drivers/codex/app-server-transport.ts | 2 + .../opencode/opencode-server-driver.test.ts | 23 + .../opencode/opencode-server-driver.ts | 9 + .../runtime-context-materializer.test.ts | 29 + .../drivers/runtime-context-materializer.ts | 27 + .../src/live/runnerd-codex-transport.ts | 2 + packages/shared/src/ai-connections.ts | 39 +- packages/shared/src/ai-provider-routing.ts | 142 +++++ packages/shared/src/index.ts | 2 + server/src/__tests__/ai-connections.test.ts | 34 ++ .../src/__tests__/ai-provider-routing.test.ts | 147 +++++ .../codex-local-adapter-environment.test.ts | 26 +- server/src/routes/agents.ts | 5 +- server/src/routes/ai-connections.ts | 9 +- .../services/agent-ai-connection-default.ts | 1 + server/src/services/ai-auth-failure.test.ts | 4 +- server/src/services/ai-auth-failure.ts | 4 +- server/src/services/ai-connection-runtime.ts | 31 +- server/src/services/ai-connections.ts | 35 +- server/src/services/ai-provider-routing.ts | 81 +++ .../heartbeat-runner-provider-config.test.ts | 9 + server/src/services/heartbeat.ts | 1 + .../native-runtime/provider-profile.ts | 5 +- ui/public/brands/apps/bedrock.svg | 1 + ui/public/brands/apps/google.svg | 1 + ui/public/brands/apps/manifest.json | 12 + .../codex-local/config-fields.test.tsx | 76 ++- ui/src/adapters/codex-local/config-fields.tsx | 61 +- ui/src/adapters/config-sections.test.tsx | 67 +- ui/src/components/AdapterMark.tsx | 59 ++ ui/src/components/AgentConfigForm.tsx | 42 +- ui/src/components/OnboardingWizard.tsx | 4 +- .../AiConnectionAccountControls.tsx | 2 +- .../AiConnectionCredentialStep.tsx | 6 +- .../ai-connections/AiConnectionField.tsx | 39 +- .../ai-connections/AiConnectionSelect.tsx | 177 ++++++ .../ai-connections/AiProviderSetup.tsx | 514 ++++++++++++++++ .../ManagedAiConnectionDetails.tsx | 12 +- ui/src/components/ai-connections/model.ts | 1 + .../ai-connections/useConnectionModels.ts | 31 + .../new-agent/AgentBasicsDialog.tsx | 59 +- ui/src/components/new-agent/NewAgentSetup.tsx | 32 +- ui/src/components/ui/select.tsx | 5 +- .../connections/ConnectionChoiceList.tsx | 5 +- .../ConnectionIntentInteractionBody.tsx | 3 +- ui/src/pages/NewAgent.test.tsx | 58 +- ui/src/pages/apps/AppsConnect.tsx | 11 + ui/src/pages/apps/Browse.tsx | 3 +- ui/src/pages/apps/app-connect-policy.ts | 1 + .../agent-settings/AgentSettingsPreview.tsx | 211 ++++--- .../provider-routing/AgentSetup.tsx | 573 ++++++++++++++++++ .../provider-routing/ConnectionCatalog.tsx | 196 ++++++ .../provider-routing/ConnectionPicker.tsx | 67 ++ .../provider-routing/ConnectionSetup.tsx | 451 ++++++++++++++ .../provider-routing/Management.tsx | 470 ++++++++++++++ .../prototypes/provider-routing/README.md | 113 ++++ .../prototypes/provider-routing/Recovery.tsx | 185 ++++++ .../provider-routing/ReviewIndex.tsx | 168 +++++ .../provider-routing/RoutingAccess.tsx | 71 +++ .../prototypes/provider-routing/model.ts | 250 ++++++++ .../prototypes/provider-routing/shared.tsx | 255 ++++++++ .../provider-routing/story-support.ts | 36 ++ .../stories/ai-connections.stories.tsx | 3 + .../provider-routing-00-overview.stories.tsx | 19 + ...provider-routing-01-onboarding.stories.tsx | 94 +++ .../provider-routing-02-connect.stories.tsx | 134 ++++ .../provider-routing-03-agent.stories.tsx | 325 ++++++++++ .../provider-routing-04-manage.stories.tsx | 115 ++++ .../provider-routing-05-recovery.stories.tsx | 71 +++ ...provider-routing-06-production.stories.tsx | 153 +++++ 83 files changed, 5796 insertions(+), 359 deletions(-) create mode 100644 packages/shared/src/ai-provider-routing.ts create mode 100644 server/src/__tests__/ai-provider-routing.test.ts create mode 100644 server/src/services/ai-provider-routing.ts create mode 100644 ui/public/brands/apps/bedrock.svg create mode 100644 ui/public/brands/apps/google.svg create mode 100644 ui/src/components/AdapterMark.tsx create mode 100644 ui/src/components/ai-connections/AiConnectionSelect.tsx create mode 100644 ui/src/components/ai-connections/AiProviderSetup.tsx create mode 100644 ui/src/components/ai-connections/useConnectionModels.ts create mode 100644 ui/storybook/prototypes/provider-routing/AgentSetup.tsx create mode 100644 ui/storybook/prototypes/provider-routing/ConnectionCatalog.tsx create mode 100644 ui/storybook/prototypes/provider-routing/ConnectionPicker.tsx create mode 100644 ui/storybook/prototypes/provider-routing/ConnectionSetup.tsx create mode 100644 ui/storybook/prototypes/provider-routing/Management.tsx create mode 100644 ui/storybook/prototypes/provider-routing/README.md create mode 100644 ui/storybook/prototypes/provider-routing/Recovery.tsx create mode 100644 ui/storybook/prototypes/provider-routing/ReviewIndex.tsx create mode 100644 ui/storybook/prototypes/provider-routing/RoutingAccess.tsx create mode 100644 ui/storybook/prototypes/provider-routing/model.ts create mode 100644 ui/storybook/prototypes/provider-routing/shared.tsx create mode 100644 ui/storybook/prototypes/provider-routing/story-support.ts create mode 100644 ui/storybook/stories/provider-routing-00-overview.stories.tsx create mode 100644 ui/storybook/stories/provider-routing-01-onboarding.stories.tsx create mode 100644 ui/storybook/stories/provider-routing-02-connect.stories.tsx create mode 100644 ui/storybook/stories/provider-routing-03-agent.stories.tsx create mode 100644 ui/storybook/stories/provider-routing-04-manage.stories.tsx create mode 100644 ui/storybook/stories/provider-routing-05-recovery.stories.tsx create mode 100644 ui/storybook/stories/provider-routing-06-production.stories.tsx diff --git a/doc/connections/AI-CONNECTIONS.md b/doc/connections/AI-CONNECTIONS.md index d31ce5fea97..4a648d0beb2 100644 --- a/doc/connections/AI-CONNECTIONS.md +++ b/doc/connections/AI-CONNECTIONS.md @@ -20,7 +20,8 @@ The shared `AI_CONNECTION_CAPABILITIES` contract defines these combinations: | --- | --- | --- | | Claude / Anthropic | Claude subscription token or Anthropic API key | Claude | | OpenAI | ChatGPT/Codex subscription or OpenAI API key | Codex | -| OpenRouter | API key | OpenCode, with an `openrouter/` model | +| OpenRouter (legacy, no routing metadata) | API key | OpenCode, with an `openrouter/` model | +| Google | API key | Gemini CLI | | Grok / xAI | Grok subscription or xAI API key | Grok | Native runner supports the corresponding existing Codex, OpenCode, and Claude @@ -38,9 +39,9 @@ and caller-supplied validation URLs are rejected. - `responsible_user`: resolve the run's responsible user's personal provider default, using that account's subscription or API key. The `method` hint does not restrict the responsible user's account. - `shared`: use the named `connectionId` and `grantId`, with audience and agent access checks. -- `delegated`: retained only to read legacy bindings. It cannot bypass human - access; a personal credential remains available only for its owner's tasks. - New configuration offers personal defaults or shared accounts. +- `delegated`: an explicit personal account selection (the wire name is retained + for compatibility). It cannot bypass human access; a personal credential + remains available only for its owner’s tasks. “Which humans can use this credential?” is the sole permission for whose work can use the account. “Just me” means the personal owner; shared accounts allow @@ -48,7 +49,8 @@ selected company members or every company member. The separate agent-access setting determines which agents can use it. There is no additional AI agent authorization, and old delegation records do not override the human audience. -A connection choice never changes the harness, model, or provider routing. +A connection choice never changes the harness or model. For a routed connection, +the selected connection owns its provider routing. Changing those separately may make a binding incompatible; saving then requires a compatible choice. Agent configuration cannot grant access to another account. @@ -512,3 +514,79 @@ identity, a different grant or responsible user, or a changed credential generat requires a fresh session. The metadata is removed before passing session params to an adapter. Temporary authentication-home paths do not change the configuration fingerprint. These checks do not relax current connection authorization. + + +## Advanced provider routing (2026-10-02) + +Use **Connectors → Connect a model provider → Advanced providers** to add an +OpenRouter, Bedrock, custom gateway (including Emissary), or local connection. +The native subscription/API-key onboarding remains the default. Provider choices +show the existing local brand artwork and reuse the existing access step. Custom +URLs, protocol, AWS region, and credential fields appear only after choosing an +advanced provider. New-agent setup also offers the connection picker under +**Use another connection**. + +At **Agents → [agent] → Harness / Runtime**, **Connection** is a dropdown of +compatible saved connections, including explicit personal accounts. The existing +model picker uses that connection’s optional model IDs and accepts manual IDs. +Changing connections preserves the model for explicit review. URLs and +credentials belong to the connection; the model belongs to the agent. + +| Harness | Implemented managed routes | +| --- | --- | +| Codex legacy and Codex New Runner (app-server) | OpenRouter; custom/local OpenAI Responses endpoints | +| Claude legacy and Claude New Runner (ACPX) | OpenRouter; custom/local Anthropic Messages; Bedrock API key or AWS access keys with optional session token | +| OpenCode legacy and New Runner | OpenRouter; custom/local Chat Completions | +| Hermes local | OpenRouter; custom/local Chat Completions | +| Gemini CLI, Grok | Their native API connections; custom routes are not advertised | + +OpenClaw Gateway, Hermes Gateway, Claude Managed, AWS AgentCore, Process, HTTP, +and legacy `acpx_local` are excluded: external agents retain their own model +configuration, and `acpx_local` is retired. Cursor/Pi/Copilot custom routing, +Vertex, ambient AWS identity, arbitrary authentication headers, and automatic +provider catalog discovery are not part of this implementation. + +`config.ai.routing` stores only kind, protocol, URL, auth method, region, and +optional model IDs/labels. The vault stores keys or the AWS credential bundle. +Fixed bindings contain only connection/grant identity. The server checks actual +connection metadata, company, owner/audience, installation, status, and protocol +before resolving secrets. Advanced connections cannot silently become native +personal defaults. Reconnect replaces credentials and preserves destination; +changing destination requires a separate connection. Credentials are never +submitted to a new URL as part of reconnect. + +Only fixed official provider endpoints receive control-plane key checks. Custom +endpoints and Bedrock are exercised by the selected harness in the selected +execution environment, through **Run test**. Saving a custom connection records +configuration; it is not proof that the model can respond. HTTPS is required for +remote URLs; loopback endpoints may use HTTP. Localhost refers to the agent’s +execution environment, including when it is a sandbox. URLs cannot contain user +credentials, query parameters, or fragments. + +Runtime projection clears alternate provider credentials and routing overrides, +uses disposable homes, and never falls back to host authentication. Codex probes +retain the selected provider home. The new runner copies only the validated +Paperclip provider stanza into its isolated Codex home, preserves its own tool +and sandbox policy, and disables shell snapshots. TypeScript and Rust launch +boundaries explicitly allow only the corresponding provider credential and +routing fields. Keys remain outside model-issued command environments on the +new Codex runner. Hermes custom endpoints use an isolated `config.yaml` with an +environment reference for the key. + +Primary configuration references consulted: +[Codex custom providers](https://developers.openai.com/codex/config-advanced/), +[OpenRouter Codex](https://openrouter.ai/docs/cookbook/coding-agents/codex-cli), +[OpenRouter Claude](https://openrouter.ai/docs/cookbook/coding-agents/claude-code-integration), +[Claude gateways](https://code.claude.com/docs/en/llm-gateway), +[Claude Bedrock](https://code.claude.com/docs/en/amazon-bedrock), +[OpenCode providers](https://opencode.ai/docs/providers/), and +[Hermes providers](https://hermes-agent.nousresearch.com/docs/integrations/providers). + +Validation includes negative company/owner/revocation/protocol checks, no-auth +vault behavior, immutable reconnect destinations, credential projection, Codex +probe isolation, and new-runner home/environment boundaries. The isolated local +test-drive exercised live OpenRouter requests using the Codex and Claude CLI +probes, then completed real tasks using Codex, Claude, and OpenCode New Runner. +The app walkthrough verified connection selection, saving, and completed tasks. +Bedrock and private gateway credentials were not available for live verification; +their mapping and validation are covered by deterministic tests. diff --git a/packages/adapters/claude-local/src/server/test.ts b/packages/adapters/claude-local/src/server/test.ts index b7cf5001b4a..2aefa947fc5 100644 --- a/packages/adapters/claude-local/src/server/test.ts +++ b/packages/adapters/claude-local/src/server/test.ts @@ -203,6 +203,8 @@ export async function testEnvironment( detail: `Detected in ${source}.`, hint: "Ensure AWS credentials (AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY or AWS_PROFILE) and AWS_REGION are configured.", }); + } else if (config.managedAiRouting) { + checks.push({ code: "claude_managed_provider_configured", level: "info", message: "Testing the selected connection’s provider and model." }); } else if (isNonEmpty(configApiKey) || isNonEmpty(hostApiKey)) { const source = isNonEmpty(configApiKey) ? "adapter config env" : "server environment"; const selectedApiKey = Boolean(config.managedAiConnection) || isNonEmpty(configApiKey); diff --git a/packages/adapters/codex-local/src/server/test.ts b/packages/adapters/codex-local/src/server/test.ts index 0994994e948..0679b0c7b90 100644 --- a/packages/adapters/codex-local/src/server/test.ts +++ b/packages/adapters/codex-local/src/server/test.ts @@ -155,6 +155,7 @@ async function prepareCodexHelloProbe(input: { path.join(os.tmpdir(), `paperclip-codex-probe-home-${input.runId}-`), ); let seededAuth = false; + let seededConfig = false; for (const file of ["auth.json", "config.toml"]) { // `fs.readFile` follows the home's `auth.json` symlink into the host's // `~/.codex`, so we copy the resolved bytes as a plain file. @@ -162,6 +163,7 @@ async function prepareCodexHelloProbe(input: { if (contents) { await fs.writeFile(path.join(probeHomeLocalDir, file), contents); if (file === "auth.json") seededAuth = true; + if (file === "config.toml") seededConfig = true; } } @@ -169,7 +171,7 @@ async function prepareCodexHelloProbe(input: { // Pointing Codex at an empty uploaded home would mask any login already // baked into the sandbox (e.g. a captured custom-image snapshot); leaving // CODEX_HOME unset lets the probe exercise that in-sandbox login instead. - if (!seededAuth) { + if (!seededAuth && !(input.managedAiConnection && seededConfig)) { return { command: input.command, args: input.args, @@ -341,8 +343,15 @@ export async function testEnvironment( } const configOpenAiKey = env.OPENAI_API_KEY; - const hostOpenAiKey = targetIsRemote ? undefined : process.env.OPENAI_API_KEY; - if (isNonEmpty(configOpenAiKey) || isNonEmpty(hostOpenAiKey)) { + const hostOpenAiKey = targetIsRemote || Object.hasOwn(env, "OPENAI_API_KEY") + ? undefined : process.env.OPENAI_API_KEY; + if (config.managedAiRouting) { + checks.push({ + code: "codex_managed_provider_configured", + level: "info", + message: "Testing the selected connection’s provider and model.", + }); + } else if (isNonEmpty(configOpenAiKey) || isNonEmpty(hostOpenAiKey)) { const source = isNonEmpty(configOpenAiKey) ? "adapter config env" : "server environment"; checks.push({ code: "codex_openai_api_key_present", @@ -420,7 +429,9 @@ export async function testEnvironment( // wrap the probe with a shell that materializes a per-run auth.json so // the CLI can authenticate. The key content is passed via env (not on // the command line) to avoid leaking it into process listings. - const probeApiKey = isNonEmpty(configOpenAiKey) + // Managed connections already contain their exact auth and provider config. + // Replacing that home with a native-key probe would test another provider. + const probeApiKey = config.managedAiConnection ? null : isNonEmpty(configOpenAiKey) ? configOpenAiKey : isNonEmpty(hostOpenAiKey) ? hostOpenAiKey diff --git a/packages/adapters/hermes/src/server/command-resolution.test.ts b/packages/adapters/hermes/src/server/command-resolution.test.ts index 0462ad5a0c4..2b8b1a97ed3 100644 --- a/packages/adapters/hermes/src/server/command-resolution.test.ts +++ b/packages/adapters/hermes/src/server/command-resolution.test.ts @@ -46,3 +46,22 @@ test("testEnvironment accepts config.command when hermesCommand is absent", asyn await rm(tempDir, { recursive: true, force: true }); } }); + +test("managed connections probe the selected environment without falling back to host keys", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "hermes-managed-probe-")); + const cliPath = path.join(tempDir, "fake-hermes"); + try { + await writeFile(cliPath, '#!/bin/sh\n[ "$HERMES_HOME" = "' + tempDir + '" ] || exit 1\n[ "$OPENAI_API_KEY" = "selected-key" ] || exit 1\n[ "$ANTHROPIC_API_KEY" = "" ] || exit 1\n[ "$1" = "chat" ] || exit 1\necho hello\n'); + await chmod(cliPath, 0o755); + const config = { command: cliPath, managedAiRouting: true, model: "custom/model", provider: "auto", env: { HERMES_HOME: tempDir, OPENAI_API_KEY: "selected-key", ANTHROPIC_API_KEY: "" } }; + const passed = await testEnvironment({ companyId: "test", adapterType: "hermes_local", config }); + expect(passed.status).toBe("pass"); + expect(passed.checks[0]?.code).toBe("hermes_hello_probe_passed"); + const failed = await testEnvironment({ companyId: "test", adapterType: "hermes_local", config: { ...config, env: { ...config.env, OPENAI_API_KEY: "wrong-key" } } }); + expect(failed.status).toBe("fail"); + expect(failed.checks[0]?.code).toBe("hermes_hello_probe_failed"); + expect(JSON.stringify(failed)).not.toContain("wrong-key"); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); diff --git a/packages/adapters/hermes/src/server/skills.ts b/packages/adapters/hermes/src/server/skills.ts index 44e9453c45b..867dd2a6bad 100644 --- a/packages/adapters/hermes/src/server/skills.ts +++ b/packages/adapters/hermes/src/server/skills.ts @@ -31,7 +31,8 @@ function resolveHermesHome(config: Record): string { ? (config.env as Record) : {}; const configuredHome = asString(env.HOME); - return configuredHome ? path.resolve(configuredHome) : os.homedir(); + const hermesHome = asString(env.HERMES_HOME); + return hermesHome ? path.resolve(hermesHome) : path.join(configuredHome ? path.resolve(configuredHome) : os.homedir(), ".hermes"); } interface SkillFrontmatter { @@ -131,7 +132,7 @@ async function buildSkillEntry( async function buildHermesSkillSnapshot(config: Record): Promise { const home = resolveHermesHome(config); - const hermesSkillsHome = path.join(home, ".hermes", "skills"); + const hermesSkillsHome = path.join(home, "skills"); // 1. Scan Paperclip-managed skills (bundled with the adapter) const paperclipEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); @@ -224,7 +225,7 @@ export async function reconcileHermesPaperclipSkills( ])) : resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); const desiredSet = new Set(desiredSkills); - const skillsHome = path.join(resolveHermesHome(config), ".hermes", "skills"); + const skillsHome = path.join(resolveHermesHome(config), "skills"); await fs.mkdir(skillsHome, { recursive: true }); const installed = await readInstalledSkillTargets(skillsHome); const availableByRuntimeName = new Map(availableEntries.map((entry) => [entry.runtimeName, entry])); diff --git a/packages/adapters/hermes/src/server/test.ts b/packages/adapters/hermes/src/server/test.ts index b75ce020cad..3fd8402efd3 100644 --- a/packages/adapters/hermes/src/server/test.ts +++ b/packages/adapters/hermes/src/server/test.ts @@ -332,6 +332,34 @@ export async function testEnvironment( const command = resolveHermesCommand(config); const checks: AdapterEnvironmentCheck[] = []; + // Managed connections must prove the selected destination with the isolated + // runtime environment. Host credentials and ~/.hermes are not evidence. + if (config.managedAiRouting) { + const remote = ctx.executionTarget?.kind === "remote"; + if (remote) { + checks.push({ level: "error", code: "hermes_remote_probe_unsupported", message: "Hermes connection testing requires a local execution environment." }); + } else { + const env = { ...process.env }; + for (const [key, value] of Object.entries((config.env ?? {}) as Record)) { + if (typeof value === "string") env[key] = value; + } + const args = ["chat", "-Q", "-q", "Respond with hello. Do not use tools.", "--max-turns", "1"]; + if (asString(config.model)) args.push("-m", asString(config.model)!); + const provider = asString(config.provider); + if (provider && provider !== "auto") args.push("--provider", provider); + try { + const { stdout } = await execFileAsync(command, args, { + env, cwd: asString(config.cwd), timeout: 45_000, maxBuffer: 1024 * 1024, + }); + if (!/\bhello\b/i.test(stdout)) throw new Error("No hello response"); + checks.push({ level: "info", code: "hermes_hello_probe_passed", message: "Hermes responded through the selected connection." }); + } catch { + checks.push({ level: "error", code: "hermes_hello_probe_failed", message: "Hermes could not complete a request through this connection.", hint: "Check the Hermes installation, endpoint, credential, and model, then test again." }); + } + } + return { adapterType: ADAPTER_TYPE, status: checks.some(check => check.level === "error") ? "fail" : "pass", checks, testedAt: new Date().toISOString() }; + } + // 1. CLI installed? const cliCheck = await checkCliInstalled(command); if (cliCheck) { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs index 9bb2c3b6c0b..611b5b19583 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs @@ -96,8 +96,20 @@ impl AcpxSidecarTransport { agent: &str, ) -> Result { let credential_keys: &[&str] = match agent { - "claude" => &["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"], - "codex" => &["OPENAI_API_KEY", "CODEX_API_KEY"], + "claude" => &[ + "ANTHROPIC_API_KEY", + "CLAUDE_CODE_OAUTH_TOKEN", + "ANTHROPIC_AUTH_TOKEN", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_BEARER_TOKEN_BEDROCK", + ], + "codex" => &[ + "OPENAI_API_KEY", + "CODEX_API_KEY", + "PAPERCLIP_AI_PROVIDER_KEY", + ], "grok" => &["XAI_API_KEY", "PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET"], "pi" => &["OPENROUTER_API_KEY"], "cursor" => &["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"], @@ -136,6 +148,21 @@ impl AcpxSidecarTransport { // The sidecar checks this controller-minted provider/session marker. keys.push("PAPERCLIP_ACPX_CREDENTIAL_BINDING"); } + if agent == "claude" { + keys.extend_from_slice(&[ + "ANTHROPIC_BASE_URL", + "CLAUDE_CODE_USE_BEDROCK", + "AWS_REGION", + "AWS_DEFAULT_REGION", + "AWS_EC2_METADATA_DISABLED", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", + "ANTHROPIC_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "CLAUDE_CODE_SUBAGENT_MODEL", + ]); + } keys.extend_from_slice(credential_keys); Self::start_with_environment_keys(config, &keys) } diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs index 95bc8579d52..ed07cf632f1 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs @@ -32,6 +32,8 @@ const WARM_ATTACHMENT_TAIL_DRAIN_LIMIT: usize = 256; const WARM_ATTACHMENT_QUIET_WINDOW: Duration = Duration::from_millis(10); const WARM_ATTACHMENT_DRAIN_DEADLINE: Duration = Duration::from_millis(100); const OPENCODE_PROVIDER_ENVIRONMENT_KEYS: &[&str] = &[ + "PAPERCLIP_AI_PROVIDER_KEY", + "PAPERCLIP_AI_PROVIDER_URL", "OPENROUTER_API_KEY", "PAPERCLIP_NATIVE_MCP_NAME", "PAPERCLIP_NATIVE_MCP_URL", @@ -792,6 +794,7 @@ const GITHUB_CREDENTIAL_ENVIRONMENT_KEYS: &[&str] = &[ ]; const CODEX_PROVIDER_ENVIRONMENT_KEYS: &[&str] = &[ + "PAPERCLIP_AI_PROVIDER_KEY", "CODEX_HOME", "OPENAI_API_KEY", "CODEX_API_KEY", diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts index f1932806069..8f8728a00a0 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts @@ -390,6 +390,9 @@ it("preserves an explicit OpenCode permission mode at the runner spawn boundary" environment: { PATH: "/bin", OPENROUTER_API_KEY: "provider-key", + PAPERCLIP_AI_PROVIDER_KEY: "managed-provider-key", + ANTHROPIC_AUTH_TOKEN: "managed-claude-key", + ANTHROPIC_BASE_URL: "https://gateway.example", PAPERCLIP_OPENCODE_PERMISSION_MODE: "deny", PAPERCLIP_OPENCODE_RUNTIME_DIR: "/runner/opencode", DATABASE_URL: "must-not-reach-runnerd", @@ -419,6 +422,9 @@ it("preserves an explicit OpenCode permission mode at the runner spawn boundary" expect(launches[0]!.environment).toMatchObject({ PATH: "/bin", OPENROUTER_API_KEY: "provider-key", + PAPERCLIP_AI_PROVIDER_KEY: "managed-provider-key", + ANTHROPIC_AUTH_TOKEN: "managed-claude-key", + ANTHROPIC_BASE_URL: "https://gateway.example", PAPERCLIP_OPENCODE_PERMISSION_MODE: "deny", PAPERCLIP_OPENCODE_RUNTIME_DIR: "/runner/opencode", }); diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts index 62f1d6207b4..54230d42455 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts @@ -29,7 +29,7 @@ import type { Duplex } from "node:stream"; import { fileURLToPath } from "node:url"; import { NativeSessionProtocolIntegrityError } from "../contracts/native-session-backend.js"; -import { ACPX_CREDENTIAL_BINDING_ENV, ACPX_CREDENTIAL_NAMES } from "../drivers/acpx/environment.js"; +import { ACPX_CREDENTIAL_BINDING_ENV, ACPX_CREDENTIAL_NAMES, CLAUDE_ROUTING_ENV_KEYS } from "../drivers/acpx/environment.js"; import { githubCredentialEnvironment } from "../github-credential-environment.js"; import { validatePrpEvent, @@ -3338,6 +3338,10 @@ const runnerPlatformEnvironmentKeys = [ ] as const; const runnerExplicitProviderEnvironmentKeys = [ + ...ACPX_CREDENTIAL_NAMES.claude.filter(name => !name.startsWith("AWS_")), + ...CLAUDE_ROUTING_ENV_KEYS, + "PAPERCLIP_AI_PROVIDER_KEY", + "PAPERCLIP_AI_PROVIDER_URL", ...ACPX_CREDENTIAL_NAMES.pi, ...ACPX_CREDENTIAL_NAMES.cursor, ...ACPX_CREDENTIAL_NAMES.copilot, @@ -3395,6 +3399,11 @@ function runnerEnvironment( const value = explicitSource[key]; if (value !== undefined) environment[key] = value; } + if (explicitSource.CLAUDE_CODE_USE_BEDROCK === "1") { + for (const key of ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_BEARER_TOKEN_BEDROCK"] as const) { + if (explicitSource[key] !== undefined) environment[key] = explicitSource[key]; + } + } Object.assign(environment, githubCredentialEnvironment(explicitSource)); } return environment; diff --git a/packages/paperclip-runner/src/drivers/acpx/environment.test.ts b/packages/paperclip-runner/src/drivers/acpx/environment.test.ts index d1e749a5191..4585bc4c9d0 100644 --- a/packages/paperclip-runner/src/drivers/acpx/environment.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/environment.test.ts @@ -5,6 +5,23 @@ afterEach(() => vi.unstubAllEnvs()); import { ACPX_CREDENTIAL_BINDING_ENV, createAcpxCredentialBinding, createAcpxSidecarHostEnvironment, createSanitizedAcpxSpawnInput } from "./environment.js"; describe("ACPX launch environment", () => { + it("keeps gateway and Bedrock settings confined to the selected harness", () => { + const source = { + ANTHROPIC_BASE_URL: "https://gateway.example", + ANTHROPIC_AUTH_TOKEN: "selected-key", + CLAUDE_CODE_USE_BEDROCK: "1", AWS_REGION: "us-east-1", + AWS_BEARER_TOKEN_BEDROCK: "bedrock-key", + PAPERCLIP_AI_PROVIDER_KEY: "codex-key", UNRELATED_SECRET: "private", + }; + expect(createSanitizedAcpxSpawnInput(source, "claude").env).toEqual({ + ANTHROPIC_BASE_URL: source.ANTHROPIC_BASE_URL, + ANTHROPIC_AUTH_TOKEN: "selected-key", CLAUDE_CODE_USE_BEDROCK: "1", + AWS_REGION: "us-east-1", AWS_BEARER_TOKEN_BEDROCK: "bedrock-key", + }); + expect(createSanitizedAcpxSpawnInput(source, "codex").env).toEqual({ PAPERCLIP_AI_PROVIDER_KEY: "codex-key" }); + expect(createSanitizedAcpxSpawnInput(source, "cursor").env).toEqual({}); + }); + it("projects only the selected agent's credentials and runtime allowlist", () => { const source = { PATH: "/bin", diff --git a/packages/paperclip-runner/src/drivers/acpx/environment.ts b/packages/paperclip-runner/src/drivers/acpx/environment.ts index a78fdcbe268..697c565454b 100644 --- a/packages/paperclip-runner/src/drivers/acpx/environment.ts +++ b/packages/paperclip-runner/src/drivers/acpx/environment.ts @@ -6,9 +6,10 @@ export const ACPX_CREDENTIAL_NAMES: Readonly agent === "pi" || agent === "cursor" || agent === "copilot"; /** Mint only at the controller's explicit task-environment boundary, never by copying a marker. */ @@ -111,6 +112,7 @@ export function createSanitizedAcpxSpawnInput( "PAPERCLIP_NATIVE_MCP_NAME", "PAPERCLIP_NATIVE_MCP_URL", ...credentialNames, + ...(agent === "claude" ? CLAUDE_ROUTING_ENV_KEYS : []), ]); let retainedBytes = 0; for (const [key, value] of Object.entries(source)) { diff --git a/packages/paperclip-runner/src/drivers/codex/app-server-transport.test.ts b/packages/paperclip-runner/src/drivers/codex/app-server-transport.test.ts index 96d277dcef3..4184701c56d 100644 --- a/packages/paperclip-runner/src/drivers/codex/app-server-transport.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/app-server-transport.test.ts @@ -16,6 +16,15 @@ function nodeTransport( } describe("Codex app-server transport limits", () => { + it("passes the selected managed provider key without admitting arbitrary host secrets", () => { + expect(createSanitizedCodexEnvironment({ + PAPERCLIP_AI_PROVIDER_KEY: "selected-provider-key", + CODEX_HOME: "/isolated/connection", + OPENROUTER_API_KEY: "ambient-other-key", + DATABASE_URL: "private-database", + })).toEqual({ PAPERCLIP_AI_PROVIDER_KEY: "selected-provider-key", CODEX_HOME: "/isolated/connection" }); + }); + it("passes only bounded controller-projected GitHub credentials", () => { expect( createSanitizedCodexEnvironment({ diff --git a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts index 7c86f2bb35c..154b7df2fcc 100644 --- a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts +++ b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts @@ -203,6 +203,8 @@ const SAFE_ENVIRONMENT_KEYS = [ "AGENT_HOME", "ALL_PROXY", "CODEX_HOME", + // Only the selected managed provider credential enters the trusted server. + "PAPERCLIP_AI_PROVIDER_KEY", "HOME", "HTTP_PROXY", "HTTPS_PROXY", diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts index bb893beab84..89bb57f7aec 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts @@ -611,6 +611,29 @@ describe("OpenCodeServerDriver", () => { } }); + it("projects a custom connection into the isolated OpenCode config", async () => { + await chmod(fixture, 0o755); + const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-routing-")); + roots.push(root); + const driver = new OpenCodeServerDriver({ + model: "paperclip/team/model-alias", + runtimeDirectory: root, + command: fixture, + environment: { PATH: process.env.PATH, PAPERCLIP_AI_PROVIDER_URL: "https://gateway.example/v1", PAPERCLIP_AI_PROVIDER_KEY: "selected-gateway-key" }, + }); + const session = await driver.openSession({ runId: "routing", normalizedSessionId: "routing", workingDirectory: root }); + try { + const configPath = join(root, "routing", "config", "opencode", "opencode.json"); + expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ + model: "paperclip/team/model-alias", small_model: "paperclip/team/model-alias", plugin: [], + provider: { paperclip: { npm: "@ai-sdk/openai-compatible", options: { baseURL: "https://gateway.example/v1", apiKey: "selected-gateway-key" }, models: { "team/model-alias": { name: "team/model-alias" } } } }, + }); + expect((await stat(configPath)).mode & 0o777).toBe(0o600); + } finally { + await session.close({ reason: "test" }); + } + }); + it("starts an authenticated isolated server, creates a session, streams usage, aborts, and cleans up", async () => { await chmod(fixture, 0o755); const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-")); diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts index 7b42e7eedda..4a68460e053 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts @@ -1976,6 +1976,7 @@ async function startRuntime(input: { bridge.secret, assignedMcp?.token, input.options.environment?.OPENROUTER_API_KEY, + input.options.environment?.PAPERCLIP_AI_PROVIDER_KEY, ]); const instructionRoot = input.options.runtimeContext?.instructions.bundle.rootPath; @@ -1995,6 +1996,14 @@ async function startRuntime(input: { // provider instead of silently falling back or rejecting a newer model. provider: { [modelProvider!]: { + ...(modelProvider === "paperclip" && input.options.environment?.PAPERCLIP_AI_PROVIDER_URL ? { + npm: "@ai-sdk/openai-compatible", + name: "Paperclip connection", + options: { + baseURL: input.options.environment.PAPERCLIP_AI_PROVIDER_URL, + apiKey: input.options.environment.PAPERCLIP_AI_PROVIDER_KEY ?? "", + }, + } : {}), models: { [providerModelId]: { name: providerModelId }, }, diff --git a/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts b/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts index 6f59edee752..062482ecad5 100644 --- a/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts +++ b/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts @@ -166,6 +166,35 @@ describe("runtime context materialization", () => { expect(config).not.toContain("unassigned"); }); + it("preserves a managed provider while excluding ambient auth, hooks, and tool configuration", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-routing-home-")); + roots.push(root); + const source = join(root, "source"); + const target = join(root, "isolated"); + await mkdir(source); + await writeFile(join(source, "auth.json"), '{"OPENAI_API_KEY":"unrelated-key"}'); + await writeFile(join(source, "config.toml"), `model_provider = "paperclip" +[model_providers.paperclip] +name = "Selected connection" +base_url = "https://openrouter.ai/api/v1" +wire_api = "responses" +requires_openai_auth = false +env_key = "PAPERCLIP_AI_PROVIDER_KEY" +[features] +shell_snapshot = true +[mcp_servers.unassigned] +command = "untrusted-command" +`); + await prepareIsolatedCodexHome({ context: null, codexHome: target, sourceCodexHome: source, apiKey: "also-unrelated" }); + const config = await readFile(join(target, "config.toml"), "utf8"); + expect(config).toContain('model_provider = "paperclip"'); + expect(config).toContain('base_url = "https://openrouter.ai/api/v1"'); + expect(config).toContain('env_key = "PAPERCLIP_AI_PROVIDER_KEY"'); + expect(config).toContain("shell_snapshot = false"); + expect(config).not.toMatch(/unrelated|untrusted|unassigned/); + await expect(stat(join(target, "auth.json"))).rejects.toThrow(); + }); + it("rejects repeated assignments without changing the current assignment", async () => { const root = await mkdtemp(join(tmpdir(), "paperclip-runtime-repeat-")); roots.push(root); diff --git a/packages/paperclip-runner/src/drivers/runtime-context-materializer.ts b/packages/paperclip-runner/src/drivers/runtime-context-materializer.ts index 3387dae6ab2..973da0bf674 100644 --- a/packages/paperclip-runner/src/drivers/runtime-context-materializer.ts +++ b/packages/paperclip-runner/src/drivers/runtime-context-materializer.ts @@ -12,6 +12,7 @@ import { writeFile, } from "node:fs/promises"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { parse as parseToml } from "smol-toml"; import type { NativeRuntimeContextSnapshot } from "../contracts/runtime-context.js"; import type { NativeMcpLaunchBinding } from "./native-mcp.js"; @@ -306,6 +307,29 @@ async function readSourceCodexAuth(sourceAuth: string): Promise { } } +/** Copy only Paperclip's value-free provider projection, never host tools or hooks. */ +async function managedCodexProviderConfig(sourceHome?: string | null): Promise { + if (!sourceHome?.trim()) return ""; + const source = await readSourceCodexAuth(join(sourceHome, "config.toml")); + if (!source) return ""; + let parsed; + try { parsed = parseToml(source.toString("utf8")); } catch { return ""; } + if (parsed.model_provider !== "paperclip") return ""; + const providers = parsed.model_providers as Record | undefined; + const provider = providers?.paperclip as Record | undefined; + if (!provider || typeof provider.base_url !== "string" || provider.wire_api !== "responses" + || provider.requires_openai_auth !== false + || (provider.env_key !== undefined && provider.env_key !== "PAPERCLIP_AI_PROVIDER_KEY")) { + throw new Error("Invalid managed Codex provider configuration"); + } + const url = new URL(provider.base_url); + if (url.username || url.password || url.search || url.hash + || (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))) { + throw new Error("Invalid managed Codex provider URL"); + } + return `model_provider = "paperclip"\n[model_providers.paperclip]\nname = "Paperclip connection"\nbase_url = ${JSON.stringify(provider.base_url)}\nwire_api = "responses"\nrequires_openai_auth = false\n${provider.env_key ? 'env_key = "PAPERCLIP_AI_PROVIDER_KEY"\n' : ""}`; +} + export async function prepareIsolatedCodexHome(input: { context: NativeRuntimeContextSnapshot | null; codexHome: string; @@ -318,9 +342,11 @@ export async function prepareIsolatedCodexHome(input: { join(input.codexHome, "skills"), ); + const providerConfig = await managedCodexProviderConfig(input.sourceCodexHome); const configPath = join(input.codexHome, "config.toml"); await rm(configPath, { force: true }); await writeFile(configPath, [ + providerConfig, // Codex shell snapshots serialize the provider process environment. The // native runner injects short-lived provider and MCP bindings, so a // snapshot would turn ephemeral credentials into durable session state. @@ -343,6 +369,7 @@ export async function prepareIsolatedCodexHome(input: { const targetAuth = join(input.codexHome, "auth.json"); await rm(targetAuth, { force: true }); + if (providerConfig) return; const apiKey = input.apiKey?.trim(); if (apiKey) { // The pinned Codex app-server authenticates API-key automation through its diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index c067f2a541e..7f8ecde50df 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -3211,6 +3211,8 @@ export function resolveRunnerdAcpxPermissionMode( } const OPEN_CODE_RUNNER_ENVIRONMENT_KEYS = new Set([ + "PAPERCLIP_AI_PROVIDER_KEY", + "PAPERCLIP_AI_PROVIDER_URL", "PATH", "LANG", "LANGUAGE", diff --git a/packages/shared/src/ai-connections.ts b/packages/shared/src/ai-connections.ts index 59176427752..1f908b4f040 100644 --- a/packages/shared/src/ai-connections.ts +++ b/packages/shared/src/ai-connections.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { aiProviderRoutingSchema, aiAwsCredentialsSchema, aiRoutingHarness, isAiRoutingCompatible, type AiProviderRouting } from "./ai-provider-routing.js"; /** Runtime authentication is a separate transport, never a tool or channel. */ export const connectionPurposeTransportSchema = z.discriminatedUnion( @@ -31,6 +32,7 @@ export const AI_PROVIDERS = [ "openai", "openrouter", "xai", + "google", ] as const; export const aiProviderSchema = z.enum(AI_PROVIDERS); export const aiAuthMethodSchema = z.enum(["subscription", "api_key"]); @@ -56,7 +58,7 @@ export const aiConnectionBindingSchema = z.discriminatedUnion("mode", [ z .object({ ...requirement, - // Legacy wire format only; human access still applies. New UI never creates it. + // Explicit personal selection. Human access still applies on every run. mode: z.literal("delegated"), connectionId: z.string().uuid(), grantId: z.string().uuid(), @@ -64,7 +66,7 @@ export const aiConnectionBindingSchema = z.discriminatedUnion("mode", [ .strict(), ]); export type AiConnectionBinding = z.infer; -export const aiConnectionMetadataSchema = z.object(requirement).strict(); +export const aiConnectionMetadataSchema = z.object({ ...requirement, routing: aiProviderRoutingSchema.optional() }).strict(); export type AiConnectionMetadata = z.infer; /** Existing integrations only. This table describes compatibility, never routing. */ @@ -77,6 +79,7 @@ export const AI_CONNECTION_CAPABILITIES: Record< >; } > = { + google: { name: "Google", methods: { api_key: { adapters: ["gemini_local"], envKey: "GEMINI_API_KEY" } } }, anthropic: { name: "Claude", methods: { @@ -115,18 +118,12 @@ export function isAiConnectionCompatible( runnerProvider?: unknown, acpxAgent?: unknown, ): boolean { - if (adapterType === "paperclip_runner") - adapterType = - runnerProvider === "claude" || - (runnerProvider === "acpx" && acpxAgent === "claude") - ? "claude_local" - : runnerProvider === "acpx" && acpxAgent === "grok" - ? "grok_local" - : runnerProvider === "codex" - ? "codex_local" - : runnerProvider === "opencode" - ? "opencode_local" - : "unsupported"; + adapterType = aiRoutingHarness(adapterType, runnerProvider, acpxAgent); + if ("routing" in requirement && requirement.routing) return requirement.method === "api_key" && isAiRoutingCompatible(requirement.routing, adapterType); + // 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); const methods = AI_CONNECTION_CAPABILITIES[requirement.provider].methods; const candidates = "mode" in requirement && requirement.mode === "responsible_user" ? Object.values(methods) @@ -165,6 +162,7 @@ export interface AiManagedConnectionSummary { provider: AiProvider; method: AiAuthMethod; name: string; + routing?: AiProviderRouting; accountLabel?: string; ownership: "personal" | "shared"; ownerUserId?: string; @@ -184,6 +182,8 @@ export const createAiConnectionSchema = z ...requirement, name: z.string().trim().min(1).max(160), ownership: z.enum(["personal", "shared"]), + routing: aiProviderRoutingSchema.optional(), + awsCredentials: aiAwsCredentialsSchema.optional(), apiKey: z.string().trim().min(1).max(32768).optional(), loginSessionId: z.string().max(128).optional(), connectionId: z.string().uuid().optional(), @@ -194,6 +194,15 @@ export const createAiConnectionSchema = z .superRefine((v, ctx) => { if (!AI_CONNECTION_CAPABILITIES[v.provider].methods[v.method]) ctx.addIssue({ code: "custom", message: "Unsupported sign-in method" }); + if (v.routing && (v.method !== "api_key" || (v.routing.kind === "openrouter" && v.provider !== "openrouter") || (v.routing.kind === "bedrock" && v.provider !== "anthropic"))) + ctx.addIssue({ code: "custom", message: "Routing requires the matching provider and API authentication." }); + if (v.routing && ["gateway", "local"].includes(v.routing.kind) && v.provider !== (v.routing.protocol === "messages" ? "anthropic" : "openai")) + ctx.addIssue({ code: "custom", message: "The provider must match the endpoint’s API format." }); + if (v.awsCredentials && v.routing?.auth !== "aws_credentials") ctx.addIssue({ code: "custom", message: "AWS credentials require AWS authentication." }); + if (v.routing?.auth === "none" || v.routing?.auth === "aws_credentials") { + if (v.apiKey || v.loginSessionId || (v.routing.auth === "aws_credentials" ? !v.awsCredentials : Boolean(v.awsCredentials))) ctx.addIssue({ code: "custom", message: "Provide only the selected authentication method." }); + return; + } if ( v.method === "api_key" ? !v.apiKey || Boolean(v.loginSessionId) @@ -210,7 +219,7 @@ export type CreateAiConnection = z.infer; export const aiConnectionLoginIntentSchema = z .object({ - provider: aiProviderSchema, + provider: z.enum(["anthropic", "openai", "xai"]), method: z.literal("subscription"), name: z.string().trim().min(1).max(160), ownership: z.enum(["personal", "shared"]), diff --git a/packages/shared/src/ai-provider-routing.ts b/packages/shared/src/ai-provider-routing.ts new file mode 100644 index 00000000000..a697f5d7dbf --- /dev/null +++ b/packages/shared/src/ai-provider-routing.ts @@ -0,0 +1,142 @@ +import { z } from "zod"; + +/** Value-free routing stored on the connection, never supplied by an agent binding. */ +export const aiProviderRoutingSchema = z + .object({ + kind: z.enum(["openrouter", "bedrock", "gateway", "local"]), + protocol: z.enum(["responses", "messages", "chat", "bedrock"]), + baseUrl: z.string().trim().max(2048).optional(), + auth: z + .enum(["bearer", "api_key", "aws_credentials", "none"]) + .default("bearer"), + region: z + .string() + .regex(/^[a-z]{2}(?:-[a-z]+)+-\d$/) + .optional(), + models: z + .array( + z + .object({ + id: z.string().trim().min(1).max(256), + label: z.string().trim().max(160).optional(), + }) + .strict(), + ) + .max(200) + .default([]), + }) + .strict() + .superRefine((route, ctx) => { + const invalid = (message: string) => + ctx.addIssue({ code: "custom", message }); + if (route.kind === "bedrock") { + if ( + route.protocol !== "bedrock" || + !route.region || + !["bearer", "aws_credentials"].includes(route.auth) || + route.baseUrl + ) + invalid( + "Bedrock requires a region and AWS credentials or a Bedrock API key.", + ); + } else if ( + route.protocol === "bedrock" || + route.auth === "aws_credentials" || + route.region + ) + invalid("AWS settings require Bedrock."); + if ( + route.kind === "openrouter" && + (route.baseUrl || route.auth !== "bearer") + ) + invalid("OpenRouter uses its official endpoint and an API key."); + if (route.auth === "api_key" && route.protocol !== "messages") + invalid("API-key header authentication requires Anthropic Messages."); + if (route.kind === "gateway" || route.kind === "local") { + try { + const url = new URL(route.baseUrl ?? ""); + const loopback = ["localhost", "127.0.0.1", "[::1]"].includes( + url.hostname, + ); + if ( + url.username || + url.password || + url.search || + url.hash || + (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) + ) + throw new Error(); + if (route.kind === "local" && !loopback) + invalid("A local endpoint must use localhost or a loopback address."); + } catch { + invalid( + "Enter an HTTPS base URL without credentials, query parameters, or a fragment. Local endpoints may use HTTP.", + ); + } + } + }); +export type AiProviderRouting = z.infer; +export const aiAwsCredentialsSchema = z + .object({ + accessKeyId: z.string().trim().min(1).max(256), + secretAccessKey: z.string().min(1).max(4096), + sessionToken: z.string().max(16384).optional(), + }) + .strict(); + +export function aiRoutingHarness( + adapter: string, + provider?: unknown, + acpxAgent?: unknown, +): string { + if (adapter !== "paperclip_runner") return adapter; + const runner = + provider === "acpx" && (acpxAgent === "claude" || acpxAgent === "grok") + ? acpxAgent + : provider; + return ( + ( + { + claude: "claude_local", + codex: "codex_local", + opencode: "opencode_local", + grok: "grok_local", + } as Record + )[String(runner)] ?? "unsupported" + ); +} +export function isAiRoutingCompatible( + route: AiProviderRouting, + harness: string, +): boolean { + if (route.kind === "bedrock") return harness === "claude_local"; + if (route.kind === "openrouter") + return [ + "claude_local", + "codex_local", + "opencode_local", + "hermes_local", + ].includes(harness); + if (route.protocol === "responses") return harness === "codex_local"; + if (route.protocol === "messages") return harness === "claude_local"; + return ["opencode_local", "hermes_local"].includes(harness); +} +export function aiRoutingBaseUrl( + route: AiProviderRouting, + harness: string, +): string { + return route.kind === "openrouter" + ? `https://openrouter.ai/api${harness === "claude_local" ? "" : "/v1"}` + : (route.baseUrl ?? "").replace(/\/+$/, ""); +} + +/** OpenCode names models with a provider prefix; other harnesses use the raw ID. */ +export function aiRoutingModel( + route: AiProviderRouting, + harness: string, + model: string, +): string { + if (!model || harness !== "opencode_local") return model; + const prefix = route.kind === "openrouter" ? "openrouter" : "paperclip"; + return model.startsWith(`${prefix}/`) ? model : `${prefix}/${model}`; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 692abd65d17..0cd5bebcd9f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2816,3 +2816,5 @@ export * from "./browser-use.js"; export * from "./types/skill-source.js"; export * from "./validators/skill-source.js"; export * from "./github-skill-repository.js"; + +export * from "./ai-provider-routing.js"; diff --git a/server/src/__tests__/ai-connections.test.ts b/server/src/__tests__/ai-connections.test.ts index 2195fafb794..72e98b9f6fd 100644 --- a/server/src/__tests__/ai-connections.test.ts +++ b/server/src/__tests__/ai-connections.test.ts @@ -120,6 +120,40 @@ describe("managed AI connections", () => { } finally { fetchSpy.mockRestore(); } }); + it("vaults routed credentials, enforces compatibility and access, and configures both Codex runners", async () => { + const routing = { kind: "openrouter", protocol: "responses", auth: "bearer", models: [{ id: "openai/gpt-5.4" }] } as const; + const saved = await service.save(companyId, "alice", { provider: "openrouter", method: "api_key", name: "Routed test", ownership: "personal", apiKey: "fixture", allAgents: true, agentIds: [], routing: { ...routing, models: [...routing.models] } }, "fixture-routed-credential"); + const selected = { provider: "openrouter", method: "api_key", mode: "delegated", ...saved } as const; + expect(JSON.stringify(await service.list(companyId, "alice"))).not.toContain("fixture-routed-credential"); + expect(await service.list(companyId, "alice")).toEqual(expect.arrayContaining([expect.objectContaining({ id: saved.connectionId, routing, isDefault: false })])); + await expect(service.setDefault(companyId, "alice", saved.grantId)).rejects.toThrow("explicit connection"); + await expect(service.select({ ...input, binding: selected, userId: "bob", adapterType: "codex_local", model: "openai/gpt-5.4" })).rejects.toThrow("not shared"); + await expect(service.select({ ...input, companyId: otherCompanyId, binding: selected, userId: "alice", adapterType: "codex_local" })).rejects.toThrow(); + for (const adapterType of ["codex_local", "paperclip_runner"]) { + const runtime = await prepareManagedAiRuntime(db, { companyId, agentId, responsibleUserId: "alice", binding: selected, adapterType, config: { provider: "codex", model: "openai/gpt-5.4", env: { OPENAI_API_KEY: "ambient" } } }); + try { + expect(runtime.config.env.OPENAI_API_KEY).toBe(""); + expect(runtime.config.env.PAPERCLIP_AI_PROVIDER_KEY).toBe("fixture-routed-credential"); + const toml = await readFile(path.join(String(runtime.config.env.CODEX_HOME), "config.toml"), "utf8"); + expect(toml).toContain('base_url = "https://openrouter.ai/api/v1"'); + expect(toml).toContain('wire_api = "responses"'); + expect(toml).not.toContain("fixture-routed-credential"); + } finally { await runtime.cleanup(); } + } + await expect(service.save(companyId, "alice", { provider: "openrouter", method: "api_key", name: "Routed test", ownership: "personal", apiKey: "fixture", connectionId: saved.connectionId, allAgents: true, agentIds: [], routing: { ...routing, kind: "gateway", baseUrl: "https://other.example/v1", models: [] } }, "fixture-replacement")).rejects.toThrow("retain"); + await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, saved.grantId)); + await expect(service.select({ ...input, binding: selected, userId: "alice", adapterType: "codex_local", model: "openai/gpt-5.4" })).rejects.toThrow("Reconnect"); + }); + it("saves no-auth endpoints without a secret and refuses protocol mismatches", async () => { + const routing = { kind: "local", protocol: "chat", auth: "none", baseUrl: "http://localhost:11434/v1", models: [] } as const; + const saved = await service.save(companyId, "alice", { provider: "openai", method: "api_key", name: "Local", ownership: "personal", allAgents: true, agentIds: [], routing: { ...routing, models: [] } }, ""); + const selected = { provider: "openai", method: "api_key", mode: "delegated", ...saved } as const; + await expect(service.select({ ...input, binding: selected, userId: "alice", adapterType: "codex_local" })).rejects.toThrow("incompatible"); + const row = await service.select({ ...input, binding: selected, userId: "alice", adapterType: "opencode_local", model: "qwen" }); + expect(row.grant.credentialSecretRefs).toEqual([]); + expect(await service.credential(row)).toBe(""); + }); + it.each([ ["anthropic", false], ["openai", false], ["anthropic", true], ["openai", true], ] as const)("turns a %s auth failure into one card and resumes after repair (switch method: %s)", async (provider, switchMethod) => { diff --git a/server/src/__tests__/ai-provider-routing.test.ts b/server/src/__tests__/ai-provider-routing.test.ts new file mode 100644 index 00000000000..da86ca4457f --- /dev/null +++ b/server/src/__tests__/ai-provider-routing.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect } from "vitest"; +import { + aiProviderRoutingSchema, + createAiConnectionSchema, + isAiConnectionCompatible, +} from "@paperclipai/shared"; +import { managedProviderRouting } from "../services/ai-provider-routing.js"; + +describe("provider routing", () => { + it.each([ + "https://user:secret@gateway.example/v1", + "https://gateway.example/v1?key=secret", + "http://169.254.169.254/", + "file:///tmp/secret", + "https://gateway.example/#secret", + ])("rejects unsafe or credential-bearing endpoint %s", (baseUrl) => { + expect( + aiProviderRoutingSchema.safeParse({ + kind: "gateway", + protocol: "responses", + baseUrl, + }).success, + ).toBe(false); + }); + it("requires exactly the selected credential and disallows routed subscriptions", () => { + const input = { + provider: "openai", + method: "api_key", + name: "Local", + ownership: "personal", + routing: { + kind: "local", + protocol: "responses", + auth: "none", + baseUrl: "http://localhost:11434/v1", + }, + }; + expect(createAiConnectionSchema.safeParse(input).success).toBe(true); + expect( + createAiConnectionSchema.safeParse({ ...input, apiKey: "unwanted" }) + .success, + ).toBe(false); + expect( + createAiConnectionSchema.safeParse({ + ...input, + method: "subscription", + loginSessionId: "session", + }).success, + ).toBe(false); + }); + it("rejects unsupported protocols and excluded remote adapters", () => { + const routing = aiProviderRoutingSchema.parse({ + kind: "gateway", + protocol: "chat", + baseUrl: "https://gateway.example/v1", + }); + for (const adapter of [ + "codex_local", + "claude_local", + "http", + "process", + "openclaw_gateway", + "hermes_gateway", + "acpx_local", + ]) + expect( + isAiConnectionCompatible( + { provider: "openai", method: "api_key", routing }, + adapter, + ), + ).toBe(false); + expect( + isAiConnectionCompatible( + { provider: "openai", method: "api_key", routing }, + "opencode_local", + ), + ).toBe(true); + expect( + isAiConnectionCompatible( + { provider: "openai", method: "api_key", routing }, + "paperclip_runner", + "model", + "aws_agentcore", + ), + ).toBe(false); + }); + it("routes OpenRouter through each harness's protocol without putting secrets in Codex config", () => { + const routing = aiProviderRoutingSchema.parse({ + kind: "openrouter", + protocol: "responses", + }); + const codex = managedProviderRouting( + routing, + "codex_local", + "fixture-secret", + "openai/gpt-5.4", + ); + expect(codex.codexConfig).toContain('wire_api = "responses"'); + expect(codex.codexConfig).not.toContain("fixture-secret"); + const claude = managedProviderRouting( + routing, + "claude_local", + "fixture-secret", + "anthropic/claude-sonnet-4.6", + ); + expect(claude.env.ANTHROPIC_BASE_URL).toBe("https://openrouter.ai/api"); + expect(claude.env.ANTHROPIC_AUTH_TOKEN).toBe("fixture-secret"); + expect(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe( + "anthropic/claude-sonnet-4.6", + ); + const opencode = managedProviderRouting( + routing, + "opencode_local", + "fixture-secret", + "anthropic/claude-sonnet-4.6", + ); + expect(opencode.config.model).toBe( + "openrouter/anthropic/claude-sonnet-4.6", + ); + }); + it("projects AWS credentials and disables ambient instance metadata", () => { + const routing = aiProviderRoutingSchema.parse({ + kind: "bedrock", + protocol: "bedrock", + auth: "aws_credentials", + region: "us-east-1", + }); + const projected = managedProviderRouting( + routing, + "claude_local", + JSON.stringify({ + accessKeyId: "fixture-id", + secretAccessKey: "fixture-secret", + sessionToken: "fixture-token", + }), + "bedrock-model", + ); + expect(projected.env).toMatchObject({ + CLAUDE_CODE_USE_BEDROCK: "1", + AWS_REGION: "us-east-1", + AWS_ACCESS_KEY_ID: "fixture-id", + AWS_SECRET_ACCESS_KEY: "fixture-secret", + AWS_SESSION_TOKEN: "fixture-token", + AWS_EC2_METADATA_DISABLED: "true", + }); + }); +}); diff --git a/server/src/__tests__/codex-local-adapter-environment.test.ts b/server/src/__tests__/codex-local-adapter-environment.test.ts index 884e40950d7..a4ab6e7100b 100644 --- a/server/src/__tests__/codex-local-adapter-environment.test.ts +++ b/server/src/__tests__/codex-local-adapter-environment.test.ts @@ -7,13 +7,18 @@ import { testEnvironment } from "@paperclipai/adapter-codex-local/server"; const itWindows = process.platform === "win32" ? it : it.skip; const itPosix = process.platform === "win32" ? it.skip : it; -async function runProbeFixture(options: { failCleanup?: boolean; error?: string } = {}) { +async function runProbeFixture(options: { failCleanup?: boolean; error?: string; managedProvider?: boolean } = {}) { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-probe-result-")); const capture = path.join(root, "capture.json"); const command = path.join(root, "codex"); + const managedHome = path.join(root, "managed"); + if (options.managedProvider) { + await fs.mkdir(managedHome); + await fs.writeFile(path.join(managedHome, "config.toml"), 'model_provider = "paperclip"\n'); + } await fs.writeFile(command, `#!${process.execPath} const fs = require('node:fs'); -fs.writeFileSync(process.env.PROBE_CAPTURE, JSON.stringify({ args: process.argv.slice(2), home: process.env.CODEX_HOME })); +fs.writeFileSync(process.env.PROBE_CAPTURE, JSON.stringify({ args: process.argv.slice(2), home: process.env.CODEX_HOME, nativeKey: process.env.OPENAI_API_KEY, providerKey: process.env.PAPERCLIP_AI_PROVIDER_KEY, config: fs.existsSync(process.env.CODEX_HOME + "/config.toml") ? fs.readFileSync(process.env.CODEX_HOME + "/config.toml", "utf8") : null })); console.error('WARN codex_core_plugins::manager: remote installed plugin bundle sync failed error=chatgpt authentication required for remote plugin catalog'); const error = process.env.PROBE_ERROR; if (error) { console.error(error); process.exit(1); } @@ -27,12 +32,13 @@ console.log(JSON.stringify({type:'turn.completed',usage:{input_tokens:1,output_t try { const result = await testEnvironment({ companyId: "company-1", adapterType: "codex_local", - config: { engine: "cli", command, cwd: root, env: { - OPENAI_API_KEY: "fixture-key", PROBE_CAPTURE: capture, + config: { engine: "cli", command, cwd: root, ...(options.managedProvider ? { managedAiConnection: { identity: "fixture" }, managedAiRouting: { kind: "openrouter" } } : {}), env: { + ...(options.managedProvider ? { CODEX_HOME: managedHome, PAPERCLIP_AI_PROVIDER_KEY: "gateway-fixture-key" } : {}), + OPENAI_API_KEY: options.managedProvider ? "" : "fixture-key", PROBE_CAPTURE: capture, PROBE_ERROR: options.error ?? "", PATH: `${root}${path.delimiter}${process.env.PATH ?? ""}`, } }, }); - return { result, capture: JSON.parse(await fs.readFile(capture, "utf8")) as { args: string[]; home: string } }; + return { result, capture: JSON.parse(await fs.readFile(capture, "utf8")) as { args: string[]; home: string; nativeKey: string; providerKey: string; config: string | null } }; } finally { const recorded = await fs.readFile(capture, "utf8").then(JSON.parse).catch(() => null); if (recorded?.home) await fs.rm(recorded.home, { recursive: true, force: true }); @@ -47,6 +53,16 @@ describe("codex_local environment diagnostics", () => { afterEach(() => { vi.unstubAllEnvs(); }); + itPosix("tests the managed provider without falling back to the host OpenAI key", async () => { + vi.stubEnv("OPENAI_API_KEY", "unrelated-host-key"); + const { result, capture } = await runProbeFixture({ managedProvider: true }); + expect(result.status).toBe("pass"); + expect(capture.nativeKey).toBe(""); + expect(capture.providerKey).toBe("gateway-fixture-key"); + expect(capture.config).toContain('model_provider = "paperclip"'); + expect(result.checks).not.toContainEqual(expect.objectContaining({ code: "codex_openai_api_key_present" })); + }); + itPosix("preserves a successful hello when probe cleanup races a background writer", async () => { const { result } = await runProbeFixture({ failCleanup: true }); expect(result.status).toBe("pass"); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index c3f1b8a422c..4fc87aad2d8 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,3 +1,4 @@ +import { aiRoutingHarness } from "@paperclipai/shared"; import { connectionIntentService } from "../services/connection-intents.js"; import { completeConnectionIntentSchema } from "@paperclipai/shared"; import { agentFileStore, agentFileTokenFromHash } from "../services/agent-file-store.js"; @@ -3352,7 +3353,7 @@ export function agentRoutes( // requirement stays for subscriptions: a stored login is a file layout // only a provider CLI reads, so proving the runtime lane can consume it // takes a real hello turn. - if (resolvedMethod === "api_key") { + if (resolvedMethod === "api_key" && !context.config.managedAiRouting) { const envKey = AI_CONNECTION_CAPABILITIES[binding.provider].methods.api_key?.envKey; const key = envKey ? parseObject(context.config.env)[envKey] : undefined; try { @@ -3367,7 +3368,7 @@ export function agentRoutes( return result; } if (!result.checks.some(check => check.code.includes("hello_probe"))) { - const providerAdapter = { anthropic: "claude_local", openai: "codex_local", openrouter: "opencode_local", xai: "grok_local" }[binding.provider]; + const providerAdapter = context.config.managedAiRouting ? aiRoutingHarness(adapterType, context.config.provider, context.config.acpxAgent) : { anthropic: "claude_local", openai: "codex_local", openrouter: "opencode_local", xai: "grok_local", google: "gemini_local" }[binding.provider]; const probe = await requireServerAdapter(providerAdapter).testEnvironment({ ...context, adapterType: providerAdapter, config: { ...context.config, engine: "cli" } }); result.checks.push(...probe.checks); result.status = probe.status === "fail" ? "fail" : result.status === "warn" || probe.status === "warn" ? "warn" : "pass"; diff --git a/server/src/routes/ai-connections.ts b/server/src/routes/ai-connections.ts index 335ed8067b5..59a44b52ba5 100644 --- a/server/src/routes/ai-connections.ts +++ b/server/src/routes/ai-connections.ts @@ -142,6 +142,7 @@ export async function validateAiApiKey( openai: "https://api.openai.com/v1/models", openrouter: "https://openrouter.ai/api/v1/key", xai: "https://api.x.ai/v1/models", + google: "https://generativelanguage.googleapis.com/v1beta/models?pageSize=1", }; let response: Response; try { @@ -149,7 +150,7 @@ export async function validateAiApiKey( redirect: "error", signal: AbortSignal.timeout(15000), headers: - provider === "anthropic" + provider === "google" ? { "x-goog-api-key": key } : provider === "anthropic" ? { "x-api-key": key, "anthropic-version": "2023-06-01" } : { Authorization: `Bearer ${key}` }, }); @@ -295,12 +296,14 @@ export function aiConnectionRoutes(db: Db, options: Parameters = { + 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"], openrouter: ["OPENROUTER_API_KEY", "OPENCODE_AUTH_JSON", "OPENCODE_CONFIG_CONTENT", "OPENCODE_CONFIG", "OPENCODE_CONFIG_DIR", "PAPERCLIP_OPENCODE_PROVIDERS"], diff --git a/server/src/services/ai-auth-failure.test.ts b/server/src/services/ai-auth-failure.test.ts index 0f17104e7e1..2edcfb807ed 100644 --- a/server/src/services/ai-auth-failure.test.ts +++ b/server/src/services/ai-auth-failure.test.ts @@ -19,8 +19,10 @@ describe("AI authentication failure recovery", () => { expect(aiBindingForAuthRecovery(adapter, config)).toMatchObject({ provider, mode: "responsible_user" }); }); it("does not guess a provider for unsupported harnesses or routes", () => { - expect(aiBindingForAuthRecovery("gemini_local", {})).toBeUndefined(); expect(aiBindingForAuthRecovery("opencode_local", { model: "anthropic/claude" })).toBeUndefined(); expect(aiBindingForAuthRecovery("paperclip_runner", { provider: "acpx", acpxAgent: "custom" })).toBeUndefined(); }); + it("offers Google's supported API-key method for Gemini recovery", () => { + expect(aiBindingForAuthRecovery("gemini_local", {})).toEqual({ provider: "google", method: "api_key", mode: "responsible_user" }); + }); }); diff --git a/server/src/services/ai-auth-failure.ts b/server/src/services/ai-auth-failure.ts index 5064ab1666b..5fb8c03e2e9 100644 --- a/server/src/services/ai-auth-failure.ts +++ b/server/src/services/ai-auth-failure.ts @@ -1,4 +1,4 @@ -import { AI_PROVIDERS, isAiConnectionCompatible, type AiConnectionBinding } from "@paperclipai/shared"; +import { AI_PROVIDERS, AI_CONNECTION_CAPABILITIES, isAiConnectionCompatible, type AiConnectionBinding } from "@paperclipai/shared"; /** Provider authentication signals only. Tool authorization and quotas need different repairs. */ export function isAiAuthenticationFailure(code: string | null | undefined): boolean { @@ -18,7 +18,7 @@ export function aiBindingForAuthRecovery( config: Record, ): AiConnectionBinding | undefined { for (const provider of AI_PROVIDERS) { - const binding = { provider, method: provider === "openrouter" ? "api_key" : "subscription", mode: "responsible_user" } as const; + const binding = { provider, method: AI_CONNECTION_CAPABILITIES[provider].methods.subscription ? "subscription" : "api_key", mode: "responsible_user" } as const; if (isAiConnectionCompatible(binding, adapterType, config.model, config.provider, config.acpxAgent)) return binding; } return undefined; diff --git a/server/src/services/ai-connection-runtime.ts b/server/src/services/ai-connection-runtime.ts index 534f5cf023a..7059db4e9e1 100644 --- a/server/src/services/ai-connection-runtime.ts +++ b/server/src/services/ai-connection-runtime.ts @@ -7,8 +7,10 @@ import { and, eq } from "drizzle-orm"; import { type Db, companySecrets, connectionGrants } from "@paperclipai/db"; import { AI_CONNECTION_CAPABILITIES, + aiConnectionMetadataSchema, aiRoutingHarness, type AiConnectionBinding, } from "@paperclipai/shared"; +import { managedProviderRouting } from "./ai-provider-routing.js"; import { aiConnectionService } from "./ai-connections.js"; import { secretService } from "./secrets.js"; import { decideCodexAuthMerge } from "@paperclipai/adapter-codex-local/server"; @@ -23,6 +25,11 @@ export function isAiConnectionBusy(error: unknown): error is HttpError { // Blank values intentionally override inherited credentials in CLI child environments. export const AI_AUTH_ENV_KEYS = [ + "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", + "ANTHROPIC_BEDROCK_BASE_URL", "ANTHROPIC_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "CLAUDE_CODE_SUBAGENT_MODEL", + "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", @@ -175,6 +182,7 @@ function managedAiHomeEnvironment(home: string): Record { CODEX_HOME: providerHome, GROK_HOME: providerHome, CLAUDE_CONFIG_DIR: providerHome, + HERMES_HOME: providerHome, }; } @@ -218,6 +226,7 @@ export async function prepareManagedAiRuntime( "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY", "PAPERCLIP_OPENCODE_PROVIDERS", + "PAPERCLIP_AI_PROVIDER_URL", "PAPERCLIP_CODEX_PROVIDERS", "OPENCODE_CONFIG_CONTENT", "OPENCODE_CONFIG", "OPENCODE_CONFIG_DIR", "GOOGLE_GEMINI_BASE_URL", "ANTHROPIC_BEDROCK_BASE_URL", ]) { if (configuredEnv[key]) throw unprocessable( @@ -225,7 +234,8 @@ export async function prepareManagedAiRuntime( { code: "ai_connection_incompatible" }, ); } - await assertManagedAiProjectAuth(input.config, input.binding.provider); + const harness = aiRoutingHarness(input.adapterType, input.config.provider, input.config.acpxAgent); + await assertManagedAiProjectAuth(input.config, harness === "claude_local" ? "anthropic" : harness === "codex_local" ? "openai" : input.binding.provider); const service = aiConnectionService(db); let selection = await service.select({ ...input, @@ -265,21 +275,22 @@ export async function prepareManagedAiRuntime( ...Object.fromEntries(AI_AUTH_ENV_KEYS.map((key) => [key, ""])), ...managedAiHomeEnvironment(home), }; + const routing = aiConnectionMetadataSchema.parse(selection.connection.config.ai).routing; const capability = AI_CONNECTION_CAPABILITIES[input.binding.provider].methods[ selection.attribution.method ]!; const authFile = path.join(providerHome, "auth.json"); - if (input.binding.provider === "openai") + if (harness === "codex_local") await writeFile( path.join(providerHome, "config.toml"), 'cli_auth_credentials_store = "file"\n', { mode: 0o600 }, ); - if (subscriptionFile) await writeFile(authFile, value, { mode: 0o600 }); - else env[capability.envKey] = value; + if (!routing && subscriptionFile) await writeFile(authFile, value, { mode: 0o600 }); + else if (!routing) env[capability.envKey] = value; if ( - input.binding.provider === "openai" && + !routing && input.binding.provider === "openai" && selection.attribution.method === "api_key" ) { env.CODEX_API_KEY = value; @@ -287,12 +298,18 @@ export async function prepareManagedAiRuntime( mode: 0o600, }); } - if (input.binding.provider === "openrouter") { + if (!routing && input.binding.provider === "openrouter") { env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ provider: { openrouter: { options: { apiKey: value } } }, }); env.OPENCODE_DISABLE_PROJECT_CONFIG = "true"; } + const projected = routing ? managedProviderRouting(routing, harness, value, typeof input.config.model === "string" ? input.config.model : "") : undefined; + if (projected) { + Object.assign(env, projected.env); + if (projected.hermesConfig) await writeFile(path.join(providerHome, "config.yaml"), projected.hermesConfig, { mode: 0o600 }); + if (projected.codexConfig) await writeFile(path.join(providerHome, "config.toml"), 'cli_auth_credentials_store = "file"\n' + projected.codexConfig, { mode: 0o600 }); + } const generation = createHash("sha256") .update(value) .digest("hex") @@ -301,6 +318,8 @@ export async function prepareManagedAiRuntime( return { config: { ...input.config, + ...projected?.config, + ...(routing ? { managedAiRouting: routing } : {}), env, managedAiConnection: { ...selection.attribution, identity }, }, diff --git a/server/src/services/ai-connections.ts b/server/src/services/ai-connections.ts index a3d2bf640c5..d8098d6cd9a 100644 --- a/server/src/services/ai-connections.ts +++ b/server/src/services/ai-connections.ts @@ -194,9 +194,8 @@ export function aiConnectionService(db: Db) { throw unprocessable( "Reconnect this account before making it your default", ); - const metadata = aiConnectionMetadataSchema.parse( - row.connection.config.ai, - ); + const metadata = aiConnectionMetadataSchema.parse(row.connection.config.ai); + if (metadata.routing) throw unprocessable("Custom providers use an explicit connection selection on the agent."); // Keep old servers' method preferences intact during an additive rollout. await tx.insert(aiConnectionDefaults) .values({ companyId, userId, ...metadata, grantId }) @@ -383,7 +382,7 @@ export function aiConnectionService(db: Db) { !installs.length && !( (input.allowUninstalledPersonal && - binding.mode === "responsible_user" && grant.subjectUserId === userId) || + (binding.mode === "responsible_user" || binding.mode === "delegated") && grant.subjectUserId === userId) || (input.allowUninstalledShared && binding.mode === "shared" && grant.kind === "organization") ) ) @@ -401,6 +400,8 @@ export function aiConnectionService(db: Db) { }; } async function credential(row: Pick>, "connection" | "grant">) { + const metadata = aiConnectionMetadataSchema.parse(row.connection.config.ai); + if (metadata.routing?.auth === "none") return ""; const ref = row.grant.credentialSecretRefs.find( (r) => r.configPath === "ai.credential", ); @@ -464,6 +465,7 @@ export function aiConnectionService(db: Db) { sessionId?: string, attemptStartedAt = new Date(), ) { + const routing = "routing" in input ? input.routing : undefined; if (!(await membership(companyId, userId))) throw forbidden("An active company member must own this connection"); const reconnect = input.connectionId @@ -486,6 +488,8 @@ export function aiConnectionService(db: Db) { input.provider ) throw unprocessable("Reconnect cannot change providers"); + if (reconnect && JSON.stringify((reconnect.connection.config.ai as AiConnectionMetadata).routing) !== JSON.stringify(routing)) + throw unprocessable("Reconnect must retain this connection’s routing. Create another connection to change its destination."); if ( reconnect && ((reconnect.connection.config.ai as AiConnectionMetadata).method !== @@ -570,7 +574,8 @@ export function aiConnectionService(db: Db) { : source?.name === `ai-${grantId}`; if (!privateSlot) secretId = undefined; } - if (secretId) + if (!verifiedCredential) { secretId = undefined; } + else if (secretId) await secrets.rotate( secretId, { value: verifiedCredential }, @@ -618,15 +623,17 @@ export function aiConnectionService(db: Db) { if (targets.length !== new Set(input.agentIds).size) throw forbidden("Agent does not belong to this company"); } - const key = `app-gallery:${input.provider}`; + const source = routing?.kind === "bedrock" || routing?.kind === "gateway" || routing?.kind === "local" ? routing.kind : input.provider; + const providerName = source === "bedrock" ? "Amazon Bedrock" : source === "gateway" ? "Model gateway" : source === "local" ? "Local models" : AI_CONNECTION_CAPABILITIES[input.provider].name; + const key = `app-gallery:${source}`; await tx .insert(toolApplications) .values({ companyId, applicationKey: key, - name: AI_CONNECTION_CAPABILITIES[input.provider].name, + name: providerName, type: "mcp_http", - metadata: { sourceTemplateKey: input.provider }, + metadata: { sourceTemplateKey: source }, ownerUserId: userId, }) .onConflictDoNothing(); @@ -640,7 +647,7 @@ export function aiConnectionService(db: Db) { eq(toolApplications.applicationKey, key), eq( toolApplications.name, - AI_CONNECTION_CAPABILITIES[input.provider].name, + providerName, ), ), ), @@ -676,8 +683,8 @@ export function aiConnectionService(db: Db) { enabled: true, healthStatus: "ok", config: { - sourceTemplateKey: input.provider, - ai: { provider: input.provider, method: input.method }, + sourceTemplateKey: source, + ai: { provider: input.provider, method: input.method, ...(routing ? { routing } : {}) }, aiIsolatedSubscription: input.method === "subscription" && input.provider !== "anthropic", }, createdByUserId: userId, @@ -704,14 +711,14 @@ export function aiConnectionService(db: Db) { /* Safe account identity is optional. */ } } - const refs = [ + const refs = secretId ? [ { secretId: secretId!, configPath: "ai.credential", required: true, versionSelector: "latest" as const, }, - ]; + ] : []; if (reconnect) await tx .update(connectionGrants) @@ -742,7 +749,7 @@ export function aiConnectionService(db: Db) { .from(toolConnections) .where(eq(toolConnections.id, id)); await syncConnectionCredentialBindings(tx, savedConnection, refs); - if (input.ownership === "personal") { + if (input.ownership === "personal" && !routing) { await tx .insert(aiConnectionDefaults) .values({ diff --git a/server/src/services/ai-provider-routing.ts b/server/src/services/ai-provider-routing.ts new file mode 100644 index 00000000000..69996a8246a --- /dev/null +++ b/server/src/services/ai-provider-routing.ts @@ -0,0 +1,81 @@ +import { + aiAwsCredentialsSchema, + aiRoutingBaseUrl, + aiRoutingModel, + type AiProviderRouting, +} from "@paperclipai/shared"; + +/** Projects one authoritative connection into an isolated harness environment. */ +export function managedProviderRouting( + route: AiProviderRouting, + harness: string, + credential: string, + model: string, +) { + const env: Record = {}; + const config: Record = {}; + let codexConfig = ""; + let hermesConfig = ""; + const baseUrl = aiRoutingBaseUrl(route, harness); + if (route.kind === "bedrock") { + env.CLAUDE_CODE_USE_BEDROCK = "1"; + env.AWS_REGION = route.region!; + env.AWS_DEFAULT_REGION = route.region!; + env.AWS_EC2_METADATA_DISABLED = "true"; + if (route.auth === "aws_credentials") { + const aws = aiAwsCredentialsSchema.parse(JSON.parse(credential)); + env.AWS_ACCESS_KEY_ID = aws.accessKeyId; + env.AWS_SECRET_ACCESS_KEY = aws.secretAccessKey; + env.AWS_SESSION_TOKEN = aws.sessionToken ?? ""; + } else env.AWS_BEARER_TOKEN_BEDROCK = credential; + } else if (harness === "codex_local") { + env.PAPERCLIP_AI_PROVIDER_KEY = credential; + codexConfig = `model_provider = "paperclip"\n[model_providers.paperclip]\nname = "Paperclip connection"\nbase_url = ${JSON.stringify(baseUrl)}\nwire_api = "responses"\nrequires_openai_auth = false\n${route.auth === "none" ? "" : 'env_key = "PAPERCLIP_AI_PROVIDER_KEY"\n'}`; + } else if (harness === "claude_local") { + env.ANTHROPIC_BASE_URL = baseUrl; + env[ + route.auth === "api_key" ? "ANTHROPIC_API_KEY" : "ANTHROPIC_AUTH_TOKEN" + ] = credential; + env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1"; + if (model) { + env.ANTHROPIC_MODEL = model; + env.ANTHROPIC_DEFAULT_OPUS_MODEL = model; + env.ANTHROPIC_DEFAULT_SONNET_MODEL = model; + env.ANTHROPIC_DEFAULT_HAIKU_MODEL = model; + env.CLAUDE_CODE_SUBAGENT_MODEL = model; + } + } else if (harness === "opencode_local") { + const provider = route.kind === "openrouter" ? "openrouter" : "paperclip"; + if (route.kind === "openrouter") env.OPENROUTER_API_KEY = credential; + else { + env.PAPERCLIP_AI_PROVIDER_KEY = credential; + env.PAPERCLIP_AI_PROVIDER_URL = baseUrl; + } + const id = model.startsWith(`${provider}/`) + ? model.slice(provider.length + 1) + : model; + env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ + provider: { + [provider]: { + ...(provider === "paperclip" + ? { npm: "@ai-sdk/openai-compatible", name: "Paperclip connection" } + : {}), + options: { baseURL: baseUrl, apiKey: credential }, + models: id ? { [id]: { name: id } } : {}, + }, + }, + enabled_providers: [provider], + }); + env.OPENCODE_DISABLE_PROJECT_CONFIG = "true"; + config.model = aiRoutingModel(route, harness, model); + } else if (harness === "hermes_local") { + env.OPENAI_BASE_URL = baseUrl; + env.OPENAI_API_KEY = credential; + env.OPENROUTER_API_KEY = route.kind === "openrouter" ? credential : ""; + config.provider = route.kind === "openrouter" ? "openrouter" : "auto"; + // Hermes now reads custom endpoint routing from config.yaml, not OPENAI_BASE_URL. + env.PAPERCLIP_AI_PROVIDER_KEY = credential; + hermesConfig = `model:\n provider: ${route.kind === "openrouter" ? '"openrouter"' : '"custom"'}\n default: ${JSON.stringify(model)}\n base_url: ${JSON.stringify(baseUrl)}\n api_mode: "chat_completions"\n${route.auth === "none" ? "" : ' api_key: "${PAPERCLIP_AI_PROVIDER_KEY}"\n'}`; + } + return { env, config, codexConfig, hermesConfig }; +} diff --git a/server/src/services/heartbeat-runner-provider-config.test.ts b/server/src/services/heartbeat-runner-provider-config.test.ts index da0a675adbe..1787dbcc89f 100644 --- a/server/src/services/heartbeat-runner-provider-config.test.ts +++ b/server/src/services/heartbeat-runner-provider-config.test.ts @@ -84,6 +84,15 @@ describe("Paperclip Runner native provider configuration", () => { }); }); + it("keeps the connection's authoritative model namespace in durable input", () => { + const projected = projectPaperclipRunnerTaskConfig("opencode_server", + { provider: "opencode", model: "openai/gpt-5.4" }, + { provider: "codex", model: "team-alias" }, + "paperclip/team-alias"); + expect(resolvePaperclipRunnerNativeProviderInput({ backend: "opencode_server", adapterConfig: projected })) + .toMatchObject({ provider: "opencode", model: "paperclip/team-alias" }); + }); + it("requires persisted Claude recovery to use the qualified identity and current profile secret", () => { const stored = { id: "00000000-0000-4000-8000-000000000001", diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index a1c57d00f21..7f3e262ca1e 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -24100,6 +24100,7 @@ export function heartbeatService( nativeRuntimeResolution.profile.backend, agent.adapterConfig, issueAssigneeOverrides?.adapterConfig, + managedAiRuntime ? readNonEmptyString(resolvedConfig.model) ?? undefined : undefined, ) : agent.adapterConfig, managedProfile, diff --git a/server/src/services/native-runtime/provider-profile.ts b/server/src/services/native-runtime/provider-profile.ts index 371c04ac593..fd3aec43911 100644 --- a/server/src/services/native-runtime/provider-profile.ts +++ b/server/src/services/native-runtime/provider-profile.ts @@ -148,11 +148,14 @@ export function projectPaperclipRunnerTaskConfig( backend: "codex_app_server" | "opencode_server", agentConfig: unknown, taskOverrides: unknown, + managedModel?: string, ): Record { const base = asRecord(agentConfig); const task = asRecord(taskOverrides); const config = { ...base }; - const model = optionalString(task.model); + // The server resolves a connection's model namespace after task overrides. + // Carry that model into durable input without allowing routing to change the harness. + const model = optionalString(managedModel) ?? optionalString(task.model); const effortKey = backend === "codex_app_server" ? ["modelReasoningEffort", "reasoningEffort", "effort"].find((key) => key in task) : undefined; diff --git a/ui/public/brands/apps/bedrock.svg b/ui/public/brands/apps/bedrock.svg new file mode 100644 index 00000000000..610805e512a --- /dev/null +++ b/ui/public/brands/apps/bedrock.svg @@ -0,0 +1 @@ +Bedrock \ No newline at end of file diff --git a/ui/public/brands/apps/google.svg b/ui/public/brands/apps/google.svg new file mode 100644 index 00000000000..e8e0f867bde --- /dev/null +++ b/ui/public/brands/apps/google.svg @@ -0,0 +1 @@ +Google \ No newline at end of file diff --git a/ui/public/brands/apps/manifest.json b/ui/public/brands/apps/manifest.json index ed490bfc212..ba9beab8a46 100644 --- a/ui/public/brands/apps/manifest.json +++ b/ui/public/brands/apps/manifest.json @@ -32,6 +32,12 @@ "catalogVisible": true, "localAsset": "/brands/apps/asana.svg" }, + { + "slug": "bedrock", + "provider": "Amazon Bedrock", + "catalogVisible": false, + "localAsset": "/brands/apps/bedrock.svg" + }, { "slug": "beehiiv", "provider": "beehiiv", @@ -154,6 +160,12 @@ "catalogVisible": true, "localAsset": "/brands/apps/google-sheets.svg" }, + { + "slug": "google", + "provider": "Google", + "catalogVisible": false, + "localAsset": "/brands/apps/google.svg" + }, { "slug": "google-drive", "provider": "Google Drive", diff --git a/ui/src/adapters/codex-local/config-fields.test.tsx b/ui/src/adapters/codex-local/config-fields.test.tsx index 261b194aa32..23b1210df61 100644 --- a/ui/src/adapters/codex-local/config-fields.test.tsx +++ b/ui/src/adapters/codex-local/config-fields.test.tsx @@ -1,12 +1,32 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vitest"; +// @vitest-environment jsdom +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import type { ReactNode } from "react"; + +import { beforeAll, describe, expect, it } from "vitest"; import { TooltipProvider } from "@/components/ui/tooltip"; import { CodexLocalConfigFields } from "./config-fields"; -function renderRunner(config: Record): string { - return renderToStaticMarkup( +beforeAll(() => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); }); + +async function renderMarkup(node: ReactNode, expand?: string): Promise { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + await act(async () => root.render(node)); + if (expand) await act(async () => { + container.querySelector(`[aria-label="${expand}"]`)?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + }); + const html = document.body.innerHTML; + await act(async () => root.unmount()); + container.remove(); + return html; +} + +async function renderRunner(config: Record, expand?: string): Promise { + return renderMarkup( ): string { hideInstructionsFile /> , + expand, ); } @@ -30,19 +51,19 @@ describe("Paperclip Runner Codex configuration", () => { ["approve-paperclip", "Automatic Paperclip actions"], ["approve-reads", "Allow Paperclip reads"], ["deny-all", "Deny all"], - ])("displays Grok's default or saved permission mode %s", (acpxPermissionMode, label) => { - const html = renderRunner({ provider: "acpx", acpxAgent: "grok", acpxPermissionMode }); - expect(html).toContain(''); + ])("displays Grok's default or saved permission mode %s", async (acpxPermissionMode, label) => { + const html = await renderRunner({ provider: "acpx", acpxAgent: "grok", acpxPermissionMode }); + expect(html).toContain('Grok Build'); expect(html).toContain('aria-label="Permission mode"'); expect(html).toContain(label); }); - it("exposes all qualified provider choices", () => { - const html = renderRunner({ provider: "codex" }); + it("exposes all qualified provider choices", async () => { + const html = await renderRunner({ provider: "codex" }, "Harness"); - expect(html).toContain(''); + expect(html).toContain('aria-label="Harness"'); expect(html).toContain("OpenCode 1.18.32"); - expect(html).toContain(''); + expect(html).toContain('ACP agents'); expect(html).not.toContain("Permission mode"); expect(html).not.toContain("Ask when requested"); expect(html).not.toContain("Ask for untrusted operations"); @@ -51,14 +72,14 @@ describe("Paperclip Runner Codex configuration", () => { expect(html).not.toContain("Bypass sandbox"); }); - it("renders OpenCode's bounded permission modes", () => { - const html = renderRunner({ + it("renders OpenCode's bounded permission modes", async () => { + const html = await renderRunner({ provider: "opencode", opencodePermissionMode: "allow", }); expect(html).toContain( - '', + 'OpenCode 1.18.32', ); expect(html).toContain("Full auto (allow)"); expect(html).toContain('aria-label="Permission mode"'); @@ -66,27 +87,28 @@ describe("Paperclip Runner Codex configuration", () => { expect(html).not.toContain("Ask for untrusted operations"); }); - it("offers qualified Claude and keeps candidate ACP agents visibly disabled", () => { - const html = renderRunner({ + it("offers qualified Claude and keeps candidate ACP agents visibly disabled", async () => { + const html = await renderRunner({ provider: "acpx", acpxAgent: "claude", acpxPermissionMode: "approve-reads", - }); + }, "ACP agent"); - expect(html).toContain(''); + expect(html).toContain('ACP agents'); expect(html).toContain("ACP agent"); - expect(html).toContain(''); - expect(html).toContain(''); - expect(html).toContain(''); - expect(html).toContain(''); + expect(html).toContain('aria-label="ACP agent"'); + expect(html.match(/role="option"[^>]*data-disabled=""/g)).toHaveLength(3); + expect(html).toContain('Cursor — qualification pending'); + expect(html).toContain('GitHub Copilot — qualification pending'); + expect(html).toContain('Pi — qualification pending'); expect(html).not.toContain("Codex via ACPX"); expect(html).not.toContain("ACPX Codex"); expect(html).not.toContain("Pi via ACPX"); expect(html).toContain("Allow Paperclip reads"); }); - it("falls back to the fail-closed Codex permission mode", () => { - const html = renderRunner({ codexPermissionMode: "unrestricted" }); + it("falls back to the fail-closed Codex permission mode", async () => { + const html = await renderRunner({ codexPermissionMode: "unrestricted" }); expect(html).toContain("Unsupported saved mode — select a qualified mode"); expect(html).toContain("cannot start or recover a Paperclip Runner run"); @@ -94,12 +116,12 @@ describe("Paperclip Runner Codex configuration", () => { expect(html).not.toContain("Full auto (never ask)"); }); - it("shows a bounded idle timeout only for warm sessions", () => { - const warmHtml = renderRunner({ + it("shows a bounded idle timeout only for warm sessions", async () => { + const warmHtml = await renderRunner({ lifecycleMode: "warm", idleTimeoutMs: 45_000, }); - const turnHtml = renderRunner({ + const turnHtml = await renderRunner({ lifecycleMode: "per_turn", idleTimeoutMs: 45_000, }); diff --git a/ui/src/adapters/codex-local/config-fields.tsx b/ui/src/adapters/codex-local/config-fields.tsx index bb737ee45ce..8583c32ff7c 100644 --- a/ui/src/adapters/codex-local/config-fields.tsx +++ b/ui/src/adapters/codex-local/config-fields.tsx @@ -1,4 +1,5 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; +import { AdapterMark } from "../../components/AdapterMark"; import { configFieldsForSection } from "../config-sections"; import type { AdapterConfigFieldsProps } from "../types"; import { @@ -36,6 +37,14 @@ const defaultOpenCodeRunnerModel = "openrouter/deepseek/deepseek-v4-flash-0731"; const defaultAcpxClaudeModel = "claude-sonnet-5"; const defaultClaudeManagedModel = "claude-sonnet-5"; const defaultAwsAgentCoreModel = "global.anthropic.claude-sonnet-4-6"; +const runnerHarnessOptions = [ + { value: "codex", label: "Codex", adapter: "codex_local" }, + { value: "opencode", label: "OpenCode 1.18.32", adapter: "opencode_local" }, + { value: "claude_managed", label: "Claude Managed", adapter: "claude_local" }, + { value: "aws_agentcore", label: "AWS AgentCore", adapter: "aws_agentcore" }, + { value: "acpx", label: "ACP agents", adapter: "acpx_local" }, + { value: "grok", label: "Grok Build", adapter: "grok_local" }, +]; export function CodexLocalConfigFields({ section, @@ -191,16 +200,15 @@ export function CodexLocalConfigFields({ )} {runnerManaged && ( - + + + {runnerHarnessOptions.map((option) => ( + + + {option.label} + + ))} + + )} {runnerManaged && runnerProvider === "acpx" && runnerSchemaValue("acpxAgent", "claude") !== "grok" && ( - + + + {PAPERCLIP_RUNNER_ACPX_PROFILES.map(profile => ( + + + {profile.label}{profile.qualified ? "" : " — qualification pending"} + + ))} + + )} {runnerManaged && runnerProvider === "claude_managed" && ( diff --git a/ui/src/adapters/config-sections.test.tsx b/ui/src/adapters/config-sections.test.tsx index 5903e75e40d..8073eb5935a 100644 --- a/ui/src/adapters/config-sections.test.tsx +++ b/ui/src/adapters/config-sections.test.tsx @@ -1,6 +1,10 @@ -import { renderToStaticMarkup } from "react-dom/server"; +// @vitest-environment jsdom +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import type { ReactNode } from "react"; + import type { ComponentType } from "react"; -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import { TooltipProvider } from "../components/ui/tooltip"; import type { AdapterConfigFieldsProps, AdapterConfigSection } from "./types"; import { CodexLocalConfigFields } from "./codex-local/config-fields"; @@ -10,13 +14,29 @@ import { ProcessConfigFields } from "./process/config-fields"; import { OpenClawGatewayConfigFields } from "./openclaw-gateway/config-fields"; import { HermesGatewayConfigFields } from "./hermes-gateway/config-fields"; -function renderSection( +beforeAll(() => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); }); + +async function renderMarkup(node: ReactNode, expand?: string): Promise { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + await act(async () => root.render(node)); + if (expand) await act(async () => { + container.querySelector(`[aria-label="${expand}"]`)?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + }); + const html = container.innerHTML; + await act(async () => root.unmount()); + container.remove(); + return html; +} + +async function renderSection( Component: ComponentType, adapterType: string, section: AdapterConfigSection, config: Record = {}, ) { - return renderToStaticMarkup( + return renderMarkup( { - it("separates provider selection from lifecycle and hides fixed Codex permissions", () => { + it("separates provider selection from lifecycle and hides fixed Codex permissions", async () => { const config = { provider: "codex", lifecycleMode: "warm", idleTimeoutMs: 45000, }; - const adapter = renderSection( + const adapter = await renderSection( CodexLocalConfigFields, "paperclip_runner", "adapter", config, ); - const configuration = renderSection( + const configuration = await renderSection( CodexLocalConfigFields, "paperclip_runner", "configuration", config, ); - const policy = renderSection( + const policy = await renderSection( CodexLocalConfigFields, "paperclip_runner", "runPolicy", config, ); - expect(adapter).toContain(''); + expect(adapter).toContain('aria-label="Harness"'); expect(adapter).not.toContain("Runner lifecycle"); expect(configuration).not.toContain("Permission mode"); expect(configuration).not.toContain("Runner lifecycle"); @@ -69,13 +89,12 @@ describe("adapter configuration sections", () => { expect(policy).not.toContain("ACP agents"); }); - it("keeps ACP agent admission choices in the adapter section", () => { + it("keeps ACP agent admission choices in the adapter section", async () => { const config = { provider: "acpx", acpxAgent: "claude" }; - const adapter = renderSection(CodexLocalConfigFields, "paperclip_runner", "adapter", config); - const policy = renderSection(CodexLocalConfigFields, "paperclip_runner", "runPolicy", config); + const adapter = await renderSection(CodexLocalConfigFields, "paperclip_runner", "adapter", config); + const policy = await renderSection(CodexLocalConfigFields, "paperclip_runner", "runPolicy", config); - expect(adapter).toContain(''); - expect(adapter).toContain(''); + expect(adapter).toContain('aria-label="ACP agent"'); expect(adapter).not.toContain("Runner lifecycle"); expect(policy).toContain("Runner lifecycle"); expect(policy).not.toContain("ACP agent"); @@ -88,31 +107,31 @@ describe("adapter configuration sections", () => { ["gemini_local", GeminiLocalConfigFields], ] as const)( "separates ACP commands and lifecycle for %s", - (type, Component) => { + async (type, Component) => { const config = { engine: "acp", agentCommand: "saved-command", warmHandleIdleMs: 1234, }; - expect(renderSection(Component, type, "advanced", config)).toContain( + expect(await renderSection(Component, type, "advanced", config)).toContain( 'value="saved-command"', ); expect( - renderSection(Component, type, "configuration", config), + await renderSection(Component, type, "configuration", config), ).not.toContain("ACP server command"); - const policy = renderSection(Component, type, "runPolicy", config); + const policy = await renderSection(Component, type, "runPolicy", config); expect(policy).toContain("ACP session mode"); expect(policy).toContain('value="1234"'); expect(policy).not.toContain("ACP server command"); }, ); - it("keeps process command and arguments under Advanced with saved values", () => { + it("keeps process command and arguments under Advanced with saved values", async () => { const config = { command: "node", args: ["worker.js", "--quiet"] }; expect( - renderSection(ProcessConfigFields, "process", "configuration", config), + await renderSection(ProcessConfigFields, "process", "configuration", config), ).toBe(""); - const advanced = renderSection( + const advanced = await renderSection( ProcessConfigFields, "process", "advanced", @@ -127,12 +146,12 @@ describe("adapter configuration sections", () => { ["hermes_gateway", HermesGatewayConfigFields], ] as const)( "moves %s timeouts without changing their values", - (type, Component) => { + async (type, Component) => { const config = { timeoutSec: 37 }; expect( - renderSection(Component, type, "configuration", config), + await renderSection(Component, type, "configuration", config), ).not.toContain('value="37"'); - expect(renderSection(Component, type, "runPolicy", config)).toContain( + expect(await renderSection(Component, type, "runPolicy", config)).toContain( 'value="37"', ); }, diff --git a/ui/src/components/AdapterMark.tsx b/ui/src/components/AdapterMark.tsx new file mode 100644 index 00000000000..8d293cbbacc --- /dev/null +++ b/ui/src/components/AdapterMark.tsx @@ -0,0 +1,59 @@ +import { getAdapterDisplay } from "@/adapters/adapter-display-registry"; +import { cn } from "@/lib/utils"; + +const brandMarks: Record = { + claude_local: { src: "/brands/claude-color.svg" }, + codex_local: { src: "/brands/codex-color.svg" }, + gemini_local: { src: "/brands/adapters/gemini-color.svg" }, + kimi_local: { + src: "/brands/adapters/kimi-color-light.svg", + dark: "/brands/adapters/kimi-color.svg", + }, + ...Object.fromEntries( + [ + ["cursor", "cursor"], + ["cursor_cloud", "cursor"], + ["grok_local", "grok"], + ["hermes_local", "hermesagent"], + ["hermes_gateway", "hermesagent"], + ["pi_local", "pi"], + ].map(([type, icon]) => [ + type, + { + src: `/brands/adapters/${icon}.svg`, + dark: `/brands/adapters/${icon}-dark.svg`, + }, + ]), + ), +}; +export function AdapterMark({ + type, + className = "size-6", +}: { + type: string; + className?: string; +}) { + const Icon = getAdapterDisplay(type).icon; + const mark = brandMarks[type]; + if (!mark) return ; + return ( + <> + + {mark.dark && ( + + )} + + ); +} diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 630db5ca156..a8322ed5741 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -1,3 +1,5 @@ +import { useConnectionModels } from "./ai-connections/useConnectionModels"; +import { aiRoutingHarness } from "@paperclipai/shared"; import { AiConnectionField } from "./ai-connections/AiConnectionField"; import { aiConnectionBindingSchema } from "@paperclipai/shared"; import { testAgentSetup } from "@/lib/test-agent-setup"; @@ -42,6 +44,8 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; +import { selectTriggerClassName } from "@/components/ui/select"; +import { AdapterMark } from "./AdapterMark"; import { FolderOpen, Heart, ChevronDown, X, Copy, Check, ExternalLink, Loader2, TriangleAlert, Bug } from "lucide-react"; import { asBoolean, asFiniteNumber, asObject, cn } from "../lib/utils"; import { copyTextToClipboard } from "../lib/clipboard"; @@ -76,7 +80,6 @@ import { getUIAdapter } from "../adapters"; import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-fields"; import { MarkdownEditor } from "./MarkdownEditor"; import { ChoosePathButton } from "./PathInstructionsModal"; -import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon"; import { ReportsToPicker } from "./ReportsToPicker"; import { EnvironmentVariablesEditor, @@ -911,7 +914,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) { }); const [refreshModelsError, setRefreshModelsError] = useState(null); const [refreshingModels, setRefreshingModels] = useState(false); - const models = fetchedModels ?? externalModels ?? []; + const connectionModels = useConnectionModels(selectedCompanyId, isCreate ? undefined : aiConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data, aiRoutingHarness(adapterType, runnerProvider, config.acpxAgent)); + const models = connectionModels?.models ?? fetchedModels ?? externalModels ?? []; const adapterCommandField = "command"; const { data: detectedModelData, @@ -946,7 +950,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) { set: isCreate ? (patch: Partial) => props.onChange(patch) : null, config, eff: eff as (group: "adapterConfig", field: string, original: T) => T, - mark: mark as (group: "adapterConfig", field: string, value: unknown) => void, + mark: (group: "adapterConfig", field: string, value: unknown) => mark(group, field, + field === "model" && typeof value === "string" ? connectionModels?.resolveModel(value) ?? value : value), models, // Resolve the effective instructions-file gate once. The instructions file // is an absolute host path, so the managed-sandbox-only policy hides it for @@ -1658,7 +1663,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { )} - {!isCreate && selectedCompanyId && | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data} model={String(eff("adapterConfig", "model", config.model) ?? "")} environmentId={currentDefaultEnvironmentId || undefined} legacy onChange={binding => mark("runtime", "runtimeConfig", { ...runtimeConfig, aiConnection: binding })} />} @@ -1732,7 +1737,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { }); return; } - mark("adapterConfig", "model", v || undefined); + mark("adapterConfig", "model", connectionModels?.resolveModel(v) || v || undefined); if (clearUnsupportedEffort) { mark("adapterConfig", thinkingEffortKey, undefined); mark("adapterConfig", "reasoningEffort", undefined); @@ -3666,19 +3671,26 @@ export function AdapterTypeDropdown({ return ( - {adapterList.map((item) => ( diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index aeeb67b1942..f9caf79d0f5 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -1016,7 +1016,7 @@ function OnboardingWizardInner({ const localLoginHealth = useQuery({ queryKey: queryKeys.health, queryFn: healthApi.get }); const canUseLocalLogin = resolvedLoginEnvironment?.driver === "local" && (localLoginHealth.data?.localAiLoginSupported ?? localLoginHealth.data?.deploymentMode === "local_trusted"); const localLogin = useLocalAiLogin(createdCompanyId, { - provider: managedProvider ?? "anthropic", method: "subscription", + provider: managedProvider === "openai" || managedProvider === "xai" ? managedProvider : "anthropic", method: "subscription", name: `My ${CONNECT_SOURCE_NAMES[adapterType] ?? managedProvider} subscription`, ownership: "personal", agentIds: [], allAgents: true, }, effectiveOnboardingOpen && step === 4 && canUseLocalLogin && credentialMode !== "api" && @@ -2815,7 +2815,7 @@ function OnboardingWizardInner({ adapterType={adapterType} environmentId={resolvedLoginEnvironmentId} chrome="onboarding" - aiConnection={managedProvider ? { provider: managedProvider, method: "subscription", name: `My ${CONNECT_SOURCE_NAMES[adapterType] ?? managedProvider} subscription`, ownership: "personal", agentIds: [], allAgents: true } : undefined} + aiConnection={managedProvider === "anthropic" || managedProvider === "openai" || managedProvider === "xai" ? { provider: managedProvider, method: "subscription", name: `My ${CONNECT_SOURCE_NAMES[adapterType] ?? managedProvider} subscription`, ownership: "personal", agentIds: [], allAgents: true } : undefined} autoStart onPromptReady={(url) => { setConnectAuthUrl(url); diff --git a/ui/src/components/ai-connections/AiConnectionAccountControls.tsx b/ui/src/components/ai-connections/AiConnectionAccountControls.tsx index 38605dce7db..b05f77f230d 100644 --- a/ui/src/components/ai-connections/AiConnectionAccountControls.tsx +++ b/ui/src/components/ai-connections/AiConnectionAccountControls.tsx @@ -27,7 +27,7 @@ export function AiConnectionAccountControls({ const activeDefault = account.isDefault && available; return (
- {ownPersonal && ( + {ownPersonal && !account.routing && (
; + if (props.provider === "openrouter" || props.provider === "google") return ; return ; } @@ -74,7 +74,7 @@ function SubscriptionConnectionStep({ companyId, provider, initialMethod, fixedM const canLogin = environment?.driver === "sandbox" && caps.data?.sandboxProviders?.[sandboxProvider]?.supportsLoginPty === true; const loading = [envs, caps, settings, experimental, general].some((query) => query.isPending); const error = environmentError ?? [envs, caps, settings, experimental, general].find((query) => query.error)?.error?.message; - const intent: AiConnectionLoginIntent = { provider, method: "subscription", name, ownership, agentIds, allAgents, connectionId }; + const intent: AiConnectionLoginIntent = { provider: provider as AiConnectionLoginIntent["provider"], method: "subscription", name, ownership, agentIds, allAgents, connectionId }; return
{!hideName && } {!suppliedEnvironmentId && !forced.forced && loginEnvironments.length > 1 && setName(event.target.value)} disabled={Boolean(connectionId)} />} {save.error &&

{save.error.message}

} - save.mutate()} disabled={save.isPending} placeholder="Enter API key here" autoFocus /> + save.mutate()} disabled={save.isPending} placeholder="Enter API key here" autoFocus />
; } diff --git a/ui/src/components/ai-connections/AiConnectionField.tsx b/ui/src/components/ai-connections/AiConnectionField.tsx index adf57ec293a..cc524512d86 100644 --- a/ui/src/components/ai-connections/AiConnectionField.tsx +++ b/ui/src/components/ai-connections/AiConnectionField.tsx @@ -2,16 +2,16 @@ import { useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { aiConnectionBindingSchema, - isAiConnectionCompatible, - type AiConnectionBinding, type AiAuthMethod, + type AiConnectionBinding, type AiProvider, type AiManagedConnectionSummary, } from "@paperclipai/shared"; import { aiConnectionsApi } from "@/api/ai-connections"; -import { AiConnectionPicker } from "./AiConnectionPicker"; -import { AiConnectionLegacyNotice } from "./AiConnectionManagement"; +import { AiConnectionSelect } from "./AiConnectionSelect"; +import { AiProviderSetup } from "./AiProviderSetup"; import { AiConnectionCredentialStep } from "./AiConnectionCredentialStep"; +import { AiConnectionLegacyNotice } from "./AiConnectionManagement"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { @@ -32,6 +32,8 @@ export function aiProviderForAdapter( codex_local: "openai", opencode_local: "openrouter", grok_local: "xai", + gemini_local: "google", + hermes_local: "openrouter", } as Record )[adapterType]; } @@ -64,6 +66,7 @@ export function AiConnectionField({ const [adopting, setAdopting] = useState(false); const [pendingAdoption, setPendingAdoption] = useState(); const [connecting, setConnecting] = useState(false); + const [advancedSetup, setAdvancedSetup] = useState(false); const [reconnecting, setReconnecting] = useState(); const [allAgents, setAllAgents] = useState(true); const [savedAccount, setSavedAccount] = useState<{ connectionId: string; grantId: string; method: AiAuthMethod }>(); @@ -94,6 +97,7 @@ export function AiConnectionField({ const openConnection = (reconnect?: AiManagedConnectionSummary) => { returnFocus.current = document.activeElement as HTMLElement; setReconnecting(reconnect); + setAdvancedSetup(false); setAllAgents(accounts.data?.canManageConnections ?? false); setSavedAccount(undefined); selectDefault.reset(); @@ -101,7 +105,7 @@ export function AiConnectionField({ }; const method: AiAuthMethod = (value?.mode !== "responsible_user" ? value?.method : undefined) ?? accounts.data?.connections.find((account) => account.provider === provider && account.isDefault)?.method - ?? (provider === "openrouter" ? "api_key" : "subscription"); + ?? (provider === "openrouter" || provider === "google" ? "api_key" : "subscription"); if (!provider) return null; if (legacy && !value && !adopting) return ( @@ -112,13 +116,8 @@ export function AiConnectionField({ ); return (
- {value && (adapterType !== "opencode_local" || Boolean(model)) && !isAiConnectionCompatible(value, adapterType, model) && ( -

- This connection does not support the current harness and model. Choose - a compatible connection before saving. -

- )} - {reconnecting ? "Reconnect account" : "Connect account"} - {reconnecting ? "Sign in again to repair your current default account. Its agent access stays the same." : "This account will become your default for this provider. Your tasks will use it; other users keep their own default."} + {advancedSetup ? "Choose a provider connection for this agent." : reconnecting ? "Sign in again to repair your current default account. Its agent access stays the same." : "This account will become your default for this provider. Your tasks will use it; other users keep their own default."} + {advancedSetup ? setAdvancedSetup(false)} + onComplete={binding => { + void client.invalidateQueries({ queryKey: ["ai-connections", companyId] }); + setConnecting(false); + changeBinding(binding); + }} + /> : <> {!reconnecting && !savedAccount &&
diff --git a/ui/src/components/ai-connections/AiConnectionSelect.tsx b/ui/src/components/ai-connections/AiConnectionSelect.tsx new file mode 100644 index 00000000000..bb418fb2c16 --- /dev/null +++ b/ui/src/components/ai-connections/AiConnectionSelect.tsx @@ -0,0 +1,177 @@ +import { useId } from "react"; +import { + isAiConnectionCompatible, + type AiConnectionBinding, +} from "@paperclipai/shared"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { AppLogo } from "@/pages/apps/AppLogo"; +import { Button } from "@/components/ui/button"; +import { aiConnectionProblem, personalAiDefault } from "./model"; +import type { AiConnectionPickerProps } from "./AiConnectionPicker"; + +/** The same connection control on Create agent and Harness / Runtime. */ +export function AiConnectionSelect({ + requirement, + connections, + value, + currentUserId, + readOnly, + loading, + error, + onRetry, + onConnect, + onChange, + adapterType, +}: AiConnectionPickerProps & { adapterType: string }) { + const selectId = useId(); + const personal = personalAiDefault(connections, requirement, currentUserId); + const selected = + value?.mode === "responsible_user" + ? personal + : connections.find( + (c) => c.id === value?.connectionId && c.grantId === value?.grantId, + ); + const compatible = connections + .filter( + (c) => + c.companyId === requirement.companyId && + isAiConnectionCompatible( + c, + adapterType, + c.provider === "openrouter" ? "openrouter/" : undefined, + ), + ) + .sort( + (a, b) => + Number(b.id === selected?.id) - Number(a.id === selected?.id) || + Number(Boolean(a.routing)) - Number(Boolean(b.routing)) || + a.name.localeCompare(b.name), + ); + const unavailable = Boolean( + value && + value.mode !== "responsible_user" && + !selected && + !loading && + !error, + ); + const canUseDefault = isAiConnectionCompatible( + { + provider: requirement.provider, + method: "api_key", + mode: "responsible_user", + }, + adapterType, + requirement.provider === "openrouter" ? "openrouter/" : undefined, + ); + const incompatible = + selected && !compatible.some((c) => c.id === selected.id); + const change = (id: string) => { + if (id === "connect") return onConnect(); + if (id === "responsible_user") + return onChange({ + provider: requirement.provider, + method: personal?.method ?? "api_key", + mode: "responsible_user", + }); + const connection = compatible.find((c) => c.id === id); + if (!connection) return; + onChange({ + provider: connection.provider, + method: connection.method, + mode: connection.ownership === "shared" ? "shared" : "delegated", + connectionId: connection.id, + grantId: connection.grantId, + } satisfies AiConnectionBinding); + }; + return ( +
+ + + {error && ( +
+

+ {error} +

+ +
+ )} + {unavailable ? ( +

+ This connection is unavailable. Choose another connection. +

+ ) : incompatible ? ( +

+ This connection does not support the selected harness. Choose a + compatible connection. +

+ ) : selected && aiConnectionProblem(selected) ? ( +

+ {aiConnectionProblem(selected)} +

+ ) : null} +
+ ); +} diff --git a/ui/src/components/ai-connections/AiProviderSetup.tsx b/ui/src/components/ai-connections/AiProviderSetup.tsx new file mode 100644 index 00000000000..6e2874af8bc --- /dev/null +++ b/ui/src/components/ai-connections/AiProviderSetup.tsx @@ -0,0 +1,514 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Network, Monitor } from "lucide-react"; +import { + aiProviderRoutingSchema, + createAiConnectionSchema, + type AiProvider, + type AiProviderRouting, + type AiConnectionBinding, + type AiManagedConnectionSummary, +} from "@paperclipai/shared"; +import { aiConnectionsApi } from "@/api/ai-connections"; +import { agentsApi } from "@/api/agents"; +import { ConnectionChoiceList } from "@/features/connections/ConnectionChoiceList"; +import { AccessStepContent } from "@/features/connections/ConnectionSetupFlow"; +import { AppLogo } from "@/pages/apps/AppLogo"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { AiConnectionCredentialStep } from "./AiConnectionCredentialStep"; + +const providers = [ + { + id: "openai", + name: "OpenAI", + description: "ChatGPT subscription or API key", + }, + { + id: "anthropic", + name: "Anthropic", + description: "Claude subscription or API key", + }, + { id: "google", name: "Google", description: "Gemini API key" }, + { id: "xai", name: "xAI", description: "Grok subscription or API key" }, + { + id: "openrouter", + name: "OpenRouter", + description: "Models through one API key", + advanced: true, + }, + { + id: "bedrock", + name: "Amazon Bedrock", + description: "AWS credentials or Bedrock API key", + advanced: true, + }, + { + id: "gateway", + name: "Custom gateway", + description: "Emissary or another compatible endpoint", + advanced: true, + }, + { + id: "local", + name: "Local endpoint", + description: "A model server in the agent’s environment", + advanced: true, + }, +] as const; +type ProviderChoice = (typeof providers)[number]["id"]; + +export function AiProviderSetup({ + companyId, + agentId, + environmentId, + initialProvider, + reconnect, + onCancel, + onComplete, +}: { + companyId: string; + agentId?: string; + environmentId?: string; + initialProvider?: ProviderChoice; + reconnect?: AiManagedConnectionSummary; + onCancel: () => void; + onComplete: ( + binding: Exclude, + ) => void; +}) { + const [provider, setProvider] = useState( + reconnect + ? reconnect.routing?.kind === "gateway" || + reconnect.routing?.kind === "local" || + reconnect.routing?.kind === "bedrock" + ? reconnect.routing.kind + : reconnect.provider + : initialProvider, + ); + const [step, setStep] = useState( + reconnect ? "connect" : initialProvider ? "access" : "provider", + ); + const [ownership, setOwnership] = useState<"personal" | "shared">( + reconnect?.ownership ?? "personal", + ); + const [allAgents, setAllAgents] = useState(!agentId); + const [agentIds, setAgentIds] = useState(new Set(agentId ? [agentId] : [])); + const [protocol, setProtocol] = useState( + reconnect?.routing?.protocol ?? "responses", + ); + const [auth, setAuth] = useState( + reconnect?.routing?.auth ?? "bearer", + ); + const [baseUrl, setBaseUrl] = useState(reconnect?.routing?.baseUrl ?? ""); + const [region, setRegion] = useState( + reconnect?.routing?.region ?? "us-east-1", + ); + const [models, setModels] = useState( + reconnect?.routing?.models.map((m) => m.id).join(", ") ?? "", + ); + const [apiKey, setApiKey] = useState(""); + const [accessKeyId, setAccessKeyId] = useState(""); + const [secretAccessKey, setSecretAccessKey] = useState(""); + const [sessionToken, setSessionToken] = useState(""); + const client = useQueryClient(); + const agents = useQuery({ + queryKey: ["agents", companyId, "provider-access"], + queryFn: () => agentsApi.list(companyId), + }); + const label = providers.find((p) => p.id === provider)?.name ?? "provider"; + const advanced = ["openrouter", "bedrock", "gateway", "local"].includes( + provider ?? "", + ); + const nativeProvider: AiProvider = + provider === "bedrock" + ? "anthropic" + : provider === "gateway" || provider === "local" + ? protocol === "messages" + ? "anthropic" + : "openai" + : (provider ?? "openai"); + const name = + reconnect?.name ?? + `${ownership === "personal" ? "My" : "Company"} ${ + provider === "gateway" && baseUrl + ? (() => { + try { + return new URL(baseUrl).hostname; + } catch { + return label; + } + })() + : label + }`; + const complete = (result: { + connectionId: string; + grantId: string; + method?: "api_key" | "subscription"; + }) => { + void client.invalidateQueries({ queryKey: ["ai-connections", companyId] }); + void client.invalidateQueries({ queryKey: ["tools"] }); + onComplete({ + provider: nativeProvider, + method: result.method ?? "api_key", + mode: ownership === "shared" ? "shared" : "delegated", + ...result, + }); + }; + const save = useMutation({ + mutationFn: () => { + const routing = + reconnect?.routing ?? + aiProviderRoutingSchema.parse({ + kind: provider, + protocol: provider === "bedrock" ? "bedrock" : protocol, + auth: provider === "openrouter" ? "bearer" : auth, + ...(provider === "bedrock" + ? { region } + : provider === "gateway" || provider === "local" + ? { baseUrl } + : {}), + models: models + .split(",") + .map((id) => id.trim()) + .filter(Boolean) + .map((id) => ({ id })), + }); + return aiConnectionsApi.create( + companyId, + createAiConnectionSchema.parse({ + provider: nativeProvider, + method: "api_key", + name, + ownership, + allAgents, + agentIds: [...agentIds], + connectionId: reconnect?.id, + routing, + ...(auth === "aws_credentials" + ? { + awsCredentials: { + accessKeyId, + secretAccessKey, + ...(sessionToken ? { sessionToken } : {}), + }, + } + : auth !== "none" + ? { apiKey } + : {}), + }), + ); + }, + onSuccess: complete, + onSettled: () => { + setApiKey(""); + setSecretAccessKey(""); + setSessionToken(""); + }, + }); + const choices = (advancedOnly: boolean) => ( + Boolean("advanced" in p && p.advanced) === advancedOnly) + .map((p) => ({ + ...p, + icon: + p.id === "gateway" || p.id === "local" ? ( + + {p.id === "gateway" ? ( + + ) : ( + + )} + + ) : ( + + ), + }))} + onSelect={(id) => { + setProvider(id as ProviderChoice); + setStep("access"); + setApiKey(""); + setAuth("bearer"); + save.reset(); + }} + /> + ); + return ( +
event.stopPropagation()}> +
+

+ {step === "provider" + ? "Connect a model provider" + : `${reconnect ? "Reconnect" : "Connect"} ${label}`} +

+ {step !== "provider" && ( +

+ {step === "access" ? "Access" : "Connect"} +

+ )} +
+ {step === "provider" ? ( + <> + {choices(false)} +
+ + Advanced providers + +
{choices(true)}
+
+ + + ) : step === "access" ? ( + <> + {agents.error && ( +

+ Could not load agents. Try again. +

+ )} + + setOwnership(kind === "organization" ? "shared" : "personal") + } + installChoice={allAgents ? "all" : "specific"} + setInstallChoice={(choice) => setAllAgents(choice === "all")} + installAgentIds={agentIds} + setInstallAgentIds={setAgentIds} + submitLabel="Continue" + onBack={() => setStep("provider")} + onContinue={() => setStep("connect")} + bare + /> + + ) : !advanced ? ( + (reconnect ? onCancel() : setStep("access"))} + onComplete={complete} + /> + ) : ( +
{ + e.preventDefault(); + save.mutate(); + }} + > + {(provider === "gateway" || provider === "local") && ( + <> + + + + )} + {provider === "bedrock" && ( + + )} + {provider !== "openrouter" && ( + + )} + {auth === "aws_credentials" ? ( + <> + + + + + ) : ( + auth !== "none" && ( + + ) + )} +
+ + Advanced model settings + + +
+

+ Test the connection with your chosen model in the agent’s execution + environment after connecting. +

+ {save.error && ( +

+ {save.error instanceof Error && save.error.name === "ZodError" + ? "Check the URL, API format, and authentication fields." + : save.error.message} +

+ )} +
+ + +
+
+ )} +
+ ); +} diff --git a/ui/src/components/ai-connections/ManagedAiConnectionDetails.tsx b/ui/src/components/ai-connections/ManagedAiConnectionDetails.tsx index 1fc4b55428a..9d5d43fead4 100644 --- a/ui/src/components/ai-connections/ManagedAiConnectionDetails.tsx +++ b/ui/src/components/ai-connections/ManagedAiConnectionDetails.tsx @@ -6,7 +6,7 @@ import { toolsApi } from "@/api/tools"; import { useNavigate } from "@/lib/router"; import { AiConnectionAccountControls } from "./AiConnectionAccountControls"; import { AiConnectionUsagePanel } from "./AiConnectionUsagePanel"; -import type { ToolConnection } from "@paperclipai/shared"; +import type { ToolConnection, AiConnectionMetadata } from "@paperclipai/shared"; import { aiMethodLabel } from "./model"; export function ManagedAiConnectionRow({ @@ -14,16 +14,11 @@ export function ManagedAiConnectionRow({ }: { connection: ToolConnection; }) { - const metadata = connection.config?.ai as - | { - provider: "anthropic" | "openai" | "openrouter" | "xai"; - method: "subscription" | "api_key"; - } - | undefined; + const metadata = connection.config?.ai as AiConnectionMetadata | undefined; if (!metadata) return null; return (

- {aiMethodLabel(metadata.provider, metadata.method)} ·{" "} + {metadata.routing ? metadata.routing.kind === "bedrock" ? `Bedrock · ${metadata.routing.region}` : metadata.routing.kind === "openrouter" ? "OpenRouter" : metadata.routing.baseUrl : aiMethodLabel(metadata.provider, metadata.method)} ·{" "} {connection.credentialPolicy === "per_user" ? "Personal" : "Company shared"} @@ -90,6 +85,7 @@ export function ManagedAiConnectionDetails({ ); return (

+ {account.routing &&
Provider settings
Destination
{account.routing.baseUrl ?? (account.routing.kind === "bedrock" ? account.routing.region : "OpenRouter")}
API format
{account.routing.kind === "openrouter" ? "Selected by harness" : account.routing.protocol}
Authentication
{account.routing.auth}
Models
{account.routing.models.map(m => m.label ?? m.id).join(", ") || "Enter a model ID on the agent"}
} = { + google: { name: "Google", logo: "/brands/apps/google.svg" }, anthropic: { name: "Claude", subscriptionName: "Claude subscription", diff --git a/ui/src/components/ai-connections/useConnectionModels.ts b/ui/src/components/ai-connections/useConnectionModels.ts new file mode 100644 index 00000000000..d68ce1c2fa0 --- /dev/null +++ b/ui/src/components/ai-connections/useConnectionModels.ts @@ -0,0 +1,31 @@ +import { useQuery } from "@tanstack/react-query"; +import { aiRoutingModel, type AiConnectionBinding } from "@paperclipai/shared"; +import { aiConnectionsApi } from "@/api/ai-connections"; + +export function useConnectionModels( + companyId: string | null | undefined, + binding: AiConnectionBinding | undefined, + harness: string, +) { + const accounts = useQuery({ + queryKey: ["ai-connections", companyId], + queryFn: () => aiConnectionsApi.list(companyId!), + enabled: Boolean(companyId && binding), + }); + const connection = accounts.data?.connections.find((c) => + binding?.mode === "responsible_user" + ? c.provider === binding.provider && c.isDefault + : c.id === binding?.connectionId && c.grantId === binding?.grantId, + ); + const routing = connection?.routing; + return routing + ? { + models: routing.models.map((m) => ({ + id: aiRoutingModel(routing, harness, m.id), + label: m.label ?? m.id, + })), + resolveModel: (model: string) => + aiRoutingModel(routing, harness, model), + } + : undefined; +} diff --git a/ui/src/components/new-agent/AgentBasicsDialog.tsx b/ui/src/components/new-agent/AgentBasicsDialog.tsx index d778c2bb146..f7845a6c460 100644 --- a/ui/src/components/new-agent/AgentBasicsDialog.tsx +++ b/ui/src/components/new-agent/AgentBasicsDialog.tsx @@ -1,5 +1,6 @@ import { useCompany } from "@/context/CompanyContext"; import { useAgentAppearanceDraft } from "@/hooks/useAgentAppearanceDraft"; +import { AdapterMark } from "../AdapterMark"; import { AgentCharacter } from "../AgentCharacter"; import { useId, useState } from "react"; import { useQuery } from "@tanstack/react-query"; @@ -25,62 +26,8 @@ export type AgentBasics = { adapterType: string; runnerProvider: string; }; -const brandMarks: Record = { - claude_local: { src: "/brands/claude-color.svg" }, - codex_local: { src: "/brands/codex-color.svg" }, - gemini_local: { src: "/brands/adapters/gemini-color.svg" }, - kimi_local: { - src: "/brands/adapters/kimi-color-light.svg", - dark: "/brands/adapters/kimi-color.svg", - }, - ...Object.fromEntries( - [ - ["cursor", "cursor"], - ["cursor_cloud", "cursor"], - ["grok_local", "grok"], - ["hermes_local", "hermesagent"], - ["hermes_gateway", "hermesagent"], - ["pi_local", "pi"], - ].map(([type, icon]) => [ - type, - { - src: `/brands/adapters/${icon}.svg`, - dark: `/brands/adapters/${icon}-dark.svg`, - }, - ]), - ), -}; -export function AdapterMark({ - type, - className = "size-6", -}: { - type: string; - className?: string; -}) { - const Icon = getAdapterDisplay(type).icon; - const mark = brandMarks[type]; - if (!mark) return ; - return ( - <> - - {mark.dark && ( - - )} - - ); -} +export { AdapterMark } from "../AdapterMark"; + function AgentBasicsCharacter() { const { selectedCompanyId } = useCompany(); const { appearance } = useAgentAppearanceDraft(`${selectedCompanyId}:new-agent`); diff --git a/ui/src/components/new-agent/NewAgentSetup.tsx b/ui/src/components/new-agent/NewAgentSetup.tsx index d65b189a2f5..04c391fc9ee 100644 --- a/ui/src/components/new-agent/NewAgentSetup.tsx +++ b/ui/src/components/new-agent/NewAgentSetup.tsx @@ -1,3 +1,4 @@ +import { useConnectionModels } from "../ai-connections/useConnectionModels"; import { AgentCharacter } from "../AgentCharacter"; import { useAgentAppearanceDraft } from "../../hooks/useAgentAppearanceDraft"; import { AiConnectionField, aiProviderForAdapter } from "../ai-connections/AiConnectionField"; @@ -155,6 +156,7 @@ function Setup({ ); const [connection, setConnection] = useState(null); const aiBinding = runtimeAiBinding ?? connection?.aiConnection; + const connectionModels = useConnectionModels(companyId, aiBinding, brandType); const [repository, setRepository] = useState(""); const [branch, setBranch] = useState(""); const [createdInSession, setCreated] = useState(null); @@ -716,7 +718,6 @@ function Setup({
@@ -740,6 +741,10 @@ function Setup({
+
+ Use another connection +
{ setConnection({ env: {}, aiConnection: binding }); setRuntimeAiBinding(undefined); resetTest(); setScreen("runtime"); }} />
+
{ setConnection(next); + setRuntimeAiBinding(undefined); resetTest(); setScreen("runtime"); }} @@ -833,21 +839,9 @@ function Setup({
-

Runtime

- {aiProviderForAdapter(brandType) && ( - connection && !aiBinding ? ( -
-

- Using the connection selected in the Connect step. -

- -
- ) : ( - { setRuntimeAiBinding(binding); resetTest(); }} /> - ) + {!connectionAdapter && aiProviderForAdapter(brandType) && ( + { setRuntimeAiBinding(binding); resetTest(); }} /> )} {models.error &&

Could not load models. Retry or enter a model ID manually.

} {((showModel && !usingKimiApi) || @@ -855,10 +849,10 @@ function Setup({
{showModel && !usingKimiApi && ( { - setModel(value); + setModel(connectionModels?.resolveModel(value) ?? value); if ( effort && !setupEfforts(adapterType, value).includes( @@ -909,7 +903,7 @@ function Setup({ )}
)} - {SETUP_LOGIN_HINTS[adapterType] && ( + {!aiBinding && SETUP_LOGIN_HINTS[adapterType] && (

{SETUP_LOGIN_HINTS[adapterType]}

diff --git a/ui/src/components/ui/select.tsx b/ui/src/components/ui/select.tsx index fa2a98a1eb9..7f8e4ab4549 100644 --- a/ui/src/components/ui/select.tsx +++ b/ui/src/components/ui/select.tsx @@ -4,6 +4,9 @@ import { Select as SelectPrimitive } from "radix-ui" import { cn } from "@/lib/utils" +// Shared with searchable picker triggers so their size and states match Select. +export const selectTriggerClassName = "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-base md:text-sm whitespace-nowrap shadow-xs transition-(--tp-color-box-shadow) outline-none focus-visible:ring-(length:--rad-3) disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" + function Select({ ...props }: React.ComponentProps) { @@ -35,7 +38,7 @@ function SelectTrigger({ data-slot="select-trigger" data-size={size} className={cn( - "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-base md:text-sm whitespace-nowrap shadow-xs transition-(--tp-color-box-shadow) outline-none focus-visible:ring-(length:--rad-3) disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + selectTriggerClassName, className )} {...props} diff --git a/ui/src/features/connections/ConnectionChoiceList.tsx b/ui/src/features/connections/ConnectionChoiceList.tsx index 577ba868de3..2aaea7f85c4 100644 --- a/ui/src/features/connections/ConnectionChoiceList.tsx +++ b/ui/src/features/connections/ConnectionChoiceList.tsx @@ -3,7 +3,7 @@ import { Check, ChevronRight, Loader2 } from "lucide-react"; /** The account-reuse rows shared by connection setup and agent bindings. */ export function ConnectionChoiceList({ choices, selectedId, pendingId, disabled, onSelect }: { - choices: { id: string; name: string; description: ReactNode; disabled?: boolean }[]; + choices: { id: string; name: string; description: ReactNode; icon?: ReactNode; disabled?: boolean }[]; selectedId?: string; pendingId?: string | null; disabled?: boolean; @@ -19,7 +19,8 @@ export function ConnectionChoiceList({ choices, selectedId, pendingId, disabled, disabled={disabled || Boolean(pendingId) || choice.disabled} onClick={() => onSelect(choice.id)} > - + {choice.icon && } + {choice.name} {choice.description} diff --git a/ui/src/features/connections/ConnectionIntentInteractionBody.tsx b/ui/src/features/connections/ConnectionIntentInteractionBody.tsx index 3a7f50c9dbd..fd858140aa8 100644 --- a/ui/src/features/connections/ConnectionIntentInteractionBody.tsx +++ b/ui/src/features/connections/ConnectionIntentInteractionBody.tsx @@ -1,3 +1,4 @@ +import { AiProviderSetup } from "@/components/ai-connections/AiProviderSetup"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -357,7 +358,7 @@ export function ConnectionIntentInteractionBody({ {completeMutation.isPending ? "Continuing…" : "Continue task"}
- : repair ? repair.canReconnect ? selectAiAccountMutation.mutate({ ...result, generation })} onCancel={() => { closeSetup(); returnFocusToCard(); }} /> : { api.getAdapterAuthSignal.mockResolvedValue({ status: "missing" }); api.testEnvironment.mockResolvedValue({ ...pass, adapterType }); await render(adapterType, "grok"); - expect(container.textContent).toContain("Connect Atlas to Grok"); + expect(container.textContent).toContain("Connect a model"); + expect(container.textContent).not.toContain("Connect Atlas to Grok"); if (method === "subscription") { await click("GrokSubscription"); await click("Complete subscription login"); @@ -375,7 +376,8 @@ describe("New agent setup", () => { }); it("uses the shared Grok connection flow and hides ignored Kimi and OpenCode effort controls", async () => { await render("grok_local"); - expect(container.textContent).toContain("Connect Atlas to Grok"); + expect(container.textContent).toContain("Connect a model"); + expect(container.textContent).not.toContain("Connect Atlas to Grok"); await render("opencode_local"); expect(container.querySelector('[aria-label="Thinking effort"]')).toBeNull(); }); @@ -470,6 +472,8 @@ describe("New agent setup", () => { expect(managedApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ apiKey: "connection-key", provider: binding.provider })); expect(api.testEnvironment.mock.calls[0][2].testCredentials).toEqual({}); expect(api.testEnvironment.mock.calls[0][2].aiConnection).toEqual(binding); + expect(container.textContent).toContain("Configure your agent"); + expect(container.querySelector('[role="combobox"][aria-label="Connection"]')).toBeNull(); expect(secrets.createUserSecretDefinition).not.toHaveBeenCalled(); await click("Finish setup"); expect(api.hire.mock.calls[0][1].runtimeConfig.aiConnection).toEqual(binding); @@ -499,6 +503,34 @@ describe("New agent setup", () => { expect(secrets.createMyUserSecret).not.toHaveBeenCalled(); expect(secrets.rotateMyUserSecret).not.toHaveBeenCalled(); }); + it.each(["codex_local", "paperclip_runner"])("keeps a saved connection chosen in Connect when hiring %s", async (adapter) => { + await render(adapter, "codex"); + await act(async () => cache.setQueryData(["ai-connections", "company-1", undefined], { + currentUserId: "user-1", + connections: [{ + id: "00000000-0000-4000-8000-000000000021", grantId: "00000000-0000-4000-8000-000000000022", companyId: "company-1", + provider: "openrouter", method: "api_key", name: "Company OpenRouter", + ownership: "shared", isDefault: false, status: "connected", + routing: { kind: "openrouter", protocol: "responses", auth: "bearer" }, + }], + })); + const disclosure = [...container.querySelectorAll("summary")].find(node => node.textContent === "Use another connection")!; + await act(async () => disclosure.click()); + const picker = container.querySelector('[role="combobox"][aria-label="Connection"]')!; + await act(async () => picker.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }))); + await settle(); + const option = [...document.querySelectorAll('[role="option"]')].find(node => node.textContent?.includes("Company OpenRouter")) as HTMLElement; + expect(option).toBeTruthy(); + await act(async () => option.click()); + await settle(); + expect(container.textContent).toContain("Configure your agent"); + expect(container.querySelector('[role="combobox"][aria-label="Connection"]')).toBeNull(); + await click("Run test"); + const binding = { provider: "openrouter", method: "api_key", mode: "shared", connectionId: "00000000-0000-4000-8000-000000000021", grantId: "00000000-0000-4000-8000-000000000022" }; + expect(api.testEnvironment.mock.calls[0][2].aiConnection).toEqual(binding); + await click("Finish setup"); + expect(api.hire.mock.calls[0][1].runtimeConfig.aiConnection).toEqual(binding); + }); it.each(["pi_local"])( "persists %s OpenRouter credentials only as a secret reference", async (adapter) => { @@ -528,11 +560,25 @@ describe("New agent setup", () => { await render("opencode_local"); const model = "openrouter/anthropic/claude-sonnet-4.6"; await fill("Model", model); - await click("Connect another account"); + const connectionSelect = container.querySelector('[role="combobox"][aria-label="Connection"]')!; + await act(async () => connectionSelect.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }))); + await settle(); + const connectOption = [...document.querySelectorAll('[role="option"]')].find(option => option.textContent?.includes("Connect an account"))!; + expect(connectOption).toBeTruthy(); + await act(async () => (connectOption as HTMLElement).click()); + await settle(); const dialog = document.querySelector('[role="dialog"]')!; expect(dialog).toBeTruthy(); expect(api.hire).not.toHaveBeenCalled(); expect(api.testEnvironment).not.toHaveBeenCalled(); + const advanced = [...dialog.querySelectorAll("summary")].find(node => node.textContent?.includes("Advanced providers"))!; + await act(async () => advanced.click()); + const openrouter = [...dialog.querySelectorAll("button")].find(node => node.textContent?.includes("OpenRouter"))!; + await act(async () => openrouter.click()); + await settle(); + const accessContinue = [...dialog.querySelectorAll("button")].find(node => node.textContent?.trim() === "Continue")!; + await act(async () => accessContinue.click()); + await settle(); const input = dialog.querySelector('[aria-label="API key"]') as HTMLInputElement; expect(input).toBeTruthy(); await act(async () => { @@ -544,11 +590,13 @@ describe("New agent setup", () => { await act(async () => connectButton.click()); await settle(); expect(document.querySelector('[role="dialog"]')).toBeNull(); - expect(managedApi.setDefault).toHaveBeenCalledWith("company-1", "managed-grant"); + expect(managedApi.setDefault).not.toHaveBeenCalled(); + expect(api.hire).not.toHaveBeenCalled(); + expect(api.testEnvironment).not.toHaveBeenCalled(); expect(managedApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ provider: "openrouter", method: "api_key", apiKey: "example-test-secret", })); - const binding = { provider: "openrouter", method: "api_key", mode: "responsible_user" }; + const binding = { provider: "openrouter", method: "api_key", mode: "delegated", connectionId: "managed-connection", grantId: "managed-grant" }; await click("Run test"); expect(api.testEnvironment.mock.calls[0][2]).toEqual(expect.objectContaining({ aiConnection: binding, testCredentials: {}, diff --git a/ui/src/pages/apps/AppsConnect.tsx b/ui/src/pages/apps/AppsConnect.tsx index 5e48ac72858..f7ada953f85 100644 --- a/ui/src/pages/apps/AppsConnect.tsx +++ b/ui/src/pages/apps/AppsConnect.tsx @@ -1,3 +1,6 @@ +import { useQuery } from "@tanstack/react-query"; +import { aiConnectionsApi } from "@/api/ai-connections"; +import { AiProviderSetup } from "@/components/ai-connections/AiProviderSetup"; import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow"; import type { ToolConnectionCredentialSource } from "@paperclipai/shared"; import { useCompany } from "@/context/CompanyContext"; @@ -16,11 +19,19 @@ export function AppsConnect({ byoOnly = false, credentialSource = "paperclip_vau const [searchParams] = useSearchParams(); const { appKey } = useParams<{ appKey?: string }>(); const source = searchParams.get("source") ?? appKey ?? searchParams.get("appKey"); + const reconnectId = searchParams.get("reconnect"); + const aiReconnectRequested = Boolean(reconnectId && ["model-provider", "openai", "anthropic", "xai", "openrouter", "google-ai", "google", "bedrock", "gateway", "local"].includes(source ?? "")); + const aiAccounts = useQuery({ queryKey: ["ai-connections", selectedCompanyId], queryFn: () => aiConnectionsApi.list(selectedCompanyId!), enabled: Boolean(selectedCompanyId && aiReconnectRequested) }); + const aiReconnect = aiAccounts.data?.connections.find(c => c.id === reconnectId); const returningToSkills = source === "github" && selectedCompanyId && skillSourceReturnPath(selectedCompanyId); function returnToSkills() { const path = selectedCompanyId && consumeSkillSourceReturn(selectedCompanyId); if (path) navigate(path); } + if (aiReconnectRequested && aiAccounts.isPending) return

Loading connection…

; + if (aiReconnectRequested && aiAccounts.isError) return

Could not load this connection. Refresh to try again.

; + if (aiReconnectRequested && !aiReconnect) return

This connection is unavailable. Return to Connectors to choose an account.

; + if (selectedCompanyId && (source === "model-provider" || source === "openrouter" || source === "google-ai" || source === "google" || source === "bedrock" || source === "gateway" || source === "local" || aiReconnect?.routing)) return navigate("/apps")} onComplete={binding => navigate(`/apps/${binding.connectionId}/permissions`)} />; return ; diff --git a/ui/src/pages/apps/Browse.tsx b/ui/src/pages/apps/Browse.tsx index c1177433249..d1a9909afd8 100644 --- a/ui/src/pages/apps/Browse.tsx +++ b/ui/src/pages/apps/Browse.tsx @@ -667,7 +667,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne return (
-
+
connection.conne className="pl-9" />
+
{loadFailed ? ( diff --git a/ui/src/pages/apps/app-connect-policy.ts b/ui/src/pages/apps/app-connect-policy.ts index 60dfbea7ed4..3af33d0cbb2 100644 --- a/ui/src/pages/apps/app-connect-policy.ts +++ b/ui/src/pages/apps/app-connect-policy.ts @@ -67,6 +67,7 @@ export function canEnterAppsConnect( ); } const source = searchParams.get("source") ?? ""; + if (["model-provider", "google-ai", "google", "bedrock", "gateway", "local"].includes(source)) return true; const entry = getAppStoreDefinition(source); if ( !chatConnectorsEnabled && diff --git a/ui/storybook/prototypes/agent-settings/AgentSettingsPreview.tsx b/ui/storybook/prototypes/agent-settings/AgentSettingsPreview.tsx index d8065003655..bca3a2a3ec3 100644 --- a/ui/storybook/prototypes/agent-settings/AgentSettingsPreview.tsx +++ b/ui/storybook/prototypes/agent-settings/AgentSettingsPreview.tsx @@ -4,6 +4,7 @@ import { useRef, useState, type ComponentProps, + type ReactNode, } from "react"; import { Route, Routes, useLocation, useNavigate } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -67,11 +68,14 @@ export function AgentSettingsPreview({ adapterType = "claude_local", testOutcome = "pass", saveFails = false, + runtimeContent, }: { initialTab?: AgentLocalDetailView; adapterType?: string; testOutcome?: TestOutcome; saveFails?: boolean; + /** Storybook-only extension point for reviewing proposed runtime controls in the existing page. */ + runtimeContent?: ReactNode; }) { const [fixtures] = useState(() => createSettingsFixtures(adapterType, testOutcome, saveFails), @@ -115,7 +119,7 @@ export function AgentSettingsPreview({ } + element={} />
{ if ( dirty && @@ -267,6 +271,16 @@ function SettingsPage() {
+
+ + {agent.name} · Agent navigation + + +
)} - {(view === "runtime" || view === "secrets") && ( -
-
- -
- {view === "runtime" && ( - <> - test.current?.()} - /> - {feedback.login && ( - test.current?.()} - onApplyStored={() => test.current?.()} + {view === "runtime" && runtimeContent + ? runtimeContent + : (view === "runtime" || view === "secrets") && ( +
+
+ +
+ {view === "runtime" && ( + <> + test.current?.()} + /> + {feedback.login && ( + test.current?.()} + onApplyStored={() => test.current?.()} + /> + )} + )} - +
)} -
- )} {view === "permissions" && ( -

- {saving - ? "Saving changes…" - : dirty - ? "You have unsaved changes." - : saved - ? "Changes saved." - : ""} -

-
- - -
- - )} + ) && + !(view === "runtime" && runtimeContent) && ( +
+

+ {saving + ? "Saving changes…" + : dirty + ? "You have unsaved changes." + : saved + ? "Changes saved." + : ""} +

+
+ + +
+
+ )}
diff --git a/ui/storybook/prototypes/provider-routing/AgentSetup.tsx b/ui/storybook/prototypes/provider-routing/AgentSetup.tsx new file mode 100644 index 00000000000..18639ba8fef --- /dev/null +++ b/ui/storybook/prototypes/provider-routing/AgentSetup.tsx @@ -0,0 +1,573 @@ +import { useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { RuntimeTestCard, type TestState } from "@/components/RuntimeTestCard"; +import { ModelSourceTiles } from "@/components/onboarding/ModelSourceTiles"; +import { CredentialModeLink } from "@/components/onboarding/CredentialModeLink"; +import { AdapterMark } from "@/components/AdapterMark"; +import { + AdapterTypeDropdown, + ModelDropdown, +} from "@/components/AgentConfigForm"; +import { Field } from "@/components/agent-config-primitives"; +import { ConnectionChoiceList } from "@/features/connections/ConnectionChoiceList"; +import { AiConnectionLegacyNotice } from "@/components/ai-connections/AiConnectionManagement"; +import { AgentSettingsPreview } from "../agent-settings/AgentSettingsPreview"; +import { ProviderApiKeyCard } from "@/components/AdapterLoginChrome"; +import { ConnectionSetup } from "./ConnectionSetup"; +import { ConnectionPicker } from "./ConnectionPicker"; +import { + AdvancedOptions, + Choice, + Footer, + Notice, + Surface, + TextField, +} from "./shared"; +import { + compatibility, + connections, + defaultConnection, + harnesses, + modelsFor, + type Connection, + type Harness, +} from "./model"; + +export type AgentProps = { + harness?: Harness; + runner?: "new" | "legacy"; + initialConnectionId?: string; + addedConnection?: Connection; + initialModel?: string; + initialTest?: TestState; + scenario?: + | "normal" + | "empty" + | "loading" + | "catalog-error" + | "denied" + | "missing-personal" + | "legacy" + | "readonly"; + testFailure?: "tools" | "network"; + newAgent?: boolean; + initialAdvanced?: boolean; +}; + +const adapterTypes: Record = { + codex: "codex_local", + claude: "claude_local", + opencode: "opencode_local", + grok: "grok_local", + pi: "pi_local", + gemini: "gemini_local", + kimi: "kimi_local", + hermes: "hermes_local", + cursor: "cursor", + copilot: "copilot_local", +}; +const excludedAdapters = new Set([ + "process", + "http", + "openclaw_gateway", + "hermes_gateway", + "claude_managed", + "aws_agentcore", + "acpx_local", + "paperclip_runner", + "cursor_cloud", +]); + +export function AgentSetup(props: AgentProps) { + const content = ; + return props.newAgent ? ( + content + ) : ( + + ); +} + +function AgentRoutingFields({ + harness: initialHarness = "codex", + runner = "new", + initialConnectionId, + addedConnection, + initialModel, + initialTest = "idle", + scenario = "normal", + testFailure, + newAgent, + initialAdvanced = false, +}: AgentProps) { + const startingConnection = + addedConnection ?? + connections.find((entry) => entry.id === initialConnectionId) ?? + defaultConnection(initialHarness); + const [harness, setHarness] = useState(initialHarness); + const [rows, setRows] = useState( + addedConnection + ? [ + ...connections.filter((entry) => entry.id !== addedConnection.id), + addedConnection, + ] + : scenario === "empty" + ? [] + : connections, + ); + const [connectionId, setConnectionId] = useState(startingConnection.id); + const [model, setModel] = useState( + initialModel ?? modelsFor(startingConnection, harness)[0], + ); + const [manual, setManual] = useState(false); + const [modelOpen, setModelOpen] = useState(false); + const [advancedOpen, setAdvancedOpen] = useState(initialAdvanced); + const [effort, setEffort] = useState("default"); + const [environment, setEnvironment] = useState("cloud"); + const [state, setState] = useState(initialTest); + const [catalogError, setCatalogError] = useState( + scenario === "catalog-error", + ); + const [creating, setCreating] = useState(false); + const [saved, setSaved] = useState(false); + const [discarded, setDiscarded] = useState(false); + const timer = useRef | undefined>(undefined); + useEffect(() => () => clearTimeout(timer.current), []); + const selected = rows.find((entry) => entry.id === connectionId); + const availableModels = selected ? modelsFor(selected, harness) : []; + const unknownModel = !manual && !availableModels.includes(model); + const reason = selected ? compatibility(selected, harness) : undefined; + const denied = scenario === "denied" || scenario === "missing-personal"; + const readOnly = scenario === "readonly"; + const nativePending = + runner === "new" && + !harnesses.find((entry) => entry.value === harness)!.native; + const invalid = + !selected || + !!reason || + unknownModel || + !model.trim() || + denied || + nativePending || + readOnly; + const change = () => { + clearTimeout(timer.current); + setState("idle"); + setSaved(false); + setDiscarded(false); + }; + const error = + testFailure === "network" + ? "This endpoint is unreachable from the selected environment. Check network access or choose another environment." + : "The model responded, but the tool-call test failed. Choose a model with tool support or check the gateway’s API format."; + const test = () => { + setState("running"); + timer.current = setTimeout( + () => setState(testFailure ? "fail" : "pass"), + 400, + ); + }; + + if (creating) + return ( + setCreating(false)} + onComplete={(connection) => { + setRows((current) => [ + ...current.filter((entry) => entry.id !== connection.id), + connection, + ]); + setConnectionId(connection.id); + setCreating(false); + change(); + }} + /> + ); + return ( + +
+ + { + const next = (Object.keys(adapterTypes) as Harness[]).find( + (key) => adapterTypes[key] === value, + ); + if (next) { + setHarness(next); + change(); + } + }} + /> + +
+ {nativePending && ( + + This harness is not yet available through Paperclip Runner. Select a + supported harness or configure its legacy adapter. + + )} + {scenario === "legacy" && ( + setCreating(true)} + /> + )} + {scenario === "loading" ? ( +

+ Loading your connections… +

+ ) : selected ? ( + <> + !compatibility(entry, harness))} + disabled={readOnly} + onSelect={(id) => { + if (id === connectionId) return; + setConnectionId(id); + setManual(false); + change(); + }} + onConnect={() => setCreating(true)} + /> + {reason && ( + {reason} Choose a compatible connection. + )} + {denied && ( + + {scenario === "missing-personal" + ? "Sam has no credential for Engineering gateway. Sam needs to connect to this destination, or choose an authorized company credential." + : "You don’t have access to this company credential. Ask its owner for access or choose another connection."} + + )} + + ) : ( + + No compatible connections yet. Connect a provider to choose a model + for Nova. + + )} + {!selected && ( + + )} + {selected && scenario !== "loading" && ( + <> + {catalogError ? ( + +

Couldn’t load models from this connection.

+ +
+ ) : ( +
+ ({ id, label: id }))} + value={model} + open={modelOpen} + onOpenChange={setModelOpen} + allowDefault={false} + required + groupByProvider={selected.provider === "openrouter"} + creatable={advancedOpen} + onChange={(value) => { + setManual(!availableModels.includes(value)); + setModel(value); + change(); + }} + /> +
+ )} + {catalogError && advancedOpen && ( + { + setManual(true); + setModel(event.target.value); + change(); + }} + hint="Use the exact ID accepted by this connection. Custom model metadata is managed on the connection." + /> + )} + {unknownModel && !catalogError && ( + + Your previous model is unavailable through {selected.name}. Choose + a model to continue. Your harness is still{" "} + {harnesses.find((entry) => entry.value === harness)!.label}. + + )} + { + setEffort(value); + change(); + }} + options={[ + { value: "default", label: "Model default" }, + { value: "low", label: "Low" }, + { value: "high", label: "High" }, + ]} + /> + { + setEnvironment(value); + change(); + }} + options={[ + { value: "cloud", label: "Company AWS workspace" }, + { value: "local", label: "Local machine" }, + ]} + /> + + +

+ To use a custom model ID or gateway alias, enter it in the Model + picker above. +

+
+ + + )} + {saved && ( + + {scenario === "legacy" + ? "Managed connection adopted." + : "Nova’s configuration saved."}{" "} + {selected?.name} · {model} + + )} + {discarded && Changes discarded.} +