From 7be51f30e181517227285f2fc718f2efd0a6a8fe Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Tue, 6 Oct 2026 11:10:54 -0700
Subject: [PATCH 1/2] docs(release): canonicalize stable notes for v2026.1005.0
(#15357)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The release process keeps stable notes under a beta-keyed path
during the soak and renames them to the versioned path after the stable
ships
> - Stable v2026.1005.0 is published. The `canonicalize_stable_notes`
job pushed the rename branch but it does not open a pull request
> - Until the rename merges, `releases/v2026.1005.0.md` does not exist
on master and the announcement links do not resolve
> - This pull request merges the workflow's rename commit. It is a pure
rename with no content changes
> - The benefit is that the repository returns to the canonical
release-notes layout and the notes link in the announcements resolves
## Linked Issues or Issue Description
**Issue type**
Missing content
**Where is the issue?**
`releases/` — the stable notes for v2026.1005.0 still live at the
beta-keyed path `releases/beta/v2026.1002.0-beta.0.md`.
**What's wrong?**
The `canonicalize_stable_notes` job in the stable release run pushed
branch `release-notes/v2026.1005.0-canonicalize` with the rename, but it
does not open a pull request. The versioned path
`releases/v2026.1005.0.md` does not exist on master until this merges.
**Suggested fix**
Merge the workflow's rename commit. The notes were already corrected
before the stable dispatch in #15251, so no content change is needed
here.
## What Changed
- Renamed `releases/beta/v2026.1002.0-beta.0.md` to
`releases/v2026.1005.0.md` (workflow commit `ecc10236`, authored by
`github-actions[bot]`)
- No content changed. This is a pure rename. The file already carries
the correct header, commit count, and Contributors section from #14928
and #15251
## Verification
- The compare view for this branch against master shows one commit and
one file with status `renamed`, zero line changes
- The GitHub Release body for v2026.1005.0 is identical to this file
(one trailing newline differs)
- `git rev-list --count v2026.1001.0..v2026.1005.0` returns 179, which
matches the Contributors section
- After merge,
https://github.com/paperclipai/paperclip/blob/master/releases/v2026.1005.0.md
returns 200
## Risks
- Low risk: a documentation-only rename with no content changes.
## Model Used
- Claude (Anthropic), model ID `claude-fable-5-1` (Claude Fable 5.1),
extended thinking enabled, tool use via Claude Code
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
---
releases/{beta/v2026.1002.0-beta.0.md => v2026.1005.0.md} | 0
1 file changed, 0 insertions(+), 0 deletions(-)
rename releases/{beta/v2026.1002.0-beta.0.md => v2026.1005.0.md} (100%)
diff --git a/releases/beta/v2026.1002.0-beta.0.md b/releases/v2026.1005.0.md
similarity index 100%
rename from releases/beta/v2026.1002.0-beta.0.md
rename to releases/v2026.1005.0.md
From 9f7057e122c0c6987044240e22ea067402e6e450 Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Tue, 6 Oct 2026 13:29:03 -0500
Subject: [PATCH 2/2] feat(connections): configure custom model providers
across agent harnesses (#14970)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents use a harness, a model, and a credential to run tasks.
> - Connections already store credentials and control who can use them.
> - Custom providers also need an endpoint and a supported API format.
> - A per-agent endpoint would duplicate credentials and access rules.
> - This pull request stores routing on the connection and projects it
into the harness.
> - Isolated credentials and protocol checks keep the selected
connection authoritative.
## Linked Issues or Issue Description
Refs #37, #13083, #14104, #14565, #12692.
#14016 is a reference only. This PR has its own schema, vault
persistence, routing validation, runtime projection, and tests. None of
the five commits in #14016 is an ancestor of this branch. We do not
depend on or plan to merge it. #14967 addresses task-pinned account
pools. #14422 addresses another provider integration.
This is the first of two linked PRs. Merge this connection change before
#15341, which refines agent setup and adds the qualification harness.
The split keeps each review below 100 changed files. Provider catalog
entries have usable setup forms in this PR. Local browser subscription
sign-in is included.
## What Changed
- Store non-secret routing metadata on AI connections. Vault provider
API keys, including Bedrock bearer API keys. Reject general AWS access
keys.
- Enforce company, owner, human audience, agent access, connection
status, and protocol checks before resolving credentials. Keep reconnect
destinations immutable and retain connection identity during key
rotation.
- Project OpenRouter and compatible custom endpoints into Codex, Claude,
OpenCode, and local Hermes. Carry these settings through both legacy and
native runner transports. Clear conflicting host credentials and redact
keys from diagnostics.
- Preserve older OpenRouter accounts and native personal defaults. Add
Google API-key accounts and migration `0306` for the two
provider-default constraints.
- Run local Claude and Codex subscription sign-in behind the existing
browser sign-in card. Use private attempt homes and owner-bound
completion instead of a copied terminal command.
- Seed isolated Gemini authentication and preserve OpenCode workspace
permissions. Keep the selected connection authoritative. The independent
Gemini and Grok workflow fixes are in #15341.
- Keep native OpenCode custom gateway keys in a runner-owned
selected-model proxy; the harness config contains only a session-scoped
capability. Honor runtime outgoing proxy and certificate settings.
Preserve streamed responses and revoke the proxy on close or startup
failure.
- Allow ordinary members to connect native personal accounts before an
agent exists.
- Repair routed accounts from task cards using the saved provider
destination, protocol, model aliases, and connection identity.
- Add provider catalog definitions, model discovery, pinned logos, and
complete native and routed setup forms. Allow a personal routed
connection before a new agent exists. Keep endpoint authentication keys
out of Hermes terminal children.
- Recover cancelled or restarted browser sign-in with a clear restart
action. Support no-auth endpoints without a vault credential. Add
isolation and recovery regressions and runtime documentation.
## Verification
- Updated with `origin/master` at `22a3ea341`. Migration `0306` follows
the new master migration and passes migration and snapshot checks.
- The integrated connection regressions passed 152 tests and 50 native
OpenCode driver tests, including key-free child-shell configuration
reads, authenticated/no-auth forwarding, streaming, model/path
restrictions, cancellation, outgoing proxy routing, and NO_PROXY bypass.
Provider setup has 14 passing tests. The pinned real OpenCode 1.18.34
executable also completed a turn through the proxy against a local
synthetic provider; the reusable key was absent from its config. A
second real-executable smoke passed with an HTTPS CONNECT proxy and
runtime-specific synthetic certificate trust. Certificate-file and
certificate-directory regressions pass.
- Task-card repair passed 48 tests, including OpenRouter, Bedrock, and
custom gateway reconnect cases. UI typecheck and token gates passed.
- The prior core regression set passed 133 tests across new-agent setup,
provider forms, browser sign-in, routing projection, and connection
authorization. Token gates and UI typecheck passed.
- Full workspace typecheck and production build passed again after the
latest integration and credential-proxy fix. The merged deterministic
runner E2E suite passed 1,400 Vitest tests and 128 Node tests.
- Full workspace typecheck passed on the prior linked combined
implementation. Production build, Storybook build, 1,316 browser-harness
Vitest tests, and 128 Node tests passed. Head `9d964c8d9` includes the
latest master integration and regenerated migration. This exact head
passed 54 remote checks with four expected skips and Greptile 5/5; no
review threads remain open. An unchanged server fixture had a random
six-character issue-prefix collision on its first attempt. All 245 tests
passed locally and the single CI retry passed.
- A provider-free terminal check used the cited supported Hermes source
and dummy keys. Gateway and OpenRouter terminal children could not read
the selected key.
- The broad local Vitest attempt passed 15,442 tests but was not green.
It had an embedded-Postgres startup failure, an HTTP logger timeout, an
origin socket error, and a browser cancellation wait timeout. The
cancellation wait was corrected. The relevant connection tests and the
full origin test file passed separately. Latest-head CI must pass before
merge.
- Prior credential-backed acceptance exercised task creation, tool use,
artifact delivery, completion, and context-dependent follow-up. Claude
legacy and native runners passed Bedrock with `us-east-1` and
`us.anthropic.claude-sonnet-4-6`.
- Historical local qualification retained 43 passing API/gateway cells
out of 46. Those attempts span earlier builds. They do not qualify this
exact commit or staging. All subscription combinations and staging
remain unqualified.
- Verify native subscription and API-key setup. Connect a regular
provider catalog row. Verify an incompatible harness and a changed
reconnect URL are rejected. Use #15341 for the complete browser
campaign.
## Risks
- Migration `0306` changes two check constraints. It preserves rows and
is safe to reapply. It takes normal constraint-change locks.
- Credential projection touches several harnesses. CLI upgrades can
change provider configuration and session behavior.
- The native OpenCode proxy adds a loopback hop, pins requests to the
selected model, limits request bodies to 16 MiB, rejects redirects, and
expires at session close. It prevents reusable keys in the child
configuration; it is not an OS isolation boundary against a process
debugger running as the same user.
- Custom endpoints must be reachable from the agent environment. Saving
a connection does not prove connectivity. Bedrock keys require rotation
before expiry.
- Gemini CLI 0.58.0 has an upstream ACP new-file error conversion
defect. Provider overloads and an unresolved follow-up timeout also
affect live Gemini qualification. We have not patched the installed CLI
or marked those cases as passing.
- OpenClaw Gateway, Hermes Gateway, Claude Managed, AWS AgentCore,
Process, HTTP, and legacy ACPX local are excluded. Vertex, ambient AWS
identity, arbitrary auth headers, and custom routing for other harnesses
are excluded.
- These PRs do not establish production or staging qualification for
every provider and login method.
## Model Used
OpenAI GPT-6 through Codex, with reasoning, repository tools, code
execution, and browser testing. The exact deployment model ID and
context window size were not exposed in this session.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip
---
doc/connections/AI-CONNECTIONS.md | 174 +++++--
.../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 ++
.../src/migrations/0306_familiar_titania.sql | 4 +
...{0301_snapshot.json => 0306_snapshot.json} | 380 +++++++++++++-
packages/db/src/migrations/meta/_journal.json | 7 +
.../db/src/schema/ai_connection_defaults.ts | 2 +-
.../db/src/schema/ai_provider_defaults.ts | 2 +-
.../runner-core/src/acpx_sidecar_transport.rs | 28 +-
.../crates/runner-core/src/codex_provider.rs | 3 +
.../src/contracts/completion-result.test.ts | 27 +
.../src/contracts/completion-result.ts | 19 +-
.../durable-prp-control-plane.test.ts | 6 +
.../durable-prp-control-plane.ts | 11 +-
.../src/drivers/acpx/environment.test.ts | 18 +
.../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 | 212 +++++++-
.../opencode/opencode-server-driver.ts | 417 ++++++++++------
.../runtime-context-materializer.test.ts | 29 ++
.../drivers/runtime-context-materializer.ts | 27 +
.../src/live/runnerd-codex-transport.ts | 2 +
.../src/protocol/result-normalization.test.ts | 30 ++
.../src/protocol/result-normalization.ts | 6 +
packages/shared/src/ai-connections.ts | 43 +-
packages/shared/src/ai-provider-routing.ts | 158 ++++++
.../shared/src/app-definitions-url.test.ts | 11 +-
.../shared/src/app-definitions.generated.ts | 8 +-
packages/shared/src/app-definitions.test.ts | 11 +-
packages/shared/src/app-definitions.ts | 3 +-
.../shared/src/app-definitions/anthropic.json | 3 +
.../shared/src/app-definitions/bedrock.json | 53 ++
.../app-definitions/chat-completions-api.json | 53 ++
.../shared/src/app-definitions/google.json | 55 ++
.../shared/src/app-definitions/local.json | 53 ++
.../src/app-definitions/messages-api.json | 53 ++
.../shared/src/app-definitions/openai.json | 3 +
.../src/app-definitions/openrouter.json | 3 +
.../src/app-definitions/responses-api.json | 53 ++
packages/shared/src/app-definitions/xai.json | 3 +
packages/shared/src/index.ts | 1 +
packages/shared/src/types/app-definition.ts | 2 +-
.../shared/src/validators/app-definition.ts | 2 +-
scripts/ingest-app-definitions.mjs | 37 +-
.../agent-hire-ai-connections.test.ts | 4 +-
server/src/__tests__/ai-connections.test.ts | 200 +++++++-
.../__tests__/ai-legacy-compatibility.test.ts | 47 +-
.../src/__tests__/ai-provider-routing.test.ts | 152 ++++++
.../codex-local-adapter-environment.test.ts | 26 +-
.../__tests__/local-ai-credentials.test.ts | 34 +-
.../src/__tests__/tool-access-service.test.ts | 11 +-
server/src/routes/agents.ts | 5 +-
server/src/routes/ai-connections.ts | 37 +-
server/src/routes/openapi.ts | 14 +-
.../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 | 58 ++-
.../src/services/ai-connection-usage.test.ts | 2 +-
server/src/services/ai-connection-usage.ts | 2 +-
server/src/services/ai-connections.ts | 36 +-
server/src/services/ai-provider-routing.ts | 76 +++
.../heartbeat-runner-provider-config.test.ts | 9 +
server/src/services/heartbeat.ts | 1 +
.../services/local-ai-browser-login.test.ts | 92 ++++
server/src/services/local-ai-browser-login.ts | 108 ++++
server/src/services/local-ai-credentials.ts | 46 +-
server/src/services/local-ai-login.ts | 80 ++-
.../native-runtime/provider-profile.ts | 5 +-
server/src/services/openrouter-models.test.ts | 10 +-
server/src/services/openrouter-models.ts | 4 +-
ui/public/brands/apps/bedrock.svg | 1 +
ui/public/brands/apps/google.svg | 1 +
ui/public/brands/apps/manifest.json | 36 ++
ui/src/api/ai-connections.ts | 1 +
ui/src/components/AdapterLoginChrome.tsx | 45 +-
ui/src/components/OnboardingWizard.test.tsx | 54 +-
ui/src/components/OnboardingWizard.tsx | 28 +-
.../AiConnectionCredentialStep.tsx | 22 +-
.../ai-connections/AiConnectionField.test.tsx | 39 +-
.../ai-connections/AiConnectionField.tsx | 40 +-
.../ai-connections/AiConnectionSelect.tsx | 192 +++++++
.../ai-connections/AiProviderSetup.test.tsx | 140 ++++++
.../ai-connections/AiProviderSetup.tsx | 468 ++++++++++++++++++
ui/src/components/ai-connections/model.ts | 1 +
.../ai-connections/useLocalAiLogin.test.tsx | 55 +-
.../ai-connections/useLocalAiLogin.ts | 30 +-
.../AgentProviderConnection.test.tsx | 23 +-
ui/src/components/ui/select.tsx | 11 +-
.../connections/ConnectionChoiceList.tsx | 5 +-
.../ConnectionIntentInteractionBody.test.tsx | 26 +
.../ConnectionIntentInteractionBody.tsx | 9 +-
.../connections/ConnectionSetupFlow.tsx | 1 +
ui/src/pages/NewAgent.test.tsx | 17 +-
ui/src/pages/apps/AppsConnect.tsx | 26 +-
99 files changed, 3965 insertions(+), 478 deletions(-)
create mode 100644 packages/db/src/migrations/0306_familiar_titania.sql
rename packages/db/src/migrations/meta/{0301_snapshot.json => 0306_snapshot.json} (99%)
create mode 100644 packages/shared/src/ai-provider-routing.ts
create mode 100644 packages/shared/src/app-definitions/bedrock.json
create mode 100644 packages/shared/src/app-definitions/chat-completions-api.json
create mode 100644 packages/shared/src/app-definitions/google.json
create mode 100644 packages/shared/src/app-definitions/local.json
create mode 100644 packages/shared/src/app-definitions/messages-api.json
create mode 100644 packages/shared/src/app-definitions/responses-api.json
create mode 100644 server/src/__tests__/ai-provider-routing.test.ts
create mode 100644 server/src/services/ai-provider-routing.ts
create mode 100644 server/src/services/local-ai-browser-login.test.ts
create mode 100644 server/src/services/local-ai-browser-login.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/ai-connections/AiConnectionSelect.tsx
create mode 100644 ui/src/components/ai-connections/AiProviderSetup.test.tsx
create mode 100644 ui/src/components/ai-connections/AiProviderSetup.tsx
diff --git a/doc/connections/AI-CONNECTIONS.md b/doc/connections/AI-CONNECTIONS.md
index d31ce5fea9..ac56651ea2 100644
--- a/doc/connections/AI-CONNECTIONS.md
+++ b/doc/connections/AI-CONNECTIONS.md
@@ -20,16 +20,17 @@ 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
ACP profiles. Connections creation and reconnect mount `AgentProviderConnection`,
the same provider tiles, method controls, API entry, and `AdapterLoginPanel` used
by agent setup. Supported sandbox environments use onboarding's existing browser
-sign-in controllers. Self-hosted installations use the shared terminal sign-in
-instructions described below and require no sandbox. Environment selection does
-not change agent execution settings.
+sign-in controllers. Self-hosted Claude and Codex installations use the same
+browser sign-in presentation with a local login runner and require no sandbox.
+Environment selection does not change agent execution settings.
API keys are validated against fixed provider endpoints; redirects
and caller-supplied validation URLs are rejected.
@@ -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.
@@ -374,19 +376,16 @@ the server preserves the managed binding and will not restore legacy fallback.
Local installations do not need a sandbox to connect a subscription. Connections,
onboarding, and agent setup share `LocalProviderLoginInstructions` and
-`useLocalAiLogin`. In local-trusted mode, Claude checks the operator’s existing
-Claude Code login. Authenticated self-hosted users instead get a separate
-`CLAUDE_CONFIG_DIR` for `claude auth login`; checking and saving only read that
-attempt’s credential files, never the server operator’s account or Keychain.
-
-Codex and Grok start a separate terminal sign-in for each connection or reconnect.
-The shared component shows a server-generated command with a fresh `CODEX_HOME`
-or `GROK_HOME`. Codex uses file credential storage in that home and `login --device-auth`, so
-signing in from another computer does not depend on a localhost callback. The home is never
-seeded with the operator's existing login: copying a rotating refresh token would
+`useLocalAiLogin`. Claude and Codex start a local provider process behind the
+browser sign-in card. Claude accepts the authorization code in that card; Codex
+displays its device code there. The user does not run a shell command. Each
+local runner requires Python 3 for its pseudo-terminal and the corresponding
+provider CLI on the Paperclip host. Each
+attempt retains a private credential home. The home is never seeded with the
+operator's existing login: copying a rotating refresh token would
allow managed runs to invalidate credentials still used by legacy agents or the
-operator's terminal. The user completes browser sign-in from that command, then
-clicks Connect. This does not require a sandbox or change the host login.
+operator's terminal. The user completes browser sign-in, then clicks Connect.
+Grok retains its terminal sign-in flow until it has a local browser login runner.
Attempts reuse `adapter_auth_sessions`, binding company, owner, provider, access
intent, reconnect target, and a 30-minute expiry. Validation and completion are
@@ -399,8 +398,7 @@ subsequently update only that grant. Reconnect preserves IDs and access settings
Starting an isolated attempt requires normal company-scoped AI-connection creation
permission. Checks, completion, cancellation, and resumption are owner-bound.
Authenticated users cannot import host credentials or use another user’s attempt.
-Claude Keychain reads remain limited to the explicit local-trusted default-home import. A failed verification creates
-no healthy connection. Preview-era Codex/Grok managed connections without the
+A failed verification creates no healthy connection. Preview-era Codex/Grok managed connections without the
isolated-subscription marker require reconnect before another managed execution;
unmanaged legacy agents retain their existing authentication paths.
@@ -439,16 +437,13 @@ authentication with a live account.
Local subscription screens share the same credential check on entry and when the
window regains focus. Waiting screens also poll until sign-in verifies. A successful
check shows the account is signed in; only **Connect** creates or reconnects the grant.
-In local-trusted mode, Claude checks the local operator’s Claude Code login.
-Authenticated Claude users, plus all Codex and Grok users, check only their
-connection-specific login home. The health response selects credential isolation,
-not whether a self-hosted user may sign in.
+Claude, Codex, and Grok check only their connection-specific login home. The
+health response selects whether a self-hosted user may sign in.
Leaving and returning to a local sign-in screen resumes its active attempt. Navigation
-does not delete a directory referenced by a copied command. **Start sign-in again**
+does not delete its credential home. **Start sign-in again**
explicitly cancels the old attempt; abandoned attempts expire after 30 minutes.
-Commands create their directory if necessary, and completed/expired attempts are
-cleaned up through the existing lifecycle.
+Completed and expired attempts are cleaned up through the existing lifecycle.
### Disposable live inline-repair test
@@ -463,7 +458,7 @@ provider key with `AI_REPAIR_TEST_KEY`. The test verifies these boundaries befor
revoking credentials or submitting work. Delete the disposable instance and revoke
its provider key after the test; failed tests may leave a paused task for inspection.
-Authenticated public deployments must configure a trusted runtime host (`PAPERCLIP_TRUSTED_MCP_RUNTIME_HOST` or `PAPERCLIP_TOOL_RUNTIME_TRUSTED_HOST`) before offering server-host subscription login, matching the local stdio runtime boundary. Health reports this capability so setup can offer a supported environment or API key instead of an unusable terminal command. Private authenticated self-hosted instances support isolated local login without that extra setting. Isolated Claude credential files must be private, owned by the server user, bounded, and free of symlinks.
+Authenticated public deployments must configure a trusted runtime host (`PAPERCLIP_TRUSTED_MCP_RUNTIME_HOST` or `PAPERCLIP_TOOL_RUNTIME_TRUSTED_HOST`) before offering server-host subscription login, matching the local stdio runtime boundary. Health reports this capability so setup can offer a supported environment or API key when local sign-in is unavailable. Private authenticated self-hosted instances support isolated local login without that extra setting. Isolated Claude credential files must be private, owned by the server user, bounded, and free of symlinks.
### Hiring and delegated work
@@ -512,3 +507,122 @@ 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)
+
+The connection API and catalog support OpenRouter, Amazon Bedrock, Google Gemini,
+Responses API, Messages API, Chat Completions API, and local endpoints.
+The catalog tags these entries `model-provider`. Responses-compatible gateways
+such as Emissary use the Responses API definition.
+
+Provider routing belongs to the connection; the model belongs to the agent.
+The API exposes compatible saved connections and optional model identifiers.
+The advanced setup UI and review stories ship in the follow-up UI change.
+Native subscription and API-key setup keep their existing controls.
+
+| 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 |
+| 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 |
+
+Migration `0306` adds Google to both account-default provider constraints. Local
+Gemini connections seed the API-key auth choice in their disposable home before
+environment probes and task execution. The settings file contains no credential.
+
+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
+catalog discovery for custom gateways are not part of this implementation.
+
+OpenRouter connections without an explicit model list automatically load its public
+[model catalog](https://openrouter.ai/docs/api/api-reference/models/list-all-models-and-their-properties),
+ordered with `sort=most-popular`. The company-scoped model discovery API preserves
+the provider's ordering and adapts model IDs to the selected harness. Explicit
+connection model lists take precedence. Catalog discovery sends no credentials.
+
+`config.ai.routing` stores only kind, protocol, URL, auth method, region, and
+optional model IDs/labels. The vault stores provider API keys, including Bedrock API keys.
+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.
+
+Native OpenCode custom gateways keep the reusable key in the runner process.
+The harness configuration contains a session-scoped loopback capability, limited
+to the configured model's Chat Completions endpoint. Streaming and provider error
+status are preserved; redirects are rejected. Closing or failing the harness
+revokes the capability and aborts outstanding requests. Upstream requests use
+session-local Node HTTP/HTTPS agents with the runtime's HTTP_PROXY, HTTPS_PROXY,
+ALL_PROXY fallback, NO_PROXY bypasses, and SSL_CERT_FILE/SSL_CERT_DIR trust. The
+harness bypasses outgoing proxies for loopback broker/MCP calls. This bounds key exposure
+from shell tools reading the configuration; it is not an OS isolation boundary
+against a process debugger running as the runner user.
+
+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.
+A follow-up Codex/OpenRouter acceptance test ran a shell calculation, completed
+the task, then resumed from a new user message and completed a second shell
+calculation with the prior context. Reconnect coverage round-trips routing
+through PostgreSQL JSONB and verifies that credential rotation retains identity
+and agent access.
+General AWS access keys are not accepted or forwarded to Claude; use a Bedrock
+API key. Support for AWS roles requires a credential broker before it can ship.
+A subsequent OpenCode tool-use check reached OpenRouter but was denied terminal
+access; its follow-up ended with `process_lost`. Treat OpenCode tool-use acceptance
+as unresolved rather than inferring it from a completed task status.
+Live Bedrock verification subsequently passed with a short-lived Bedrock API key,
+region `us-east-1`, and `us.anthropic.claude-sonnet-4-6`. The saved connection
+passed **Run test**. Claude legacy and Claude New Runner each ran a terminal
+calculation, completed the task, and ran a context-dependent follow-up. Actual
+tool output was verified for all four successful runs. Private gateways still
+have deterministic mapping and validation coverage but need live verification
+in the target deployment. Short-lived Bedrock keys must be rotated before expiry.
+
+### Gateway completion compatibility
+
+Provider-facing `paperclip_finish` accepts an omitted or `null` continuation for
+`done`, `completed`, and `needs_review`. This supports gateways that require all
+declared tool properties to be present. Normalization removes only `null`;
+non-yielding tool calls still reject a continuation object, and `yielded` still
+requires a complete `response_wake` object.
+
+Task-card account repair uses the provider reconnect form for routed accounts,
+retaining the saved endpoint, protocol, model aliases, and connection identity.
diff --git a/packages/adapters/claude-local/src/server/test.ts b/packages/adapters/claude-local/src/server/test.ts
index b7cf5001b4..2aefa947fc 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 0994994e94..0679b0c7b9 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 0462ad5a0c..2b8b1a97ed 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 44e9453c45..867dd2a6ba 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 b75ce020ca..3fd8402efd 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/db/src/migrations/0306_familiar_titania.sql b/packages/db/src/migrations/0306_familiar_titania.sql
new file mode 100644
index 0000000000..6375d384de
--- /dev/null
+++ b/packages/db/src/migrations/0306_familiar_titania.sql
@@ -0,0 +1,4 @@
+ALTER TABLE "ai_connection_defaults" DROP CONSTRAINT IF EXISTS "ai_connection_defaults_provider_check";--> statement-breakpoint
+ALTER TABLE "ai_provider_defaults" DROP CONSTRAINT IF EXISTS "ai_provider_defaults_provider_check";--> statement-breakpoint
+ALTER TABLE "ai_connection_defaults" ADD CONSTRAINT "ai_connection_defaults_provider_check" CHECK ("ai_connection_defaults"."provider" in ('anthropic','openai','openrouter','xai','google'));--> statement-breakpoint
+ALTER TABLE "ai_provider_defaults" ADD CONSTRAINT "ai_provider_defaults_provider_check" CHECK ("ai_provider_defaults"."provider" in ('anthropic','openai','openrouter','xai','google'));
diff --git a/packages/db/src/migrations/meta/0301_snapshot.json b/packages/db/src/migrations/meta/0306_snapshot.json
similarity index 99%
rename from packages/db/src/migrations/meta/0301_snapshot.json
rename to packages/db/src/migrations/meta/0306_snapshot.json
index 6a4bf1f5c1..e36467ec39 100644
--- a/packages/db/src/migrations/meta/0301_snapshot.json
+++ b/packages/db/src/migrations/meta/0306_snapshot.json
@@ -1,6 +1,6 @@
{
- "id": "357aa875-870e-4232-9b3f-4194a2c74844",
- "prevId": "dfa5064e-7b35-4d20-8a3d-aa808c4d92e0",
+ "id": "164deda4-7a03-45fb-8236-d6ea0c43da11",
+ "prevId": "392e3276-35d9-41b8-b703-9d592081bae8",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -2894,7 +2894,7 @@
"checkConstraints": {
"ai_connection_defaults_provider_check": {
"name": "ai_connection_defaults_provider_check",
- "value": "\"ai_connection_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai')"
+ "value": "\"ai_connection_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai','google')"
},
"ai_connection_defaults_method_check": {
"name": "ai_connection_defaults_method_check",
@@ -3367,7 +3367,7 @@
"checkConstraints": {
"ai_provider_defaults_provider_check": {
"name": "ai_provider_defaults_provider_check",
- "value": "\"ai_provider_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai')"
+ "value": "\"ai_provider_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai','google')"
}
},
"isRLSEnabled": false
@@ -51112,6 +51112,142 @@
"checkConstraints": {},
"isRLSEnabled": false
},
+ "public.mcp_event_admissions": {
+ "name": "mcp_event_admissions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "subscription_id": {
+ "name": "subscription_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "grant_id": {
+ "name": "grant_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reserves_subscription": {
+ "name": "reserves_subscription",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "mcp_event_admissions_expiry_idx": {
+ "name": "mcp_event_admissions_expiry_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "mcp_event_admissions_grant_idx": {
+ "name": "mcp_event_admissions_grant_idx",
+ "columns": [
+ {
+ "expression": "grant_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "mcp_event_admissions_company_idx": {
+ "name": "mcp_event_admissions_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "mcp_event_admissions_company_id_companies_id_fk": {
+ "name": "mcp_event_admissions_company_id_companies_id_fk",
+ "tableFrom": "mcp_event_admissions",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "mcp_event_admissions_grant_id_mcp_oauth_grants_id_fk": {
+ "name": "mcp_event_admissions_grant_id_mcp_oauth_grants_id_fk",
+ "tableFrom": "mcp_event_admissions",
+ "tableTo": "mcp_oauth_grants",
+ "columnsFrom": [
+ "grant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
"public.mcp_event_deliveries": {
"name": "mcp_event_deliveries",
"schema": "",
@@ -51565,6 +51701,13 @@
"primaryKey": false,
"notNull": true
},
+ "grant_types": {
+ "name": "grant_types",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[\"authorization_code\",\"refresh_token\"]'::jsonb"
+ },
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
@@ -51581,6 +51724,181 @@
"checkConstraints": {},
"isRLSEnabled": false
},
+ "public.mcp_oauth_device_requests": {
+ "name": "mcp_oauth_device_requests",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "device_code_hash": {
+ "name": "device_code_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_code_hash": {
+ "name": "user_code_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resource": {
+ "name": "resource",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "requested_company_id": {
+ "name": "requested_company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_hash": {
+ "name": "source_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "grant_id": {
+ "name": "grant_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "interval_seconds": {
+ "name": "interval_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 5
+ },
+ "next_poll_at": {
+ "name": "next_poll_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "mcp_oauth_device_code_uq": {
+ "name": "mcp_oauth_device_code_uq",
+ "columns": [
+ {
+ "expression": "device_code_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "mcp_oauth_user_code_uq": {
+ "name": "mcp_oauth_user_code_uq",
+ "columns": [
+ {
+ "expression": "user_code_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "mcp_oauth_device_expiry_idx": {
+ "name": "mcp_oauth_device_expiry_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "mcp_oauth_device_requests_client_id_mcp_oauth_clients_id_fk": {
+ "name": "mcp_oauth_device_requests_client_id_mcp_oauth_clients_id_fk",
+ "tableFrom": "mcp_oauth_device_requests",
+ "tableTo": "mcp_oauth_clients",
+ "columnsFrom": [
+ "client_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "mcp_oauth_device_requests_grant_id_mcp_oauth_grants_id_fk": {
+ "name": "mcp_oauth_device_requests_grant_id_mcp_oauth_grants_id_fk",
+ "tableFrom": "mcp_oauth_device_requests",
+ "tableTo": "mcp_oauth_grants",
+ "columnsFrom": [
+ "grant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
"public.mcp_oauth_grants": {
"name": "mcp_oauth_grants",
"schema": "",
@@ -51706,6 +52024,54 @@
"checkConstraints": {},
"isRLSEnabled": false
},
+ "public.mcp_oauth_metadata_admissions": {
+ "name": "mcp_oauth_metadata_admissions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "source_hash": {
+ "name": "source_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "mcp_oauth_metadata_admissions_expiry_idx": {
+ "name": "mcp_oauth_metadata_admissions_expiry_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
"public.mcp_oauth_requests": {
"name": "mcp_oauth_requests",
"schema": "",
@@ -51752,6 +52118,12 @@
"primaryKey": false,
"notNull": true
},
+ "requested_company_id": {
+ "name": "requested_company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
"grant_id": {
"name": "grant_id",
"type": "uuid",
diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json
index bb045d8e1d..d8885a5c32 100644
--- a/packages/db/src/migrations/meta/_journal.json
+++ b/packages/db/src/migrations/meta/_journal.json
@@ -2129,6 +2129,13 @@
"when": 1791300719193,
"tag": "0305_chubby_vin_gonzales",
"breakpoints": true
+ },
+ {
+ "idx": 306,
+ "version": "7",
+ "when": 1791308714862,
+ "tag": "0306_familiar_titania",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/db/src/schema/ai_connection_defaults.ts b/packages/db/src/schema/ai_connection_defaults.ts
index 3dd1a7463b..4b2c5c7b2c 100644
--- a/packages/db/src/schema/ai_connection_defaults.ts
+++ b/packages/db/src/schema/ai_connection_defaults.ts
@@ -42,7 +42,7 @@ export const aiConnectionDefaults = pgTable(
}),
check(
"ai_connection_defaults_provider_check",
- sql`${t.provider} in ('anthropic','openai','openrouter','xai')`,
+ sql`${t.provider} in ('anthropic','openai','openrouter','xai','google')`,
),
check(
"ai_connection_defaults_method_check",
diff --git a/packages/db/src/schema/ai_provider_defaults.ts b/packages/db/src/schema/ai_provider_defaults.ts
index 148e2e2dc8..b73dbff1cc 100644
--- a/packages/db/src/schema/ai_provider_defaults.ts
+++ b/packages/db/src/schema/ai_provider_defaults.ts
@@ -16,5 +16,5 @@ export const aiProviderDefaults = pgTable("ai_provider_defaults", {
}, (t) => [
uniqueIndex("ai_provider_defaults_owner_provider_uq").on(t.companyId, t.userId, t.provider),
foreignKey({ columns: [t.companyId, t.grantId], foreignColumns: [connectionGrants.companyId, connectionGrants.id], name: "ai_provider_defaults_company_grant_fk" }),
- check("ai_provider_defaults_provider_check", sql`${t.provider} in ('anthropic','openai','openrouter','xai')`),
+ check("ai_provider_defaults_provider_check", sql`${t.provider} in ('anthropic','openai','openrouter','xai','google')`),
]);
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 9bb2c3b6c0..61d66307dc 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,17 @@ 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_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 +145,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 bf6d79a874..e5a93bf12b 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/contracts/completion-result.test.ts b/packages/paperclip-runner/src/contracts/completion-result.test.ts
index 065b68c0be..796791e904 100644
--- a/packages/paperclip-runner/src/contracts/completion-result.test.ts
+++ b/packages/paperclip-runner/src/contracts/completion-result.test.ts
@@ -1,3 +1,4 @@
+import { normalizeLegacyPrpStructuredRunResult } from "../protocol/result-normalization.js";
import Ajv2020 from "ajv/dist/2020.js";
import { describe, expect, it } from "vitest";
import {
@@ -135,6 +136,19 @@ describe("provider-neutral completion result schema", () => {
expect(validate(response)).toBe(false);
});
+ it.each(["done", "needs_review", "completed"])("accepts explicit no-continuation at the provider boundary for %s", (disposition) => {
+ const providerValidate = new Ajv2020({ allErrors: true, strict: false })
+ .compile(PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA);
+ const response = {
+ ...structuredClone(baseResult),
+ reportedWorkDisposition: disposition,
+ continuation: null,
+ };
+ expect(providerValidate(response)).toBe(true);
+ // The canonical output remains strict; normalization removes only null.
+ expect(validate(response)).toBe(false);
+ });
+
it("exposes concrete completion fields while retaining response-wake validation", () => {
// The live Codex code-mode renderer reduced a conditional-only root allOf
// to `args: unknown`. Keep this tool object-shaped for provider discovery.
@@ -158,6 +172,7 @@ describe("provider-neutral completion result schema", () => {
};
expect(providerValidate(yielded)).toBe(true);
expect(providerValidate({ ...yielded, continuation: undefined })).toBe(false);
+ expect(providerValidate({ ...yielded, continuation: null })).toBe(false);
expect(providerValidate({ ...yielded, continuation: { kind: "response_wake" } })).toBe(false);
expect(providerValidate({
...yielded, continuation: { ...yielded.continuation, kind: "same_agent" },
@@ -176,6 +191,18 @@ describe("provider-neutral completion result schema", () => {
expect(providerValidate(providerResult)).toBe(true);
});
+ it.each([undefined, null, "", " ", "artifact:verified-result"])("normalizes optional verification artifact metadata (%s)", (artifactRef) => {
+ const providerValidate = new Ajv2020({ strict: false }).compile(PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA);
+ const input = { ...structuredClone(baseResult), verification: [{ commandOrCheck: "Check answer", status: "passed", artifactRef }] };
+ expect(providerValidate(input)).toBe(true);
+ const normalized = normalizeLegacyPrpStructuredRunResult(input);
+ expect(validate(normalized)).toBe(true);
+ expect(normalized).toMatchObject({ verification: [{ commandOrCheck: "Check answer", status: "passed" }] });
+ if (artifactRef?.trim()) expect(normalized).toHaveProperty("verification.0.artifactRef", artifactRef);
+ else expect(normalized).not.toHaveProperty("verification.0.artifactRef");
+ expect(providerValidate({ ...input, verification: [{ ...input.verification[0], artifactRef: 42 }] })).toBe(false);
+ });
+
it("requires a reason code for verification that was not run", () => {
const result = structuredClone(baseResult);
result.verification = [{ commandOrCheck: "Run tests", status: "not_run" } as never];
diff --git a/packages/paperclip-runner/src/contracts/completion-result.ts b/packages/paperclip-runner/src/contracts/completion-result.ts
index b727a94296..87d8a34a29 100644
--- a/packages/paperclip-runner/src/contracts/completion-result.ts
+++ b/packages/paperclip-runner/src/contracts/completion-result.ts
@@ -273,7 +273,10 @@ const providerVerificationCompatibilitySchema = {
detail: { type: "string" },
result: { type: "string" },
cwd: { type: "string" },
- artifactRef: { type: "string", minLength: 1 },
+ artifactRef: {
+ type: ["string", "null"],
+ description: "Reference to a real verification artifact. Omit or use null when no artifact exists; empty values are normalized away.",
+ },
},
},
} as const;
@@ -360,15 +363,23 @@ export const PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA = {
reportedWorkDisposition: { enum: ["done", "needs_review", "yielded", "completed"] },
verification: providerVerificationCompatibilitySchema,
attentionRequests: providerAttentionCompatibilitySchema,
- continuation: responseWakeContinuationSchema,
+ // Responses-compatible gateways can require every declared property in
+ // tool calls. Give non-yielding results an explicit absence value instead
+ // of forcing callers to invent a response-wake continuation.
+ continuation: {
+ ...responseWakeContinuationSchema,
+ type: ["object", "null"],
+ description:
+ "Use null or omit this field for done, completed, or needs_review. Only yielded requires a response-wake object with kind, summary, and idempotencyKey.",
+ },
},
// Keep the provider-facing root a concrete object. Codex code-mode renders a
// root allOf containing only an if/then constraint as `args: unknown`, hiding
// every required field from the model. This equivalent direct conditional
// preserves validation without obscuring the object-shaped tool signature.
if: { properties: { reportedWorkDisposition: { const: "yielded" } }, required: ["reportedWorkDisposition"] },
- then: { required: ["continuation"] },
- else: { not: { required: ["continuation"] } },
+ then: { required: ["continuation"], properties: { continuation: { type: "object" } } },
+ else: { properties: { continuation: { type: "null" } } },
} as const;
export const PRP_BLOCK_RESULT_PROVIDER_INPUT_SCHEMA = {
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 03ba01a956..9492b21b60 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
@@ -391,6 +391,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",
@@ -420,6 +423,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 c7f8cc9e87..bbf604d4dc 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,
@@ -3371,6 +3371,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,
@@ -3428,6 +3432,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_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 d1e749a519..5449b7791f 100644
--- a/packages/paperclip-runner/src/drivers/acpx/environment.test.ts
+++ b/packages/paperclip-runner/src/drivers/acpx/environment.test.ts
@@ -5,6 +5,24 @@ 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",
+ AWS_ACCESS_KEY_ID: "general-aws-key", AWS_SECRET_ACCESS_KEY: "general-aws-secret", AWS_SESSION_TOKEN: "general-aws-session",
+ 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 a78fdcbe26..3ce0790542 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 96d277dcef..4184701c56 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 7c86f2bb35..154b7df2fc 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 315de03875..59d740d994 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
@@ -5,11 +5,17 @@ import {
mkdtemp,
readFile,
readdir,
+ realpath,
rm,
stat,
+ symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
+import { createServer as createHttpServer } from "node:http";
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+import { getCACertificates } from "node:tls";
import { join, resolve } from "node:path";
import { afterAll, afterEach, describe, expect, it } from "vitest";
@@ -611,6 +617,130 @@ 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: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+\/v1$/), apiKey: expect.any(String) }, models: { "team/model-alias": { name: "team/model-alias" } } } },
+ });
+ expect((await stat(configPath)).mode & 0o777).toBe(0o600);
+ const shell = await promisify(execFile)("sh", ["-c", 'cat "$1"', "sh", configPath]);
+ expect(shell.stdout).not.toContain("selected-gateway-key");
+ const environment = JSON.parse(await readFile(join(root, "routing", "data", "fake-environment.json"), "utf8"));
+ expect(environment.keys).not.toContain("PAPERCLIP_AI_PROVIDER_KEY");
+ } finally {
+ await session.close({ reason: "test" });
+ }
+ });
+
+ it.each(["gateway-key", ""])("forwards selected-model streams without exposing the reusable key and revokes the proxy on close (%s)", async key => {
+ const received: Array<{ path: string; authorization?: string; body: unknown }> = [];
+ const upstream = createHttpServer((request, response) => {
+ void (async () => {
+ const chunks = [];
+ for await (const chunk of request) chunks.push(Buffer.from(chunk));
+ const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
+ received.push({ path: request.url!, authorization: request.headers.authorization, body });
+ response.writeHead(200, { "content-type": "text/event-stream", "x-provider-private-header": "private" });
+ response.write('data: {"choices":[]}\n\n');
+ if (body.messages[0].content !== "hold") response.end('data: [DONE]\n\n');
+ })().catch(() => response.destroy());
+ });
+ await new Promise(resolve => upstream.listen(0, "127.0.0.1", resolve));
+ const address = upstream.address();
+ if (!address || typeof address === "string") throw new Error("Missing fixture address");
+ const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-proxy-"));
+ roots.push(root);
+ let session: Awaited> | undefined;
+ try {
+ const driver = new OpenCodeServerDriver({
+ model: "paperclip/team/model-alias", runtimeDirectory: root, command: fixture,
+ environment: { PATH: process.env.PATH, PAPERCLIP_AI_PROVIDER_URL: `http://127.0.0.1:${address.port}/custom/v1`, PAPERCLIP_AI_PROVIDER_KEY: key },
+ });
+ session = await driver.openSession({ runId: "proxy", normalizedSessionId: "proxy", workingDirectory: root });
+ const config = JSON.parse(await readFile(join(root, "proxy", "config", "opencode", "opencode.json"), "utf8"));
+ const { baseURL, apiKey } = config.provider.paperclip.options;
+ expect(apiKey).not.toBe(key);
+ const url = `${baseURL}/chat/completions`;
+ const headers = { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" };
+ const payload = { model: "team/model-alias", stream: true, messages: [{ role: "user", content: "test" }] };
+ expect((await fetch(url, { method: "POST", body: JSON.stringify(payload) })).status).toBe(401);
+ expect((await fetch(`${baseURL}/models`, { headers })).status).toBe(404);
+ expect((await fetch(url, { method: "POST", headers, body: JSON.stringify({ ...payload, model: "other" }) })).status).toBe(400);
+ expect(received).toHaveLength(0);
+ const result = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) });
+ expect(result.headers.get("x-provider-private-header")).toBeNull();
+ expect(await result.text()).toBe('data: {"choices":[]}\n\ndata: [DONE]\n\n');
+ expect(received).toEqual([{ path: "/custom/v1/chat/completions", authorization: key ? `Bearer ${key}` : undefined, body: payload }]);
+ const pending = await fetch(url, { method: "POST", headers, body: JSON.stringify({ ...payload, messages: [{ role: "user", content: "hold" }] }) });
+ const pendingText = pending.text().then(() => "unexpected completion", () => "aborted");
+ await session.close({ reason: "test" });
+ session = undefined;
+ expect(await pendingText).toBe("aborted");
+ await expect(fetch(url, { method: "POST", headers, body: JSON.stringify(payload) })).rejects.toThrow();
+ } finally {
+ await session?.close({ reason: "test" });
+ await new Promise(resolve => { upstream.close(() => resolve()); upstream.closeAllConnections(); });
+ }
+ });
+
+ it.each([["HTTP_PROXY", "file"], ["ALL_PROXY", "directory"]] as const)("uses the runtime's %s, %s trust, and NO_PROXY without changing global transport", async (proxySetting, trust) => {
+ const requests: string[] = [];
+ const outgoingProxy = createHttpServer((request, response) => {
+ requests.push(request.url!);
+ request.resume();
+ response.writeHead(200, { "content-type": "application/json" }).end('{"choices":[]}');
+ });
+ await new Promise(resolve => outgoingProxy.listen(0, "127.0.0.1", resolve));
+ const address = outgoingProxy.address();
+ if (!address || typeof address === "string") throw new Error("Missing proxy fixture address");
+ const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-outgoing-proxy-"));
+ roots.push(root);
+ const certificateDir = join(root, "certificates");
+ await mkdir(certificateDir);
+ const certificatePath = join(certificateDir, "runtime-ca.pem");
+ await writeFile(certificatePath, getCACertificates("default")[0]!);
+ await writeFile(join(certificateDir, "README"), "Non-certificate directory entries must be ignored.");
+ const trustEnvironment = trust === "file" ? { SSL_CERT_FILE: certificatePath } : { SSL_CERT_DIR: certificateDir };
+ const headersAndUrl = async (sessionId: string, noProxy: string) => {
+ const driver = new OpenCodeServerDriver({
+ model: "paperclip/team/model-alias", runtimeDirectory: root, command: fixture,
+ environment: { ...trustEnvironment, PATH: process.env.PATH, PAPERCLIP_AI_PROVIDER_URL: "http://gateway.invalid/v1", PAPERCLIP_AI_PROVIDER_KEY: "fixture-key", [proxySetting]: `http://127.0.0.1:${address.port}`, NO_PROXY: noProxy },
+ });
+ const session = await driver.openSession({ runId: sessionId, normalizedSessionId: sessionId, workingDirectory: root });
+ const config = JSON.parse(await readFile(join(root, sessionId, "config", "opencode", "opencode.json"), "utf8"));
+ return { session, url: `${config.provider.paperclip.options.baseURL}/chat/completions`, headers: { Authorization: `Bearer ${config.provider.paperclip.options.apiKey}`, "Content-Type": "application/json" } };
+ };
+ try {
+ const proxied = await headersAndUrl("proxied", "");
+ try {
+ const response = await fetch(proxied.url, { method: "POST", headers: proxied.headers, body: JSON.stringify({ model: "team/model-alias", messages: [] }) });
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({ choices: [] });
+ expect(requests).toEqual(["http://gateway.invalid/v1/chat/completions"]);
+ } finally { await proxied.session.close({ reason: "test" }); }
+ const bypassed = await headersAndUrl("bypassed", "gateway.invalid");
+ try {
+ const response = await fetch(bypassed.url, { method: "POST", headers: bypassed.headers, body: JSON.stringify({ model: "team/model-alias", messages: [] }) });
+ expect(response.status).toBe(502);
+ expect(requests).toHaveLength(1);
+ } finally { await bypassed.session.close({ reason: "test" }); }
+ } finally {
+ await new Promise(resolve => { outgoingProxy.close(() => resolve()); outgoingProxy.closeAllConnections(); });
+ }
+ });
+
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-"));
@@ -781,7 +911,7 @@ describe("OpenCodeServerDriver", () => {
);
expect(config).toContain("openrouter/deepseek/deepseek-v4-flash-0731");
expect(config).toContain('"*": "allow"');
- expect(config).toContain('"external_directory": "deny"');
+ expect(JSON.parse(config).permission.external_directory).toMatchObject({ "*": "deny", [`${workspace}/**`]: "allow" });
expect(
events.some((event) => event.eventType === "runtime_request.created"),
).toBe(false);
@@ -1314,8 +1444,10 @@ describe("OpenCodeServerDriver", () => {
}
expect(submittedPrompt).toMatchObject({
system: systemInstructions,
- tools: { question: true },
});
+ // A prompt tools map replaces OpenCode's session permissions. Question is
+ // enabled in config without overwriting the allow/ask/deny or path policy.
+ expect(submittedPrompt).not.toHaveProperty("tools");
expect(JSON.stringify(submittedPrompt?.parts ?? null)).not.toContain(
PAPERCLIP_EXECUTION_PROMPT,
);
@@ -1413,7 +1545,6 @@ describe("OpenCodeServerDriver", () => {
expect(submittedPrompt).toMatchObject({
providerID: "openrouter",
modelID: "deepseek/deepseek-v4-flash-0731",
- tools: { question: true },
parts: [
{
type: "text",
@@ -1421,6 +1552,7 @@ describe("OpenCodeServerDriver", () => {
},
],
});
+ expect(submittedPrompt).not.toHaveProperty("tools");
expect(submittedPrompt).not.toHaveProperty("system");
await recovered!.session!.close({ reason: "recovery-test" });
});
@@ -1808,7 +1940,7 @@ describe("OpenCodeServerDriver", () => {
);
expect(config.permission).toMatchObject({
"*": permissionMode,
- external_directory: "deny",
+ external_directory: { "*": "deny", [`${workspace}/**`]: "allow" },
});
expect(config.provider.openrouter.models).toHaveProperty(
"deepseek/deepseek-v4-flash-0731",
@@ -1819,6 +1951,35 @@ describe("OpenCodeServerDriver", () => {
},
);
+ it.skipIf(process.platform === "win32")("allows the selected workspace alias and its canonical path while denying other directories", async () => {
+ await chmod(fixture, 0o755);
+ const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-workspace-alias-"));
+ roots.push(root);
+ const workspace = join(root, "actual-workspace");
+ const alias = join(root, "workspace-alias");
+ await mkdir(workspace);
+ await symlink(workspace, alias, "dir");
+ const canonical = await realpath(workspace);
+ const driver = new OpenCodeServerDriver({
+ model: "openrouter/deepseek/deepseek-v4-flash-0731",
+ permissionMode: "allow",
+ runtimeDirectory: root,
+ command: fixture,
+ environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" },
+ });
+ const session = await driver.openSession({
+ runId: "run-workspace-alias", normalizedSessionId: "alias-session",
+ workingDirectory: alias,
+ });
+ try {
+ const config = JSON.parse(await readFile(join(root, "alias-session", "config", "opencode", "opencode.json"), "utf8"));
+ expect(config.permission.external_directory).toEqual({
+ "*": "deny", [alias]: "allow", [`${alias}/**`]: "allow",
+ [canonical]: "allow", [`${canonical}/**`]: "allow",
+ });
+ } finally { await session.close({ reason: "workspace alias test complete" }); }
+ });
+
it("clears a stale active turn that already has a persisted terminal fingerprint", async () => {
await chmod(fixture, 0o755);
const root = await mkdtemp(
@@ -2319,16 +2480,20 @@ describe("OpenCodeServerDriver", () => {
const exitingFixture = join(root, "exit-before-health.mjs");
await writeFile(
exitingFixture,
- "#!/usr/bin/env node\nprocess.stderr.write(`credential=${process.env.OPENROUTER_API_KEY}\\nauthorization=super-secret-opencode-token\\n`);\nprocess.exit(17);\n",
+ "#!/usr/bin/env node\nimport { readFileSync } from 'node:fs';\nconst config = JSON.parse(readFileSync(`${process.env.XDG_CONFIG_HOME}/opencode/opencode.json`, 'utf8'));\nprocess.stderr.write(`credential=${process.env.OPENROUTER_API_KEY}\\ngateway=${config.provider.paperclip.options.apiKey}\\nauthorization=super-secret-opencode-token\\n`);\nprocess.exit(17);\n",
{ mode: 0o755 },
);
+ const diagnostics: string[] = [];
const driver = new OpenCodeServerDriver({
- model: "openrouter/deepseek/deepseek-v4-flash-0731",
+ model: "paperclip/team/model-alias",
runtimeDirectory: root,
command: exitingFixture,
+ onDiagnostic: (message) => { diagnostics.push(message); },
environment: {
PATH: process.env.PATH,
OPENROUTER_API_KEY: "fixture-key",
+ PAPERCLIP_AI_PROVIDER_KEY: "fixture-custom-gateway-key",
+ PAPERCLIP_AI_PROVIDER_URL: "https://gateway.example/v1",
},
});
const error = await driver
@@ -2346,6 +2511,41 @@ describe("OpenCodeServerDriver", () => {
expect(error).toContain("stage=health");
expect(error).toContain("[REDACTED]");
expect(error).not.toContain("fixture-key");
+ expect(error).not.toContain("fixture-custom-gateway-key");
expect(error).not.toContain("super-secret-opencode-token");
+ expect(diagnostics.join("")).toContain("gateway=[REDACTED]");
+ expect(diagnostics.join("")).not.toContain("fixture-custom-gateway-key");
+ });
+
+ it.each(["network", "response"])("redacts custom gateway keys in %s errors", async (failure) => {
+ await chmod(fixture, 0o755);
+ const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-"));
+ const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-workspace-"));
+ roots.push(root, workspace);
+ const key = "fixture-custom-gateway-key";
+ const driver = new OpenCodeServerDriver({
+ model: "paperclip/team/model-alias",
+ runtimeDirectory: root,
+ command: fixture,
+ environment: {
+ PATH: process.env.PATH,
+ PAPERCLIP_AI_PROVIDER_KEY: key,
+ PAPERCLIP_AI_PROVIDER_URL: "https://gateway.example/v1",
+ },
+ fetch: async (input, init) => {
+ if (String(input).endsWith("/session") && init?.method === "POST") {
+ if (failure === "network") throw new Error(`Gateway rejected ${key}`);
+ return new Response(`Gateway rejected ${key}`, { status: 400 });
+ }
+ return fetch(input, init);
+ },
+ });
+ const error = await driver.openSession({
+ runId: "run-gateway-error",
+ normalizedSessionId: "gateway-error",
+ workingDirectory: workspace,
+ }).then(() => "provider unexpectedly started", (cause: unknown) => String(cause));
+ expect(error).toContain("Gateway rejected [REDACTED]");
+ expect(error).not.toContain(key);
});
});
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 ab72e2cc4a..e82b068527 100644
--- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts
+++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts
@@ -5,10 +5,16 @@ import {
mkdir,
mkdtemp,
readFile,
+ readdir,
+ realpath,
rm,
writeFile,
} from "node:fs/promises";
import { createServer } from "node:net";
+import { Agent as HttpAgent, createServer as createHttpServer, request as requestHttp, type IncomingMessage } from "node:http";
+import { Agent as HttpsAgent, request as requestHttps } from "node:https";
+import { getCACertificates } from "node:tls";
+import { pipeline } from "node:stream/promises";
import { dirname, join, resolve } from "node:path";
import {
@@ -262,6 +268,7 @@ export class OpenCodeServerDriver implements HarnessDriver {
recovered: false,
reason: redact(String(error), [
this.#options.environment?.OPENROUTER_API_KEY,
+ this.#options.environment?.PAPERCLIP_AI_PROVIDER_KEY,
]),
};
}
@@ -610,10 +617,9 @@ class OpenCodeHarnessSession implements HarnessSession {
// at this HTTP boundary.
providerID,
modelID,
- // This exact OpenCode version passed the native-question conformance
- // suite. question.asked is adapted into PRP v2 and its reply/reject API
- // remains private to this driver.
- tools: { question: true },
+ // Keep question enabled in the isolated config. OpenCode 1.18.32
+ // turns a prompt's deprecated `tools` map into replacement session
+ // permissions, so a sparse override here discards the session policy.
...(this.#sendFullContext
? { system: this.#systemInstructions }
: {}),
@@ -1969,6 +1975,110 @@ class OpenCodeHarnessSession implements HarnessSession {
}
}
+/** Retain the reusable gateway key in the runner; the harness gets a session-scoped capability. */
+async function startOpenCodeProviderProxy(baseUrl: string, key: string, model: string, environment: NodeJS.ProcessEnv) {
+ const upstreamUrl = new URL(`${baseUrl.replace(/\/+$/, "")}/chat/completions`);
+ const token = randomBytes(32).toString("base64url");
+ // Agent-local settings keep one runtime's transport configuration out of other sessions.
+ const proxyEnv = {
+ HTTP_PROXY: environment.http_proxy ?? environment.HTTP_PROXY ?? environment.all_proxy ?? environment.ALL_PROXY,
+ HTTPS_PROXY: environment.https_proxy ?? environment.HTTPS_PROXY ?? environment.http_proxy ?? environment.HTTP_PROXY ?? environment.all_proxy ?? environment.ALL_PROXY,
+ NO_PROXY: environment.no_proxy ?? environment.NO_PROXY,
+ };
+ const ca = [...getCACertificates("default")];
+ if (environment.SSL_CERT_FILE) ca.push(await readFile(environment.SSL_CERT_FILE, "utf8"));
+ if (environment.SSL_CERT_DIR) {
+ for (const entry of await readdir(environment.SSL_CERT_DIR, { withFileTypes: true })) {
+ if (!entry.isDirectory()) {
+ const certificate = await readFile(join(environment.SSL_CERT_DIR, entry.name), "utf8");
+ if (certificate.includes("-----BEGIN CERTIFICATE-----")) ca.push(certificate);
+ }
+ }
+ }
+ const agent = upstreamUrl.protocol === "https:" ? new HttpsAgent({ proxyEnv, ca }) : new HttpAgent({ proxyEnv });
+ const controllers = new Set();
+ const server = createHttpServer((request, response) => {
+ const controller = new AbortController();
+ controllers.add(controller);
+ response.once("close", () => controller.abort());
+ void (async () => {
+ if (request.headers.authorization !== `Bearer ${token}`) {
+ response.writeHead(401).end();
+ return;
+ }
+ if (request.method !== "POST" || request.url !== "/v1/chat/completions") {
+ response.writeHead(404).end();
+ return;
+ }
+ const chunks: Buffer[] = [];
+ let bytes = 0;
+ for await (const chunk of request) {
+ bytes += chunk.length;
+ if (bytes > 16 * 1024 * 1024) {
+ response.writeHead(413).end();
+ return;
+ }
+ chunks.push(Buffer.from(chunk));
+ }
+ const body = Buffer.concat(chunks);
+ let payload: Record;
+ try { payload = JSON.parse(body.toString("utf8")); }
+ catch { response.writeHead(400).end(); return; }
+ if (!payload || typeof payload !== "object" || payload.model !== model) {
+ response.writeHead(400).end();
+ return;
+ }
+ const upstream = await new Promise((resolve, reject) => {
+ const outgoing = (upstreamUrl.protocol === "https:" ? requestHttps : requestHttp)(upstreamUrl, {
+ method: "POST",
+ agent,
+ headers: { "Content-Type": "application/json", "Content-Length": body.length, ...(key ? { Authorization: `Bearer ${key}` } : {}) },
+ signal: AbortSignal.any([controller.signal, AbortSignal.timeout(600_000)]),
+ }, resolve);
+ outgoing.once("error", reject);
+ outgoing.end(body);
+ });
+ // Never forward authentication to a redirect destination.
+ const status = upstream.statusCode ?? 502;
+ if (status >= 300 && status < 400) {
+ upstream.destroy();
+ throw new Error("Provider redirects are not supported");
+ }
+ const headers: Record = {};
+ for (const name of ["content-type", "content-encoding", "cache-control", "retry-after"]) {
+ const value = upstream.headers[name];
+ if (typeof value === "string") headers[name] = value;
+ }
+ response.writeHead(status, headers);
+ await pipeline(upstream, response);
+ })().catch(() => {
+ if (!response.headersSent) response.writeHead(502).end("Provider request failed");
+ else response.destroy();
+ }).finally(() => controllers.delete(controller));
+ });
+ try {
+ await new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); });
+ });
+ } catch (error) { agent.destroy(); throw error; }
+ const address = server.address();
+ if (!address || typeof address === "string") {
+ server.close();
+ agent.destroy();
+ throw new Error("Could not bind OpenCode provider proxy");
+ }
+ return {
+ baseURL: `http://127.0.0.1:${address.port}/v1`,
+ token,
+ close: async () => {
+ for (const controller of controllers) controller.abort();
+ agent.destroy();
+ await new Promise((resolve) => { server.close(() => resolve()); server.closeAllConnections(); });
+ },
+ };
+}
+
async function startRuntime(input: {
options: OpenCodeServerDriverOptions;
root: string;
@@ -2020,143 +2130,171 @@ async function startRuntime(input: {
const assignedMcp = nativeMcpLaunchBinding(
input.options.environment ?? process.env,
);
- input.trace?.addSensitiveValues([
+ const sensitiveValues = [
password,
authHeader,
bridge.secret,
assignedMcp?.token,
input.options.environment?.OPENROUTER_API_KEY,
- ]);
+ input.options.environment?.PAPERCLIP_AI_PROVIDER_KEY,
+ ].filter((value): value is string => Boolean(value));
+ input.trace?.addSensitiveValues(sensitiveValues);
const instructionRoot =
input.options.runtimeContext?.instructions.bundle.rootPath;
+ // OpenCode canonicalizes tool paths (for example /var -> /private/var on
+ // macOS). Permit the assigned workspace under either spelling; operations
+ // within it still obey the selected allow/ask/deny permission mode.
+ const externalDirectories: Record = { "*": "deny" };
+ for (const root of new Set([input.cwd, await realpath(input.cwd)])) {
+ externalDirectories[root] = "allow";
+ externalDirectories[`${root}/**`] = "allow";
+ }
+ if (instructionRoot) externalDirectories[`${instructionRoot}/**`] = "allow";
const [modelProvider, ...modelIdParts] = input.options.model.split("/");
const providerModelId = modelIdParts.join("/");
- const config = {
- $schema: "https://opencode.ai/config.json",
- model: input.options.model,
- small_model: input.options.model,
- share: "disabled",
- // The configured entry is already composed exactly once into the session
- // system prompt; siblings remain available through the read-only root.
- instructions: [],
- plugin: [],
- // OpenCode's bundled models.dev snapshot can lag behind OpenRouter's live
- // catalog. Bind the already-qualified exact model slug into the built-in
- // provider instead of silently falling back or rejecting a newer model.
- provider: {
- [modelProvider!]: {
- models: {
- [providerModelId]: { name: providerModelId },
+ const providerProxy = modelProvider === "paperclip" && input.options.environment?.PAPERCLIP_AI_PROVIDER_URL
+ ? await startOpenCodeProviderProxy(input.options.environment.PAPERCLIP_AI_PROVIDER_URL, input.options.environment.PAPERCLIP_AI_PROVIDER_KEY ?? "", providerModelId, input.options.environment).catch(async error => {
+ await bridge.close().catch(() => {});
+ await rm(isolatedHome, { recursive: true, force: true }).catch(() => {});
+ throw error;
+ })
+ : null;
+ if (providerProxy) {
+ sensitiveValues.push(providerProxy.token);
+ input.trace?.addSensitiveValues([providerProxy.token]);
+ }
+ let child: ChildProcess | undefined;
+ try {
+ const config = {
+ $schema: "https://opencode.ai/config.json",
+ model: input.options.model,
+ small_model: input.options.model,
+ share: "disabled",
+ // The configured entry is already composed exactly once into the session
+ // system prompt; siblings remain available through the read-only root.
+ instructions: [],
+ plugin: [],
+ // OpenCode's bundled models.dev snapshot can lag behind OpenRouter's live
+ // catalog. Bind the already-qualified exact model slug into the built-in
+ // provider instead of silently falling back or rejecting a newer model.
+ provider: {
+ [modelProvider!]: {
+ ...(providerProxy ? {
+ npm: "@ai-sdk/openai-compatible",
+ name: "Paperclip connection",
+ options: {
+ baseURL: providerProxy.baseURL,
+ apiKey: providerProxy.token,
+ },
+ } : {}),
+ models: {
+ [providerModelId]: { name: providerModelId },
+ },
},
},
- },
- tools: {
- question: true,
- },
- permission: {
- "*": input.options.permissionMode ?? "allow",
- question: "allow",
- "paperclip_*": "allow",
- "mcp__paperclip__*": "allow",
- external_directory: instructionRoot
- ? { "*": "deny", [`${instructionRoot}/**`]: "allow" }
- : "deny",
- },
- mcp: {
- paperclip: {
- type: "remote",
- url: bridge.url,
- enabled: true,
- oauth: false,
- headers: { Authorization: `Bearer ${bridge.secret}` },
- timeout: 30_000,
+ tools: {
+ question: true,
},
- ...(assignedMcp
- ? {
- [assignedMcp.name]: {
- type: "remote",
- url: assignedMcp.url,
- enabled: true,
- oauth: false,
- headers: { Authorization: `Bearer ${assignedMcp.token}` },
- timeout: 30_000,
- },
- }
- : {}),
- },
- };
- await writeFile(
- join(configHome, "opencode", "opencode.json"),
- `${JSON.stringify(config, null, 2)}\n`,
- { mode: 0o600 },
- );
- const environment = sanitizedEnvironment(
- input.options.environment ?? process.env,
- {
- HOME: isolatedHome,
- XDG_CONFIG_HOME: configHome,
- XDG_DATA_HOME: dataHome,
- XDG_CACHE_HOME: cacheHome,
- OPENCODE_DISABLE_PROJECT_CONFIG: "true",
- OPENCODE_SERVER_USERNAME: username,
- OPENCODE_SERVER_PASSWORD: password,
- },
- );
- const isolateProcessGroup = input.options.isolateProcessGroup ?? true;
- const stdio: Array<"ignore" | "pipe" | number> = ["ignore", "ignore", "pipe"];
- if (input.options.commandFd !== undefined) {
- while (stdio.length <= input.options.commandFd) stdio.push("ignore");
- stdio[input.options.commandFd] = input.options.commandFd;
- }
- input.options.commandLifecycle?.beforeSpawn();
- const child = spawn(
- input.options.command ?? "opencode",
- ["serve", "--hostname", "127.0.0.1", "--port", String(port)],
- {
- cwd: input.cwd,
- env: environment,
- stdio,
- detached: globalThis.process.platform !== "win32" && isolateProcessGroup,
- },
- );
- if (child.pid !== undefined) {
- try {
- input.options.commandLifecycle?.afterSpawn();
- } catch (error) {
- child.kill("SIGKILL");
- throw error;
+ permission: {
+ "*": input.options.permissionMode ?? "allow",
+ question: "allow",
+ "paperclip_*": "allow",
+ "mcp__paperclip__*": "allow",
+ external_directory: externalDirectories,
+ },
+ mcp: {
+ paperclip: {
+ type: "remote",
+ url: bridge.url,
+ enabled: true,
+ oauth: false,
+ headers: { Authorization: `Bearer ${bridge.secret}` },
+ timeout: 30_000,
+ },
+ ...(assignedMcp
+ ? {
+ [assignedMcp.name]: {
+ type: "remote",
+ url: assignedMcp.url,
+ enabled: true,
+ oauth: false,
+ headers: { Authorization: `Bearer ${assignedMcp.token}` },
+ timeout: 30_000,
+ },
+ }
+ : {}),
+ },
+ };
+ await writeFile(
+ join(configHome, "opencode", "opencode.json"),
+ `${JSON.stringify(config, null, 2)}\n`,
+ { mode: 0o600 },
+ );
+ const environment = sanitizedEnvironment(
+ input.options.environment ?? process.env,
+ {
+ HOME: isolatedHome,
+ XDG_CONFIG_HOME: configHome,
+ XDG_DATA_HOME: dataHome,
+ XDG_CACHE_HOME: cacheHome,
+ OPENCODE_DISABLE_PROJECT_CONFIG: "true",
+ OPENCODE_SERVER_USERNAME: username,
+ OPENCODE_SERVER_PASSWORD: password,
+ ...(providerProxy ? { NO_PROXY: [input.options.environment?.no_proxy ?? input.options.environment?.NO_PROXY, "127.0.0.1", "localhost"].filter(Boolean).join(",") } : {}),
+ },
+ );
+ const isolateProcessGroup = input.options.isolateProcessGroup ?? true;
+ const stdio: Array<"ignore" | "pipe" | number> = ["ignore", "ignore", "pipe"];
+ if (input.options.commandFd !== undefined) {
+ while (stdio.length <= input.options.commandFd) stdio.push("ignore");
+ stdio[input.options.commandFd] = input.options.commandFd;
}
- }
- let diagnostics = "";
- child.stderr?.on("data", (chunk) => {
- const raw = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
- const redactedDiagnostic = redact(raw.toString("utf8"), [
- password,
- input.options.environment?.OPENROUTER_API_KEY,
- ]);
- diagnostics = `${diagnostics}${redactedDiagnostic}`.slice(-8_192);
- const frameId = input.trace?.frame({
- direction: "provider_stderr",
- raw,
- transport: "process_stderr",
- nativeMethod: "opencode serve stderr",
- });
- if (frameId) {
- input.trace?.interpretation({
- frameId,
- stage: "typescript_opencode_process_transport",
- ruleId: "opencode.stderr",
- disposition: "operator_only",
- reason:
- "OpenCode stderr is retained only in the restricted trace sidecar",
+ input.options.commandLifecycle?.beforeSpawn();
+ child = spawn(
+ input.options.command ?? "opencode",
+ ["serve", "--hostname", "127.0.0.1", "--port", String(port)],
+ {
+ cwd: input.cwd,
+ env: environment,
+ stdio,
+ detached: globalThis.process.platform !== "win32" && isolateProcessGroup,
+ },
+ );
+ if (child.pid !== undefined) {
+ try {
+ input.options.commandLifecycle?.afterSpawn();
+ } catch (error) {
+ child.kill("SIGKILL");
+ throw error;
+ }
+ }
+ let diagnostics = "";
+ child.stderr?.on("data", (chunk) => {
+ const raw = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
+ const redactedDiagnostic = redact(raw.toString("utf8"), sensitiveValues);
+ diagnostics = `${diagnostics}${redactedDiagnostic}`.slice(-8_192);
+ const frameId = input.trace?.frame({
+ direction: "provider_stderr",
+ raw,
+ transport: "process_stderr",
+ nativeMethod: "opencode serve stderr",
});
- }
- input.options.onDiagnostic?.(redactedDiagnostic);
- });
- try {
+ if (frameId) {
+ input.trace?.interpretation({
+ frameId,
+ stage: "typescript_opencode_process_transport",
+ ruleId: "opencode.stderr",
+ disposition: "operator_only",
+ reason:
+ "OpenCode stderr is retained only in the restricted trace sidecar",
+ });
+ }
+ input.options.onDiagnostic?.(redactedDiagnostic);
+ });
+ const providerChild = child;
await new Promise((resolve, reject) => {
- child.once("spawn", resolve);
- child.once("error", reject);
+ providerChild.once("spawn", resolve);
+ providerChild.once("error", reject);
});
if (child.pid)
await input.options.onSpawn?.({
@@ -2196,29 +2334,27 @@ async function startRuntime(input: {
process: child,
bridge,
trace: input.trace,
- sensitiveValues: [
- password,
- input.options.environment?.OPENROUTER_API_KEY,
- ].filter((value): value is string => Boolean(value)),
+ sensitiveValues,
close: async (closeInput = {}) => {
+ await providerProxy?.close();
await bridge.close().catch(() => {});
- if (child.exitCode === null && child.signalCode === null && child.pid) {
+ if (providerChild.exitCode === null && providerChild.signalCode === null && providerChild.pid) {
try {
if (globalThis.process.platform === "win32" || !isolateProcessGroup)
- child.kill("SIGTERM");
- else globalThis.process.kill(-child.pid, "SIGTERM");
+ providerChild.kill("SIGTERM");
+ else globalThis.process.kill(-providerChild.pid, "SIGTERM");
} catch {
- child.kill("SIGTERM");
+ providerChild.kill("SIGTERM");
}
}
- await waitForExit(child, 2_000);
- if (child.exitCode === null && child.signalCode === null && child.pid) {
+ await waitForExit(providerChild, 2_000);
+ if (providerChild.exitCode === null && providerChild.signalCode === null && providerChild.pid) {
try {
if (globalThis.process.platform === "win32" || !isolateProcessGroup)
- child.kill("SIGKILL");
- else globalThis.process.kill(-child.pid, "SIGKILL");
+ providerChild.kill("SIGKILL");
+ else globalThis.process.kill(-providerChild.pid, "SIGKILL");
} catch {
- child.kill("SIGKILL");
+ providerChild.kill("SIGKILL");
}
}
await rm(join(configHome, "opencode", "opencode.json"), {
@@ -2233,8 +2369,9 @@ async function startRuntime(input: {
},
};
} catch (error) {
+ await providerProxy?.close();
await bridge.close().catch(() => {});
- child.kill("SIGKILL");
+ child?.kill("SIGKILL");
await rm(join(configHome, "opencode", "opencode.json"), {
force: true,
}).catch(() => undefined);
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 6f59edee75..062482ecad 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 3387dae6ab..973da0bf67 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 68a7c84cc8..b73efc381b 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/paperclip-runner/src/protocol/result-normalization.test.ts b/packages/paperclip-runner/src/protocol/result-normalization.test.ts
index fcce070e36..7128beae7d 100644
--- a/packages/paperclip-runner/src/protocol/result-normalization.test.ts
+++ b/packages/paperclip-runner/src/protocol/result-normalization.test.ts
@@ -6,6 +6,36 @@ import {
import { validatePrpStructuredRunResult } from "./replay-contract.js";
describe("normalizePrpResultSignals", () => {
+ it("normalizes an explicit null continuation without weakening completion rules", () => {
+ const providerResult = {
+ reportedWorkDisposition: "done",
+ summary: "Renamed the task to bobathan.",
+ completionClaim: {
+ contractRevision: "1",
+ objectiveSatisfied: true,
+ criteria: [{ criterionId: "objective", status: "satisfied", evidenceRefs: [] }],
+ remainingWork: [],
+ },
+ evidence: [],
+ verification: [],
+ continuation: null,
+ };
+ const validated = validatePrpStructuredRunResult(providerResult);
+ expect(validated).toMatchObject({ ok: true, result: { reportedWorkDisposition: "done" } });
+ if (validated.ok) expect(validated.result).not.toHaveProperty("continuation");
+ expect(providerResult).toHaveProperty("continuation", null);
+ expect(validatePrpStructuredRunResult({
+ ...providerResult,
+ reportedWorkDisposition: "yielded",
+ })).toMatchObject({ ok: false });
+ expect(normalizeLegacyPrpStructuredRunResult({
+ ...providerResult,
+ continuation: { kind: "response_wake", summary: "Contradictory wait.", idempotencyKey: "wait-1" },
+ })).toHaveProperty("continuation", {
+ kind: "response_wake", summary: "Contradictory wait.", idempotencyKey: "wait-1",
+ });
+ });
+
it("accepts legacy completion aliases as one canonical PRP result", () => {
const legacy = {
schema: "paperclip.run_result.v1",
diff --git a/packages/paperclip-runner/src/protocol/result-normalization.ts b/packages/paperclip-runner/src/protocol/result-normalization.ts
index 47ab63c309..ae27d13bf1 100644
--- a/packages/paperclip-runner/src/protocol/result-normalization.ts
+++ b/packages/paperclip-runner/src/protocol/result-normalization.ts
@@ -119,6 +119,12 @@ export function normalizeLegacyPrpStructuredRunResult(value: unknown): unknown {
const source = record(value);
if (Object.keys(source).length === 0) return value;
const normalized: Record = { ...source };
+ // Some provider transports require all tool properties to be present. Null
+ // explicitly means no continuation; preserve every non-null value so strict
+ // canonical validation still rejects contradictory or malformed waits.
+ if (normalized.continuation === null) {
+ delete normalized.continuation;
+ }
// Provider tool-callers do not consistently echo constant discriminator
// fields even when they are present in the advertised JSON schema. The
// transport already selected the semantic-result tool, so adding its
diff --git a/packages/shared/src/ai-connections.ts b/packages/shared/src/ai-connections.ts
index 898a5f246e..2d214ef079 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, 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;
@@ -185,6 +183,7 @@ export const createAiConnectionSchema = z
...requirement,
name: z.string().trim().min(1).max(160),
ownership: z.enum(["personal", "shared"]),
+ routing: aiProviderRoutingSchema.optional(),
apiKey: z.string().trim().min(1).max(32768).optional(),
loginSessionId: z.string().max(128).optional(),
connectionId: z.string().uuid().optional(),
@@ -195,6 +194,14 @@ 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.routing?.auth === "none") {
+ if (v.apiKey || v.loginSessionId) ctx.addIssue({ code: "custom", message: "Provide only the selected authentication method." });
+ return;
+ }
if (
v.method === "api_key"
? !v.apiKey || Boolean(v.loginSessionId)
@@ -211,7 +218,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"]),
@@ -230,10 +237,14 @@ export const localAiConnectionSchema = aiConnectionLoginIntentSchema.extend({
export const localAiLoginStartSchema = aiConnectionLoginIntentSchema.extend({ restart: z.boolean().optional() });
export interface LocalAiLoginStatus {
status: "ready" | "sign_in_required" | "expired";
+ authorizationUrl?: string;
+ code?: string;
+ error?: string;
}
export interface LocalAiLoginAttempt {
sessionId: string;
- command: string;
+ /** Grok still uses terminal sign-in until it has an in-app login runner. */
+ command?: string;
expiresAt: string;
}
diff --git a/packages/shared/src/ai-provider-routing.ts b/packages/shared/src/ai-provider-routing.ts
new file mode 100644
index 0000000000..79bdc28fbd
--- /dev/null
+++ b/packages/shared/src/ai-provider-routing.ts
@@ -0,0 +1,158 @@
+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", "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 ||
+ route.auth !== "bearer" ||
+ route.baseUrl
+ )
+ invalid(
+ "Bedrock requires a region and a Bedrock API key.",
+ );
+ } else if (
+ route.protocol === "bedrock" ||
+ 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 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}`;
+}
+
+/** Catalog identity is derived from routing, including accounts saved before these rows existed. */
+export function aiConnectionCatalogSlug(provider: string, routing?: Pick): string {
+ if (routing?.kind === "gateway") {
+ return routing.protocol === "messages" ? "messages-api"
+ : routing.protocol === "chat" ? "chat-completions-api" : "responses-api";
+ }
+ return routing?.kind ?? provider;
+}
+
+/** Direct catalog setup presets; legacy gateway links still open their existing setup. */
+export function aiProviderSetupPreset(source: string | null): {
+ provider: "openrouter" | "bedrock" | "gateway" | "local" | "google";
+ protocol?: AiProviderRouting["protocol"];
+ label?: string;
+} | undefined {
+ switch (source) {
+ case "responses-api": return { provider: "gateway", protocol: "responses", label: "Responses API" };
+ case "messages-api": return { provider: "gateway", protocol: "messages", label: "Messages API" };
+ case "chat-completions-api": return { provider: "gateway", protocol: "chat", label: "Chat Completions API" };
+ case "google-ai": return { provider: "google" };
+ case "google": case "openrouter": case "bedrock": case "gateway": case "local": return { provider: source };
+ default: return undefined;
+ }
+}
diff --git a/packages/shared/src/app-definitions-url.test.ts b/packages/shared/src/app-definitions-url.test.ts
index 5c93e8c2f8..eec26f63dd 100644
--- a/packages/shared/src/app-definitions-url.test.ts
+++ b/packages/shared/src/app-definitions-url.test.ts
@@ -43,10 +43,19 @@ describe("tool app gallery URL matching", () => {
});
it("keeps tool gallery entries reachable through at least one pattern", () => {
- for (const app of CONNECTABLE_APP_DEFINITIONS.filter((app) => app.methods.some((method) => method.purpose !== "channel"))) {
+ for (const app of CONNECTABLE_APP_DEFINITIONS.filter((app) => app.methods.some((method) => method.purpose !== "channel" && method.purpose !== "ai"))) {
const example = app.urlPatterns[0]?.replace("*", "example");
expect(example, `${app.slug} has a pattern`).toBeTruthy();
expect(getAppDefinitionForUrl(example!)?.slug).toBe(app.slug);
}
});
+
+ it("offers region and custom model connections without claiming arbitrary URLs", () => {
+ for (const slug of ["bedrock", "responses-api", "messages-api", "chat-completions-api", "local"]) {
+ const app = CONNECTABLE_APP_DEFINITIONS.find(app => app.slug === slug);
+ expect(app?.methods.every(method => method.purpose === "ai")).toBe(true);
+ expect(app?.urlPatterns).toEqual([]);
+ }
+ expect(getAppDefinitionForUrl("https://customer-gateway.example/v1")).toBeNull();
+ });
});
diff --git a/packages/shared/src/app-definitions.generated.ts b/packages/shared/src/app-definitions.generated.ts
index d85f7b9b36..341682b259 100644
--- a/packages/shared/src/app-definitions.generated.ts
+++ b/packages/shared/src/app-definitions.generated.ts
@@ -80,5 +80,11 @@ import a78 from "./app-definitions/google-workspace-search.json" with { type: "j
import a79 from "./app-definitions/openai.json" with { type: "json" };
import a80 from "./app-definitions/openrouter.json" with { type: "json" };
import a81 from "./app-definitions/xai.json" with { type: "json" };
+import a82 from "./app-definitions/google.json" with { type: "json" };
+import a83 from "./app-definitions/bedrock.json" with { type: "json" };
+import a84 from "./app-definitions/responses-api.json" with { type: "json" };
+import a85 from "./app-definitions/messages-api.json" with { type: "json" };
+import a86 from "./app-definitions/chat-completions-api.json" with { type: "json" };
+import a87 from "./app-definitions/local.json" with { type: "json" };
import type { AppDefinition } from "./types/app-definition.js";
-export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78,a79,a80,a81] as AppDefinition[];
+export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78,a79,a80,a81,a82,a83,a84,a85,a86,a87] as AppDefinition[];
diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts
index 4a050a35b0..6ef7651c3c 100644
--- a/packages/shared/src/app-definitions.test.ts
+++ b/packages/shared/src/app-definitions.test.ts
@@ -233,6 +233,15 @@ describe("AppDefinition catalog", () => {
it("validates all Wave 1 definitions", () =>
expect(() => appDefinitionsSchema.parse(APP_DEFINITIONS)).not.toThrow());
+ it("lists model providers as regular tagged catalog entries", () => {
+ const slugs = ["openai", "anthropic", "openrouter", "xai", "google", "bedrock", "responses-api", "messages-api", "chat-completions-api", "local"];
+ expect(APP_STORE_DEFINITIONS.filter(app => app.tags?.includes("model-provider")).map(app => app.slug).sort()).toEqual(slugs.sort());
+ for (const slug of slugs) {
+ const app = APP_STORE_DEFINITIONS.find(app => app.slug === slug)!;
+ expect(app.methods.some(method => method.purpose === "ai" && method.transport === "runtime_auth")).toBe(true);
+ }
+ });
+
it("contains every established provider plus the reviewed self-serve catalog", () => {
expect(APP_DEFINITIONS.map((app) => app.slug)).toEqual(
expect.arrayContaining([
@@ -729,7 +738,7 @@ describe("AppDefinition catalog", () => {
"ticktick",
"xero",
]);
- expect(APP_STORE_DEFINITIONS).toHaveLength(59);
+ expect(APP_STORE_DEFINITIONS).toHaveLength(65);
const connectableSlugs = new Set(
CONNECTABLE_APP_DEFINITIONS.map((entry) => entry.slug),
);
diff --git a/packages/shared/src/app-definitions.ts b/packages/shared/src/app-definitions.ts
index 83410b49bf..322efe5bb8 100644
--- a/packages/shared/src/app-definitions.ts
+++ b/packages/shared/src/app-definitions.ts
@@ -4,7 +4,8 @@ import type { AppDefinition, ConnectionMethodDef, FieldDef } from "./types/app-d
import type { ToolConnectionOwnership } from "./types/tool-access.js";
export const CONNECTABLE_APP_SLUGS = new Set([
- "anthropic", "openai", "openrouter", "xai",
+ "anthropic", "openai", "openrouter", "xai", "google", "bedrock",
+ "responses-api", "messages-api", "chat-completions-api", "local",
"agentmail",
"browser-use-cloud",
"cognee",
diff --git a/packages/shared/src/app-definitions/anthropic.json b/packages/shared/src/app-definitions/anthropic.json
index bd16bb3444..52eb85f360 100644
--- a/packages/shared/src/app-definitions/anthropic.json
+++ b/packages/shared/src/app-definitions/anthropic.json
@@ -71,5 +71,8 @@
"name": "ANTHROPIC_API_KEY"
}
}
+ ],
+ "tags": [
+ "model-provider"
]
}
diff --git a/packages/shared/src/app-definitions/bedrock.json b/packages/shared/src/app-definitions/bedrock.json
new file mode 100644
index 0000000000..962d8a722a
--- /dev/null
+++ b/packages/shared/src/app-definitions/bedrock.json
@@ -0,0 +1,53 @@
+{
+ "schemaVersion": 1,
+ "slug": "bedrock",
+ "name": "Amazon Bedrock",
+ "description": "Use Claude through Amazon Bedrock with a Bedrock API key and AWS region.",
+ "categories": [
+ "ai"
+ ],
+ "branding": {
+ "logoUrl": "/brands/apps/bedrock.svg"
+ },
+ "urlPatterns": [],
+ "methods": [
+ {
+ "key": "ai-api_key",
+ "label": "Amazon Bedrock API key",
+ "purpose": "ai",
+ "transport": "runtime_auth",
+ "auth": "api_key",
+ "ai": {
+ "provider": "anthropic",
+ "method": "api_key"
+ },
+ "grantKinds": [
+ "user",
+ "organization"
+ ],
+ "ownershipModes": [
+ "customer"
+ ],
+ "whenToUse": "Use Claude through Amazon Bedrock with a Bedrock API key and AWS region.",
+ "guidanceMd": "Use your personal account or an explicitly shared company account.",
+ "riskTier": "S3",
+ "credentialFields": [
+ {
+ "key": "apiKey",
+ "label": "API key",
+ "type": "password",
+ "required": true,
+ "placeholder": "Enter API key",
+ "secret": true
+ }
+ ],
+ "keyPlacement": {
+ "location": "env",
+ "name": "AWS_BEARER_TOKEN_BEDROCK"
+ }
+ }
+ ],
+ "tags": [
+ "model-provider"
+ ]
+}
diff --git a/packages/shared/src/app-definitions/chat-completions-api.json b/packages/shared/src/app-definitions/chat-completions-api.json
new file mode 100644
index 0000000000..021e0df41b
--- /dev/null
+++ b/packages/shared/src/app-definitions/chat-completions-api.json
@@ -0,0 +1,53 @@
+{
+ "schemaVersion": 1,
+ "slug": "chat-completions-api",
+ "name": "Chat Completions API",
+ "description": "Connect any compatible harness to a Chat Completions-compatible provider or gateway.",
+ "categories": [
+ "ai"
+ ],
+ "branding": {
+ "logoUrl": "/brands/apps/api-key-generic.svg"
+ },
+ "urlPatterns": [],
+ "methods": [
+ {
+ "key": "ai-api_key",
+ "label": "Chat Completions API API key",
+ "purpose": "ai",
+ "transport": "runtime_auth",
+ "auth": "api_key",
+ "ai": {
+ "provider": "openai",
+ "method": "api_key"
+ },
+ "grantKinds": [
+ "user",
+ "organization"
+ ],
+ "ownershipModes": [
+ "customer"
+ ],
+ "whenToUse": "Connect any compatible harness to a Chat Completions-compatible provider or gateway.",
+ "guidanceMd": "Use your personal account or an explicitly shared company account.",
+ "riskTier": "S3",
+ "credentialFields": [
+ {
+ "key": "apiKey",
+ "label": "API key",
+ "type": "password",
+ "required": true,
+ "placeholder": "Enter API key",
+ "secret": true
+ }
+ ],
+ "keyPlacement": {
+ "location": "env",
+ "name": "OPENAI_API_KEY"
+ }
+ }
+ ],
+ "tags": [
+ "model-provider"
+ ]
+}
diff --git a/packages/shared/src/app-definitions/google.json b/packages/shared/src/app-definitions/google.json
new file mode 100644
index 0000000000..794282359d
--- /dev/null
+++ b/packages/shared/src/app-definitions/google.json
@@ -0,0 +1,55 @@
+{
+ "schemaVersion": 1,
+ "slug": "google",
+ "name": "Google Gemini",
+ "description": "Connect Google Gemini accounts for your agents.",
+ "categories": [
+ "ai"
+ ],
+ "branding": {
+ "logoUrl": "/brands/apps/google.svg"
+ },
+ "urlPatterns": [
+ "https://generativelanguage.googleapis.com/*"
+ ],
+ "methods": [
+ {
+ "key": "ai-api_key",
+ "label": "Google Gemini API key",
+ "purpose": "ai",
+ "transport": "runtime_auth",
+ "auth": "api_key",
+ "ai": {
+ "provider": "google",
+ "method": "api_key"
+ },
+ "grantKinds": [
+ "user",
+ "organization"
+ ],
+ "ownershipModes": [
+ "customer"
+ ],
+ "whenToUse": "Authenticate an agent with this account.",
+ "guidanceMd": "Use your personal account or an explicitly shared company account.",
+ "riskTier": "S3",
+ "credentialFields": [
+ {
+ "key": "apiKey",
+ "label": "API key",
+ "type": "password",
+ "required": true,
+ "placeholder": "Enter API key",
+ "secret": true
+ }
+ ],
+ "keyPlacement": {
+ "location": "env",
+ "name": "GEMINI_API_KEY"
+ }
+ }
+ ],
+ "tags": [
+ "model-provider"
+ ]
+}
diff --git a/packages/shared/src/app-definitions/local.json b/packages/shared/src/app-definitions/local.json
new file mode 100644
index 0000000000..c9cd6dab36
--- /dev/null
+++ b/packages/shared/src/app-definitions/local.json
@@ -0,0 +1,53 @@
+{
+ "schemaVersion": 1,
+ "slug": "local",
+ "name": "Local endpoint",
+ "description": "Use a local model server in the agent’s execution environment.",
+ "categories": [
+ "ai"
+ ],
+ "branding": {
+ "logoUrl": "/brands/apps/api-key-generic.svg"
+ },
+ "urlPatterns": [],
+ "methods": [
+ {
+ "key": "ai-api_key",
+ "label": "Local endpoint API key",
+ "purpose": "ai",
+ "transport": "runtime_auth",
+ "auth": "api_key",
+ "ai": {
+ "provider": "openai",
+ "method": "api_key"
+ },
+ "grantKinds": [
+ "user",
+ "organization"
+ ],
+ "ownershipModes": [
+ "customer"
+ ],
+ "whenToUse": "Use a local model server in the agent’s execution environment.",
+ "guidanceMd": "Use your personal account or an explicitly shared company account.",
+ "riskTier": "S3",
+ "credentialFields": [
+ {
+ "key": "apiKey",
+ "label": "API key",
+ "type": "password",
+ "required": true,
+ "placeholder": "Enter API key",
+ "secret": true
+ }
+ ],
+ "keyPlacement": {
+ "location": "env",
+ "name": "OPENAI_API_KEY"
+ }
+ }
+ ],
+ "tags": [
+ "model-provider"
+ ]
+}
diff --git a/packages/shared/src/app-definitions/messages-api.json b/packages/shared/src/app-definitions/messages-api.json
new file mode 100644
index 0000000000..c5c8d855ee
--- /dev/null
+++ b/packages/shared/src/app-definitions/messages-api.json
@@ -0,0 +1,53 @@
+{
+ "schemaVersion": 1,
+ "slug": "messages-api",
+ "name": "Messages API",
+ "description": "Connect any compatible harness to an Anthropic Messages-compatible provider or gateway.",
+ "categories": [
+ "ai"
+ ],
+ "branding": {
+ "logoUrl": "/brands/apps/api-key-generic.svg"
+ },
+ "urlPatterns": [],
+ "methods": [
+ {
+ "key": "ai-api_key",
+ "label": "Messages API API key",
+ "purpose": "ai",
+ "transport": "runtime_auth",
+ "auth": "api_key",
+ "ai": {
+ "provider": "anthropic",
+ "method": "api_key"
+ },
+ "grantKinds": [
+ "user",
+ "organization"
+ ],
+ "ownershipModes": [
+ "customer"
+ ],
+ "whenToUse": "Connect any compatible harness to an Anthropic Messages-compatible provider or gateway.",
+ "guidanceMd": "Use your personal account or an explicitly shared company account.",
+ "riskTier": "S3",
+ "credentialFields": [
+ {
+ "key": "apiKey",
+ "label": "API key",
+ "type": "password",
+ "required": true,
+ "placeholder": "Enter API key",
+ "secret": true
+ }
+ ],
+ "keyPlacement": {
+ "location": "env",
+ "name": "ANTHROPIC_API_KEY"
+ }
+ }
+ ],
+ "tags": [
+ "model-provider"
+ ]
+}
diff --git a/packages/shared/src/app-definitions/openai.json b/packages/shared/src/app-definitions/openai.json
index e3ef286564..14708e9bac 100644
--- a/packages/shared/src/app-definitions/openai.json
+++ b/packages/shared/src/app-definitions/openai.json
@@ -70,5 +70,8 @@
"name": "OPENAI_API_KEY"
}
}
+ ],
+ "tags": [
+ "model-provider"
]
}
diff --git a/packages/shared/src/app-definitions/openrouter.json b/packages/shared/src/app-definitions/openrouter.json
index 6f95ff8a8b..d4d1be16fc 100644
--- a/packages/shared/src/app-definitions/openrouter.json
+++ b/packages/shared/src/app-definitions/openrouter.json
@@ -49,5 +49,8 @@
"name": "OPENROUTER_API_KEY"
}
}
+ ],
+ "tags": [
+ "model-provider"
]
}
diff --git a/packages/shared/src/app-definitions/responses-api.json b/packages/shared/src/app-definitions/responses-api.json
new file mode 100644
index 0000000000..29ad92e4c4
--- /dev/null
+++ b/packages/shared/src/app-definitions/responses-api.json
@@ -0,0 +1,53 @@
+{
+ "schemaVersion": 1,
+ "slug": "responses-api",
+ "name": "Responses API",
+ "description": "Connect any compatible harness to an OpenAI Responses-compatible provider or gateway, including Emissary.",
+ "categories": [
+ "ai"
+ ],
+ "branding": {
+ "logoUrl": "/brands/apps/api-key-generic.svg"
+ },
+ "urlPatterns": [],
+ "methods": [
+ {
+ "key": "ai-api_key",
+ "label": "Responses API API key",
+ "purpose": "ai",
+ "transport": "runtime_auth",
+ "auth": "api_key",
+ "ai": {
+ "provider": "openai",
+ "method": "api_key"
+ },
+ "grantKinds": [
+ "user",
+ "organization"
+ ],
+ "ownershipModes": [
+ "customer"
+ ],
+ "whenToUse": "Connect any compatible harness to an OpenAI Responses-compatible provider or gateway, including Emissary.",
+ "guidanceMd": "Use your personal account or an explicitly shared company account.",
+ "riskTier": "S3",
+ "credentialFields": [
+ {
+ "key": "apiKey",
+ "label": "API key",
+ "type": "password",
+ "required": true,
+ "placeholder": "Enter API key",
+ "secret": true
+ }
+ ],
+ "keyPlacement": {
+ "location": "env",
+ "name": "OPENAI_API_KEY"
+ }
+ }
+ ],
+ "tags": [
+ "model-provider"
+ ]
+}
diff --git a/packages/shared/src/app-definitions/xai.json b/packages/shared/src/app-definitions/xai.json
index ee1f729db5..6b60b02111 100644
--- a/packages/shared/src/app-definitions/xai.json
+++ b/packages/shared/src/app-definitions/xai.json
@@ -70,5 +70,8 @@
"name": "XAI_API_KEY"
}
}
+ ],
+ "tags": [
+ "model-provider"
]
}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 511861df3b..ee050d555e 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -2825,6 +2825,7 @@ export * from "./public-mcp.js";
export * from "./mcp-setup.js";
+export * from "./ai-provider-routing.js";
export { aiConnectionRouterSlug, aiConnectionRouterAppDefinition, aiConnectionRouterPluginKey } from "./ai-connection-router.js";
export { isAppAggregator, aggregatorManagementUrl, aggregatorAppsSyncSchema, aggregatorAppsRefreshSchema, arcadeDiscoverySetupSchema, type AggregatorAppSnapshot, type AggregatorAppsResponse, type ArcadeDiscoverySetupInput } from "./aggregator-apps.js";
diff --git a/packages/shared/src/types/app-definition.ts b/packages/shared/src/types/app-definition.ts
index 2e8d2d5c43..e00b97f4f7 100644
--- a/packages/shared/src/types/app-definition.ts
+++ b/packages/shared/src/types/app-definition.ts
@@ -3,7 +3,7 @@ export type AppCategory = "ai"|"analytics"|"commerce"|"communication"|"content"|
export type OAuthRedirectConstraints = "https-or-loopback-http";
export interface FieldDef { key:string; label:string; type:"text"|"password"|"textarea"|"datetime"|"select"|"checkbox"; required?:boolean; advanced?:boolean; hidden?:boolean; placeholder?:string; helperMd?:string; secret?:boolean; prefix?:string; defaultValue?:string|boolean; validation?:{pattern?:string;maxLength?:number}; options?:Array<{value:string;label:string}>; transport?:{location:"query"|"header";name:string;format?:"string"|"csv"|"boolean";omitFalse?:boolean} }
export interface ConnectionMethodDef { ai?: import("../ai-connections.js").AiConnectionMetadata; key:string; label?:string; purpose?:ToolConnectionPurpose; provider?:"slack"|"github"|"discord"|"microsoft-teams"|"telegram"|"agentmail"|"imessage-photon"; transport:ToolConnectionTransport; auth:"oauth"|"api_key"|"none"; oauthStrategy?:"paperclip_cloud_connector"|"paperclip_id_connector"; connectorProfile?:string; oauthClientSecretRequired?:boolean; capabilityProfile?:{key:string;label:string;description?:string}; grantKinds?:ConnectionGrantKind[]; ownershipModes:ToolConnectionOwnership[]; whenToUse:string; defaults?:{serverUrl?:string;serverUrlTemplate?:string;discoveryUrl?:string|null;serviceHost?:string;templateKey?:string;authorizationEndpoint?:string;tokenEndpoint?:string;metadataUrl?:string;scopesHint?:string[];oauthAuthorizationParams?:{access_type?:"offline";prompt?:"consent"};toolArgumentDefaults?:Record}; tenantFields?:FieldDef[]; extensionFields?:FieldDef[]; configRequirements?:{atLeastOneOf?:string[]}; credentialFields?:FieldDef[]; keyPlacement?:{location:"header"|"query"|"body_json"|"env";name:string;prefix?:string|null}; credentialSources?:{vercelConnect?:{services:string[];principalModes:VercelConnectPrincipalMode[];scopes:string[];header:{name:string;prefix?:string|null}}}; guidanceMd:string; consoleLinks?:{register?:string;keys?:string;settings?:string;docs?:string}; warnings?:string[]; variants?:Array<{key:string;label:string;whenToUse:string;tenantFields?:FieldDef[]}>; riskTier:"S1"|"S2"|"S3"|"S4"; requiredResourceFilters?:string[] }
-export interface AppDefinition { aiConnectionRouter?: { pluginKey: string }; agentInstructions?: import("../connection-instructions.js").ConnectionInstructionTemplate; schemaVersion:1; slug:string; name:string; description:string; categories:AppCategory[]; featured?:boolean; branding:{logoUrl:string;darkLogoUrl?:string;backgroundColor?:string;accentColor?:string}; urlPatterns:string[]; docsUrl?:string; setupPrerequisite?:{title:string;description:string;steps?:string[];actionLabel:string;actionUrl:string}; redirectConstraints?:OAuthRedirectConstraints; methods:ConnectionMethodDef[]; suggestable?:boolean; availability?:{available:boolean;reason?:string;robotEmail?:string}; ownershipAvailability?:Partial> }
+export interface AppDefinition { aiConnectionRouter?: { pluginKey: string }; agentInstructions?: import("../connection-instructions.js").ConnectionInstructionTemplate; schemaVersion:1; slug:string; name:string; description:string; categories:AppCategory[]; tags?:string[]; featured?:boolean; branding:{logoUrl:string;darkLogoUrl?:string;backgroundColor?:string;accentColor?:string}; urlPatterns:string[]; docsUrl?:string; setupPrerequisite?:{title:string;description:string;steps?:string[];actionLabel:string;actionUrl:string}; redirectConstraints?:OAuthRedirectConstraints; methods:ConnectionMethodDef[]; suggestable?:boolean; availability?:{available:boolean;reason?:string;robotEmail?:string}; ownershipAvailability?:Partial> }
export type SelfServeMcpAuthMode =
| "dcr"
diff --git a/packages/shared/src/validators/app-definition.ts b/packages/shared/src/validators/app-definition.ts
index 5e48887fe6..d220a2d889 100644
--- a/packages/shared/src/validators/app-definition.ts
+++ b/packages/shared/src/validators/app-definition.ts
@@ -8,7 +8,7 @@ const appBrandAssetUrlSchema=z.string().refine((value)=>{
},{message:"Brand assets must be HTTPS URLs or local /brands/apps SVG/PNG paths"});
const field=z.object({key:z.string().min(1),label:z.string().min(1),type:z.enum(["text","password","textarea","datetime","select","checkbox"]),required:z.boolean().optional(),advanced:z.boolean().optional(),hidden:z.boolean().optional(),placeholder:z.string().optional(),helperMd:z.string().optional(),secret:z.boolean().optional(),prefix:z.string().optional(),defaultValue:z.union([z.string(),z.boolean()]).optional(),validation:z.object({pattern:z.string().optional(),maxLength:z.number().int().positive().optional()}).optional(),options:z.array(z.object({value:z.string(),label:z.string()})).optional(),transport:z.object({location:z.enum(["query","header"]),name:z.string().min(1),format:z.enum(["string","csv","boolean"]).optional(),omitFalse:z.boolean().optional()}).optional()}).superRefine((v,c)=>{if(v.required&&v.type!=="checkbox"&&!v.placeholder)c.addIssue({code:"custom",message:"Required fields need placeholders",path:["placeholder"]});if(v.type==="select"&&(!v.options||v.options.length===0))c.addIssue({code:"custom",message:"Select fields need options",path:["options"]});if(v.hidden&&v.defaultValue===undefined)c.addIssue({code:"custom",message:"Hidden fields need defaults",path:["defaultValue"]})});
export const connectionMethodDefSchema=z.object({key:z.string().min(1),label:z.string().min(1).optional(),purpose:z.union([toolConnectionPurposeSchema,z.literal("ai")]).optional(),provider:z.enum(["slack","github","discord","microsoft-teams","telegram","agentmail","imessage-photon"]).optional(),transport:z.union([toolConnectionTransportSchema,z.literal("runtime_auth")]),ai:aiConnectionMetadataSchema.optional(),auth:z.enum(["oauth","api_key","none"]),oauthStrategy:z.enum(["paperclip_cloud_connector","paperclip_id_connector"]).optional(),connectorProfile:z.string().regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/).optional(),oauthClientSecretRequired:z.boolean().optional(),capabilityProfile:z.object({key:z.string().min(1),label:z.string().min(1),description:z.string().min(1).optional()}).optional(),grantKinds:z.array(connectionGrantKindSchema).min(1).optional(),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().optional(),serverUrlTemplate:z.string().regex(/^https:\/\//).optional(),discoveryUrl:z.string().url().nullable().optional(),serviceHost:z.string().optional(),templateKey:z.string().optional(),authorizationEndpoint:z.string().url().optional(),tokenEndpoint:z.string().url().optional(),metadataUrl:z.string().url().optional(),scopesHint:z.array(z.string()).optional(),oauthAuthorizationParams:z.object({access_type:z.literal("offline").optional(),prompt:z.literal("consent").optional()}).optional(),toolArgumentDefaults:z.record(z.string(),z.unknown()).optional()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).optional(),configRequirements:z.object({atLeastOneOf:z.array(z.string().min(1)).min(1).optional()}).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),credentialSources:z.object({vercelConnect:z.object({services:z.array(z.string().min(1)).min(1),principalModes:z.array(z.enum(["app","user"])).min(1),scopes:z.array(z.string().min(1)).min(1),header:z.object({name:z.string().min(1),prefix:z.string().nullable().optional()})}).optional()}).optional(),guidanceMd:z.string().min(1),consoleLinks:z.object({register:z.string().url().optional(),keys:z.string().url().optional(),settings:z.string().url().optional(),docs:z.string().url().optional()}).optional(),warnings:z.array(z.string()).optional(),variants:z.array(z.object({key:z.string(),label:z.string(),whenToUse:z.string(),tenantFields:z.array(field).optional()})).optional(),riskTier:z.enum(["S1","S2","S3","S4"]),requiredResourceFilters:z.array(z.string()).optional()}).superRefine((v,c)=>{if((v.transport==="runtime_auth")!==Boolean(v.ai))c.addIssue({code:"custom",message:"Runtime authentication requires AI metadata and AI metadata requires runtime_auth",path:["ai"]});if(v.ai&&!AI_CONNECTION_CAPABILITIES[v.ai.provider].methods[v.ai.method])c.addIssue({code:"custom",message:"Unsupported AI sign-in method",path:["ai","method"]});if(v.ai&&v.auth!==(v.ai.method==="subscription"?"oauth":"api_key"))c.addIssue({code:"custom",message:"AI sign-in method must match authentication",path:["auth"]});const purpose=v.purpose??"tool";if((purpose==="ai")!==(v.transport==="runtime_auth"))c.addIssue({code:"custom",message:"AI methods require runtime_auth and runtime_auth requires AI purpose",path:["purpose"]});if(v.transport==="chat_sdk"&&purpose!=="channel")c.addIssue({code:"custom",message:"Chat SDK methods must be channel connections",path:["purpose"]});if(purpose==="channel"&&v.transport!=="chat_sdk"&&!(v.provider==="agentmail"&&v.transport==="rest_api"))c.addIssue({code:"custom",message:"Channel connections must use the Chat SDK transport",path:["transport"]});if(purpose==="channel"&&!v.provider)c.addIssue({code:"custom",message:"Channel connections require a chat provider",path:["provider"]});if(v.auth==="api_key"&&!v.keyPlacement&&purpose!=="channel")c.addIssue({code:"custom",message:"API-key tool methods require keyPlacement",path:["keyPlacement"]});if(v.oauthStrategy&&v.auth!=="oauth")c.addIssue({code:"custom",message:"OAuth strategies require OAuth auth",path:["oauthStrategy"]});if(v.oauthStrategy&&!v.connectorProfile)c.addIssue({code:"custom",message:"Paperclip Cloud connector methods require connectorProfile",path:["connectorProfile"]});if(v.connectorProfile&&!v.oauthStrategy)c.addIssue({code:"custom",message:"connectorProfile requires a Paperclip Cloud OAuth strategy",path:["connectorProfile"]});if(v.credentialSources?.vercelConnect&&(v.transport!=="mcp_remote"||v.auth==="none"))c.addIssue({code:"custom",message:"Vercel Connect requires an authenticated remote MCP method",path:["credentialSources","vercelConnect"]});const keys=new Set([...(v.tenantFields??[]),...(v.extensionFields??[])].map((entry)=>entry.key));for(const key of v.configRequirements?.atLeastOneOf??[])if(!keys.has(key))c.addIssue({code:"custom",message:"Config requirement references an unknown field",path:["configRequirements","atLeastOneOf"]});if(v.defaults?.serverUrl&&v.defaults.serverUrlTemplate)c.addIssue({code:"custom",message:"Use either serverUrl or serverUrlTemplate",path:["defaults"]});for(const placeholder of v.defaults?.serverUrlTemplate?.matchAll(/\{([a-zA-Z0-9_-]+)\}/g)??[])if(!keys.has(placeholder[1]))c.addIssue({code:"custom",message:"Server URL template references an unknown field",path:["defaults","serverUrlTemplate"]})});
-export const appDefinitionSchema=z.object({agentInstructions:connectionInstructionTemplateSchema.optional(),aiConnectionRouter:z.object({pluginKey:z.string().min(1)}).optional(),schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),featured:z.boolean().optional(),branding:z.object({logoUrl:appBrandAssetUrlSchema,darkLogoUrl:appBrandAssetUrlSchema.optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),setupPrerequisite:z.object({title:z.string().min(1),description:z.string().min(1),steps:z.array(z.string().min(1)).min(1).optional(),actionLabel:z.string().min(1),actionUrl:z.string().url()} ).optional(),redirectConstraints:z.enum(["https-or-loopback-http"]).optional(),methods:z.array(connectionMethodDefSchema),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()}).superRefine((value, ctx) => {
+export const appDefinitionSchema=z.object({agentInstructions:connectionInstructionTemplateSchema.optional(),aiConnectionRouter:z.object({pluginKey:z.string().min(1)}).optional(),schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),tags:z.array(z.string().min(1)).optional(),featured:z.boolean().optional(),branding:z.object({logoUrl:appBrandAssetUrlSchema,darkLogoUrl:appBrandAssetUrlSchema.optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),setupPrerequisite:z.object({title:z.string().min(1),description:z.string().min(1),steps:z.array(z.string().min(1)).min(1).optional(),actionLabel:z.string().min(1),actionUrl:z.string().url()} ).optional(),redirectConstraints:z.enum(["https-or-loopback-http"]).optional(),methods:z.array(connectionMethodDefSchema),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()}).superRefine((value, ctx) => {
if (value.aiConnectionRouter) {
if (value.methods.length !== 0) ctx.addIssue({ code: "custom", message: "Pooled connectors use existing connections, not authentication methods", path: ["methods"] });
if (!value.categories.includes("ai")) ctx.addIssue({ code: "custom", message: "Pooled connectors must use the AI category", path: ["categories"] });
diff --git a/scripts/ingest-app-definitions.mjs b/scripts/ingest-app-definitions.mjs
index acb746dee9..b6efe12f25 100644
--- a/scripts/ingest-app-definitions.mjs
+++ b/scripts/ingest-app-definitions.mjs
@@ -1718,13 +1718,36 @@ const inferState = (slug, state) => {
};
};
// Runtime credentials share the provider catalog, but never expose tool actions.
-for (const [slug, name, subscription, envKey] of [["anthropic", "Claude", true, "ANTHROPIC_API_KEY"], ["openai", "OpenAI", true, "OPENAI_API_KEY"], ["openrouter", "OpenRouter", false, "OPENROUTER_API_KEY"], ["xai", "Grok", true, "XAI_API_KEY"]]) {
- let app=apps.find(a=>a.slug===slug);
- if(!app){app={schemaVersion:1,slug,name,description:`Connect ${name} accounts for your agents.`,categories:["ai"],branding:brandingFor(slug),urlPatterns:[{"openai":"https://api.openai.com/*","openrouter":"https://openrouter.ai/api/*","xai":"https://api.x.ai/*"}[slug]],methods:[]};apps.push(app);}
- const methods=(subscription?["subscription","api_key"]:["api_key"]).map(authMethod=>({key:`ai-${authMethod}`,label:authMethod==="subscription"?`${name} subscription`:`${name} API key`,purpose:"ai",transport:"runtime_auth",auth:authMethod==="subscription"?"oauth":"api_key",ai:{provider:slug,method:authMethod},grantKinds:["user","organization"],ownershipModes:["customer"],whenToUse:"Authenticate an agent with this account.",guidanceMd:"Use your personal account or an explicitly shared company account.",riskTier:"S3",...(authMethod==="api_key"?{credentialFields:[field("apiKey","API key","Enter API key")],keyPlacement:{location:"env",name:envKey}}:{})}));
- // Legacy REST entries have no tool execution adapter. Only offer the supported
- // AI account flow; saved REST connections remain removable through Connections.
- app.methods = [...methods, ...app.methods.filter(method => method.transport !== "rest_api")];
+const aiCatalogEntries = [
+ { slug: "anthropic", name: "Claude", provider: "anthropic", subscription: true, envKey: "ANTHROPIC_API_KEY" },
+ { slug: "openai", name: "OpenAI", provider: "openai", subscription: true, envKey: "OPENAI_API_KEY", url: "https://api.openai.com/*" },
+ { slug: "openrouter", name: "OpenRouter", provider: "openrouter", envKey: "OPENROUTER_API_KEY", url: "https://openrouter.ai/api/*" },
+ { slug: "xai", name: "Grok", provider: "xai", subscription: true, envKey: "XAI_API_KEY", url: "https://api.x.ai/*" },
+ { slug: "google", name: "Google Gemini", provider: "google", envKey: "GEMINI_API_KEY", url: "https://generativelanguage.googleapis.com/*" },
+ { slug: "bedrock", name: "Amazon Bedrock", provider: "anthropic", envKey: "AWS_BEARER_TOKEN_BEDROCK", description: "Use Claude through Amazon Bedrock with a Bedrock API key and AWS region." },
+ { slug: "responses-api", name: "Responses API", provider: "openai", envKey: "OPENAI_API_KEY", description: "Connect any compatible harness to an OpenAI Responses-compatible provider or gateway, including Emissary." },
+ { slug: "messages-api", name: "Messages API", provider: "anthropic", envKey: "ANTHROPIC_API_KEY", description: "Connect any compatible harness to an Anthropic Messages-compatible provider or gateway." },
+ { slug: "chat-completions-api", name: "Chat Completions API", provider: "openai", envKey: "OPENAI_API_KEY", description: "Connect any compatible harness to a Chat Completions-compatible provider or gateway." },
+ { slug: "local", name: "Local endpoint", provider: "openai", envKey: "OPENAI_API_KEY", description: "Use a local model server in the agent’s execution environment." },
+];
+for (const { slug, name, provider, subscription, envKey, url, description } of aiCatalogEntries) {
+ let app = apps.find(a => a.slug === slug);
+ if (!app) {
+ app = { schemaVersion: 1, slug, name, description: description ?? `Connect ${name} accounts for your agents.`, categories: ["ai"], branding: brandingFor(slug), urlPatterns: url ? [url] : [], methods: [] };
+ apps.push(app);
+ }
+ app.tags = [...new Set([...(app.tags ?? []), "model-provider"])];
+ const methods = (subscription ? ["subscription", "api_key"] : ["api_key"]).map(authMethod => ({
+ key: `ai-${authMethod}`, label: authMethod === "subscription" ? `${name} subscription` : `${name} API key`,
+ purpose: "ai", transport: "runtime_auth", auth: authMethod === "subscription" ? "oauth" : "api_key",
+ ai: { provider, method: authMethod }, grantKinds: ["user", "organization"], ownershipModes: ["customer"],
+ whenToUse: description ?? "Authenticate an agent with this account.",
+ guidanceMd: "Use your personal account or an explicitly shared company account.", riskTier: "S3",
+ ...(authMethod === "api_key" ? { credentialFields: [field("apiKey", "API key", "Enter API key")], keyPlacement: { location: "env", name: envKey } } : {}),
+ }));
+ // Legacy REST entries have no tool execution adapter. Only offer the supported
+ // AI account flow; saved REST connections remain removable through Connections.
+ app.methods = [...methods, ...app.methods.filter(method => method.transport !== "rest_api")];
}
// Every tool method has a checked-in permission review. Discovery metadata is
// evidence for reviewers, never a runtime instruction to request more scopes.
diff --git a/server/src/__tests__/agent-hire-ai-connections.test.ts b/server/src/__tests__/agent-hire-ai-connections.test.ts
index cd12debd64..fb30faa12f 100644
--- a/server/src/__tests__/agent-hire-ai-connections.test.ts
+++ b/server/src/__tests__/agent-hire-ai-connections.test.ts
@@ -118,7 +118,9 @@ describe("agent-created hires use managed AI connections", () => {
await db.update(agents).set({ runtimeConfig: { aiConnection: p.binding } }).where(eq(agents.id, f.agentId));
const response = await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Incompatible teammate", role: "engineer", adapterType: "claude_local" });
expect(response.status, JSON.stringify(response.body)).toBe(422);
- expect(response.body.error).toContain("no compatible harness");
+ // Fixed bindings contain identity only; compatibility is decided from
+ // authoritative connection metadata when selecting each pool member.
+ expect(response.body.details?.code).toBe("ai_connection_pool_no_eligible_member");
expect(await db.select().from(agents).where(eq(agents.companyId, f.companyId))).toHaveLength(1);
});
diff --git a/server/src/__tests__/ai-connections.test.ts b/server/src/__tests__/ai-connections.test.ts
index 2195fafb79..63bac4ff3a 100644
--- a/server/src/__tests__/ai-connections.test.ts
+++ b/server/src/__tests__/ai-connections.test.ts
@@ -1,18 +1,20 @@
import { connectionIntentService } from "../services/connection-intents.js";
import { connectionIntentDeliveryService } from "../services/connection-intent-delivery.js";
import { issueRecoveryActionService } from "../services/issue-recovery-actions.js";
+import { localAiLoginService } from "../services/local-ai-login.js";
import * as localCredentials from "../services/local-ai-credentials.js";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
-import { mkdtemp, rm, access, readFile, writeFile } from "node:fs/promises";
+import { mkdtemp, realpath, rm, access, readFile, writeFile, stat } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { and, eq, sql } from "drizzle-orm";
-import { createDb, companies, agents, heartbeatRuns, companyMemberships, connectionGrants, connectionGrantDelegations, connectionGrantMembers, toolConnections, toolConnectionInstalls, aiConnectionDefaults, aiProviderDefaults, adapterAuthSessions, environments, issues, issueThreadInteractions, issueRecoveryActions, connectionIntentDeliveries, agentWakeupRequests, companySecrets, principalPermissionGrants } from "@paperclipai/db";
+import { createDb, companies, agents, heartbeatRuns, companyMemberships, connectionGrants, toolApplications, connectionGrantDelegations, connectionGrantMembers, toolConnections, toolConnectionInstalls, aiConnectionDefaults, aiProviderDefaults, adapterAuthSessions, environments, issues, issueThreadInteractions, issueRecoveryActions, connectionIntentDeliveries, agentWakeupRequests, companySecrets, principalPermissionGrants } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "@paperclipai/db/test-embedded-postgres";
import { aiConnectionService } from "../services/ai-connections.js";
import * as executionTarget from "@paperclipai/adapter-utils/execution-target";
import { prepareManagedAiRuntime, assertManagedAiProjectAuth } from "../services/ai-connection-runtime.js";
+import { execute as executeGemini, testEnvironment as testGeminiEnvironment } from "@paperclipai/adapter-gemini-local/server";
import { toolAccessService } from "../services/tool-access.js";
import { secretService } from "../services/secrets.js";
import { aiConnectionBindingSchema, connectionPurposeTransportSchema, isAiConnectionCompatible } from "@paperclipai/shared";
@@ -20,6 +22,9 @@ import express from "express";
import request from "supertest";
import { aiConnectionRoutes, canInstallSharedAiConnectionForNewAgent, responsibleUserForAiRequest } from "../routes/ai-connections.js";
import { validateAiApiKey } from "../routes/ai-connections.js";
+vi.mock("../services/local-ai-browser-login.js", () => ({
+ startLocalBrowserLogin: () => ({ authorizationUrl: "https://auth.openai.com/codex/device", code: "ABCD-EFGHJ", abort: () => {} }),
+}));
let database: Awaited>;
let db: ReturnType;
@@ -33,7 +38,7 @@ const input = { companyId, agentId, adapterType: "claude_local", binding };
const create = (userId: string, name: string, ownership: "personal" | "shared" = "personal") => service.save(companyId, userId, { provider: "anthropic", method: "api_key", ownership, name, apiKey: "fixture", agentIds: [], allAgents: true }, `fixture-${name}`);
beforeAll(async () => {
- home = await mkdtemp(path.join(os.tmpdir(), "paperclip-ai-tests-"));
+ home = await realpath(await mkdtemp(path.join(os.tmpdir(), "paperclip-ai-tests-")));
vi.stubEnv("PAPERCLIP_HOME", home);
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "ai-connection-fixture");
database = await startEmbeddedPostgresTestDatabase("paperclip-ai-db-");
@@ -46,6 +51,96 @@ beforeAll(async () => {
afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); if (home) await rm(home, { recursive: true, force: true }); });
describe("managed AI connections", () => {
+ it.each([
+ ["openai", "codex_local", "responses"],
+ ["anthropic", "claude_local", "messages"],
+ ["openai", "opencode_local", "chat"],
+ ] as const)("prepares a no-auth %s endpoint without a vault secret", async (provider, adapterType, protocol) => {
+ const account = await service.save(companyId, "alice", {
+ provider, method: "api_key", ownership: "personal", name: `No-auth ${adapterType}`,
+ agentIds: [], allAgents: true,
+ routing: { kind: "local", protocol, baseUrl: "http://127.0.0.1:9000/v1", auth: "none", models: [] },
+ }, "");
+ const binding = { provider, method: "api_key", mode: "connection", connectionId: account.connectionId, grantId: account.grantId } as const;
+ const config = { model: "fixture-model", env: { OPENAI_API_KEY: "host-key", ANTHROPIC_API_KEY: "host-key" } };
+ const first = await prepareManagedAiRuntime(db, { ...input, adapterType, responsibleUserId: "alice", binding, config });
+ const second = await prepareManagedAiRuntime(db, { ...input, adapterType, responsibleUserId: "alice", binding, config });
+ try {
+ expect(first.sessionIdentity).toBe(second.sessionIdentity);
+ expect(first.sessionIdentity).toContain("no-auth");
+ expect(JSON.stringify(first.config)).not.toContain("host-key");
+ const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, account.grantId));
+ expect(grant.credentialSecretRefs).toEqual([]);
+ } finally { await Promise.all([first.cleanup(), second.cleanup()]); }
+ });
+ it("authenticates local Gemini probes and runs with the saved key in an isolated home", async () => {
+ const root = await mkdtemp(path.join(home, "gemini-auth-"));
+ const command = path.join(root, "gemini");
+ await writeFile(command, `#!/usr/bin/env node
+const fs = require("node:fs");
+const path = require("node:path");
+const settings = JSON.parse(fs.readFileSync(path.join(process.env.HOME, ".gemini/settings.json"), "utf8"));
+if (settings.selectedAuthType !== "gemini-api-key" || settings.security?.auth?.selectedType !== "gemini-api-key" || process.env.GEMINI_API_KEY !== "saved-google-key") {
+ console.error("Invalid auth method selected");
+ process.exit(1);
+}
+console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "gemini-managed" }));
+console.log(JSON.stringify({ type: "assistant", message: { content: [{ type: "output_text", text: "hello" }] } }));
+console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello", session_id: "gemini-managed" }));
+`, { mode: 0o700 });
+ const saved = await service.save(companyId, "alice", {
+ provider: "google", method: "api_key", ownership: "personal", name: "Saved Google",
+ apiKey: "fixture", allAgents: true, agentIds: [],
+ }, "saved-google-key");
+ const runtime = await prepareManagedAiRuntime(db, {
+ companyId, agentId, responsibleUserId: "alice", adapterType: "gemini_local",
+ binding: { provider: "google", method: "api_key", mode: "responsible_user" },
+ config: { engine: "cli", command, cwd: root, promptTemplate: "Say hello.", paperclipRuntimeSkills: [], env: { GEMINI_API_KEY: "ambient-google-key" } },
+ });
+ try {
+ expect(runtime.attribution.grantId).toBe(saved.grantId);
+ expect(runtime.home).not.toBe(os.homedir());
+ const settingsFile = path.join(runtime.home!, ".gemini/settings.json");
+ expect(await readFile(settingsFile, "utf8")).not.toContain("saved-google-key");
+ expect((await stat(settingsFile)).mode & 0o777).toBe(0o600);
+ const probe = await testGeminiEnvironment({ companyId, adapterType: "gemini_local", config: runtime.config });
+ expect(probe.status).toBe("pass");
+ expect(probe.checks).toContainEqual(expect.objectContaining({ code: "gemini_hello_probe_passed" }));
+ const result = await executeGemini({
+ runId: randomUUID(),
+ agent: { id: agentId, companyId, name: "Gemini", adapterType: "gemini_local", adapterConfig: { engine: "cli" } },
+ runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
+ config: runtime.config, context: {}, onLog: async () => {},
+ });
+ expect(result.exitCode).toBe(0);
+ expect(result.sessionId).toBe("gemini-managed");
+ } finally {
+ await runtime.cleanup();
+ await rm(root, { recursive: true, force: true });
+ }
+ await expect(access(runtime.home!)).rejects.toThrow();
+ });
+
+ it("preserves Google account defaults when the provider constraint migration is reapplied", async () => {
+ const saved = await service.save(companyId, "bob", {
+ provider: "google", method: "api_key", ownership: "personal", name: "Google migration",
+ apiKey: "fixture", allAgents: true, agentIds: [],
+ }, "google-migration-key");
+ const migration = await readFile(new URL("../../../packages/db/src/migrations/0306_familiar_titania.sql", import.meta.url), "utf8");
+ for (let attempt = 0; attempt < 2; attempt += 1) {
+ await db.transaction(async (tx) => {
+ for (const statement of migration.split("--> statement-breakpoint")) {
+ if (statement.trim()) await tx.execute(sql.raw(statement));
+ }
+ });
+ }
+ const selected = await service.select({
+ companyId, agentId, userId: "bob", adapterType: "gemini_local",
+ binding: { provider: "google", method: "api_key", mode: "responsible_user" },
+ });
+ expect(selected.grant.id).toBe(saved.grantId);
+ });
+
it.each([false, true])("reports the authoritative connection-manager capability for custom grants (manager: %s)", async (manager) => {
const userId = `custom-manager-${manager}`;
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "member" });
@@ -120,6 +215,61 @@ 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("reconnects JSONB routing without changing its identity or access", async () => {
+ const routing = { kind: "gateway", protocol: "responses", auth: "bearer", baseUrl: "https://gateway.example/v1", models: [{ id: "gateway-model" }] } as const;
+ const input = { provider: "openai", method: "api_key", name: "Reconnect gateway", ownership: "personal", allAgents: false, agentIds: [agentId], routing: { ...routing, models: [...routing.models] } } as const;
+ const saved = await service.save(companyId, "alice", { ...input, agentIds: [...input.agentIds] }, "old-gateway-credential");
+ const [stored] = await db.select().from(toolConnections).where(eq(toolConnections.id, saved.connectionId));
+ expect(stored!.config.ai).toMatchObject({ routing });
+ expect(stored!.config.sourceTemplateKey).toBe("responses-api");
+ const [application] = await db.select().from(toolApplications).where(eq(toolApplications.id, stored!.applicationId));
+ expect(application).toMatchObject({ applicationKey: "app-gallery:responses-api", metadata: { sourceTemplateKey: "responses-api" } });
+ // PostgreSQL JSONB reorders object keys; compare values, not serialization.
+ const reordered = { models: [...routing.models], auth: routing.auth, baseUrl: routing.baseUrl, protocol: routing.protocol, kind: routing.kind };
+ expect(await service.save(companyId, "alice", { ...input, agentIds: [], allAgents: true, connectionId: saved.connectionId, routing: reordered }, "new-gateway-credential")).toEqual(saved);
+ const selection = { companyId, agentId, userId: "alice", adapterType: "codex_local", binding: { provider: "openai", method: "api_key", mode: "delegated", ...saved } } as const;
+ const selected = await service.select(selection);
+ expect(await service.credential(selected)).toBe("new-gateway-credential");
+ const installs = await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, saved.connectionId));
+ expect(installs).toEqual([expect.objectContaining({ targetType: "agent", targetId: agentId })]);
+ await expect(service.save(companyId, "alice", { ...input, agentIds: [agentId], connectionId: saved.connectionId, routing: { ...reordered, baseUrl: "https://different.example/v1" } }, "rejected-credential")).rejects.toThrow("retain");
+ expect(await service.credential(await service.select(selection))).toBe("new-gateway-credential");
+ });
+
+ 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) => {
@@ -644,7 +794,22 @@ describe("managed AI connections", () => {
expect(network).not.toHaveBeenCalled();
} finally { network.mockRestore(); }
});
- it("imports only for the local operator and preserves identity and permissions on reconnect", async () => {
+ it.each(["anthropic", "openai"] as const)("reports a missing %s browser process after a server restart", async provider => {
+ const [environment] = await db.select().from(environments).where(eq(environments.driver, "local")).limit(1);
+ const id = randomUUID();
+ const owner = `restart-${provider}`;
+ const intent = { provider, method: "subscription", ownership: "personal", name: "Interrupted sign-in", allAgents: false, agentIds: [] } as const;
+ await db.insert(adapterAuthSessions).values({ id, publicSessionId: id, companyId, environmentId: environment.id, startedByUserId: owner, adapterType: provider === "anthropic" ? "claude_local" : "codex_local", aiConnection: { ...intent, agentIds: [] }, connectionMethod: "local_subscription", status: "waiting_for_user", expiresAt: new Date(Date.now() + 60_000) });
+ const reader = vi.spyOn(localCredentials, "readVerifiedLocalAiCredential").mockRejectedValue(new Error("No credential yet"));
+ try {
+ const login = localAiLoginService(db);
+ await expect(login.check(companyId, owner, { ...intent, agentIds: [] }, id)).resolves.toEqual({ status: "sign_in_required", error: "The server restarted during sign-in. Start sign-in again." });
+ await expect(login.check(companyId, "bob", { ...intent, agentIds: [] }, id)).rejects.toThrow("not found");
+ reader.mockResolvedValue("completed-before-restart");
+ await expect(login.check(companyId, owner, { ...intent, agentIds: [] }, id)).resolves.toEqual({ status: "ready" });
+ } finally { reader.mockRestore(); await db.delete(adapterAuthSessions).where(eq(adapterAuthSessions.id, id)); }
+ });
+ it("requires an owned browser sign-in attempt for local subscriptions", async () => {
const reader = vi.spyOn(localCredentials, "readVerifiedLocalAiCredential").mockResolvedValue("fixture-local-token");
const app = express();
app.use(express.json());
@@ -662,30 +827,18 @@ describe("managed AI connections", () => {
expect((await request(app).post(`${url}/check`).send(payload)).status).toBe(403);
expect(reader).not.toHaveBeenCalled();
const checked = await request(app).post(`${url}/check`).set("x-local", "yes").send(payload);
- expect(checked.status).toBe(200);
- expect(checked.body).toEqual({ status: "ready" });
+ expect(checked.status).toBe(422);
expect((await service.list(companyId, "alice")).some(c => c.name === payload.name)).toBe(false);
const connected = await request(app).post(url).set("x-local", "yes").send(payload);
- expect(connected.status).toBe(201);
- expect(JSON.stringify(connected.body)).not.toContain("fixture-local-token");
- const before = await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connected.body.connectionId));
- expect(before.map(i => [i.targetType, i.targetId])).toEqual([["agent", agentId]]);
- const reconnected = await request(app).post(url).set("x-local", "yes").send({ ...payload, connectionId: connected.body.connectionId, allAgents: true });
- expect(reconnected.status).toBe(201);
- expect(reconnected.body).toEqual(connected.body);
- const after = await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connected.body.connectionId));
- expect(after).toEqual(before);
- reader.mockRejectedValueOnce(Object.assign(new Error("Sign in locally and retry"), { status: 422 }));
- const failed = await request(app).post(url).set("x-local", "yes").send({ ...payload, name: "Unsuccessful local login" });
- expect(failed.status).toBe(422);
- expect((await service.list(companyId, "alice")).some(c => c.name === "Unsuccessful local login")).toBe(false);
+ expect(connected.status).toBe(422);
+ expect(reader).not.toHaveBeenCalled();
const codex = { ...payload, provider: "openai", name: "Isolated terminal login" };
const attempts = `${url}/attempts`;
expect((await request(app).post(attempts).send(codex)).status).toBe(403); // This member cannot authorize agentId.
expect((await request(app).post(url).set("x-local", "yes").send(codex)).status).toBe(422);
const prepared = await request(app).post(attempts).set("x-local", "yes").send(codex);
expect(prepared.status).toBe(201);
- expect(prepared.body.command).toMatch(/^\(export CODEX_HOME=.* && mkdir -p .* && codex -c .* login --device-auth\)$/);
+ expect(prepared.body.command).toBeUndefined();
expect((await request(app).post(attempts).set("x-local", "yes").send(codex)).body).toEqual(prepared.body);
expect((await request(app).delete(`${attempts}/${prepared.body.sessionId}`).set("x-test-user", "bob").send()).status).toBe(404);
expect((await request(app).delete(`${attempts}/${prepared.body.sessionId}`).set("x-local", "yes").send()).status).toBe(200);
@@ -732,13 +885,18 @@ describe("managed AI connections", () => {
const started = await request(app).post(`${base}/attempts`).send(intent);
expect(started.status).toBe(201);
expect(started.headers["cache-control"]).toBe("no-store");
- expect(started.body.command).toContain(provider === "anthropic" ? "CLAUDE_CONFIG_DIR=" : "login --device-auth");
+ expect(started.body.command).toBeUndefined();
expect((await request(app).post(`${base}/attempts`).send(intent)).body).toEqual(started.body);
const input = { ...intent, localSessionId: started.body.sessionId };
for (const endpoint of [base, `${base}/check`]) {
expect((await request(app).post(endpoint).set("x-test-user", "bob").send(input)).status).toBe(404);
expect((await request(app).post(endpoint.replace(companyId, otherCompanyId)).send(input)).status).toBe(403);
}
+ if (provider === "anthropic") {
+ const codeUrl = `${base}/attempts/${started.body.sessionId}/code`;
+ expect((await request(app).post(codeUrl).set("x-test-user", "bob").send({ browserCode: "fixture-code" })).status).toBe(404);
+ expect((await request(app).post(codeUrl).send({ browserCode: "fixture-code" })).status).toBe(422);
+ }
expect(reader).not.toHaveBeenCalled();
const checked = await request(app).post(`${base}/check`).send(input);
expect(checked.body).toEqual({ status: "ready" });
diff --git a/server/src/__tests__/ai-legacy-compatibility.test.ts b/server/src/__tests__/ai-legacy-compatibility.test.ts
index ee9a3e05a6..f5d56cbd9a 100644
--- a/server/src/__tests__/ai-legacy-compatibility.test.ts
+++ b/server/src/__tests__/ai-legacy-compatibility.test.ts
@@ -13,6 +13,16 @@ import { localAiLoginService } from "../services/local-ai-login.js";
import { readVerifiedLocalAiCredential } from "../services/local-ai-credentials.js";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
+const browserLogin = vi.hoisted(() => ({ submitCode: vi.fn(), abort: vi.fn() }));
+vi.mock("../services/local-ai-browser-login.js", () => ({
+ startLocalBrowserLogin: () => ({
+ authorizationUrl: "https://auth.openai.com/codex/device",
+ code: "TEST-CODE",
+ submitCode: browserLogin.submitCode,
+ abort: browserLogin.abort,
+ }),
+}));
+
let database: Awaited>;
let db: ReturnType;
let home: string;
@@ -72,6 +82,35 @@ const loginIntent = () => ({ ...intent, agentIds: [] });
const auth = (mark: string, hour = 10) => JSON.stringify({ tokens: { account_id: "fixture-account", id_token: `id-${mark}`, access_token: `access-${mark}`, refresh_token: `refresh-${mark}` }, last_refresh: `2026-09-10T${hour}:00:00Z` });
const directoryFor = (id: string) => path.join(resolvePaperclipInstanceRoot(), "ai-local-logins", id);
+it("accepts a Claude code once, only from its owner, and audits without the code", async () => {
+ const login = localAiLoginService(db);
+ const intent = { ...loginIntent(), provider: "anthropic" as const };
+ const attempt = await login.start(companyId, owner, intent);
+ browserLogin.submitCode.mockClear();
+ await expect(login.submitCode(companyId, "another-owner", attempt.sessionId, "fixture-secret-code")).rejects.toThrow("not found");
+ await expect(login.submitCode(randomUUID(), owner, attempt.sessionId, "fixture-secret-code")).rejects.toThrow("not found");
+ expect(browserLogin.submitCode).not.toHaveBeenCalled();
+ await login.submitCode(companyId, owner, attempt.sessionId, "fixture-secret-code");
+ expect(browserLogin.submitCode).toHaveBeenCalledExactlyOnceWith("fixture-secret-code");
+ await expect(login.submitCode(companyId, owner, attempt.sessionId, "fixture-secret-code")).rejects.toThrow("cannot accept a code");
+ const events = await db.select().from(activityLog).where(eq(activityLog.entityId, attempt.sessionId));
+ expect(events.filter(event => event.action === "ai_connection.local_login_code_submitted")).toHaveLength(1);
+ expect(JSON.stringify(events)).not.toContain("fixture-secret-code");
+ await login.cancel(companyId, owner, attempt.sessionId);
+});
+
+it.each(["cancelled", "expired"])("rejects code submission to a %s Claude attempt", async state => {
+ const login = localAiLoginService(db);
+ const intent = { ...loginIntent(), provider: "anthropic" as const };
+ const attempt = await login.start(companyId, owner, intent);
+ browserLogin.submitCode.mockClear();
+ if (state === "cancelled") await login.cancel(companyId, owner, attempt.sessionId);
+ else await db.update(adapterAuthSessions).set({ expiresAt: new Date(Date.now() - 1000) }).where(eq(adapterAuthSessions.id, attempt.sessionId));
+ await expect(login.submitCode(companyId, owner, attempt.sessionId, "fixture-code")).rejects.toThrow("expired or was cancelled");
+ expect(browserLogin.submitCode).not.toHaveBeenCalled();
+ await login.reapExpired();
+});
+
it("isolates sign-in and refresh from the host, survives restart, and completes only once", async () => {
const hostHome = path.join(home, "host-codex");
await mkdir(hostHome);
@@ -82,15 +121,17 @@ it("isolates sign-in and refresh from the host, survives restart, and completes
const attempt = await login.start(companyId, owner, loginIntent());
const directory = directoryFor(attempt.sessionId);
expect(await localAiLoginService(db).start(companyId, owner, loginIntent())).toEqual(attempt);
- expect(attempt.command).toContain(`(export CODEX_HOME='${directory}' && mkdir -p "$CODEX_HOME" && codex`);
+ expect(attempt.command).toBeUndefined();
expect(await readFile(path.join(directory, "config.toml"), "utf8")).toContain('cli_auth_credentials_store = "file"');
- expect(await login.check(companyId, owner, loginIntent(), attempt.sessionId)).toEqual({ status: "sign_in_required" });
+ expect(await localAiLoginService(db).check(companyId, owner, loginIntent(), attempt.sessionId)).toEqual({
+ status: "sign_in_required", authorizationUrl: "https://auth.openai.com/codex/device", code: "TEST-CODE",
+ });
// Resuming a valid attempt also repairs a missing directory without changing its ID.
await rm(directory, { recursive: true });
expect(await login.start(companyId, owner, loginIntent())).toEqual(attempt);
expect(await readFile(path.join(directory, "config.toml"), "utf8")).toContain('cli_auth_credentials_store = "file"');
// A valid host login cannot satisfy an unfinished connection-specific login.
- await expect(login.complete(companyId, owner, attempt.sessionId, loginIntent())).rejects.toThrow("sign-in command shown");
+ await expect(login.complete(companyId, owner, attempt.sessionId, loginIntent())).rejects.toThrow("Finish browser sign-in");
expect((await aiConnectionService(db).list(companyId, owner)).filter(c => c.provider === "openai")).toHaveLength(0);
await writeFile(path.join(directory, "auth.json"), auth("independent-login"));
expect(await login.check(companyId, owner, loginIntent(), attempt.sessionId)).toEqual({ status: "ready" });
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 0000000000..4e46b0f80d
--- /dev/null
+++ b/server/src/__tests__/ai-provider-routing.test.ts
@@ -0,0 +1,152 @@
+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.each(["gateway", "openrouter"] as const)("uses a terminal-filtered secret variable for Hermes %s authentication", kind => {
+ const route = aiProviderRoutingSchema.parse({ kind, protocol: "chat", auth: "bearer", ...(kind === "gateway" ? { baseUrl: "https://gateway.example/v1" } : {}) });
+ const projected = managedProviderRouting(route, "hermes_local", "selected-key", "fixture-model");
+ expect(projected.env.OPENAI_API_KEY).toBe("selected-key");
+ expect(projected.env.PAPERCLIP_AI_PROVIDER_KEY).toBeUndefined();
+ expect(projected.hermesConfig).toContain('api_key: "${OPENAI_API_KEY}"');
+ expect(projected.hermesConfig).not.toContain("selected-key");
+ expect(Object.entries(projected.env).filter(([, value]) => value === "selected-key").map(([name]) => name)).toEqual(kind === "openrouter" ? ["OPENAI_API_KEY", "OPENROUTER_API_KEY"] : ["OPENAI_API_KEY"]);
+ });
+ 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 only a Bedrock API key and rejects general AWS access keys", () => {
+ const routing = aiProviderRoutingSchema.parse({
+ kind: "bedrock",
+ protocol: "bedrock",
+ auth: "bearer",
+ region: "us-east-1",
+ });
+ const projected = managedProviderRouting(
+ routing,
+ "claude_local",
+ "fixture-bedrock-key",
+ "bedrock-model",
+ );
+ expect(projected.env).toMatchObject({
+ CLAUDE_CODE_USE_BEDROCK: "1",
+ AWS_REGION: "us-east-1",
+ AWS_BEARER_TOKEN_BEDROCK: "fixture-bedrock-key",
+ AWS_EC2_METADATA_DISABLED: "true",
+ });
+ expect(aiProviderRoutingSchema.safeParse({ ...routing, auth: "aws_credentials" }).success).toBe(false);
+ for (const key of ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN"]) expect(projected.env).not.toHaveProperty(key);
+ });
+});
diff --git a/server/src/__tests__/codex-local-adapter-environment.test.ts b/server/src/__tests__/codex-local-adapter-environment.test.ts
index 884e40950d..a4ab6e7100 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/__tests__/local-ai-credentials.test.ts b/server/src/__tests__/local-ai-credentials.test.ts
index be138c306c..2042eb0e2f 100644
--- a/server/src/__tests__/local-ai-credentials.test.ts
+++ b/server/src/__tests__/local-ai-credentials.test.ts
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { readVerifiedLocalAiCredential } from "../services/local-ai-credentials.js";
-const mocks = vi.hoisted(() => ({ claude: vi.fn(), claudeIsolatedKeychain: vi.fn(), claudeQuota: vi.fn(), codex: vi.fn(), codexQuota: vi.fn(), readFile: vi.fn(), credentialFile: vi.fn() }));
-vi.mock("@paperclipai/adapter-claude-local/server", () => ({ readClaudeToken: mocks.claude, readIsolatedClaudeKeychainToken: mocks.claudeIsolatedKeychain, fetchClaudeQuota: mocks.claudeQuota }));
+const mocks = vi.hoisted(() => ({ claude: vi.fn(), claudeIsolatedKeychain: vi.fn(), codex: vi.fn(), codexQuota: vi.fn(), readFile: vi.fn(), credentialFile: vi.fn() }));
+vi.mock("@paperclipai/adapter-claude-local/server", () => ({ readClaudeToken: mocks.claude, readIsolatedClaudeKeychainToken: mocks.claudeIsolatedKeychain }));
vi.mock("@paperclipai/adapter-codex-local/server", () => ({ readCodexAuthInfo: mocks.codex, fetchCodexQuota: mocks.codexQuota }));
vi.mock("../services/local-ai-credential-file.js", () => ({ readLocalAiCredentialFile: mocks.credentialFile }));
vi.mock("node:fs/promises", () => ({ default: { readFile: mocks.readFile } }));
@@ -11,18 +11,16 @@ describe("explicit local subscription import", () => {
mocks.credentialFile.mockResolvedValue(JSON.stringify({ claudeAiOauth: { accessToken: "isolated-claude" } }));
await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).resolves.toBe("isolated-claude");
expect(mocks.credentialFile).toHaveBeenCalledWith("/isolated/claude/.credentials.json");
- expect(mocks.claudeQuota).toHaveBeenCalledWith("isolated-claude");
expect(mocks.claude).not.toHaveBeenCalled();
});
it("does not fall back to ambient Claude auth when an isolated login is absent or invalid", async () => {
mocks.claude.mockResolvedValue("server-operator-token");
mocks.claudeIsolatedKeychain.mockResolvedValue(null);
mocks.credentialFile.mockRejectedValue(new Error("No file"));
- await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).rejects.toThrow("sign-in command shown");
+ await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).rejects.toThrow("Finish browser sign-in");
mocks.credentialFile.mockResolvedValue("malformed");
- await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).rejects.toThrow("sign-in command shown");
+ await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).rejects.toThrow("Finish browser sign-in");
expect(mocks.claude).not.toHaveBeenCalled();
- expect(mocks.claudeQuota).not.toHaveBeenCalled();
expect(mocks.claudeIsolatedKeychain).toHaveBeenCalledWith("/isolated/claude");
});
it("verifies a macOS isolated login from the home's suffixed Keychain item when no credentials file exists", async () => {
@@ -33,7 +31,6 @@ describe("explicit local subscription import", () => {
mocks.claudeIsolatedKeychain.mockResolvedValue("isolated-keychain-claude");
await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).resolves.toBe("isolated-keychain-claude");
expect(mocks.claudeIsolatedKeychain).toHaveBeenCalledWith("/isolated/claude");
- expect(mocks.claudeQuota).toHaveBeenCalledWith("isolated-keychain-claude");
expect(mocks.claude).not.toHaveBeenCalled();
});
it("prefers the credentials file over the Keychain for an isolated login", async () => {
@@ -48,12 +45,6 @@ describe("explicit local subscription import", () => {
expect(mocks.credentialFile).toHaveBeenLastCalledWith("/isolated/claude/credentials.json");
expect(mocks.claude).not.toHaveBeenCalled();
});
- it("verifies Claude's local credential, including explicit Keychain access", async () => {
- mocks.claude.mockResolvedValue("fixture-claude");
- await expect(readVerifiedLocalAiCredential("anthropic")).resolves.toBe("fixture-claude");
- expect(mocks.claude).toHaveBeenCalledWith({ allowKeychain: true });
- expect(mocks.claudeQuota).toHaveBeenCalledWith("fixture-claude");
- });
it("reads Codex refresh credentials only from the isolated login home", async () => {
mocks.codex.mockResolvedValue({ accessToken: "access", refreshToken: "refresh", idToken: "identity", accountId: "account", lastRefresh: "date" });
const result = JSON.parse(await readVerifiedLocalAiCredential("openai", "/isolated/login"));
@@ -70,17 +61,18 @@ describe("explicit local subscription import", () => {
expect(fetch).toHaveBeenCalledWith("https://api.x.ai/v1/models", expect.objectContaining({ redirect: "error" }));
});
it("rejects missing and invalid logins with actionable, redacted errors", async () => {
- mocks.claude.mockResolvedValue(null);
- await expect(readVerifiedLocalAiCredential("anthropic")).rejects.toThrow("claude auth login");
- mocks.claude.mockResolvedValue("fixture-secret");
- mocks.claudeQuota.mockRejectedValue(new Error("credential fixture-secret rejected"));
- await expect(readVerifiedLocalAiCredential("anthropic")).rejects.toThrow(/^Could not verify the local subscription\. Run claude auth login in a terminal on the machine running Paperclip, then try Connect again\.$/);
+ await expect(readVerifiedLocalAiCredential("anthropic")).rejects.toThrow("Start browser sign-in");
+ mocks.credentialFile.mockResolvedValue(JSON.stringify({ claudeAiOauth: { accessToken: "" } }));
+ mocks.claudeIsolatedKeychain.mockResolvedValue(null);
+ await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).rejects.toThrow(/^Could not verify the local subscription\. Finish browser sign-in for this connection, then try Connect again\.$/);
mocks.codex.mockResolvedValue({ accessToken: "incomplete" });
- await expect(readVerifiedLocalAiCredential("openai", "/isolated/login")).rejects.toThrow("sign-in command shown");
+ await expect(readVerifiedLocalAiCredential("openai", "/isolated/login")).rejects.toThrow("Finish browser sign-in");
expect(mocks.codexQuota).not.toHaveBeenCalled();
});
- it.each(["openai", "xai"] as const)("never clones the ambient rotating %s login", async (provider) => {
- await expect(readVerifiedLocalAiCredential(provider)).rejects.toThrow("separate local sign-in");
+ it.each(["anthropic", "openai", "xai"] as const)("never clones the ambient rotating %s login", async (provider) => {
+ await expect(readVerifiedLocalAiCredential(provider)).rejects.toThrow(provider === "xai"
+ ? "Start local sign-in"
+ : "Start browser sign-in");
expect(mocks.codex).not.toHaveBeenCalled();
expect(mocks.readFile).not.toHaveBeenCalled();
});
diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts
index c0412d866b..2c79e54826 100644
--- a/server/src/__tests__/tool-access-service.test.ts
+++ b/server/src/__tests__/tool-access-service.test.ts
@@ -5172,9 +5172,18 @@ describeEmbeddedPostgres("tool access service", () => {
"github",
"github-code-review-bot",
"youcom",
+ "openrouter",
+ "bedrock",
+ "responses-api",
+ "messages-api",
+ "chat-completions-api",
+ "local",
]),
);
- expect(res.body.apps).toHaveLength(59);
+ expect(res.body.apps).toHaveLength(65);
+ for (const slug of ["openrouter", "bedrock", "responses-api", "messages-api", "chat-completions-api", "local"]) {
+ expect(res.body.apps.find((app: { slug: string }) => app.slug === slug).tags).toContain("model-provider");
+ }
expect(
res.body.apps.find((app: { slug: string }) => app.slug === "gmail")
.ownershipAvailability,
diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts
index 0f7e74bbee..418bf32237 100644
--- a/server/src/routes/agents.ts
+++ b/server/src/routes/agents.ts
@@ -1,3 +1,4 @@
+import { aiRoutingHarness } from "@paperclipai/shared";
import { aiConnectionRouterService, poolMemberRuntimeConfig } from "../services/ai-connection-router.js";
import { connectionIntentService } from "../services/connection-intents.js";
import { completeConnectionIntentSchema } from "@paperclipai/shared";
@@ -3432,7 +3433,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 {
@@ -3447,7 +3448,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 adcb505e5a..15246afa1d 100644
--- a/server/src/routes/ai-connections.ts
+++ b/server/src/routes/ai-connections.ts
@@ -1,5 +1,4 @@
import { supportsLocalAiLogin } from "../services/local-ai-login-policy.js";
-import { readVerifiedLocalAiCredential } from "../services/local-ai-credentials.js";
import { localAiLoginService } from "../services/local-ai-login.js";
import { z } from "zod";
import { Router, type Request } from "express";
@@ -17,6 +16,7 @@ import {
aiConnectionLoginIntentSchema,
localAiConnectionSchema,
localAiLoginStartSchema,
+ browserCodeSchema,
isAiConnectionCompatible,
type AiConnectionLoginIntent,
type AiProvider,
@@ -144,6 +144,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 {
@@ -151,7 +152,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}` },
});
@@ -223,9 +224,6 @@ export function aiConnectionRoutes(db: Db, options: Parameters {
+ const companyId = req.params.companyId as string;
+ assertLocalLoginAvailable();
+ assertBoard(req);
+ assertCompanyAccess(req, companyId);
+ const id = z.string().uuid().parse(req.params.sessionId);
+ await localLogin.submitCode(companyId, getActorInfo(req).actorId, id, browserCodeSchema.parse(req.body.browserCode));
+ res.setHeader("Cache-Control", "no-store");
+ res.json({ ok: true });
+ });
router.get("/companies/:companyId/ai-connections", async (req, res) => {
const companyId = req.params.companyId as string;
assertBoard(req);
@@ -328,12 +336,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 0f17104e7e..2edcfb807e 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 5064ab1666..5fb8c03e2e 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 ea691f5f3d..bce54ad3d2 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,
};
}
@@ -224,6 +232,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(
@@ -231,7 +240,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,
@@ -258,13 +268,15 @@ export async function prepareManagedAiRuntime(
"The selected default changed. Retry this execution.",
);
const credentialRef = selection.grant.credentialSecretRefs.find((ref) => ref.configPath === "ai.credential");
- if (!credentialRef) throw unprocessable("The selected AI credential is unavailable");
- const readFreshness = async () => (await db.select({ epoch: companySecrets.aiSessionEpoch, version: companySecrets.latestVersion })
- .from(companySecrets).where(and(eq(companySecrets.companyId, input.companyId), eq(companySecrets.id, credentialRef.secretId))).limit(1))[0];
- const freshness = await readFreshness();
- const value = await service.credential(selection);
- const afterRead = await readFreshness();
- if (!freshness || freshness.version !== afterRead?.version || freshness.epoch !== afterRead.epoch) throw unprocessable("The AI credential changed during preparation; retry this execution");
+ const routing = aiConnectionMetadataSchema.parse(selection.connection.config.ai).routing;
+ const noAuth = routing?.auth === "none";
+ if (!noAuth && !credentialRef) throw unprocessable("The selected AI credential is unavailable");
+ const readFreshness = async () => credentialRef ? (await db.select({ epoch: companySecrets.aiSessionEpoch, version: companySecrets.latestVersion })
+ .from(companySecrets).where(and(eq(companySecrets.companyId, input.companyId), eq(companySecrets.id, credentialRef.secretId))).limit(1))[0] : undefined;
+ const freshness = noAuth ? undefined : await readFreshness();
+ const value = noAuth ? "" : await service.credential(selection);
+ const afterRead = noAuth ? undefined : await readFreshness();
+ if (!noAuth && (!freshness || freshness.version !== afterRead?.version || freshness.epoch !== afterRead.epoch)) throw unprocessable("The AI credential changed during preparation; retry this execution");
home = await mkdtemp(
path.join(
os.tmpdir(),
@@ -283,16 +295,16 @@ export async function prepareManagedAiRuntime(
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;
@@ -300,22 +312,40 @@ 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";
}
+ if (!routing && input.binding.provider === "google") {
+ // Gemini headless CLI requires an explicit auth choice even with an API key.
+ // Seed only the disposable connection home, never the operator's settings.
+ const geminiHome = path.join(home, ".gemini");
+ await mkdir(geminiHome, { mode: 0o700 });
+ await writeFile(path.join(geminiHome, "settings.json"), JSON.stringify({
+ selectedAuthType: "gemini-api-key",
+ security: { auth: { selectedType: "gemini-api-key" } },
+ }), { mode: 0o600 });
+ }
+ 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")
.slice(0, 16);
const identity = `${selection.grant.id}:${input.responsibleUserId ?? "shared"}:${generation}`;
- const sessionIdentity = `${selection.grant.id}:${input.responsibleUserId ?? "shared"}:${credentialRef.secretId}:${freshness.epoch}`;
+ const sessionIdentity = `${selection.grant.id}:${input.responsibleUserId ?? "shared"}:${noAuth ? "no-auth" : `${credentialRef!.secretId}:${freshness!.epoch}`}`;
return {
sessionIdentity,
config: {
...input.config,
+ ...projected?.config,
+ ...(routing ? { managedAiRouting: routing } : {}),
env,
managedAiConnection: { ...selection.attribution, identity, sessionIdentity },
},
diff --git a/server/src/services/ai-connection-usage.test.ts b/server/src/services/ai-connection-usage.test.ts
index cd4900d42e..07862376af 100644
--- a/server/src/services/ai-connection-usage.test.ts
+++ b/server/src/services/ai-connection-usage.test.ts
@@ -214,7 +214,7 @@ describe("connection usage probes", () => {
expect(unlimited).toMatchObject({ status: "ok", overage: null, limits: [{ used: 20, limit: null, usedPercent: null, limitReached: null }] });
});
- it.each(["openai", "anthropic", "xai"] as const)("makes unsupported %s API probes explicit without a request", async provider => {
+ it.each(["openai", "anthropic", "xai", "google"] as const)("makes unsupported %s API probes explicit without a request", async provider => {
const request = fixture({});
expect(await probeAiConnectionUsage({ provider, method: "api_key" }, "key", { request })).toMatchObject({ status: "unsupported", limits: [], overage: null });
expect(request).not.toHaveBeenCalled();
diff --git a/server/src/services/ai-connection-usage.ts b/server/src/services/ai-connection-usage.ts
index 74619ef2fe..c827a05e51 100644
--- a/server/src/services/ai-connection-usage.ts
+++ b/server/src/services/ai-connection-usage.ts
@@ -332,7 +332,7 @@ export async function probeAiConnectionUsage(
options: { request?: typeof fetch } = {},
): Promise> {
const base = { ...metadata, checkedAt: new Date().toISOString(), source: null, planType: null, limits: [], overage: null };
- if (!supportsAiConnectionUsage(metadata.provider, metadata.method)) {
+ if (metadata.provider === "google" || !supportsAiConnectionUsage(metadata.provider, metadata.method)) {
return { ...base, status: "unsupported", errorCode: "unsupported", message: messages.unsupported };
}
try {
diff --git a/server/src/services/ai-connections.ts b/server/src/services/ai-connections.ts
index 7cfdbeb155..b7f0f53174 100644
--- a/server/src/services/ai-connections.ts
+++ b/server/src/services/ai-connections.ts
@@ -1,5 +1,6 @@
import { syncConnectionCredentialBindings } from "./connection-credential-bindings.js";
import { createHash, randomUUID } from "node:crypto";
+import { isDeepStrictEqual } from "node:util";
import { and, eq, inArray, or } from "drizzle-orm";
import {
type Db,
@@ -19,6 +20,8 @@ import {
} from "@paperclipai/db";
import {
AI_CONNECTION_CAPABILITIES,
+ aiConnectionCatalogSlug,
+ getAppStoreDefinition,
aiConnectionMetadataSchema,
aiSubscriptionNeedsIsolatedLogin,
isAiConnectionCompatible,
@@ -194,9 +197,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 })
@@ -401,6 +403,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 +468,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 +491,8 @@ export function aiConnectionService(db: Db) {
input.provider
)
throw unprocessable("Reconnect cannot change providers");
+ if (reconnect && !isDeepStrictEqual((reconnect.connection.config.ai as AiConnectionMetadata).routing, 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 +577,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 +626,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 = aiConnectionCatalogSlug(input.provider, routing);
+ const providerName = getAppStoreDefinition(source)?.name ?? 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 +650,7 @@ export function aiConnectionService(db: Db) {
eq(toolApplications.applicationKey, key),
eq(
toolApplications.name,
- AI_CONNECTION_CAPABILITIES[input.provider].name,
+ providerName,
),
),
),
@@ -676,8 +686,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 +714,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 +752,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 0000000000..33ceffb336
--- /dev/null
+++ b/server/src/services/ai-provider-routing.ts
@@ -0,0 +1,76 @@
+import {
+ 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";
+ 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.
+ // Hermes strips OPENAI_API_KEY from terminal children. A custom variable
+ // would expose the reusable gateway key to task-controlled shell commands.
+ 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: "${OPENAI_API_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 da0a675adb..1787dbcc89 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 8d51256000..27007d2b77 100644
--- a/server/src/services/heartbeat.ts
+++ b/server/src/services/heartbeat.ts
@@ -24255,6 +24255,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/local-ai-browser-login.test.ts b/server/src/services/local-ai-browser-login.test.ts
new file mode 100644
index 0000000000..410cc02d0e
--- /dev/null
+++ b/server/src/services/local-ai-browser-login.test.ts
@@ -0,0 +1,92 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { startLocalBrowserLogin } from "./local-ai-browser-login.js";
+
+const initialPath = process.env.PATH;
+let root: string | undefined;
+afterEach(async () => {
+ process.env.PATH = initialPath;
+ if (root) await rm(root, { recursive: true, force: true });
+ root = undefined;
+});
+
+async function fakeCli(name: string, source: string) {
+ root ??= await mkdtemp(path.join(os.tmpdir(), "paperclip-browser-login-"));
+ const bin = path.join(root, "bin");
+ await mkdir(bin, { recursive: true });
+ await writeFile(path.join(bin, name), `#!/bin/sh\n${source}\n`, { mode: 0o700 });
+ process.env.PATH = `${bin}${path.delimiter}${initialPath}`;
+ const home = path.join(root, "credential-home");
+ await mkdir(home, { mode: 0o700 });
+ return home;
+}
+
+describe.skipIf(process.platform === "win32")("local browser subscription login", () => {
+ it("surfaces a Codex device link and code from a local PTY without a user shell command", async () => {
+ const home = await fakeCli("codex", [
+ 'printf "1. Open this link in your browser and sign in to your account\\n"',
+ 'printf "https://auth.openai.com/codex/device\\n"',
+ 'printf "2. Enter this one-time code (expires in 15 minutes)\\n"',
+ 'printf "ABCD-EFGHJ\\n"',
+ ].join("\n"));
+ const login = startLocalBrowserLogin("openai", home);
+ await vi.waitFor(() => expect(login.outcome).toBe("success"), { timeout: 5000 });
+ expect(login.authorizationUrl).toBe("https://auth.openai.com/codex/device");
+ expect(login.code).toBe("ABCD-EFGHJ");
+ });
+
+ it("accepts a Claude browser code and stores its token only in the attempt home", async () => {
+ const url = "https://claude.com/cai/oauth/authorize?client_id=cid&code=abcdefgh&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&redirect_uri=https%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2Fcallback&response_type=code&scope=user&state=0123456789abcdef";
+ const home = await fakeCli("claude", [
+ 'printf "Welcome to Claude Code\\nOpening browser to sign in…\\nBrowser didn\x27t open? Use the url below to sign in (c to copy)\\n"',
+ `printf '%s\\n' '${url}'`,
+ 'printf "Paste code here if prompted >\\n"',
+ 'read -r entered',
+ 'printf "✓ Long-lived authentication token created successfully!\\n\\nYour OAuth token (valid for 1 year):\\n\\nsk-ant-oat01-AAAABBBBCCCCDDDDEEEE11112222FFFFGGGG_HHHH-IIII\\n\\nStore this token securely. You won\x27t be able to see it again.\\n"',
+ ].join("\n"));
+ const login = startLocalBrowserLogin("anthropic", home);
+ await vi.waitFor(() => expect(login.authorizationUrl).toBe(url), { timeout: 5000 });
+ login.submitCode?.("fixture-code");
+ await vi.waitFor(() => expect(login.outcome).toBe("success"), { timeout: 5000 });
+ const credential = JSON.parse(await readFile(path.join(home, ".credentials.json"), "utf8"));
+ expect(credential.claudeAiOauth.accessToken).toMatch(/^sk-ant-oat01-/);
+ expect((await stat(path.join(home, ".credentials.json"))).mode & 0o777).toBe(0o600);
+ });
+
+ it("terminates the local provider process when sign-in is cancelled", async () => {
+ const home = await fakeCli("codex", 'echo $$ > "$CODEX_HOME/login.pid"\nexec sleep 60');
+ const login = startLocalBrowserLogin("openai", home);
+ let pid = 0;
+ await vi.waitFor(async () => { pid = Number(await readFile(path.join(home, "login.pid"), "utf8")); expect(pid).toBeGreaterThan(0); });
+ login.abort();
+ await vi.waitFor(() => expect(login.outcome).toBe("failure"), { timeout: 5000 });
+ await vi.waitFor(() => expect(() => process.kill(pid, 0)).toThrow(), { timeout: 5000 });
+ });
+
+ it("cancels Claude while waiting for its browser code and terminates the provider", async () => {
+ const home = await fakeCli("claude", [
+ 'echo $$ > "$CLAUDE_CONFIG_DIR/login.pid"',
+ 'printf "Welcome to Claude Code\\nOpening browser to sign in…\\nBrowser didn\x27t open? Use the url below to sign in (c to copy)\\n"',
+ 'printf "https://claude.com/cai/oauth/authorize?client_id=cid&code=abcdefgh&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&redirect_uri=https%%3A%%2F%%2Fplatform.claude.com%%2Foauth%%2Fcode%%2Fcallback&response_type=code&scope=user&state=0123456789abcdef\\n"',
+ 'printf "Paste code here if prompted >\\n"',
+ 'read -r entered',
+ ].join("\n"));
+ const login = startLocalBrowserLogin("anthropic", home);
+ await vi.waitFor(() => expect(login.authorizationUrl).toBeDefined());
+ const pid = Number(await readFile(path.join(home, "login.pid"), "utf8"));
+ login.abort();
+ await vi.waitFor(() => expect(login.outcome).toBe("failure"), { timeout: 5000 });
+ await vi.waitFor(() => expect(() => process.kill(pid, 0)).toThrow(), { timeout: 5000 });
+ await expect(readFile(path.join(home, ".credentials.json"))).rejects.toThrow();
+ });
+
+ it("returns a fixed failure without exposing provider error output", async () => {
+ const home = await fakeCli("codex", 'echo "private-provider-error" >&2\nexit 1');
+ const login = startLocalBrowserLogin("openai", home);
+ await vi.waitFor(() => expect(login.outcome).toBe("failure"), { timeout: 5000 });
+ expect(JSON.stringify(login)).not.toContain("private-provider-error");
+ expect(login.authorizationUrl).toBeUndefined();
+ });
+});
diff --git a/server/src/services/local-ai-browser-login.ts b/server/src/services/local-ai-browser-login.ts
new file mode 100644
index 0000000000..ad3ee6dfe7
--- /dev/null
+++ b/server/src/services/local-ai-browser-login.ts
@@ -0,0 +1,108 @@
+import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
+import { open } from "node:fs/promises";
+import { constants } from "node:fs";
+import path from "node:path";
+import { runSetupTokenLogin } from "@paperclipai/adapter-claude-local/server";
+import { runDeviceLogin } from "@paperclipai/adapter-codex-local/server";
+
+export type LocalBrowserLoginState = {
+ authorizationUrl?: string;
+ code?: string;
+ outcome?: "success" | "failure";
+ submitCode?: (code: string) => void;
+ abort: () => void;
+};
+
+// Node has no built-in PTY API. Python's standard-library pty module supplies
+// the terminal required by both CLIs. The Docker image already includes
+// python3. This bridge forwards only bytes; provider output is never logged.
+const PYTHON_PTY_BRIDGE = String.raw`
+import errno, os, pty, select, signal, sys
+pid, master = pty.fork()
+if pid == 0:
+ os.execvpe(sys.argv[1], sys.argv[1:], os.environ)
+def stop(_signal, _frame):
+ try: os.killpg(pid, signal.SIGKILL)
+ except ProcessLookupError: pass
+ sys.exit(1)
+signal.signal(signal.SIGTERM, stop)
+while True:
+ readable, _, _ = select.select([master, 0], [], [])
+ if 0 in readable:
+ data = os.read(0, 4096)
+ if data: os.write(master, data)
+ else: stop(None, None)
+ if master in readable:
+ try: data = os.read(master, 4096)
+ except OSError as error:
+ if error.errno == errno.EIO: break
+ raise
+ if not data: break
+ os.write(1, data)
+_, status = os.waitpid(pid, 0)
+sys.exit(os.waitstatus_to_exitcode(status))
+`;
+
+/** Run the same browser-code login used by sandbox connections on the local host. */
+export function startLocalBrowserLogin(provider: "anthropic" | "openai", home: string): LocalBrowserLoginState {
+ const controller = new AbortController();
+ const state: LocalBrowserLoginState = { abort: () => controller.abort() };
+ const executable = provider === "anthropic" ? "claude" : "codex";
+ const args = provider === "anthropic" ? ["setup-token"] : ["login", "--device-auth"];
+ let child: ChildProcessWithoutNullStreams | null = null;
+ const driver = {
+ start(_command: string, onData: (chunk: string) => void): Promise<{ exitCode: number | null }> {
+ const env = { ...process.env,
+ ANTHROPIC_API_KEY: "", ANTHROPIC_AUTH_TOKEN: "", CLAUDE_CODE_OAUTH_TOKEN: "",
+ OPENAI_API_KEY: "", CODEX_API_KEY: "",
+ CLAUDE_CONFIG_DIR: provider === "anthropic" ? home : process.env.CLAUDE_CONFIG_DIR,
+ CODEX_HOME: provider === "openai" ? home : process.env.CODEX_HOME,
+ BROWSER: "true",
+ };
+ return new Promise((resolve, reject) => {
+ child = spawn("python3", ["-u", "-c", PYTHON_PTY_BRIDGE, executable, ...args], { env, stdio: ["pipe", "pipe", "pipe"] });
+ child.stdout.on("data", (bytes: Buffer) => onData(bytes.toString("utf8")));
+ // Provider output may contain secrets. Never log or retain stderr.
+ child.stderr.resume();
+ child.once("error", reject);
+ child.once("close", (exitCode) => resolve({ exitCode }));
+ });
+ },
+ write(input: string) { child?.stdin.write(input); },
+ stop() { child?.kill("SIGTERM"); },
+ async dispose() { child?.kill("SIGTERM"); },
+ async readFile(file: string) { const { readLocalAiCredentialFile } = await import("./local-ai-credential-file.js"); return Buffer.from(await readLocalAiCredentialFile(file)); },
+ };
+ if (provider === "anthropic") {
+ let deliverCode: ((code: string) => void) | undefined;
+ const codeReady = new Promise((resolve) => { deliverCode = resolve; });
+ state.submitCode = (code) => { deliverCode?.(code); deliverCode = undefined; };
+ void runSetupTokenLogin(driver, {
+ timeoutMs: 300_000, signal: controller.signal,
+ onPrompt: (prompt) => { state.authorizationUrl = prompt.url; },
+ provideCode: (signal) => new Promise((resolve, reject) => {
+ const abort = () => reject(new Error("Local sign-in cancelled"));
+ if (signal.aborted) { abort(); return; }
+ signal.addEventListener("abort", abort, { once: true });
+ codeReady.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
+ }),
+ onCredential: async (bytes) => {
+ const file = await open(path.join(home, ".credentials.json"),
+ constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
+ try {
+ await file.writeFile(JSON.stringify({ claudeAiOauth: { accessToken: bytes.toString("utf8") } }));
+ } finally {
+ await file.close();
+ }
+ },
+ }).then((result) => { state.outcome = result.outcome === "success" ? "success" : "failure"; })
+ .catch(() => { state.outcome = "failure"; });
+ } else {
+ void runDeviceLogin(driver, {
+ timeoutMs: 300_000, signal: controller.signal,
+ onPrompt: (prompt) => { state.authorizationUrl = prompt.url; state.code = prompt.code; },
+ }).then((result) => { state.outcome = result.outcome === "success" ? "success" : "failure"; })
+ .catch(() => { state.outcome = "failure"; });
+ }
+ return state;
+}
diff --git a/server/src/services/local-ai-credentials.ts b/server/src/services/local-ai-credentials.ts
index 88466b5398..8fa10626d6 100644
--- a/server/src/services/local-ai-credentials.ts
+++ b/server/src/services/local-ai-credentials.ts
@@ -1,40 +1,40 @@
import { readLocalAiCredentialFile } from "./local-ai-credential-file.js";
import fs from "node:fs/promises";
import path from "node:path";
-import { readClaudeToken, readIsolatedClaudeKeychainToken, fetchClaudeQuota } from "@paperclipai/adapter-claude-local/server";
+import { readIsolatedClaudeKeychainToken } from "@paperclipai/adapter-claude-local/server";
import { readCodexAuthInfo, fetchCodexQuota } from "@paperclipai/adapter-codex-local/server";
import { parseGrokAuthPayload, hasUsableGrokAuthValue } from "@paperclipai/adapter-grok-local/server";
import type { AiProvider } from "@paperclipai/shared";
import { unprocessable } from "../errors.js";
-/** Read an owned login home, or an explicitly authorized local-operator import. */
+/** Read and verify a connection-specific login home. */
export async function readVerifiedLocalAiCredential(provider: AiProvider, loginHome?: string): Promise {
if (provider === "openrouter") throw unprocessable("OpenRouter requires an API key.");
- if ((provider === "openai" || provider === "xai") && !loginHome)
- throw unprocessable("Start a separate local sign-in for this connection before connecting.");
+ if (!loginHome) throw unprocessable(provider === "xai"
+ ? "Start local sign-in for this connection before connecting."
+ : "Start browser sign-in for this connection before connecting.");
try {
if (provider === "anthropic") {
// Never change process.env or fall back to the server account when an
// authenticated user's isolated login is missing or invalid.
let token: string | null = null;
- if (loginHome) {
- for (const name of [".credentials.json", "credentials.json"]) {
- const raw = await readLocalAiCredentialFile(path.join(loginHome, name)).catch(() => null);
- if (!raw) continue;
- let parsed;
- try { parsed = JSON.parse(raw); } catch { continue; }
- const value = parsed?.claudeAiOauth?.accessToken;
- if (typeof value === "string" && value.length) { token = value; break; }
- }
- // On macOS the CLI stores the isolated login in the auth home's own
- // suffixed Keychain item rather than a credentials file. The helper
- // never consults the unsuffixed operator item.
- if (!token) token = await readIsolatedClaudeKeychainToken(loginHome);
- } else {
- token = await readClaudeToken({ allowKeychain: true });
+ for (const name of [".credentials.json", "credentials.json"]) {
+ const raw = await readLocalAiCredentialFile(path.join(loginHome, name)).catch(() => null);
+ if (!raw) continue;
+ let parsed;
+ try { parsed = JSON.parse(raw); } catch { continue; }
+ const value = parsed?.claudeAiOauth?.accessToken;
+ if (typeof value === "string" && value.length) { token = value; break; }
}
+ // On macOS the CLI stores the isolated login in the auth home's own
+ // suffixed Keychain item rather than a credentials file. The helper
+ // never consults the unsuffixed operator item.
+ if (!token) token = await readIsolatedClaudeKeychainToken(loginHome);
if (!token) throw new Error("Missing login");
- await fetchClaudeQuota(token);
+ // Claude's setup-token OAuth flow requests user:inference. The usage API
+ // requires user:profile and returns 403 for that valid token, so it cannot
+ // serve as a sign-in check. This isolated credential is written only after
+ // the provider CLI completes the code exchange successfully.
return token;
}
if (provider === "openai") {
@@ -55,8 +55,8 @@ export async function readVerifiedLocalAiCredential(provider: AiProvider, loginH
return raw;
} catch {
// Provider/CLI errors may contain credential material; never return them.
- throw unprocessable(provider === "anthropic" && !loginHome
- ? "Could not verify the local subscription. Run claude auth login in a terminal on the machine running Paperclip, then try Connect again."
- : "Could not verify the local subscription. Run the sign-in command shown for this connection, finish signing in, then try Connect again.");
+ throw unprocessable(provider === "xai"
+ ? "Could not verify the local subscription. Run the sign-in command shown for this connection, finish signing in, then try Connect again."
+ : "Could not verify the local subscription. Finish browser sign-in for this connection, then try Connect again.");
}
}
diff --git a/server/src/services/local-ai-login.ts b/server/src/services/local-ai-login.ts
index 8f29a7164a..3492d47896 100644
--- a/server/src/services/local-ai-login.ts
+++ b/server/src/services/local-ai-login.ts
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
+import { realpathSync } from "node:fs";
import { mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { and, eq, inArray, lte, sql } from "drizzle-orm";
@@ -9,22 +10,26 @@ import { notFound, unprocessable } from "../errors.js";
import { aiConnectionService } from "./ai-connections.js";
import { readVerifiedLocalAiCredential } from "./local-ai-credentials.js";
import { logActivity } from "./activity-log.js";
+import { startLocalBrowserLogin, type LocalBrowserLoginState } from "./local-ai-browser-login.js";
const LOCAL_LOGIN_METHOD = "local_subscription";
const ATTEMPT_DURATION_MS = 30 * 60 * 1000;
function loginHome(id: string) {
- return path.join(resolvePaperclipInstanceRoot(), "ai-local-logins", id);
+ const root = resolvePaperclipInstanceRoot();
+ // macOS exposes /var through a system symlink to /private/var. Credential
+ // reads use O_NOFOLLOW_ANY, so generate login homes from the canonical root.
+ // A fresh instance root may not exist yet; prepareHome creates it first.
+ let canonicalRoot = root;
+ try { canonicalRoot = realpathSync(root); }
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
+ return path.join(canonicalRoot, "ai-local-logins", id);
}
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
function presentAttempt(id: string, expiresAt: Date, provider: string): LocalAiLoginAttempt {
const directory = loginHome(id);
return {
sessionId: id, expiresAt: expiresAt.toISOString(),
- command: provider === "openai"
- ? `(export CODEX_HOME=${shellQuote(directory)} && mkdir -p "$CODEX_HOME" && codex -c 'cli_auth_credentials_store="file"' login --device-auth)`
- : provider === "anthropic"
- ? `(export CLAUDE_CONFIG_DIR=${shellQuote(directory)} && mkdir -p "$CLAUDE_CONFIG_DIR" && claude auth login)`
- : `(export GROK_HOME=${shellQuote(directory)} && mkdir -p "$GROK_HOME" && grok login --device-auth)`,
+ ...(provider === "xai" ? { command: `(export GROK_HOME=${shellQuote(directory)} && mkdir -p "$GROK_HOME" && grok login --device-auth)` } : {}),
};
}
async function prepareHome(id: string, provider: string) {
@@ -41,9 +46,21 @@ function sameTarget(a: AiConnectionLoginIntent, b: AiConnectionLoginIntent) {
JSON.stringify([...a.agentIds].sort()) === JSON.stringify([...b.agentIds].sort());
}
-/** Local terminal sign-ins share the durable attempt/credential lifecycle, but
- * never seed their home from the operator's rotating CLI credential. */
+// Route handlers construct this service per request. Keep live browser processes
+// shared so status polling and code submission can reach the original process.
+const browserLogins = new Map();
+
+/** Local sign-ins share the durable attempt/credential lifecycle, but never seed
+ * their home from the operator's rotating CLI credential. */
export function localAiLoginService(db: Db) {
+ function stopBrowserLogin(id: string) {
+ browserLogins.get(id)?.abort();
+ browserLogins.delete(id);
+ }
+ function ensureBrowserLogin(id: string, provider: string) {
+ if (provider === "xai" || browserLogins.has(id)) return;
+ browserLogins.set(id, startLocalBrowserLogin(provider as "anthropic" | "openai", loginHome(id)));
+ }
async function reapExpired() {
// Bounded batches use the existing expires-at index. Replaying is safe.
const rows = await db.select({ id: adapterAuthSessions.id }).from(adapterAuthSessions)
@@ -55,6 +72,7 @@ export function localAiLoginService(db: Db) {
const [session] = await tx.select().from(adapterAuthSessions)
.where(eq(adapterAuthSessions.id, row.id)).for("update");
if (!session || !session.expiresAt || session.expiresAt.getTime() > Date.now()) return;
+ stopBrowserLogin(row.id);
await rm(loginHome(row.id), { recursive: true, force: true });
await tx.update(adapterAuthSessions).set({
status: session.connectionId ? "authenticated" : "timed_out",
@@ -68,7 +86,7 @@ export function localAiLoginService(db: Db) {
if (intent.provider !== "openai" && intent.provider !== "xai" && intent.provider !== "anthropic")
throw unprocessable("This provider does not use a separate local login home.");
await reapExpired();
- return db.transaction(async (tx) => {
+ const attempt = await db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`ai-local-login:${companyId}:${userId}:${intent.provider}`}, 0))`);
const adapterType = intent.provider === "openai" ? "codex_local" : intent.provider === "anthropic" ? "claude_local" : "grok_local";
const [existing] = await tx.select().from(adapterAuthSessions).where(and(
@@ -85,6 +103,7 @@ export function localAiLoginService(db: Db) {
}
if (!restart || existing.connectionMethod !== LOCAL_LOGIN_METHOD)
throw unprocessable("Another sign-in is still open. Finish it, or choose Start sign-in again to replace a local attempt.");
+ stopBrowserLogin(existing.id);
await rm(loginHome(existing.id), { recursive: true, force: true });
await tx.update(adapterAuthSessions).set({ status: "cancelled", finishedAt: new Date(), updatedAt: new Date() })
.where(eq(adapterAuthSessions.id, existing.id));
@@ -120,14 +139,18 @@ export function localAiLoginService(db: Db) {
}
return presentAttempt(id, expiresAt, intent.provider);
});
+ ensureBrowserLogin(attempt.sessionId, intent.provider);
+ return attempt;
}
// Read-only credential detection: never saves a connection or refreshes another
// login. Scope and intent are checked before touching an attempt's directory.
async function check(companyId: string, userId: string, intent: AiConnectionLoginIntent, id?: string): Promise {
let directory: string | undefined;
- if (id || intent.provider !== "anthropic") {
- if (!id) throw unprocessable("Start local sign-in before checking this account.");
+ {
+ if (!id) throw unprocessable(intent.provider === "xai"
+ ? "Start local sign-in before checking this account."
+ : "Start browser sign-in before checking this account.");
const [session] = await db.select().from(adapterAuthSessions).where(and(
eq(adapterAuthSessions.id, id), eq(adapterAuthSessions.companyId, companyId),
eq(adapterAuthSessions.startedByUserId, userId),
@@ -143,10 +166,39 @@ export function localAiLoginService(db: Db) {
await readVerifiedLocalAiCredential(intent.provider, directory);
return { status: "ready" };
} catch {
- return { status: "sign_in_required" };
+ const login = id ? browserLogins.get(id) : undefined;
+ return { status: "sign_in_required", authorizationUrl: login?.authorizationUrl, code: login?.code,
+ ...(!login && intent.provider !== "xai"
+ ? { error: "The server restarted during sign-in. Start sign-in again." }
+ : login?.outcome === "failure"
+ ? { error: "Sign-in ended before the account was connected. Start sign-in again." }
+ : {}) };
}
}
+ async function submitCode(companyId: string, userId: string, id: string, code: string) {
+ await db.transaction(async (tx) => {
+ const [session] = await tx.select().from(adapterAuthSessions).where(and(
+ eq(adapterAuthSessions.id, id), eq(adapterAuthSessions.companyId, companyId),
+ eq(adapterAuthSessions.startedByUserId, userId), eq(adapterAuthSessions.connectionMethod, LOCAL_LOGIN_METHOD),
+ eq(adapterAuthSessions.adapterType, "claude_local"),
+ )).for("update");
+ if (!session) throw notFound("Local sign-in attempt not found.");
+ if (session.status !== "waiting_for_user" || !session.expiresAt || session.expiresAt.getTime() <= Date.now())
+ throw unprocessable("This sign-in attempt has expired or was cancelled. Start sign-in again.");
+ const login = browserLogins.get(id);
+ if (!login?.authorizationUrl || !login.submitCode || login.outcome)
+ throw unprocessable("This sign-in attempt cannot accept a code. Start sign-in again.");
+ const submit = login.submitCode;
+ login.submitCode = undefined;
+ submit(code);
+ await logActivity(tx as unknown as Db, {
+ companyId, actorType: "user", actorId: userId, action: "ai_connection.local_login_code_submitted",
+ entityType: "adapter_auth_session", entityId: id, details: { provider: "anthropic" },
+ });
+ });
+ }
+
async function complete(companyId: string, userId: string, id: string, intent: AiConnectionLoginIntent) {
const result = await db.transaction(async (tx) => {
const [session] = await tx.select().from(adapterAuthSessions).where(and(
@@ -176,6 +228,7 @@ export function localAiLoginService(db: Db) {
return saved;
});
// Failed cleanup can be retried by the same completed attempt or reaper.
+ stopBrowserLogin(id);
await rm(loginHome(id), { recursive: true, force: true });
return result;
}
@@ -188,6 +241,7 @@ export function localAiLoginService(db: Db) {
eq(adapterAuthSessions.connectionMethod, LOCAL_LOGIN_METHOD),
)).for("update");
if (!session) throw notFound("Local sign-in attempt not found.");
+ stopBrowserLogin(id);
await rm(loginHome(id), { recursive: true, force: true });
if (!session.connectionId && session.status !== "cancelled") {
await tx.update(adapterAuthSessions).set({
@@ -201,5 +255,5 @@ export function localAiLoginService(db: Db) {
}
});
}
- return { start, check, complete, cancel, reapExpired };
+ return { start, check, submitCode, complete, cancel, reapExpired };
}
diff --git a/server/src/services/native-runtime/provider-profile.ts b/server/src/services/native-runtime/provider-profile.ts
index 45afa9f310..d14953de11 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/server/src/services/openrouter-models.test.ts b/server/src/services/openrouter-models.test.ts
index a4335bd223..cb47ee3223 100644
--- a/server/src/services/openrouter-models.test.ts
+++ b/server/src/services/openrouter-models.test.ts
@@ -2,16 +2,20 @@ import { afterEach, expect, it, vi } from "vitest";
afterEach(() => { vi.unstubAllGlobals(); vi.resetModules(); });
-it("lists and caches public OpenRouter models without sending credentials", async () => {
+it("lists and caches public OpenRouter models in popularity order without sending credentials", async () => {
const fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: [
+ { id: "z-ai/glm-5", name: "Z.AI GLM" },
{ id: "anthropic/claude-sonnet-4.5", name: "Claude Sonnet" }, { id: 42 },
] }) });
vi.stubGlobal("fetch", fetch);
const { listOpenRouterModels } = await import("./openrouter-models.js");
- expect(await listOpenRouterModels()).toEqual([{ id: "openrouter/anthropic/claude-sonnet-4.5", label: "Claude Sonnet" }]);
+ expect(await listOpenRouterModels()).toEqual([
+ { id: "openrouter/z-ai/glm-5", label: "Z.AI GLM" },
+ { id: "openrouter/anthropic/claude-sonnet-4.5", label: "Claude Sonnet" },
+ ]);
await listOpenRouterModels();
expect(fetch).toHaveBeenCalledTimes(1);
- expect(fetch).toHaveBeenCalledWith("https://openrouter.ai/api/v1/models", { signal: expect.any(AbortSignal) });
+ expect(fetch).toHaveBeenCalledWith("https://openrouter.ai/api/v1/models?sort=most-popular", { signal: expect.any(AbortSignal) });
await listOpenRouterModels(true);
expect(fetch).toHaveBeenCalledTimes(2);
});
diff --git a/server/src/services/openrouter-models.ts b/server/src/services/openrouter-models.ts
index a0c9414caf..0e1ae87be8 100644
--- a/server/src/services/openrouter-models.ts
+++ b/server/src/services/openrouter-models.ts
@@ -8,13 +8,13 @@ export async function listOpenRouterModels(refresh = false): Promise Date.now()) return cached.models;
if (pending) return pending;
pending = (async () => {
- const response = await fetch("https://openrouter.ai/api/v1/models", { signal: AbortSignal.timeout(10_000) });
+ const response = await fetch("https://openrouter.ai/api/v1/models?sort=most-popular", { signal: AbortSignal.timeout(10_000) });
if (!response.ok) throw new Error("Could not load OpenRouter models. Retry or enter a model ID manually.");
const body = await response.json() as { data?: Array<{ id?: unknown; name?: unknown }> };
if (!Array.isArray(body.data)) throw new Error("OpenRouter returned an invalid model catalog.");
const models = body.data.flatMap(model => typeof model.id === "string" && model.id.includes("/")
? [{ id: `openrouter/${model.id}`, label: typeof model.name === "string" ? model.name : model.id }]
- : []).sort((a, b) => a.label.localeCompare(b.label));
+ : []);
cached = { until: Date.now() + 60_000, models };
return models;
})();
diff --git a/ui/public/brands/apps/bedrock.svg b/ui/public/brands/apps/bedrock.svg
new file mode 100644
index 0000000000..610805e512
--- /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 0000000000..e8e0f867bd
--- /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 ed490bfc21..cbb149dab3 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": true,
+ "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": true,
+ "localAsset": "/brands/apps/google.svg"
+ },
{
"slug": "google-drive",
"provider": "Google Drive",
@@ -536,6 +548,30 @@
"localAsset": "/brands/apps/browser-use.svg",
"sourceUrl": "https://browser-use.com/logo-primary.svg",
"retrievedAt": "2026-09-29"
+ },
+ {
+ "slug": "chat-completions-api",
+ "provider": "Chat Completions API",
+ "catalogVisible": true,
+ "localAsset": "/brands/apps/api-key-generic.svg"
+ },
+ {
+ "slug": "local",
+ "provider": "Local endpoint",
+ "catalogVisible": true,
+ "localAsset": "/brands/apps/api-key-generic.svg"
+ },
+ {
+ "slug": "messages-api",
+ "provider": "Messages API",
+ "catalogVisible": true,
+ "localAsset": "/brands/apps/api-key-generic.svg"
+ },
+ {
+ "slug": "responses-api",
+ "provider": "Responses API",
+ "catalogVisible": true,
+ "localAsset": "/brands/apps/api-key-generic.svg"
}
]
}
diff --git a/ui/src/api/ai-connections.ts b/ui/src/api/ai-connections.ts
index c7cb9946ea..cfe3118836 100644
--- a/ui/src/api/ai-connections.ts
+++ b/ui/src/api/ai-connections.ts
@@ -4,6 +4,7 @@ export const aiConnectionsApi = {
probeUsage: (companyId: string, connectionId: string, grantId?: string) => api.get(`/companies/${companyId}/ai-connections/${connectionId}/usage${grantId ? `?grantId=${encodeURIComponent(grantId)}` : ""}`),
startLocalLogin: (companyId: string, input: AiConnectionLoginIntent & { restart?: boolean }) => api.post(`/companies/${companyId}/ai-connections/local/attempts`, input),
checkLocalLogin: (companyId: string, input: AiConnectionLoginIntent & { localSessionId?: string }) => api.post(`/companies/${companyId}/ai-connections/local/check`, input),
+ submitLocalLoginCode: (companyId: string, sessionId: string, browserCode: string) => api.post(`/companies/${companyId}/ai-connections/local/attempts/${sessionId}/code`, { browserCode }),
cancelLocalLogin: (companyId: string, sessionId: string) => api.delete(`/companies/${companyId}/ai-connections/local/attempts/${sessionId}`),
connectLocal: (companyId: string, input: AiConnectionLoginIntent & { localSessionId?: string }) => api.post<{ connectionId: string; grantId: string }>(`/companies/${companyId}/ai-connections/local`, input),
activeRuns: (companyId: string, connectionId: string) => api.get>(`/companies/${companyId}/ai-connections/${connectionId}/active-runs`),
diff --git a/ui/src/components/AdapterLoginChrome.tsx b/ui/src/components/AdapterLoginChrome.tsx
index 6d7aa7e63e..4116981917 100644
--- a/ui/src/components/AdapterLoginChrome.tsx
+++ b/ui/src/components/AdapterLoginChrome.tsx
@@ -469,20 +469,51 @@ export function ProviderApiKeyCard({
/** Shared instructions for local subscription setup in every authentication host. */
export function LocalProviderLoginInstructions({ adapterType, login }: {
adapterType: string;
- login?: { isolated?: boolean; command?: string; preparing: boolean; status?: "ready" | "sign_in_required" | "expired" | null; error: string | null; retry: () => void };
+ login?: { isolated?: boolean; command?: string; authorizationUrl?: string | null; code?: string | null; submitCode?: (code: string) => Promise; preparing: boolean; status?: "ready" | "sign_in_required" | "expired" | null; error: string | null; retry: () => void };
}) {
const [showCommand, setShowCommand] = useState(false);
+ const [browserCode, setBrowserCode] = useState("");
+ const [submitting, setSubmitting] = useState(false);
const provider = adapterType === "claude_local" ? "Claude Code" : adapterType === "grok_local" ? "Grok CLI" : "Codex CLI";
const isolated = login?.isolated ?? (adapterType === "codex_local" || adapterType === "grok_local");
- const command = isolated ? login?.command : "claude auth login";
- if (login?.preparing) return Checking local {provider} sign-in…
;
+ const command = login?.command;
+ const browserLogin = adapterType === "claude_local" || adapterType === "codex_local";
+ async function submitBrowserCode() {
+ if (!browserCode.trim() || !login?.submitCode || submitting) return;
+ setSubmitting(true);
+ try {
+ await login.submitCode(browserCode.trim());
+ setBrowserCode("");
+ } catch {
+ // The login hook presents the request error beside the sign-in card.
+ } finally {
+ setSubmitting(false);
+ }
+ }
+ if (login?.preparing) return Preparing sign-in…
;
const ready = login?.status === "ready";
return
{ready ? <>
{provider} is signed in. Click Connect to use this account.
- {!showCommand &&
setShowCommand(true)}>Use a different account }
- > :
{isolated ? `Sign in to ${provider} for this connection on the machine running Paperclip. Your existing terminal login stays separate.` : `Connect uses your local ${provider} account on the machine running Paperclip.`}
}
- {(!ready || showCommand) && !login?.error && <>
+ {!showCommand &&
browserLogin ? login?.retry() : setShowCommand(true)}>Use a different account }
+ > : !browserLogin &&
{isolated ? `Sign in to ${provider} for this connection on the machine running Paperclip. Your existing terminal login stays separate.` : `Connect uses your local ${provider} account on the machine running Paperclip.`}
}
+ {browserLogin && !ready && !login?.authorizationUrl && !login?.error &&
Preparing browser sign-in…
}
+ {browserLogin && !ready && !login?.error && login?.authorizationUrl &&
+ {adapterType === "claude_local" ?
+ void submitBrowserCode()}
+ disabled={submitting}
+ />
+ void submitBrowserCode()}>Submit code
+
: }
+ }
+ {!browserLogin && (!ready || showCommand) && !login?.error && <>
Run this in a terminal on that machine and finish signing in in your browser. We’ll check automatically when you return.
{command &&
{command}
@@ -490,6 +521,6 @@ export function LocalProviderLoginInstructions({ adapterType, login }: {
}
>}
{login?.error &&
{login.error}
}
- {login && !login.preparing && (isolated || login.error) &&
{isolated ? "Start sign-in again" : "Check again"} }
+ {login && !login.preparing && !ready && (isolated || login.error) &&
{isolated ? "Start sign-in again" : "Check again"} }
;
}
diff --git a/ui/src/components/OnboardingWizard.test.tsx b/ui/src/components/OnboardingWizard.test.tsx
index c82482c5f1..c6294d78ac 100644
--- a/ui/src/components/OnboardingWizard.test.tsx
+++ b/ui/src/components/OnboardingWizard.test.tsx
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { act } from "react";
+import type { LocalAiLoginStatus } from "@paperclipai/shared";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -16,8 +17,9 @@ const localHealth = vi.hoisted(() => ({ get: vi.fn() }));
vi.mock("@/api/health", () => ({ healthApi: localHealth }));
const managedApi = vi.hoisted(() => ({
list: vi.fn(async () => ({ currentUserId: "user-1", connections: [] })),
- startLocalLogin: vi.fn(async () => ({ sessionId: "local-attempt", command: "CODEX_HOME='/fixture/login' codex login", expiresAt: "2026-09-11T20:00:00Z" })),
- checkLocalLogin: vi.fn(async () => ({ status: "sign_in_required" as "sign_in_required" | "ready" })),
+ startLocalLogin: vi.fn(async () => ({ sessionId: "local-attempt", expiresAt: "2099-01-01T00:00:00Z" })),
+ submitLocalLoginCode: vi.fn(async () => ({ ok: true })),
+ checkLocalLogin: vi.fn(async (): Promise => ({ status: "sign_in_required" })),
cancelLocalLogin: vi.fn(async () => ({})),
connectLocal: vi.fn(async () => ({ connectionId: "local-connection", grantId: "local-grant" })),
create: vi.fn(async () => ({ connectionId: "managed-connection", grantId: "managed-grant" })),
@@ -161,7 +163,7 @@ const mockProjectsApi = vi.hoisted(() => ({
// model/harness picker internals are out of scope here, so stub the adapter
// layer entirely and drive it through this knob.
const mockAdapterRegistry = vi.hoisted(() => ({
- list: [] as Array<{ type: string }>,
+ list: [] as Array<{ type: string; recommended?: boolean }>,
disabled: new Set(),
}));
@@ -198,7 +200,7 @@ vi.mock("../adapters/adapter-display-registry", () => ({
// then sat in the "Advanced settings" disclosure and was reachable anyway;
// with the step down to a tile row built from this flag, it made that row
// empty in every test and hid the surface under it.
- recommended: type === "claude_local" || type === "codex_local",
+ recommended: type === "claude_local" || type === "codex_local" || mockAdapterRegistry.list.some(entry => entry.type === type && entry.recommended),
label: type,
description: "",
icon: () => null,
@@ -324,7 +326,11 @@ function isArcPrimary(text: string): boolean {
describe("OnboardingWizard restore-gate (stale localStorage across accounts)", () => {
beforeEach(() => {
localHealth.get.mockResolvedValue({ deploymentMode: "authenticated" });
- managedApi.checkLocalLogin.mockReset().mockResolvedValue({ status: "sign_in_required" });
+ managedApi.checkLocalLogin.mockReset().mockResolvedValue({
+ status: "sign_in_required",
+ authorizationUrl: "https://claude.ai/oauth/authorize?code=true",
+ code: "TEST-CODE",
+ });
managedApi.connectLocal.mockReset().mockResolvedValue({ connectionId: "local-connection", grantId: "local-grant" });
mockAuthApi.getSession.mockResolvedValue({
session: { id: "session-b", userId: SESSION_USER_ID },
@@ -430,7 +436,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
async function clickByText(match: (text: string) => boolean) {
const el = [...document.body.querySelectorAll("button")].find((b) =>
- match(b.textContent?.trim() ?? ""),
+ match(b.getAttribute("aria-label")?.startsWith("Use ") ? b.getAttribute("aria-label")! : b.textContent?.trim() ?? ""),
)!;
await act(async () => {
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@@ -521,7 +527,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
const clickText = async (match: (t: string) => boolean) => {
const el = [...document.body.querySelectorAll("button")].find((b) =>
- match(b.textContent?.trim() ?? ""),
+ match(b.getAttribute("aria-label")?.startsWith("Use ") ? b.getAttribute("aria-label")! : b.textContent?.trim() ?? ""),
)!;
await act(async () => {
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@@ -598,7 +604,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
const clickText = async (match: (t: string) => boolean) => {
const el = [...document.body.querySelectorAll("button")].find((b) =>
- match(b.textContent?.trim() ?? ""),
+ match(b.getAttribute("aria-label")?.startsWith("Use ") ? b.getAttribute("aria-label")! : b.textContent?.trim() ?? ""),
)!;
await act(async () => {
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@@ -777,7 +783,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
const clickByText = async (match: (text: string) => boolean) => {
const el = [...document.body.querySelectorAll("button")].find((b) =>
- match(b.textContent?.trim() ?? ""),
+ match(b.getAttribute("aria-label")?.startsWith("Use ") ? b.getAttribute("aria-label")! : b.textContent?.trim() ?? ""),
)!;
await act(async () => {
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@@ -3063,7 +3069,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
const clickByText = async (match: (text: string) => boolean) => {
const el = [...document.body.querySelectorAll("button")].find((b) =>
- match(b.textContent?.trim() ?? ""),
+ match(b.getAttribute("aria-label")?.startsWith("Use ") ? b.getAttribute("aria-label")! : b.textContent?.trim() ?? ""),
)!;
await act(async () => {
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@@ -3136,7 +3142,8 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
const { root, queryClient } = await openStep4({ adapterType });
try {
await pickSource(label);
- expect(document.body.textContent).toContain("Run this in a terminal");
+ expect(document.body.textContent).toContain("Sign in to");
+ expect(document.body.textContent).not.toContain("Run this in a terminal");
expect(button("Connect").disabled).toBe(false);
await detected();
expectTesting();
@@ -3208,7 +3215,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
await settle();
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
- expect(document.body.textContent).toContain("Run this in a terminal");
+ expect(document.body.textContent).toContain("Sign in to");
expect(button("Connect").disabled).toBe(false);
} finally { await act(async () => root.unmount()); }
});
@@ -3331,19 +3338,34 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
await settle();
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(document.body.textContent).not.toContain("is ready to work!");
- if (navigation === "provider") expect(document.body.textContent).toContain("claude auth login");
+ if (navigation === "provider") expect(document.body.textContent).toContain("Preparing browser sign-in");
} finally { if (mounted) await act(async () => root.unmount()); }
});
});
- it("shows local Claude instructions and saves its connection before hiring", async () => {
+ it("keeps Gemini local login out of managed Claude subscription setup", async () => {
+ localHealth.get.mockResolvedValue({ deploymentMode: "local_trusted" });
+ mockEnvironmentsApi.list.mockResolvedValue([LOCAL_ENVIRONMENT]);
+ mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null });
+ mockAdapterRegistry.list.push({ type: "gemini_local", recommended: true });
+ const { root } = await openStep4({ adapterType: "gemini_local" });
+ try {
+ await pickSource(/Gemini|Google|gemini_local/);
+ expect(managedApi.startLocalLogin).not.toHaveBeenCalled();
+ expect(managedApi.connectLocal).not.toHaveBeenCalled();
+ expect(document.body.textContent).not.toContain("claude auth login");
+ expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
+ } finally { await act(async () => root.unmount()); }
+ });
+
+ it("shows the Claude browser sign-in card and saves its connection before hiring", async () => {
localHealth.get.mockResolvedValue({ deploymentMode: "local_trusted" });
mockEnvironmentsApi.list.mockResolvedValue([LOCAL_ENVIRONMENT]);
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null });
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
- expect(document.body.textContent).toContain("claude auth login");
- expect(document.body.textContent).toContain("machine running Paperclip");
+ expect(document.body.textContent).toContain("Sign in to Claude then come back and enter authorization code");
+ expect(document.body.textContent).not.toContain("claude auth login");
expect(document.body.textContent).not.toContain("No managed sandbox");
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
const connect = [...document.body.querySelectorAll("button")].find(b => b.textContent?.trim().startsWith("Connect"));
diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx
index aeeb67b194..8749e63cf4 100644
--- a/ui/src/components/OnboardingWizard.tsx
+++ b/ui/src/components/OnboardingWizard.tsx
@@ -762,6 +762,7 @@ function OnboardingWizardInner({
const apiKeySecretRef = useRef<{ key: string; companyId: string; envKey: string; binding?: Awaited>["binding"]; aiConnection?: AiConnectionBinding } | null>(null);
const managedSubscriptionRef = useRef<{ companyId: string; binding: AiConnectionBinding } | null>(null);
const managedProvider = aiProviderForAdapter(adapterType);
+ const managedSubscriptionProvider = managedProvider === "anthropic" || managedProvider === "openai" || managedProvider === "xai" ? managedProvider : undefined;
function managedBindingForStep(): AiConnectionBinding | undefined {
if (credentialMode === "api") return selectedApiKey?.aiConnection ?? (
!selectedApiKey && apiKeySecretRef.current?.companyId === createdCompanyId && apiKeySecretRef.current.envKey === apiKeyEnvKeyFor(adapterType)
@@ -1014,14 +1015,14 @@ function OnboardingWizardInner({
// input here, so this gate alone only decides whether the login mechanism
// could ever apply to the current adapter and environment.
const localLoginHealth = useQuery({ queryKey: queryKeys.health, queryFn: healthApi.get });
- const canUseLocalLogin = resolvedLoginEnvironment?.driver === "local" && (localLoginHealth.data?.localAiLoginSupported ?? localLoginHealth.data?.deploymentMode === "local_trusted");
+ const canUseLocalLogin = Boolean(managedSubscriptionProvider) && resolvedLoginEnvironment?.driver === "local" && (localLoginHealth.data?.localAiLoginSupported ?? localLoginHealth.data?.deploymentMode === "local_trusted");
const localLogin = useLocalAiLogin(createdCompanyId, {
- provider: managedProvider ?? "anthropic", method: "subscription",
+ provider: managedSubscriptionProvider ?? "anthropic", method: "subscription",
name: `My ${CONNECT_SOURCE_NAMES[adapterType] ?? managedProvider} subscription`,
ownership: "personal", agentIds: [], allAgents: true,
}, effectiveOnboardingOpen && step === 4 && canUseLocalLogin && credentialMode !== "api" &&
Boolean(managedProvider) && !savedSubscription && !savedKeys.storedLogin.data && !managedBindingForStep(),
- { allowHostClaude: localLoginHealth.data?.deploymentMode === "local_trusted" });
+ );
// A result from a previous selection must not hire or advance this wizard.
// Environment query updates are not user navigation: the test resolves its
// own environment, and those updates must not interrupt the pending attempt.
@@ -2815,7 +2816,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);
@@ -2850,7 +2851,7 @@ function OnboardingWizardInner({
);
}}
onConnected={() => {
- if (managedProvider) managedSubscriptionRef.current = { companyId: createdCompanyId, binding: { provider: managedProvider, method: "subscription", mode: "responsible_user" } };
+ if (managedSubscriptionProvider) managedSubscriptionRef.current = { companyId: createdCompanyId, binding: { provider: managedSubscriptionProvider, method: "subscription", mode: "responsible_user" } };
setConnectAuthUrl(null);
// Not into a card the customer has left. The panel is
// still mounted through Back's exit, and a login that
@@ -2990,8 +2991,11 @@ function OnboardingWizardInner({
Prompt:{" "}
Respond with hello.
- {adapterType === "cursor" ||
- adapterType === "codex_local" ||
+ {adapterType === "claude_local" || adapterType === "codex_local" ? (
+
+ If authentication fails, connect your subscription in the browser above or use an API key.
+
+ ) : adapterType === "cursor" ||
adapterType === "gemini_local" ||
adapterType === "kimi_local" ||
adapterType === "opencode_local" ? (
@@ -3010,9 +3014,7 @@ function OnboardingWizardInner({
{adapterType === "cursor"
? "agent login"
- : adapterType === "codex_local"
- ? "codex login"
- : adapterType === "gemini_local"
+ : adapterType === "gemini_local"
? "gemini auth"
: adapterType === "kimi_local"
? "kimi login"
@@ -3021,11 +3023,7 @@ function OnboardingWizardInner({
.
) : (
-
- If login is required, run{" "}
- claude login {" "}
- and retry.
-
+ If login is required, connect the provider above and retry.
)}
)}
diff --git a/ui/src/components/ai-connections/AiConnectionCredentialStep.tsx b/ui/src/components/ai-connections/AiConnectionCredentialStep.tsx
index eb48221af7..2043569e51 100644
--- a/ui/src/components/ai-connections/AiConnectionCredentialStep.tsx
+++ b/ui/src/components/ai-connections/AiConnectionCredentialStep.tsx
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useState, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { type AiProvider, type AiAuthMethod, type AiConnectionLoginIntent } from "@paperclipai/shared";
import { AgentProviderConnection } from "@/components/new-agent/AgentProviderConnection";
@@ -26,17 +26,19 @@ type Props = {
agentIds: string[];
allAgents: boolean;
environmentId?: string;
+ defaults?: ReactNode;
+ disabled?: boolean;
onComplete: (result: { connectionId: string; grantId: string; method: AiAuthMethod }) => void;
onCancel: () => void;
};
/** Connections hosts the same provider step as agent setup, with its own save intent. */
export function AiConnectionCredentialStep(props: Props) {
- if (props.provider === "openrouter") return ;
+ if (props.provider === "openrouter" || props.provider === "google") return ;
return ;
}
-function SubscriptionConnectionStep({ companyId, provider, initialMethod, fixedMethod, connectionId, name: initialName, hideName, nameForMethod, ownership, agentIds, allAgents, environmentId: suppliedEnvironmentId, onComplete, onCancel }: Props) {
+function SubscriptionConnectionStep({ companyId, provider, initialMethod, fixedMethod, connectionId, name: initialName, hideName, nameForMethod, ownership, agentIds, allAgents, environmentId: suppliedEnvironmentId, onComplete, onCancel, defaults, disabled }: Props) {
const [name, setName] = useState(initialName);
const [chosenEnvironment, setChosenEnvironment] = useState();
const client = useQueryClient();
@@ -74,7 +76,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 &&
Connection name setName(event.target.value)} disabled={Boolean(connectionId)} /> }
{!suppliedEnvironmentId && !forced.forced && loginEnvironments.length > 1 &&
@@ -82,6 +84,7 @@ function SubscriptionConnectionStep({ companyId, provider, initialMethod, fixedM
{loginEnvironments.map((env) => {env.name} )}
}
{error &&
{error}
}
+ {defaults}
{loading ?
Preparing sign-in…
:
{}}
testConnection={async () => false}
- managedAccount={{ intent, nameForMethod, initialMethod, fixedMethod: fixedMethod ?? Boolean(connectionId), disabled: loading || Boolean(error) || !name.trim(), onComplete: (result) => { void client.invalidateQueries({ queryKey: ["ai-connections", companyId] }); onComplete(result); } }}
+ managedAccount={{ intent, nameForMethod, initialMethod, fixedMethod: fixedMethod ?? Boolean(connectionId), disabled: disabled || loading || Boolean(error) || !name.trim(), onComplete: (result) => { void client.invalidateQueries({ queryKey: ["ai-connections", companyId] }); void client.invalidateQueries({ queryKey: ["tools"] }); onComplete(result); } }}
/>}
;
}
-function ApiKeyConnectionStep({ companyId, provider, connectionId, name: initialName, hideName, nameForMethod, ownership, agentIds, allAgents, onComplete, onCancel }: Props) {
+function ApiKeyConnectionStep({ companyId, provider, connectionId, name: initialName, hideName, nameForMethod, ownership, agentIds, allAgents, onComplete, onCancel, defaults, disabled }: Props) {
const [name, setName] = useState(initialName);
const [apiKey, setApiKey] = useState("");
const client = useQueryClient();
const save = useMutation({
mutationFn: () => aiConnectionsApi.create(companyId, { provider, method: "api_key", name: connectionId ? name : nameForMethod?.("api_key") ?? name, ownership, agentIds, allAgents, connectionId, apiKey }),
- onSuccess: (result) => { void client.invalidateQueries({ queryKey: ["ai-connections", companyId] }); onComplete({ ...result, method: "api_key" }); },
+ onSuccess: (result) => { void client.invalidateQueries({ queryKey: ["ai-connections", companyId] }); void client.invalidateQueries({ queryKey: ["tools"] }); onComplete({ ...result, method: "api_key" }); },
onSettled: () => setApiKey(""),
});
return
{!hideName &&
Connection name setName(event.target.value)} disabled={Boolean(connectionId)} /> }
{save.error &&
{save.error.message}
}
-
save.mutate()} disabled={save.isPending} placeholder="Enter API key here" autoFocus />
- Cancel save.mutate()}>{save.isPending ? "Connecting…" : "Connect"}
+ save.mutate()} disabled={disabled || save.isPending} placeholder="Enter API key here" autoFocus />
+ {defaults}
+ Cancel save.mutate()}>{save.isPending ? "Connecting…" : "Connect"}
;
}
diff --git a/ui/src/components/ai-connections/AiConnectionField.test.tsx b/ui/src/components/ai-connections/AiConnectionField.test.tsx
index 7ab32e4afe..9603e6fd1f 100644
--- a/ui/src/components/ai-connections/AiConnectionField.test.tsx
+++ b/ui/src/components/ai-connections/AiConnectionField.test.tsx
@@ -18,6 +18,7 @@ vi.mock("./AiConnectionCredentialStep", () => ({
},
}));
vi.mock("./AiConnectionManagement", () => ({ AiConnectionLegacyNotice: () => null }));
+vi.mock("./AiProviderSetup", () => ({ AiProviderSetup: () => Advanced provider setup
}));
vi.mock("@/pages/apps/AppLogo", () => ({ AppLogo: () => null }));
let root: Root;
@@ -36,15 +37,26 @@ async function settle() {
flushSync(() => {});
}
}
-async function mount(connections: AiManagedConnectionSummary[], canManageConnections = true) {
+async function mount(connections: AiManagedConnectionSummary[], canManageConnections = true, preferAdvanced = false) {
mocks.list.mockResolvedValue({ currentUserId: "owner", connections, canManageConnections });
flushSync(() => root.render(
));
await settle();
}
-function click(label: string) {
+async function click(label: string) {
+ if (label === "Connect another account") {
+ const trigger = document.querySelector('[role="combobox"][aria-label="Connection"]')!;
+ flushSync(() => trigger.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })));
+ await settle();
+ const option = Array.from(document.querySelectorAll('[role="option"]')).find(item => item.textContent?.includes("Connect an account"));
+ expect(option).toBeDefined();
+ flushSync(() => option!.click());
+ await settle();
+ return;
+ }
const button = Array.from(document.querySelectorAll("button")).find(item => item.textContent === label);
expect(button, `Missing button: ${label}`).toBeDefined();
flushSync(() => button!.click());
@@ -62,7 +74,7 @@ afterEach(() => { flushSync(() => root.unmount()); client.clear(); container.rem
it("reconnects the unavailable personal default in place", async () => {
await mount([account()]);
- click("Reconnect account");
+ await click("Reconnect account");
expect(credentialProps).toMatchObject({ connectionId: "old-connection", initialMethod: "subscription", fixedMethod: true });
credentialProps!.onComplete({ connectionId: "old-connection", grantId: "old-grant", method: "subscription" });
await settle();
@@ -70,9 +82,16 @@ it("reconnects the unavailable personal default in place", async () => {
expect(onChange).toHaveBeenCalledWith({ provider: "anthropic", method: "subscription", mode: "responsible_user" });
});
+it("opens provider setup directly when connecting from the advanced mode", async () => {
+ await mount([], true, true);
+ await click("Connect another account");
+ expect(document.body.textContent).toContain("Advanced provider setup");
+ expect(credentialProps).toBeUndefined();
+});
+
it("selects the returned new grant and actual method before adopting the personal default", async () => {
await mount([account()]);
- click("Connect another account");
+ await click("Connect another account");
expect(document.body.textContent).toContain("default");
expect(credentialProps).toMatchObject({ connectionId: undefined, allAgents: true });
let resolveDefault!: () => void;
@@ -88,7 +107,7 @@ it("selects the returned new grant and actual method before adopting the persona
it("lets the owner limit a new personal connection to this agent", async () => {
await mount([]);
- click("Connect another account");
+ await click("Connect another account");
const checkbox = document.querySelector('[role="checkbox"]')!;
expect(checkbox).not.toBeNull();
expect(checkbox.getAttribute("aria-checked")).toBe("true");
@@ -98,13 +117,13 @@ it("lets the owner limit a new personal connection to this agent", async () => {
it("keeps default-update failures visible and retries without another provider login", async () => {
await mount([account()]);
- click("Connect another account");
+ await click("Connect another account");
mocks.setDefault.mockRejectedValueOnce(new Error("Default update failed"));
credentialProps!.onComplete({ connectionId: "new-connection", grantId: "new-grant", method: "api_key" });
await settle();
expect(document.body.textContent).toContain("Default update failed");
expect(onChange).not.toHaveBeenCalled();
- click("Retry default selection");
+ await click("Retry default selection");
await settle();
expect(mocks.setDefault).toHaveBeenCalledTimes(2);
expect(onChange).toHaveBeenCalledWith({ provider: "anthropic", method: "api_key", mode: "responsible_user" });
@@ -117,12 +136,14 @@ it("does not offer reconnection for a healthy default or another owner's account
it("keeps an ordinary member's new connection scoped to the current agent by default", async () => {
await mount([], false);
- click("Connect another account");
+ expect(document.body.textContent).toContain("You have no default account");
+ await click("Connect another account");
+ expect(document.querySelector('[role="checkbox"]')!.disabled).toBe(true);
expect(credentialProps).toMatchObject({ allAgents: false, agentIds: ["agent"] });
});
it("uses the server's connection-manager permission for company-wide access", async () => {
await mount([], true);
- click("Connect another account");
+ await click("Connect another account");
expect(credentialProps).toMatchObject({ allAgents: true });
});
diff --git a/ui/src/components/ai-connections/AiConnectionField.tsx b/ui/src/components/ai-connections/AiConnectionField.tsx
index 726cfa24f8..0af1ab5433 100644
--- a/ui/src/components/ai-connections/AiConnectionField.tsx
+++ b/ui/src/components/ai-connections/AiConnectionField.tsx
@@ -3,16 +3,17 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
aiConnectionBindingSchema,
isAiConnectionCompatible,
- type AiConnectionBinding,
type AiRuntimeConnectionBinding,
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 {
@@ -33,6 +34,8 @@ export function aiProviderForAdapter(
codex_local: "openai",
opencode_local: "openrouter",
grok_local: "xai",
+ gemini_local: "google",
+ hermes_local: "openrouter",
} as Record
)[adapterType];
}
@@ -47,6 +50,7 @@ export function AiConnectionField({
environmentId,
legacy = false,
readOnly = false,
+ preferAdvanced = false,
routerAdapterType,
}: {
companyId: string;
@@ -59,6 +63,7 @@ export function AiConnectionField({
environmentId?: string;
legacy?: boolean;
readOnly?: boolean;
+ preferAdvanced?: boolean;
routerAdapterType?: string;
}) {
const provider = aiProviderForAdapter(adapterType);
@@ -67,6 +72,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 }>();
@@ -97,6 +103,7 @@ export function AiConnectionField({
const openConnection = (reconnect?: AiManagedConnectionSummary) => {
returnFocus.current = document.activeElement as HTMLElement;
setReconnecting(reconnect);
+ setAdvancedSetup(!reconnect && (preferAdvanced || provider === "openrouter"));
setAllAgents(accounts.data?.canManageConnections ?? false);
setSavedAccount(undefined);
selectDefault.reset();
@@ -104,7 +111,7 @@ export function AiConnectionField({
};
const method: AiAuthMethod = (value && value.mode !== "router" && value.mode !== "responsible_user" ? value.method : undefined)
?? accounts.data?.connections.find((account) => account.provider === provider && account.isDefault)?.method
- ?? (provider === "openrouter" ? "api_key" : "subscription");
+ ?? (provider === "openrouter" || provider === "google" ? "api_key" : "subscription");
const compatiblePools = agentId ? accounts.data?.pools?.filter(pool => pool.enabled && pool.members.some(member => isAiConnectionCompatible(member.binding, routerAdapterType ?? adapterType, member.profile.model, member.profile.provider, member.profile.acpxAgent))) ?? [] : [];
if (!provider) return null;
if (legacy && !value && !adopting)
@@ -134,7 +141,8 @@ export function AiConnectionField({
{value?.mode === "router" && New tasks rotate. Existing tasks keep their account. }
}
- {value?.mode !== "router" &&
{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 ? preferAdvanced ? setConnecting(false) : setAdvancedSetup(false)}
+ onComplete={binding => {
+ void client.invalidateQueries({ queryKey: ["ai-connections", companyId] });
+ setConnecting(false);
+ changeBinding(binding);
+ }}
+ /> : <>
{!reconnecting && !savedAccount &&
- setAllAgents(checked === true)} />
+ setAllAgents(checked === true)} />
Allow all agents in this company to use this account for my tasks
}
{savedAccount ?
@@ -217,7 +236,7 @@ export function AiConnectionField({
initialMethod={reconnecting?.method ?? method}
fixedMethod={Boolean(reconnecting)}
connectionId={reconnecting?.id}
- name={reconnecting?.name ?? `My ${provider === "anthropic" ? "Claude" : provider === "openai" ? "OpenAI" : provider === "xai" ? "Grok" : "OpenRouter"} ${method === "subscription" ? "subscription" : "API"}`}
+ name={reconnecting?.name ?? `My ${provider === "anthropic" ? "Claude" : provider === "openai" ? "OpenAI" : provider === "xai" ? "Grok" : provider === "google" ? "Gemini" : "OpenRouter"} ${method === "subscription" ? "subscription" : "API"}`}
ownership="personal"
agentIds={agentId ? [agentId] : []}
allAgents={allAgents}
@@ -228,6 +247,11 @@ export function AiConnectionField({
selectDefault.mutate(result);
}}
/>}
+ {!reconnecting && !savedAccount &&
+ Advanced providers
+ setAdvancedSetup(true)}>Choose another provider or gateway
+ }
+ >}
diff --git a/ui/src/components/ai-connections/AiConnectionSelect.tsx b/ui/src/components/ai-connections/AiConnectionSelect.tsx
new file mode 100644
index 0000000000..bb5fbbe04e
--- /dev/null
+++ b/ui/src/components/ai-connections/AiConnectionSelect.tsx
@@ -0,0 +1,192 @@
+import { useId } from "react";
+import { Plus, UserRound } from "lucide-react";
+import {
+ aiConnectionCatalogSlug,
+ 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";
+import type { AiConnectionSummary } from "./model";
+
+function ConnectionLabel({ connection, label }: { connection: AiConnectionSummary; label?: string }) {
+ return (
+
+
+ {label ?? connection.name}
+
+ );
+}
+
+/** The same connection control on Create agent and Harness / Runtime. */
+export function AiConnectionSelect({
+ requirement,
+ connections,
+ value,
+ currentUserId,
+ readOnly,
+ loading,
+ error,
+ onRetry,
+ onConnect,
+ onReconnect,
+ 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 (
+
+
+ Connection
+
+
+
+
+ {selected
+ ?
+ : value?.mode === "responsible_user"
+ ? "Responsible user’s default"
+ : value ? "Unavailable connection" : undefined}
+
+
+
+ {incompatible && selected && (
+
+
+
+ )}
+ {compatible.map((c) => (
+
+
+
+ ))}
+ {(canUseDefault || value?.mode === "responsible_user") && (
+
+
+ Responsible user’s default
+
+ )}
+
+
+ Connect an account…
+
+
+
+ {!readOnly && onReconnect && !loading && !error && (
+
Reconnect account
+ )}
+ {error && (
+
+
+ {error}
+
+
+ Retry connections
+
+
+ )}
+ {value?.mode === "responsible_user" && !personal && !loading && !error ? (
+
+ You have no default account for this provider. Connect an account or choose another connection.
+
+ ) : 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.test.tsx b/ui/src/components/ai-connections/AiProviderSetup.test.tsx
new file mode 100644
index 0000000000..e1f8851a30
--- /dev/null
+++ b/ui/src/components/ai-connections/AiProviderSetup.test.tsx
@@ -0,0 +1,140 @@
+// @vitest-environment jsdom
+import { act, type ComponentProps } from "react";
+import { createRoot } from "react-dom/client";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { expect, it, vi } from "vitest";
+import { AiProviderSetup } from "./AiProviderSetup";
+import { aiConnectionsApi } from "@/api/ai-connections";
+import { TooltipProvider } from "@/components/ui/tooltip";
+import { aiProviderSetupPreset } from "@paperclipai/shared";
+import type { AiConnectionCredentialStep } from "./AiConnectionCredentialStep";
+
+let credentialProps: ComponentProps | undefined;
+vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: { create: vi.fn(async () => ({ connectionId: "connection", grantId: "grant" })), list: vi.fn(async () => ({ currentUserId: "owner", connections: [], canManageConnections: true })) } }));
+vi.mock("@/api/agents", () => ({ agentsApi: { list: vi.fn(async () => []) } }));
+vi.mock("./AiConnectionCredentialStep", () => ({ AiConnectionCredentialStep: (props: ComponentProps) => { credentialProps = props; return Existing credential flow
; } }));
+
+it("reconnects an older OpenRouter account without adding routing metadata", async () => {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const container = document.createElement("div");
+ const root = createRoot(container);
+ const onComplete = vi.fn();
+ const account = { id: "connection", grantId: "grant", companyId: "company", provider: "openrouter", method: "api_key", name: "Existing OpenRouter", ownership: "personal", ownerUserId: "owner", isDefault: true, status: "needs_attention" } as const;
+ try {
+ await act(async () => root.render( {}} onComplete={onComplete} /> ));
+ expect(container.textContent).toContain("Existing credential flow");
+ expect(credentialProps).toMatchObject({ connectionId: account.id, provider: "openrouter", initialMethod: "api_key", fixedMethod: true });
+ credentialProps!.onComplete({ connectionId: account.id, grantId: account.grantId, method: "api_key" });
+ expect(onComplete).toHaveBeenCalledWith({ provider: "openrouter", method: "api_key", mode: "delegated", connectionId: account.id, grantId: account.grantId });
+ } finally {
+ await act(async () => root.unmount());
+ client.clear();
+ }
+});
+
+const providerSources = ["openrouter", "bedrock", "responses-api", "messages-api", "chat-completions-api", "local"];
+it("shows advanced providers directly when entered from advanced connection mode", async () => {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
+ client.setQueryData(["ai-connections", "company", undefined], { canManageConnections: true, connections: [] });
+ client.setQueryData(["agents", "company", "provider-access"], []);
+ const container = document.createElement("div");
+ const root = createRoot(container);
+ try {
+ await act(async () => root.render( {}} onComplete={() => {}} /> ));
+ const choices = Array.from(container.querySelectorAll("button[aria-label]")).map(button => button.getAttribute("aria-label"));
+ expect(choices).toEqual(["OpenRouter", "Amazon Bedrock", "Custom gateway", "Local endpoint"]);
+ expect(container.querySelector("details")).toBeNull();
+ } finally {
+ await act(async () => root.unmount()); client.clear();
+ }
+});
+
+it.each(providerSources)("opens %s directly with shared access folded under Advanced", async source => {
+ const preset = aiProviderSetupPreset(source)!;
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
+ client.setQueryData(["ai-connections", "company", undefined], { canManageConnections: true, connections: [] });
+ client.setQueryData(["agents", "company", "provider-access"], [{ id: "agent", name: "Nova" }]);
+ const container = document.createElement("div");
+ document.body.append(container);
+ const root = createRoot(container);
+ const onCancel = vi.fn();
+ try {
+ await act(async () => root.render( {}} /> ));
+ expect(container.textContent).not.toContain("Connect a model provider");
+ expect(container.textContent).toContain("Connects for everyone in your organization, available to all agents.");
+ expect(container.querySelector('[role="radio"]')).toBeNull();
+ const change = Array.from(container.querySelectorAll('button')).find(b => b.textContent === "Change")!;
+ expect(change.getAttribute("aria-expanded")).toBe("false");
+ await act(async () => change.click());
+ const radio = (text: string) => Array.from(container.querySelectorAll('[role="radio"]')).find(b => b.textContent?.includes(text)) as HTMLButtonElement;
+ expect(radio("Any human in the organization").getAttribute("aria-checked")).toBe("true");
+ expect(radio("Any agent").getAttribute("aria-checked")).toBe("true");
+ await act(async () => radio("Just me").click());
+ expect(container.textContent).toContain("Connects as you, available to all agents.");
+ await act(async () => Array.from(container.querySelectorAll('button')).find(b => b.textContent === "Back")!.click());
+ expect(onCancel).toHaveBeenCalledOnce();
+ } finally {
+ await act(async () => root.unmount()); container.remove(); client.clear();
+ }
+});
+
+it("saves the default organization grant and all-agent installation from the direct connect screen", async () => {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
+ client.setQueryData(["ai-connections", "company", undefined], { canManageConnections: true, connections: [] });
+ client.setQueryData(["agents", "company", "provider-access"], []);
+ const container = document.createElement("div");
+ const root = createRoot(container);
+ try {
+ await act(async () => root.render( {}} onComplete={() => {}} /> ));
+ const input = container.querySelector('input[aria-label="API key"]')!;
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "fixture-key");
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ await act(async () => container.querySelector('form')!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })));
+ expect(aiConnectionsApi.create).toHaveBeenCalledWith("company", expect.objectContaining({ ownership: "shared", allAgents: true, agentIds: [], apiKey: "fixture-key", routing: expect.objectContaining({ kind: "openrouter" }) }));
+ } finally {
+ await act(async () => root.unmount()); client.clear();
+ }
+});
+
+
+it("allows an ordinary member to save a personal gateway before the agent exists", async () => {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
+ client.setQueryData(["ai-connections", "company", undefined], { canManageConnections: false, connections: [] });
+ client.setQueryData(["agents", "company", "provider-access"], []);
+ const container = document.createElement("div");
+ const root = createRoot(container);
+ const onComplete = vi.fn();
+ try {
+ await act(async () => root.render( {}} onComplete={onComplete} /> ));
+ const input = container.querySelector('input[aria-label="API key"]')!;
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "member-key");
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ const connect = container.querySelector('button[type="submit"]') as HTMLButtonElement;
+ expect(connect.disabled).toBe(false);
+ await act(async () => container.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })));
+ expect(aiConnectionsApi.create).toHaveBeenCalledWith("company", expect.objectContaining({ ownership: "personal", allAgents: false, agentIds: [], apiKey: "member-key" }));
+ expect(onComplete).toHaveBeenCalledWith(expect.objectContaining({ mode: "delegated", connectionId: "connection", grantId: "grant" }));
+ } finally {
+ await act(async () => root.unmount()); client.clear();
+ }
+});
+
+
+it.each(["google", "openai", "anthropic", "xai"] as const)("allows an ordinary member to connect a personal %s account before an agent exists", async provider => {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
+ client.setQueryData(["ai-connections", "company", undefined], { canManageConnections: false, connections: [] });
+ client.setQueryData(["agents", "company", "provider-access"], []);
+ const container = document.createElement("div");
+ const root = createRoot(container);
+ credentialProps = undefined;
+ try {
+ await act(async () => root.render( {}} onComplete={() => {}} /> ));
+ expect(credentialProps).toMatchObject({ provider, ownership: "personal", allAgents: false, agentIds: [], disabled: false });
+ } finally {
+ await act(async () => root.unmount()); client.clear();
+ }
+});
diff --git a/ui/src/components/ai-connections/AiProviderSetup.tsx b/ui/src/components/ai-connections/AiProviderSetup.tsx
new file mode 100644
index 0000000000..0bafcf0c3a
--- /dev/null
+++ b/ui/src/components/ai-connections/AiProviderSetup.tsx
@@ -0,0 +1,468 @@
+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 { ConnectionAccessDefaults, connectionDefaultSummarySentence } 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: "Bedrock API key and AWS region",
+ 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,
+ initialProtocol,
+ providerLabel,
+ advancedOnly = false,
+ reconnect,
+ onCancel,
+ onComplete,
+}: {
+ companyId: string;
+ agentId?: string;
+ environmentId?: string;
+ initialProvider?: ProviderChoice;
+ initialProtocol?: AiProviderRouting["protocol"];
+ providerLabel?: string;
+ advancedOnly?: boolean;
+ 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 || initialProvider ? "connect" : "provider",
+ );
+ const [ownershipChoice, setOwnership] = useState<"personal" | "shared">();
+ const [allAgentsChoice, setAllAgents] = useState();
+ const [agentIds, setAgentIds] = useState(new Set(agentId ? [agentId] : []));
+ const [protocol, setProtocol] = useState(
+ reconnect?.routing?.protocol ?? initialProtocol ?? "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 client = useQueryClient();
+ const accounts = useQuery({
+ queryKey: ["ai-connections", companyId, agentId],
+ queryFn: () => aiConnectionsApi.list(companyId, agentId),
+ });
+ const canManageConnections = accounts.data?.canManageConnections ?? false;
+ const ownership = reconnect?.ownership ?? ownershipChoice ?? (!agentId && canManageConnections ? "shared" : "personal");
+ const allAgents = allAgentsChoice ?? (!agentId && canManageConnections);
+ const agents = useQuery({
+ queryKey: ["agents", companyId, "provider-access"],
+ queryFn: () => agentsApi.list(companyId),
+ });
+ const label = providerLabel ?? providers.find((p) => p.id === provider)?.name ?? "provider";
+ const advanced = reconnect ? Boolean(reconnect.routing) : ["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 !== "none" ? { apiKey } : {}),
+ }),
+ );
+ },
+ onSuccess: complete,
+ onSettled: () => {
+ setApiKey("");
+ },
+ });
+ 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("connect");
+ setApiKey("");
+ setAuth("bearer");
+ save.reset();
+ }}
+ />
+ );
+ const modelSettings = (
+
+ Model IDs (comma separated)
+ setModels(e.target.value)}
+ disabled={Boolean(reconnect)}
+ />
+
+ Use the provider’s model ID or your gateway’s alias. You can
+ also enter one on the agent.
+
+
+ );
+ const accessDefaults = !reconnect ? (
+ setOwnership(kind === "organization" ? "shared" : "personal")}
+ installChoice={allAgents ? "all" : "specific"}
+ setInstallChoice={choice => setAllAgents(choice === "all")}
+ installAgentIds={agentIds}
+ setInstallAgentIds={setAgentIds}
+ disabled={save.isPending}
+ notice={!canManageConnections ? ["Only a connection manager can share this credential with everyone or give every agent access."] : undefined}
+ />
+ ) : undefined;
+ const cancel = () => reconnect || initialProvider ? onCancel() : setStep("provider");
+ if (!reconnect && accounts.isPending) return Loading connection permissions…
;
+ if (!reconnect && accounts.isError) return Could not load connection permissions. void accounts.refetch()}>Retry
;
+ return (
+ event.stopPropagation()}>
+
+
+ {step === "provider"
+ ? "Connect a model provider"
+ : `${reconnect ? "Reconnect" : "Connect"} ${label}`}
+
+
+ {agents.isError &&
Could not load agents. void agents.refetch()}>Retry
}
+ {step === "provider" ? (
+ <>
+ {advancedOnly ? choices(true) : <>
+ {choices(false)}
+
+
+ Advanced providers
+
+ {choices(true)}
+
+ >}
+
+ Cancel
+
+ >
+ ) : !advanced ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/ui/src/components/ai-connections/model.ts b/ui/src/components/ai-connections/model.ts
index 8f2c2cbb50..9806071e94 100644
--- a/ui/src/components/ai-connections/model.ts
+++ b/ui/src/components/ai-connections/model.ts
@@ -7,6 +7,7 @@ export const AI_PROVIDERS: Record<
AiProvider,
{ name: string; subscriptionName?: string; logo?: string }
> = {
+ google: { name: "Google", logo: "/brands/apps/google.svg" },
anthropic: {
name: "Claude",
subscriptionName: "Claude subscription",
diff --git a/ui/src/components/ai-connections/useLocalAiLogin.test.tsx b/ui/src/components/ai-connections/useLocalAiLogin.test.tsx
index 4434632609..1bb18c5d49 100644
--- a/ui/src/components/ai-connections/useLocalAiLogin.test.tsx
+++ b/ui/src/components/ai-connections/useLocalAiLogin.test.tsx
@@ -6,26 +6,27 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { useLocalAiLogin } from "./useLocalAiLogin";
import { LocalProviderLoginInstructions } from "../AdapterLoginChrome";
-const api = vi.hoisted(() => ({ startLocalLogin: vi.fn(), checkLocalLogin: vi.fn(), cancelLocalLogin: vi.fn(), connectLocal: vi.fn() }));
+const api = vi.hoisted(() => ({ startLocalLogin: vi.fn(), checkLocalLogin: vi.fn(), submitLocalLoginCode: vi.fn(), cancelLocalLogin: vi.fn(), connectLocal: vi.fn() }));
vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: api }));
let root: ReturnType;
let host: HTMLDivElement;
beforeEach(() => {
vi.resetAllMocks();
- api.startLocalLogin.mockImplementation(async () => ({ sessionId: "attempt-1", command: "isolated codex login", expiresAt: "2099-01-01T00:00:00Z" }));
- api.checkLocalLogin.mockResolvedValue({ status: "sign_in_required" });
+ api.startLocalLogin.mockImplementation(async () => ({ sessionId: "attempt-1", expiresAt: "2099-01-01T00:00:00Z" }));
+ api.checkLocalLogin.mockResolvedValue({ status: "sign_in_required", authorizationUrl: "https://auth.openai.com/codex/device", code: "ABCD-12345" });
+ api.submitLocalLoginCode.mockResolvedValue({ ok: true });
api.cancelLocalLogin.mockResolvedValue({});
api.connectLocal.mockResolvedValue({ connectionId: "connection", grantId: "grant" });
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
});
afterEach(() => { flushSync(() => root.unmount()); host.remove(); });
function Harness({ name = "Account", provider = "openai", enabled = true }: { name?: string; provider?: "anthropic" | "openai"; enabled?: boolean }) {
- const login = useLocalAiLogin("company", { provider, method: "subscription", name, ownership: "personal", agentIds: [], allAgents: true }, enabled, { allowHostClaude: true });
+ const login = useLocalAiLogin("company", { provider, method: "subscription", name, ownership: "personal", agentIds: [], allAgents: true }, enabled);
return <> void login.connect()}>Connect >;
}
it("checks once under StrictMode, preserves renaming and navigation, and cancels only on explicit retry", async () => {
flushSync(() => root.render( ));
- await vi.waitFor(() => expect(host.textContent).toContain("isolated codex login"));
+ await vi.waitFor(() => expect(host.textContent).toContain("ABCD-12345"));
expect(api.startLocalLogin).toHaveBeenCalledTimes(1);
expect(api.checkLocalLogin).toHaveBeenCalledTimes(1);
expect(api.cancelLocalLogin).not.toHaveBeenCalled();
@@ -33,7 +34,7 @@ it("checks once under StrictMode, preserves renaming and navigation, and cancels
expect(api.startLocalLogin).toHaveBeenCalledTimes(1);
flushSync(() => root.render( ));
flushSync(() => root.render( ));
- await vi.waitFor(() => expect(host.textContent).toContain("isolated codex login"));
+ await vi.waitFor(() => expect(host.textContent).toContain("ABCD-12345"));
expect(api.cancelLocalLogin).not.toHaveBeenCalled();
flushSync(() => Array.from(host.querySelectorAll('button')).find(b => b.textContent === 'Connect')!.click());
await vi.waitFor(() => expect(api.connectLocal).toHaveBeenCalledWith("company", expect.objectContaining({ name: "Renamed", localSessionId: "attempt-1" })));
@@ -45,24 +46,24 @@ it("checks once under StrictMode, preserves renaming and navigation, and cancels
it.each(["anthropic", "openai"] as const)("detects an already-signed-in %s account before showing instructions, and does not save it until Connect", async provider => {
api.checkLocalLogin.mockResolvedValue({ status: "ready" });
flushSync(() => root.render( ));
- expect(host.textContent).toContain("Checking local");
+ expect(host.textContent).toContain("Preparing sign-in");
await vi.waitFor(() => expect(host.textContent).toContain("is signed in"));
expect(host.textContent).not.toContain("Run this in a terminal");
expect(api.connectLocal).not.toHaveBeenCalled();
- if (provider === "anthropic") expect(api.startLocalLogin).not.toHaveBeenCalled();
+ expect(api.startLocalLogin).toHaveBeenCalledTimes(1);
});
-it("detects terminal completion on focus without needing a Connect attempt", async () => {
+it("detects browser completion on focus without needing a Connect attempt", async () => {
flushSync(() => root.render( ));
- await vi.waitFor(() => expect(host.textContent).toContain("isolated codex login"));
+ await vi.waitFor(() => expect(host.textContent).toContain("ABCD-12345"));
api.checkLocalLogin.mockResolvedValue({ status: "ready" });
window.dispatchEvent(new Event('focus'));
await vi.waitFor(() => expect(host.textContent).toContain("is signed in"));
- expect(host.textContent).not.toContain("isolated codex login");
+ expect(host.textContent).not.toContain("ABCD-12345");
expect(api.connectLocal).not.toHaveBeenCalled();
});
-it("keeps a copied command's attempt alive after leaving the page", async () => {
+it("keeps a browser sign-in attempt alive after leaving the page", async () => {
flushSync(() => root.render( ));
- await vi.waitFor(() => expect(host.textContent).toContain("isolated codex login"));
+ await vi.waitFor(() => expect(host.textContent).toContain("ABCD-12345"));
flushSync(() => root.render(Another page
));
await new Promise(resolve => setTimeout(resolve, 10));
expect(api.cancelLocalLogin).not.toHaveBeenCalled();
@@ -76,6 +77,32 @@ it("explicit retry can replace an attempt opened in another authentication host"
flushSync(() => root.render( ));
await vi.waitFor(() => expect(host.textContent).toContain("Another sign-in"));
flushSync(() => Array.from(host.querySelectorAll('button')).find(b => b.textContent === 'Start sign-in again')!.click());
- await vi.waitFor(() => expect(host.textContent).toContain("isolated codex login"));
+ await vi.waitFor(() => expect(host.textContent).toContain("ABCD-12345"));
expect(api.startLocalLogin).toHaveBeenLastCalledWith("company", expect.objectContaining({ restart: true }));
});
+
+it("submits a Claude browser code through the owned local attempt", async () => {
+ api.checkLocalLogin.mockResolvedValue({ status: "sign_in_required", authorizationUrl: "https://claude.ai/oauth/authorize?client_id=test" });
+ flushSync(() => root.render( ));
+ await vi.waitFor(() => expect(host.querySelector('input[aria-label="Authorization code"]')).not.toBeNull());
+ const input = host.querySelector('input[aria-label="Authorization code"]') as HTMLInputElement;
+ flushSync(() => {
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "fixture-code");
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ flushSync(() => Array.from(host.querySelectorAll("button")).find((button) => button.textContent === "Submit code")?.click());
+ await vi.waitFor(() => expect(api.submitLocalLoginCode).toHaveBeenCalledWith("company", "attempt-1", "fixture-code"));
+ expect(host.textContent).not.toContain("claude auth login");
+});
+
+it("hides a failed device prompt and lets the user restart sign-in", async () => {
+ api.checkLocalLogin.mockResolvedValue({ status: "sign_in_required", authorizationUrl: "https://auth.openai.com/codex/device", code: "OLD-CODE", error: "Sign-in ended. Start sign-in again." });
+ flushSync(() => root.render( ));
+ await vi.waitFor(() => expect(host.querySelector('[role="alert"]')?.textContent).toContain("Sign-in ended"));
+ expect(host.textContent).not.toContain("OLD-CODE");
+ expect(host.querySelector('a[href="https://auth.openai.com/codex/device"]')).toBeNull();
+ api.checkLocalLogin.mockResolvedValue({ status: "sign_in_required", authorizationUrl: "https://auth.openai.com/codex/device", code: "NEW-CODE" });
+ flushSync(() => Array.from(host.querySelectorAll("button")).find(b => b.textContent === "Start sign-in again")!.click());
+ await vi.waitFor(() => expect(host.textContent).toContain("NEW-CODE"));
+ expect(host.querySelector('[role="alert"]')).toBeNull();
+});
diff --git a/ui/src/components/ai-connections/useLocalAiLogin.ts b/ui/src/components/ai-connections/useLocalAiLogin.ts
index 4d84cf7297..8e6a34ecf1 100644
--- a/ui/src/components/ai-connections/useLocalAiLogin.ts
+++ b/ui/src/components/ai-connections/useLocalAiLogin.ts
@@ -3,11 +3,13 @@ import type { AiConnectionLoginIntent, LocalAiLoginAttempt, LocalAiLoginStatus }
import { aiConnectionsApi } from "@/api/ai-connections";
/** Every authentication host uses the same local credential check and login lifecycle. */
-export function useLocalAiLogin(companyId: string | null, intent: AiConnectionLoginIntent, enabled: boolean, options: { allowHostClaude?: boolean } = {}) {
- const isolated = intent.provider !== "anthropic" || !options.allowHostClaude;
+export function useLocalAiLogin(companyId: string | null, intent: AiConnectionLoginIntent, enabled: boolean, _legacyOptions?: { allowHostClaude?: boolean }) {
+ const isolated = true;
const active = Boolean(companyId && enabled);
const [attempt, setAttempt] = useState(null);
const [status, setStatus] = useState(null);
+ const [authorizationUrl, setAuthorizationUrl] = useState(null);
+ const [code, setCode] = useState(null);
const [error, setError] = useState(null);
const [generation, setGeneration] = useState(0);
const latestIntent = useRef(intent);
@@ -27,6 +29,8 @@ export function useLocalAiLogin(companyId: string | null, intent: AiConnectionLo
setAttempt(null);
setError(null);
setStatus(null);
+ setAuthorizationUrl(null);
+ setCode(null);
if (!active || !companyId) return;
let cancelled = false;
let checking = false;
@@ -54,9 +58,11 @@ export function useLocalAiLogin(companyId: string | null, intent: AiConnectionLo
});
if (cancelled) return;
setStatus(next.status);
- setError(next.status === "expired" ? "This sign-in attempt expired. Start sign-in again." : null);
- // Stop polling a verified account. Focus still rechecks after a terminal
- // visit; awaiting terminal login never requires repeated Connect clicks.
+ setAuthorizationUrl(next.authorizationUrl ?? null);
+ setCode(next.code ?? null);
+ setError(next.error ?? (next.status === "expired" ? "This sign-in attempt expired. Start sign-in again." : null));
+ // Stop polling a verified account. Focus rechecks after the provider
+ // browser visit; sign-in never requires repeated Connect clicks.
if (next.status === "sign_in_required") timer = setTimeout(() => void check(), 5000);
} catch (cause) {
if (!cancelled) setError(cause instanceof Error ? cause.message : "Could not check local sign-in.");
@@ -72,17 +78,27 @@ export function useLocalAiLogin(companyId: string | null, intent: AiConnectionLo
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onFocus);
// Navigation is not cancellation. The server resumes this bounded attempt
- // when the user returns and reaps abandoned attempts after expiry. Deleting
- // here made copied CODEX_HOME commands point at nonexistent directories.
+ // when the user returns and reaps abandoned attempts after expiry.
};
}, [companyId, active, isolated, target, generation]);
return {
isolated,
command: attempt?.command,
+ authorizationUrl,
+ code,
status,
preparing: active && !status && !error,
error,
retry: () => { restartRequested.current = true; cancelCurrent(); setGeneration((value) => value + 1); },
+ submitCode: async (browserCode: string) => {
+ if (!companyId || !attempt) throw new Error("Start sign-in before submitting a code.");
+ try {
+ await aiConnectionsApi.submitLocalLoginCode(companyId, attempt.sessionId, browserCode);
+ } catch (cause) {
+ setError("Could not submit the authorization code. Start sign-in again.");
+ throw cause;
+ }
+ },
connect: (input = intent) => {
if (!companyId) throw new Error("Choose a company before connecting.");
if (isolated && !attempt) throw new Error("Prepare local sign-in before connecting.");
diff --git a/ui/src/components/new-agent/AgentProviderConnection.test.tsx b/ui/src/components/new-agent/AgentProviderConnection.test.tsx
index fdca64942a..a9116af73b 100644
--- a/ui/src/components/new-agent/AgentProviderConnection.test.tsx
+++ b/ui/src/components/new-agent/AgentProviderConnection.test.tsx
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
+import type { LocalAiLoginStatus } from "@paperclipai/shared";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -18,7 +19,7 @@ const managedApi = vi.hoisted(() => ({
loginResult: vi.fn(async () => ({ connectionId: "login-account", grantId: "login-grant" })),
connectLocal: vi.fn(async () => ({ connectionId: "local-account", grantId: "local-grant" })),
startLocalLogin: vi.fn(async () => ({ sessionId: "local-attempt", command: "CODEX_HOME='/fixture/isolated-login' codex login", expiresAt: "2026-09-11T20:00:00Z" })),
- checkLocalLogin: vi.fn(async () => ({ status: "sign_in_required" as const })),
+ checkLocalLogin: vi.fn(async (): Promise => ({ status: "sign_in_required" })),
cancelLocalLogin: vi.fn(async () => ({})),
create: vi.fn(async () => ({ connectionId: "managed-connection", grantId: "managed-grant" })),
}));
@@ -234,14 +235,12 @@ describe("AgentProviderConnection reuse", () => {
});
it.each(["claude_local", "codex_local"] as const)("prepares and completes an isolated subscription on an authenticated self-hosted instance: %s", async adapterType => {
const onComplete = vi.fn();
- const command = adapterType === "claude_local" ? "CLAUDE_CONFIG_DIR='/isolated/claude' claude auth login" : "CODEX_HOME='/isolated/codex' codex login --device-auth";
- managedApi.startLocalLogin.mockResolvedValue({ sessionId: "local-attempt", command, expiresAt: "2099-01-01T00:00:00Z" });
+ managedApi.checkLocalLogin.mockResolvedValue({ status: "ready" });
const intent = { provider: adapterType === "claude_local" ? "anthropic" as const : "openai" as const, method: "subscription" as const, name: "Self-hosted account", ownership: "personal" as const, agentIds: [], allAgents: false };
await mount(adapterType, false, false, false, false, false, { intent, onComplete }, true, "authenticated");
openProvider();
- await vi.waitFor(() => expect(host.textContent).toContain(command));
- expect(host.textContent).toContain("Your existing terminal login stays separate");
- expect(host.textContent).not.toContain("Connect uses your local");
+ await vi.waitFor(() => expect(host.textContent).toContain("is signed in"));
+ expect(host.textContent).not.toMatch(/CLAUDE_CONFIG_DIR|CODEX_HOME|Run this in a terminal/);
expect(managedApi.startLocalLogin).toHaveBeenCalledWith("c1", intent);
expect(managedApi.checkLocalLogin).toHaveBeenCalledWith("c1", { ...intent, localSessionId: "local-attempt" });
click("Connect");
@@ -250,25 +249,27 @@ describe("AgentProviderConnection reuse", () => {
});
it.each(["claude_local", "codex_local"] as const)("connects a local subscription without a sandbox and supports retry: %s", async (adapterType) => {
const onComplete = vi.fn();
+ managedApi.checkLocalLogin.mockResolvedValue({ status: "ready" });
const intent = { provider: adapterType === "claude_local" ? "anthropic" as const : "openai" as const, method: "subscription" as const, name: "My account", ownership: "personal" as const, agentIds: [], allAgents: false };
await mount(adapterType, false, false, false, false, false, { intent, onComplete }, true);
openProvider();
- await vi.waitFor(() => expect(host.textContent).toContain(adapterType === "claude_local" ? "claude auth login" : "codex login"));
- expect(host.textContent).toContain("machine running Paperclip");
+ await vi.waitFor(() => expect(host.textContent).toContain("is signed in"));
+ expect(host.textContent).not.toMatch(/claude auth login|codex login|CODEX_HOME/);
expect(host.textContent).not.toContain("sandbox");
managedApi.connectLocal.mockRejectedValueOnce(new Error("Run local login and try again"));
click("Connect");
await vi.waitFor(() => expect(host.textContent).toContain("Run local login and try again"));
expect(onComplete).not.toHaveBeenCalled();
if (adapterType === "codex_local") {
- click("Start sign-in again");
+ expect(host.textContent).not.toContain("Start sign-in again");
+ click("Use a different account");
await vi.waitFor(() => expect(host.textContent).not.toContain("Run local login and try again"));
await vi.waitFor(() => expect(managedApi.cancelLocalLogin).toHaveBeenCalledWith("c1", "local-attempt"));
- await vi.waitFor(() => expect(host.textContent).toContain("codex login"));
+ await vi.waitFor(() => expect(host.textContent).toContain("is signed in"));
}
click("Connect");
await vi.waitFor(() => expect(onComplete).toHaveBeenCalledWith({ connectionId: "local-account", grantId: "local-grant", method: "subscription" }));
- expect(managedApi.connectLocal).toHaveBeenCalledWith("c1", adapterType === "codex_local" ? { ...intent, localSessionId: "local-attempt" } : intent);
+ expect(managedApi.connectLocal).toHaveBeenCalledWith("c1", { ...intent, localSessionId: "local-attempt" });
expect(mocks.loginPanel).not.toHaveBeenCalled();
});
it("leaves a completed local account saved when its host is cancelled", async () => {
diff --git a/ui/src/components/ui/select.tsx b/ui/src/components/ui/select.tsx
index fa2a98a1eb..9c35a9b240 100644
--- a/ui/src/components/ui/select.tsx
+++ b/ui/src/components/ui/select.tsx
@@ -4,6 +4,15 @@ import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
+export const nativeSelectClassName = "w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-sm leading-5 outline-none focus-visible:ring-2 focus-visible:ring-ring"
+
+export function NativeSelect({ className, ...props }: React.ComponentProps<"select">) {
+ return
+}
+
+// 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 +44,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 577ba868de..2aaea7f85c 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.icon} }
+
{choice.name}
{choice.description}
diff --git a/ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx b/ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx
index 44562e7bcf..efe20b6472 100644
--- a/ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx
+++ b/ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx
@@ -30,6 +30,7 @@ vi.mock("@/api/email", () => ({ emailApi: {
} }));
const credentialRender = vi.hoisted(() => vi.fn());
+const routedCredentialRender = vi.hoisted(() => vi.fn());
const setupOptionsMock = vi.hoisted(() => vi.fn());
const completeMock = vi.hoisted(() => vi.fn());
const declineMock = vi.hoisted(() => vi.fn());
@@ -68,6 +69,16 @@ vi.mock("@/components/ai-connections/AiConnectionCredentialStep", () => ({
; },
}));
+vi.mock("@/components/ai-connections/AiProviderSetup", () => ({
+ AiProviderSetup: (props: { reconnect: { id: string }; onComplete: (binding: { connectionId: string; grantId: string; method: "api_key" }) => void; onCancel: () => void }) => {
+ routedCredentialRender(props);
+ return
+ props.onComplete({ connectionId: props.reconnect.id, grantId: "grant", method: "api_key" })}>Reconnect routed account
+ Cancel routed repair
+
;
+ },
+}));
+
vi.mock("./ConnectionSetupFlow", () => ({
ConnectionSetupFlow: (props: {
requestedAgentId?: string;
@@ -477,6 +488,21 @@ describe("ConnectionIntentInteractionBody dialog behavior", () => {
describe("AI repair inside the card", () => {
const interaction: ConnectionIntentInteraction = { ...pendingConnectionIntentInteraction, payload: { ...pendingConnectionIntentInteraction.payload, purpose: "ai" } };
const connection = { id: "selected-account", name: "My Codex account", provider: "openai", method: "api_key", ownership: "personal", ownerName: "Dotta", status: "revoked" };
+ it.each(["openrouter", "bedrock", "gateway"] as const)("retains the selected %s route through task-card account repair", async kind => {
+ const routing = kind === "bedrock"
+ ? { kind, protocol: "bedrock", region: "us-east-1", auth: "bearer", models: [] }
+ : { kind, protocol: "responses", auth: "bearer", models: [], ...(kind === "gateway" ? { baseUrl: "https://gateway.example/v1" } : {}) };
+ const routedConnection = { ...connection, provider: kind === "bedrock" ? "anthropic" : kind === "openrouter" ? "openrouter" : "openai", routing };
+ setupOptionsMock.mockResolvedValue({ interaction, existingConnections: [], aiRepair: { connection: routedConnection, canReconnect: true } });
+ completeMock.mockResolvedValue({ ...interaction, status: "accepted" });
+ renderBody(interaction); await flush();
+ await act(() => button("Fix connection")!.click());
+ expect(document.querySelector('[data-testid="shared-ai-credentials"]')).toBeNull();
+ expect(routedCredentialRender.mock.lastCall![0]).toMatchObject({ companyId: interaction.companyId, agentId: interaction.payload.requestingAgentId, reconnect: routedConnection });
+ await act(() => button("Reconnect routed account")!.click());
+ expect(completeMock).toHaveBeenCalledWith(interaction.id, connection.id);
+ });
+
it.each([false, true])("requires atomic validated legacy adoption (validation fails: %s)", async (fails) => {
const binding = { provider: "openai", method: "subscription", mode: "responsible_user" };
setupOptionsMock.mockResolvedValue({ interaction, existingConnections: [], aiConnection: binding, aiConnectionRequiresAdoption: true });
diff --git a/ui/src/features/connections/ConnectionIntentInteractionBody.tsx b/ui/src/features/connections/ConnectionIntentInteractionBody.tsx
index 21e32b48a3..2fe45ef46f 100644
--- a/ui/src/features/connections/ConnectionIntentInteractionBody.tsx
+++ b/ui/src/features/connections/ConnectionIntentInteractionBody.tsx
@@ -14,6 +14,7 @@ import { connectionIntentsApi } from "@/api/connection-intents";
import { aiConnectionsApi } from "@/api/ai-connections";
import { agentsApi } from "@/api/agents";
import { AiConnectionCredentialStep } from "@/components/ai-connections/AiConnectionCredentialStep";
+import { AiProviderSetup } from "@/components/ai-connections/AiProviderSetup";
import { defaultAiConnectionName } from "@/components/ai-connections/model";
import { AppLogo } from "@/pages/apps/AppLogo";
import { AgentAvatar } from "@/components/AgentAvatar";
@@ -393,7 +394,13 @@ export function ConnectionIntentInteractionBody({
{completeMutation.isPending ? "Continuing…" : "Continue task"}
- : repair ? repair.canReconnect ? selectAiAccountMutation.mutate({ connectionId: binding.connectionId, grantId: binding.grantId, method: binding.method ?? "api_key", generation })}
+ onCancel={() => { closeSetup(); returnFocusToCard(); }}
+ /> : method.key === connectionMethodKey)?.ai
?? (!connectionMethodKey && entry?.methods.every(method => method.ai) ? entry.methods[0]?.ai : undefined);
const credentialStep = entry ? renderCredentialStep?.({ app: entry, name: galleryName || entry.name, grantKind: effectiveGrantKind, agentIds: [...installAgentIds], allAgents: installChoice === "all", onBack: () => backToGallery() }) ?? (aiMethod && selectedCompanyId ? <> {
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 input = dialog.querySelector('[aria-label="API key"]') as HTMLInputElement;
expect(input).toBeTruthy();
await act(async () => {
@@ -544,11 +555,11 @@ 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(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 8d61d6ba53..afa5e5ba88 100644
--- a/ui/src/pages/apps/AppsConnect.tsx
+++ b/ui/src/pages/apps/AppsConnect.tsx
@@ -1,13 +1,15 @@
+import { useQuery } from "@tanstack/react-query";
+import { aiConnectionsApi } from "@/api/ai-connections";
+import { AiProviderSetup } from "@/components/ai-connections/AiProviderSetup";
import { AiConnectionPoolConnector } from "@/components/ai-connections/AiConnectionPoolConnector";
import { useEffect } from "react";
-import { useQuery } from "@tanstack/react-query";
import { isRetiredComposioConnection } from "@paperclipai/shared";
import { findComposioCatalogApp } from "@paperclipai/shared/aggregator-app-catalog";
import { toolsApi } from "@/api/tools";
import { queryKeys } from "@/lib/queryKeys";
import { Button } from "@/components/ui/button";
import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow";
-import type { ToolConnectionCredentialSource } from "@paperclipai/shared";
+import { aiProviderSetupPreset, type ToolConnectionCredentialSource } from "@paperclipai/shared";
import { useCompany } from "@/context/CompanyContext";
import { useNavigate, useParams, useSearchParams } from "@/lib/router";
import { consumeSkillSourceReturn, skillSourceReturnPath } from "@/lib/skill-source-connect-return";
@@ -24,6 +26,11 @@ 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 preset = aiProviderSetupPreset(source);
+ const aiReconnectRequested = Boolean(reconnectId && (preset || ["openai", "anthropic", "xai"].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 gallery = useQuery({ queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"), queryFn: () => toolsApi.listGallery(selectedCompanyId!), enabled: !!selectedCompanyId && !!source?.startsWith("ai-router-") });
const router = gallery.data?.apps.find(app => app.slug === source)?.aiConnectionRouter;
const toolkit = searchParams.get("targetToolkit");
@@ -48,6 +55,21 @@ export function AppsConnect({ byoOnly = false, credentialSource = "paperclip_vau
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 && (preset || aiReconnect?.routing)) return (
+ navigate("/apps")}
+ onComplete={binding => navigate(`/apps/${binding.connectionId}/permissions`)}
+ />
+ );
if (source?.startsWith("ai-router-")) {
if (gallery.isPending) return Loading connector…
;
if (!router) return {gallery.error?.message ?? "This connection pool plugin is unavailable. Enable it in Plugins."}
;