From fa9b3d22a175b4143a8312098776b0755f43ee71 Mon Sep 17 00:00:00 2001
From: Dotta
Date: Tue, 6 Oct 2026 09:21:04 -0500
Subject: [PATCH] refactor(connections): keep runtime and browser-login changes
independently usable
Keep the provider runtime PR below the review file limit. Preserve browser sign-in compatibility and provider branding here; ship advanced setup, stories, and the qualification harness in the linked UI PR.
Co-Authored-By: Paperclip
---
README.md | 3 -
doc/DOCKER.md | 11 +-
doc/connections/AI-CONNECTIONS.md | 43 +++-----
.../acpx-engine/filesystem-protocol.test.ts | 100 ------------------
packages/adapters/hermes/README.md | 1 -
packages/adapters/hermes/src/index.ts | 1 -
.../hermes/src/server/execute.onspawn.test.ts | 12 ---
.../adapters/hermes/src/server/execute.ts | 6 +-
.../servers/acp-filesystem-agent.mjs | 43 --------
skills/paperclip/references/artifacts.md | 5 -
.../scripts/paperclip-upload-artifact.sh | 17 +--
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 | 2 +-
ui/src/components/ai-connections/model.ts | 1 +
.../ai-connections/useLocalAiLogin.test.tsx | 55 +++++++---
.../ai-connections/useLocalAiLogin.ts | 30 ++++--
.../AgentProviderConnection.test.tsx | 23 ++--
23 files changed, 229 insertions(+), 290 deletions(-)
delete mode 100644 packages/adapter-utils/src/acpx-engine/filesystem-protocol.test.ts
delete mode 100644 scripts/mcp-fixtures/servers/acp-filesystem-agent.mjs
create mode 100644 ui/public/brands/apps/bedrock.svg
create mode 100644 ui/public/brands/apps/google.svg
diff --git a/README.md b/README.md
index a0db4476643..b3f130123d8 100644
--- a/README.md
+++ b/README.md
@@ -417,9 +417,6 @@ This starts the UI and API at `http://localhost:3100`. An embedded PostgreSQL da
> **Requirements:** Node.js 24.11+, pnpm 9.15+
-Local Claude and Codex subscription sign-in also needs Python 3 and the
-corresponding provider CLI on the Paperclip host. The Docker image includes them.
-
Source development also builds the native Paperclip Runner when enabled (the self-hosted default). Install a Rust toolchain, or set `PAPERCLIP_RUNNER_BINARY` to a compatible prebuilt runner.
diff --git a/doc/DOCKER.md b/doc/DOCKER.md
index d59ddc102bd..7d9bb1ea954 100644
--- a/doc/DOCKER.md
+++ b/doc/DOCKER.md
@@ -212,25 +212,24 @@ The image pre-installs:
- `claude` (Anthropic Claude Code CLI)
- `codex` (OpenAI Codex CLI)
-- `python3` (used to run local subscription sign-in in a pseudo-terminal)
-Start the container, then open **Apps → Connections** to connect a Claude or
-Codex subscription in your browser. You can also enter a provider API key there.
-You do not run a shell command or configure a CLI home.
+If you want local adapter runs inside the container, pass API keys when starting the container:
```sh
docker run --name paperclip \
-p 3100:3100 \
-e HOST=0.0.0.0 \
-e PAPERCLIP_HOME=/paperclip \
+ -e OPENAI_API_KEY=... \
+ -e ANTHROPIC_API_KEY=... \
-v "$(pwd)/data/docker-paperclip:/paperclip" \
paperclip-local
```
Notes:
-- The image provides the CLI and Python prerequisites for local subscription sign-in.
-- Adapter environment checks in Paperclip surface missing authentication or CLI prerequisites.
+- Without API keys, the app still runs normally.
+- Adapter environment checks in Paperclip will surface missing auth/CLI prerequisites.
## Podman Quadlet (systemd)
diff --git a/doc/connections/AI-CONNECTIONS.md b/doc/connections/AI-CONNECTIONS.md
index 5ab74a8ff97..7038464b40f 100644
--- a/doc/connections/AI-CONNECTIONS.md
+++ b/doc/connections/AI-CONNECTIONS.md
@@ -10,11 +10,6 @@ homes. Managed default/shared accounts are additional choices in that same
selector. Selecting “Sign in to another account” survives background refreshes;
Claude authorization paste keeps upstream's immediate Connecting feedback.
-New-agent Connect offers three persistent tiles: the provider's subscription,
-the provider's API key, and Advanced. All three stay visible while the selected
-mode's form is shown below. Advanced opens the existing compatible-connection
-picker and provider setup. Execution environment selection lives in Configure.
-
Storybook's simulated controllers and page annotations do not run in the app.
## Compatibility and selection
@@ -384,8 +379,8 @@ onboarding, and agent setup share `LocalProviderLoginInstructions` and
`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 (included in the Docker
-image) and the corresponding provider CLI on the Paperclip host. 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
@@ -516,24 +511,15 @@ fingerprint. These checks do not relax current connection authorization.
## Advanced provider routing (2026-10-02)
-Use the regular rows on **Connectors** to add OpenRouter, Amazon Bedrock,
-Google Gemini, a Responses API, Messages API, Chat Completions API, or local
-endpoint connection. Each row has its own Connect action and saved accounts.
-The catalog tags these entries `model-provider`; no category UI is shown.
-Responses-compatible gateways such as Emissary use the Responses API row.
-The native subscription/API-key onboarding remains the default. Provider choices
-show the existing local brand artwork and reuse the existing access step. Custom
-URLs, protocol, AWS region, and credential fields appear only after choosing the
-corresponding connector. New connections default to everyone in the organization
-and all agents when the actor has permission; the existing Advanced disclosure
-contains the controls to narrow access, without a separate Access step. New-agent setup also offers the connection picker under
-**Use another connection**.
+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.
-At **Agents → [agent] → Harness / Runtime**, **Connection** is a dropdown of
-compatible saved connections, including explicit personal accounts. The existing
-model picker uses that connection’s optional model IDs and accepts manual IDs.
-Changing connections preserves the model for explicit review. URLs and
-credentials belong to the connection; the model belongs to the agent.
+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 |
| --- | --- |
@@ -543,7 +529,7 @@ credentials belong to the connection; the model belongs to the agent.
| Hermes local | OpenRouter; custom/local Chat Completions |
| Gemini CLI, Grok | Their native API connections; custom routes are not advertised |
-Migration `0295` adds Google to both account-default provider constraints. Local
+Migration `0300` 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.
@@ -555,10 +541,9 @@ 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`. New-agent setup and agent settings share this
-discovery path, preserve the provider's ordering, and adapt model IDs to the selected
-harness. Explicit connection model lists take precedence. Catalog discovery sends
-no credentials; a failed request offers refresh and manual model entry.
+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.
diff --git a/packages/adapter-utils/src/acpx-engine/filesystem-protocol.test.ts b/packages/adapter-utils/src/acpx-engine/filesystem-protocol.test.ts
deleted file mode 100644
index 2962e1a2dd9..00000000000
--- a/packages/adapter-utils/src/acpx-engine/filesystem-protocol.test.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import fs from "node:fs/promises";
-import os from "node:os";
-import path from "node:path";
-import { fileURLToPath } from "node:url";
-import { afterEach, expect, it } from "vitest";
-import { createAcpRuntime, createAgentRegistry, createRuntimeStore } from "acpx/runtime";
-
-const roots: string[] = [];
-const fixture = fileURLToPath(new URL("../../../../scripts/mcp-fixtures/servers/acp-filesystem-agent.mjs", import.meta.url));
-afterEach(async () => {
- await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
-});
-
-async function workspace() {
- const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acp-filesystem-"));
- roots.push(root);
- const cwd = path.join(root, "workspace");
- await fs.mkdir(cwd);
- return { root, cwd: await fs.realpath(cwd) };
-}
-
-async function drive(input: {
- cwd: string;
- root: string;
- permissionMode?: "approve-all" | "deny-all";
- operations: Array<{ method: string; path: string; content?: string }>;
-}) {
- let stderr = "";
- const runtime = createAcpRuntime({
- cwd: input.cwd,
- sessionStore: createRuntimeStore({ stateDir: path.join(input.root, "state") }),
- agentRegistry: createAgentRegistry({ overrides: { custom: `${JSON.stringify(process.execPath.replaceAll("\\", "/"))} ${JSON.stringify(fixture.replaceAll("\\", "/"))}` } }),
- permissionMode: input.permissionMode ?? "approve-all",
- nonInteractivePermissions: "deny",
- inheritProcessEnv: false,
- onAgentStderr: (chunk: string) => { stderr += chunk; },
- });
- const handle = await runtime.ensureSession({
- sessionKey: "filesystem-protocol", agent: "custom", mode: "oneshot",
- cwd: input.cwd, sessionOptions: { env: {} },
- });
- try {
- for await (const _event of runtime.runTurn({
- handle, text: JSON.stringify({ operations: input.operations }),
- mode: "prompt", requestId: "filesystem-protocol", timeoutMs: 5_000,
- })) { /* Drain the real ACP turn. */ }
- return stderr.split("\n").filter((line) => line.startsWith("FS_RESULT "))
- .map((line) => JSON.parse(line.slice("FS_RESULT ".length)));
- } finally {
- await runtime.close({ handle, reason: "done" });
- }
-}
-
-it("reports a missing file as an ACP resource error and then creates and reads it", async () => {
- const input = await workspace();
- const file = path.join(input.cwd, "new.json");
- const content = '{"proof":"synthetic"}\n';
- const receipts = await drive({ ...input, operations: [
- { method: "fs/read_text_file", path: file },
- { method: "fs/write_text_file", path: file, content },
- { method: "fs/read_text_file", path: file },
- ] });
- expect(receipts).toHaveLength(3);
- expect(receipts[0].error).toMatchObject({ code: -32002, message: expect.stringContaining("Resource not found") });
- expect(receipts[1].error).toBeUndefined();
- expect(receipts[2].result.content).toBe(content);
- expect(await fs.readFile(file, "utf8")).toBe(content);
-});
-
-it("keeps unrelated paths denied for both reads and writes", async () => {
- const input = await workspace();
- const outside = path.join(await fs.realpath(input.root), "outside.json");
- await fs.writeFile(outside, "unchanged");
- const receipts = await drive({ ...input, operations: [
- { method: "fs/read_text_file", path: outside },
- { method: "fs/write_text_file", path: outside, content: "changed" },
- ] });
- expect(receipts).toHaveLength(2);
- expect(receipts.every((receipt) => receipt.error && receipt.error.code !== -32002)).toBe(true);
- expect(await fs.readFile(outside, "utf8")).toBe("unchanged");
-});
-
-it("preserves deny-all permissions instead of converting denials into missing files", async () => {
- const input = await workspace();
- const file = path.join(input.cwd, "denied.json");
- const receipts = await drive({ ...input, permissionMode: "deny-all", operations: [
- { method: "fs/read_text_file", path: file },
- { method: "fs/write_text_file", path: file, content: "denied" },
- ] });
- expect(receipts).toHaveLength(2);
- expect(receipts.every((receipt) => receipt.error && receipt.error.code !== -32002)).toBe(true);
- await expect(fs.stat(file)).rejects.toMatchObject({ code: "ENOENT" });
-});
-
-it.skipIf(process.platform === "win32")("keeps other filesystem failures distinct from missing files", async () => {
- const input = await workspace();
- const receipts = await drive({ ...input, operations: [{ method: "fs/read_text_file", path: input.cwd }] });
- expect(receipts).toHaveLength(1);
- expect(receipts[0].error.code).not.toBe(-32002);
-});
diff --git a/packages/adapters/hermes/README.md b/packages/adapters/hermes/README.md
index fb9d267e7bf..64f6ae39dd9 100644
--- a/packages/adapters/hermes/README.md
+++ b/packages/adapters/hermes/README.md
@@ -237,7 +237,6 @@ Available toolsets: `terminal`, `file`, `web`, `browser`, `code_execution`, `vis
| Field | Type | Default | Description |
|-------|------|---------|-------------|
-| `cwd` | string | *(assigned task workspace)* | Absolute working directory override. Without an override, use Paperclip's resolved task workspace. |
| `persistSession` | boolean | `true` | Resume sessions across heartbeats |
| `worktreeMode` | boolean | `false` | Git worktree isolation |
| `checkpoints` | boolean | `false` | Enable filesystem checkpoints for rollback |
diff --git a/packages/adapters/hermes/src/index.ts b/packages/adapters/hermes/src/index.ts
index 1c1b9d33984..3494bf59708 100644
--- a/packages/adapters/hermes/src/index.ts
+++ b/packages/adapters/hermes/src/index.ts
@@ -99,7 +99,6 @@ tools, persistent memory, session persistence, skills, and MCP support.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
-| cwd | string | (assigned task workspace) | Absolute working directory override; otherwise use Paperclip's resolved task workspace |
| persistSession | boolean | true | Resume sessions across heartbeats |
| worktreeMode | boolean | false | Use git worktree for isolated changes |
| checkpoints | boolean | false | Enable filesystem checkpoints |
diff --git a/packages/adapters/hermes/src/server/execute.onspawn.test.ts b/packages/adapters/hermes/src/server/execute.onspawn.test.ts
index ed0bb48d723..1adf450a506 100644
--- a/packages/adapters/hermes/src/server/execute.onspawn.test.ts
+++ b/packages/adapters/hermes/src/server/execute.onspawn.test.ts
@@ -113,18 +113,6 @@ describe("hermes-local adapter onSpawn forwarding", () => {
expect(call[2]).toContainEqual(expect.stringContaining("Current task brief"));
});
- it("launches in the assigned task workspace when no cwd was configured", async () => {
- const { ctx } = makeCtx();
- await execute({ ...ctx, context: { ...ctx.context, paperclipWorkspace: { cwd: "/private/qa/agent-workspace" } } } as any);
- expect(vi.mocked(serverUtils.runChildProcess).mock.calls.at(-1)![3].cwd).toBe("/private/qa/agent-workspace");
- });
-
- it("preserves an explicitly configured cwd", async () => {
- const { ctx } = makeCtx({ cwd: "/private/qa/configured-workspace" });
- await execute({ ...ctx, context: { ...ctx.context, paperclipWorkspace: { cwd: "/private/qa/agent-workspace" } } } as any);
- expect(vi.mocked(serverUtils.runChildProcess).mock.calls.at(-1)![3].cwd).toBe("/private/qa/configured-workspace");
- });
-
it("runChildProcess opts type includes onSpawn", () => {
// Type-level assertion: if onSpawn were removed from the type,
// this file would fail to compile. The runtime test above catches
diff --git a/packages/adapters/hermes/src/server/execute.ts b/packages/adapters/hermes/src/server/execute.ts
index 21d27eeb09d..ca70427b8d1 100644
--- a/packages/adapters/hermes/src/server/execute.ts
+++ b/packages/adapters/hermes/src/server/execute.ts
@@ -510,12 +510,8 @@ export async function execute(
if (envCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = envCommentId;
// ── Resolve working directory ──────────────────────────────────────────
- const workspace = ctx.context?.paperclipWorkspace;
- const workspaceCwd = workspace && typeof workspace === "object"
- ? cfgString((workspace as Record).cwd)
- : undefined;
const cwd =
- cfgString(config.cwd) || workspaceCwd || cfgString(ctx.config?.workspaceDir) || ".";
+ cfgString(config.cwd) || cfgString(ctx.config?.workspaceDir) || ".";
try {
await ensureAbsoluteDirectory(cwd);
} catch {
diff --git a/scripts/mcp-fixtures/servers/acp-filesystem-agent.mjs b/scripts/mcp-fixtures/servers/acp-filesystem-agent.mjs
deleted file mode 100644
index c8248ee5fdf..00000000000
--- a/scripts/mcp-fixtures/servers/acp-filesystem-agent.mjs
+++ /dev/null
@@ -1,43 +0,0 @@
-#!/usr/bin/env node
-// Credential-free ACP filesystem requests, driven through the real client.
-import { createInterface } from "node:readline";
-
-const pending = new Map();
-let nextId = 100;
-const send = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
-const request = (method, params) => new Promise((resolve) => {
- const id = nextId++;
- pending.set(id, resolve);
- send({ jsonrpc: "2.0", id, method, params });
-});
-
-createInterface({ input: process.stdin }).on("line", async (line) => {
- const message = JSON.parse(line);
- if (pending.has(message.id)) {
- pending.get(message.id)(message);
- pending.delete(message.id);
- return;
- }
- let result;
- if (message.method === "initialize") {
- result = {
- protocolVersion: 1,
- agentCapabilities: { loadSession: false, sessionCapabilities: { close: {} } },
- agentInfo: { name: "paperclip-acp-filesystem-fixture", version: "1.0.0" },
- };
- } else if (message.method === "session/new") {
- result = { sessionId: "filesystem-fixture" };
- } else if (message.method === "session/prompt") {
- const { operations } = JSON.parse(message.params.prompt.find((entry) => entry.type === "text").text);
- for (const { method, ...params } of operations) {
- const response = await request(method, { sessionId: "filesystem-fixture", ...params });
- process.stderr.write(`FS_RESULT ${JSON.stringify(response)}\n`);
- }
- result = { stopReason: "end_turn" };
- } else if (message.method === "session/cancel") {
- return;
- } else {
- result = {};
- }
- if (message.id !== undefined) send({ jsonrpc: "2.0", id: message.id, result });
-});
diff --git a/skills/paperclip/references/artifacts.md b/skills/paperclip/references/artifacts.md
index e632e7d58ae..ad2f8493653 100644
--- a/skills/paperclip/references/artifacts.md
+++ b/skills/paperclip/references/artifacts.md
@@ -156,8 +156,3 @@ confirms storage and response binding in Paperclip; it does not confirm delivery
to an external chat provider. If registration fails, use the returned error to
resolve the failure or explain the limitation; do not describe a workspace path
as an uploaded file.
-
-The shell helper keeps temporary responses and retry receipts in the assigned
-workspace's private `.paperclip/artifact-helper` directory, so CLI sandboxes can
-write them even when the controller's `TMPDIR` is inaccessible.
-`PAPERCLIP_HELPER_STATE_DIR` overrides that directory when explicitly configured.
diff --git a/skills/paperclip/scripts/paperclip-upload-artifact.sh b/skills/paperclip/scripts/paperclip-upload-artifact.sh
index 87733dded19..0dfa111d5e2 100755
--- a/skills/paperclip/scripts/paperclip-upload-artifact.sh
+++ b/skills/paperclip/scripts/paperclip-upload-artifact.sh
@@ -113,17 +113,6 @@ sha256_text() {
fi
}
-helper_state_directory() {
- printf '%s' "${PAPERCLIP_HELPER_STATE_DIR:-${PAPERCLIP_WORKSPACE_CWD:-$PWD}/.paperclip/artifact-helper}"
-}
-
-helper_response_file() {
- local directory
- directory="$(helper_state_directory)"
- # TMPDIR can name a controller-owned directory outside a CLI sandbox.
- (umask 077; mkdir -p "$directory" && mktemp "$directory/response.XXXXXX")
-}
-
request_json() {
local method="$1"
local url="$2"
@@ -131,7 +120,7 @@ request_json() {
local response_file
local status_code
- response_file="$(helper_response_file)"
+ response_file="$(mktemp)"
if [[ -n "$body" ]]; then
status_code="$(
curl -sS -X "$method" -w '%{http_code}' -o "$response_file" \
@@ -174,7 +163,7 @@ upload_file() {
escaped_path="${path//\\/\\\\}"
escaped_path="${escaped_path//\"/\\\"}"
- response_file="$(helper_response_file)"
+ response_file="$(mktemp)"
status_code="$(
curl -sS -X POST -w '%{http_code}' -o "$response_file" \
"$url" \
@@ -234,7 +223,7 @@ acquire_operation_lock() {
local attempts=0
umask 077
- operation_state_root="$(helper_state_directory)"
+ operation_state_root="${PAPERCLIP_HELPER_STATE_DIR:-${TMPDIR:-/tmp}/paperclip-upload-artifact}"
mkdir -p "$operation_state_root"
operation_lock_path="$operation_state_root/$operation_key.lock"
operation_lock_owner="$$|$(process_start_identity "$$" || true)"
diff --git a/ui/public/brands/apps/bedrock.svg b/ui/public/brands/apps/bedrock.svg
new file mode 100644
index 00000000000..610805e512a
--- /dev/null
+++ b/ui/public/brands/apps/bedrock.svg
@@ -0,0 +1 @@
+Bedrock
\ No newline at end of file
diff --git a/ui/public/brands/apps/google.svg b/ui/public/brands/apps/google.svg
new file mode 100644
index 00000000000..e8e0f867bde
--- /dev/null
+++ b/ui/public/brands/apps/google.svg
@@ -0,0 +1 @@
+Google
\ No newline at end of file
diff --git a/ui/public/brands/apps/manifest.json b/ui/public/brands/apps/manifest.json
index ed490bfc212..cbb149dab32 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 c7cb9946ea4..cfe31188368 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 6d7aa7e63eb..41169819170 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 c82482c5f14..c6294d78ac4 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 aeeb67b1942..8749e63cf4d 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 eb48221af7f..ec151c4a75e 100644
--- a/ui/src/components/ai-connections/AiConnectionCredentialStep.tsx
+++ b/ui/src/components/ai-connections/AiConnectionCredentialStep.tsx
@@ -74,7 +74,7 @@ function SubscriptionConnectionStep({ companyId, provider, initialMethod, fixedM
const canLogin = environment?.driver === "sandbox" && caps.data?.sandboxProviders?.[sandboxProvider]?.supportsLoginPty === true;
const loading = [envs, caps, settings, experimental, general].some((query) => query.isPending);
const error = environmentError ?? [envs, caps, settings, experimental, general].find((query) => query.error)?.error?.message;
- const intent: AiConnectionLoginIntent = { provider, method: "subscription", name, ownership, agentIds, allAgents, connectionId };
+ const intent: AiConnectionLoginIntent = { provider: provider as AiConnectionLoginIntent["provider"], method: "subscription", name, ownership, agentIds, allAgents, connectionId };
return
{!hideName &&
Connection name setName(event.target.value)} disabled={Boolean(connectionId)} /> }
{!suppliedEnvironmentId && !forced.forced && loginEnvironments.length > 1 &&
diff --git a/ui/src/components/ai-connections/model.ts b/ui/src/components/ai-connections/model.ts
index 8f2c2cbb50f..9806071e942 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 44346326096..1bb18c5d49c 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 4d84cf72975..8e6a34ecf19 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 fdca64942ab..a9116af73b1 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 () => {