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 && } - :

{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 &&

{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} + /> + +
: } +
} + {!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) && } + {login && !login.preparing && !ready && (isolated || login.error) && }
; } 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 && } {!suppliedEnvironmentId && !forced.forced && loginEnvironments.length > 1 &&