From 45a841bc745df2fcc344b6d59766ee4bcd0ab60c Mon Sep 17 00:00:00 2001
From: Dotta
Date: Sat, 3 Oct 2026 06:57:17 -0500
Subject: [PATCH] Use browser subscription sign-in on self-hosted installations
Reuse the cloud Claude and Codex login drivers behind isolated local attempts, and present the shared browser-code UI in connection setup.
Co-Authored-By: Paperclip
---
README.md | 3 +
doc/DOCKER.md | 11 +-
doc/connections/AI-CONNECTIONS.md | 43 +++-----
packages/shared/src/ai-connections.ts | 6 +-
server/src/__tests__/ai-connections.test.ts | 36 +++---
.../__tests__/ai-legacy-compatibility.test.ts | 47 +++++++-
.../__tests__/local-ai-credentials.test.ts | 34 +++---
server/src/routes/ai-connections.ts | 34 +++---
server/src/routes/openapi.ts | 14 ++-
.../services/local-ai-browser-login.test.ts | 75 +++++++++++++
server/src/services/local-ai-browser-login.ts | 103 ++++++++++++++++++
server/src/services/local-ai-credentials.ts | 46 ++++----
server/src/services/local-ai-login.ts | 76 ++++++++++---
ui/src/api/ai-connections.ts | 1 +
ui/src/components/AdapterLoginChrome.tsx | 45 ++++++--
ui/src/components/OnboardingWizard.test.tsx | 35 +++---
ui/src/components/OnboardingWizard.tsx | 19 ++--
.../ai-connections/useLocalAiLogin.test.tsx | 55 +++++++---
.../ai-connections/useLocalAiLogin.ts | 30 +++--
.../AgentProviderConnection.test.tsx | 29 ++---
.../new-agent/AgentProviderConnection.tsx | 3 +-
.../stories/local-ai-subscription.stories.tsx | 61 +++++++++++
22 files changed, 607 insertions(+), 199 deletions(-)
create mode 100644 server/src/services/local-ai-browser-login.test.ts
create mode 100644 server/src/services/local-ai-browser-login.ts
create mode 100644 ui/storybook/stories/local-ai-subscription.stories.tsx
diff --git a/README.md b/README.md
index b3f130123d8..a0db4476643 100644
--- a/README.md
+++ b/README.md
@@ -417,6 +417,9 @@ 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 7d9bb1ea954..d59ddc102bd 100644
--- a/doc/DOCKER.md
+++ b/doc/DOCKER.md
@@ -212,24 +212,25 @@ 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)
-If you want local adapter runs inside the container, pass API keys when starting the container:
+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.
```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:
-- Without API keys, the app still runs normally.
-- Adapter environment checks in Paperclip will surface missing auth/CLI prerequisites.
+- The image provides the CLI and Python prerequisites for local subscription sign-in.
+- Adapter environment checks in Paperclip surface missing authentication or CLI prerequisites.
## Podman Quadlet (systemd)
diff --git a/doc/connections/AI-CONNECTIONS.md b/doc/connections/AI-CONNECTIONS.md
index c143265c372..5ab74a8ff97 100644
--- a/doc/connections/AI-CONNECTIONS.md
+++ b/doc/connections/AI-CONNECTIONS.md
@@ -33,9 +33,9 @@ Native runner supports the corresponding existing Codex, OpenCode, and Claude
ACP profiles. Connections creation and reconnect mount `AgentProviderConnection`,
the same provider tiles, method controls, API entry, and `AdapterLoginPanel` used
by agent setup. Supported sandbox environments use onboarding's existing browser
-sign-in controllers. Self-hosted installations use the shared terminal sign-in
-instructions described below and require no sandbox. Environment selection does
-not change agent execution settings.
+sign-in controllers. Self-hosted Claude and Codex installations use the same
+browser sign-in presentation with a local login runner and require no sandbox.
+Environment selection does not change agent execution settings.
API keys are validated against fixed provider endpoints; redirects
and caller-supplied validation URLs are rejected.
@@ -381,19 +381,16 @@ the server preserves the managed binding and will not restore legacy fallback.
Local installations do not need a sandbox to connect a subscription. Connections,
onboarding, and agent setup share `LocalProviderLoginInstructions` and
-`useLocalAiLogin`. In local-trusted mode, Claude checks the operator’s existing
-Claude Code login. Authenticated self-hosted users instead get a separate
-`CLAUDE_CONFIG_DIR` for `claude auth login`; checking and saving only read that
-attempt’s credential files, never the server operator’s account or Keychain.
-
-Codex and Grok start a separate terminal sign-in for each connection or reconnect.
-The shared component shows a server-generated command with a fresh `CODEX_HOME`
-or `GROK_HOME`. Codex uses file credential storage in that home and `login --device-auth`, so
-signing in from another computer does not depend on a localhost callback. The home is never
-seeded with the operator's existing login: copying a rotating refresh token would
+`useLocalAiLogin`. Claude and Codex start a local provider process behind the
+browser sign-in card. Claude accepts the authorization code in that card; Codex
+displays its device code there. The user does not run a shell command. Each
+local runner requires Python 3 for its pseudo-terminal (included in the Docker
+image) and the corresponding provider CLI on the Paperclip host. Each
+attempt retains a private credential home. The home is never seeded with the
+operator's existing login: copying a rotating refresh token would
allow managed runs to invalidate credentials still used by legacy agents or the
-operator's terminal. The user completes browser sign-in from that command, then
-clicks Connect. This does not require a sandbox or change the host login.
+operator's terminal. The user completes browser sign-in, then clicks Connect.
+Grok retains its terminal sign-in flow until it has a local browser login runner.
Attempts reuse `adapter_auth_sessions`, binding company, owner, provider, access
intent, reconnect target, and a 30-minute expiry. Validation and completion are
@@ -406,8 +403,7 @@ subsequently update only that grant. Reconnect preserves IDs and access settings
Starting an isolated attempt requires normal company-scoped AI-connection creation
permission. Checks, completion, cancellation, and resumption are owner-bound.
Authenticated users cannot import host credentials or use another user’s attempt.
-Claude Keychain reads remain limited to the explicit local-trusted default-home import. A failed verification creates
-no healthy connection. Preview-era Codex/Grok managed connections without the
+A failed verification creates no healthy connection. Preview-era Codex/Grok managed connections without the
isolated-subscription marker require reconnect before another managed execution;
unmanaged legacy agents retain their existing authentication paths.
@@ -446,16 +442,13 @@ authentication with a live account.
Local subscription screens share the same credential check on entry and when the
window regains focus. Waiting screens also poll until sign-in verifies. A successful
check shows the account is signed in; only **Connect** creates or reconnects the grant.
-In local-trusted mode, Claude checks the local operator’s Claude Code login.
-Authenticated Claude users, plus all Codex and Grok users, check only their
-connection-specific login home. The health response selects credential isolation,
-not whether a self-hosted user may sign in.
+Claude, Codex, and Grok check only their connection-specific login home. The
+health response selects whether a self-hosted user may sign in.
Leaving and returning to a local sign-in screen resumes its active attempt. Navigation
-does not delete a directory referenced by a copied command. **Start sign-in again**
+does not delete its credential home. **Start sign-in again**
explicitly cancels the old attempt; abandoned attempts expire after 30 minutes.
-Commands create their directory if necessary, and completed/expired attempts are
-cleaned up through the existing lifecycle.
+Completed and expired attempts are cleaned up through the existing lifecycle.
### Disposable live inline-repair test
@@ -470,7 +463,7 @@ provider key with `AI_REPAIR_TEST_KEY`. The test verifies these boundaries befor
revoking credentials or submitting work. Delete the disposable instance and revoke
its provider key after the test; failed tests may leave a paused task for inspection.
-Authenticated public deployments must configure a trusted runtime host (`PAPERCLIP_TRUSTED_MCP_RUNTIME_HOST` or `PAPERCLIP_TOOL_RUNTIME_TRUSTED_HOST`) before offering server-host subscription login, matching the local stdio runtime boundary. Health reports this capability so setup can offer a supported environment or API key instead of an unusable terminal command. Private authenticated self-hosted instances support isolated local login without that extra setting. Isolated Claude credential files must be private, owned by the server user, bounded, and free of symlinks.
+Authenticated public deployments must configure a trusted runtime host (`PAPERCLIP_TRUSTED_MCP_RUNTIME_HOST` or `PAPERCLIP_TOOL_RUNTIME_TRUSTED_HOST`) before offering server-host subscription login, matching the local stdio runtime boundary. Health reports this capability so setup can offer a supported environment or API key when local sign-in is unavailable. Private authenticated self-hosted instances support isolated local login without that extra setting. Isolated Claude credential files must be private, owned by the server user, bounded, and free of symlinks.
### Hiring and delegated work
diff --git a/packages/shared/src/ai-connections.ts b/packages/shared/src/ai-connections.ts
index 9b8a111042e..433b39a90b0 100644
--- a/packages/shared/src/ai-connections.ts
+++ b/packages/shared/src/ai-connections.ts
@@ -236,10 +236,14 @@ export const localAiConnectionSchema = aiConnectionLoginIntentSchema.extend({
export const localAiLoginStartSchema = aiConnectionLoginIntentSchema.extend({ restart: z.boolean().optional() });
export interface LocalAiLoginStatus {
status: "ready" | "sign_in_required" | "expired";
+ authorizationUrl?: string;
+ code?: string;
+ error?: string;
}
export interface LocalAiLoginAttempt {
sessionId: string;
- command: string;
+ /** Grok still uses terminal sign-in until it has an in-app login runner. */
+ command?: string;
expiresAt: string;
}
diff --git a/server/src/__tests__/ai-connections.test.ts b/server/src/__tests__/ai-connections.test.ts
index e150f107753..7db996715b0 100644
--- a/server/src/__tests__/ai-connections.test.ts
+++ b/server/src/__tests__/ai-connections.test.ts
@@ -4,7 +4,7 @@ import { issueRecoveryActionService } from "../services/issue-recovery-actions.j
import * as localCredentials from "../services/local-ai-credentials.js";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
-import { mkdtemp, rm, access, readFile, writeFile, stat } from "node:fs/promises";
+import { mkdtemp, realpath, rm, access, readFile, writeFile, stat } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { and, eq, sql } from "drizzle-orm";
@@ -21,6 +21,9 @@ import express from "express";
import request from "supertest";
import { aiConnectionRoutes, canInstallSharedAiConnectionForNewAgent, responsibleUserForAiRequest } from "../routes/ai-connections.js";
import { validateAiApiKey } from "../routes/ai-connections.js";
+vi.mock("../services/local-ai-browser-login.js", () => ({
+ startLocalBrowserLogin: () => ({ authorizationUrl: "https://auth.openai.com/codex/device", code: "ABCD-EFGHJ", abort: () => {} }),
+}));
let database: Awaited>;
let db: ReturnType;
@@ -34,7 +37,7 @@ const input = { companyId, agentId, adapterType: "claude_local", binding };
const create = (userId: string, name: string, ownership: "personal" | "shared" = "personal") => service.save(companyId, userId, { provider: "anthropic", method: "api_key", ownership, name, apiKey: "fixture", agentIds: [], allAgents: true }, `fixture-${name}`);
beforeAll(async () => {
- home = await mkdtemp(path.join(os.tmpdir(), "paperclip-ai-tests-"));
+ home = await realpath(await mkdtemp(path.join(os.tmpdir(), "paperclip-ai-tests-")));
vi.stubEnv("PAPERCLIP_HOME", home);
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "ai-connection-fixture");
database = await startEmbeddedPostgresTestDatabase("paperclip-ai-db-");
@@ -768,7 +771,7 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
expect(network).not.toHaveBeenCalled();
} finally { network.mockRestore(); }
});
- it("imports only for the local operator and preserves identity and permissions on reconnect", async () => {
+ it("requires an owned browser sign-in attempt for local subscriptions", async () => {
const reader = vi.spyOn(localCredentials, "readVerifiedLocalAiCredential").mockResolvedValue("fixture-local-token");
const app = express();
app.use(express.json());
@@ -786,30 +789,18 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
expect((await request(app).post(`${url}/check`).send(payload)).status).toBe(403);
expect(reader).not.toHaveBeenCalled();
const checked = await request(app).post(`${url}/check`).set("x-local", "yes").send(payload);
- expect(checked.status).toBe(200);
- expect(checked.body).toEqual({ status: "ready" });
+ expect(checked.status).toBe(422);
expect((await service.list(companyId, "alice")).some(c => c.name === payload.name)).toBe(false);
const connected = await request(app).post(url).set("x-local", "yes").send(payload);
- expect(connected.status).toBe(201);
- expect(JSON.stringify(connected.body)).not.toContain("fixture-local-token");
- const before = await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connected.body.connectionId));
- expect(before.map(i => [i.targetType, i.targetId])).toEqual([["agent", agentId]]);
- const reconnected = await request(app).post(url).set("x-local", "yes").send({ ...payload, connectionId: connected.body.connectionId, allAgents: true });
- expect(reconnected.status).toBe(201);
- expect(reconnected.body).toEqual(connected.body);
- const after = await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connected.body.connectionId));
- expect(after).toEqual(before);
- reader.mockRejectedValueOnce(Object.assign(new Error("Sign in locally and retry"), { status: 422 }));
- const failed = await request(app).post(url).set("x-local", "yes").send({ ...payload, name: "Unsuccessful local login" });
- expect(failed.status).toBe(422);
- expect((await service.list(companyId, "alice")).some(c => c.name === "Unsuccessful local login")).toBe(false);
+ expect(connected.status).toBe(422);
+ expect(reader).not.toHaveBeenCalled();
const codex = { ...payload, provider: "openai", name: "Isolated terminal login" };
const attempts = `${url}/attempts`;
expect((await request(app).post(attempts).send(codex)).status).toBe(403); // This member cannot authorize agentId.
expect((await request(app).post(url).set("x-local", "yes").send(codex)).status).toBe(422);
const prepared = await request(app).post(attempts).set("x-local", "yes").send(codex);
expect(prepared.status).toBe(201);
- expect(prepared.body.command).toMatch(/^\(export CODEX_HOME=.* && mkdir -p .* && codex -c .* login --device-auth\)$/);
+ expect(prepared.body.command).toBeUndefined();
expect((await request(app).post(attempts).set("x-local", "yes").send(codex)).body).toEqual(prepared.body);
expect((await request(app).delete(`${attempts}/${prepared.body.sessionId}`).set("x-test-user", "bob").send()).status).toBe(404);
expect((await request(app).delete(`${attempts}/${prepared.body.sessionId}`).set("x-local", "yes").send()).status).toBe(200);
@@ -856,13 +847,18 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
const started = await request(app).post(`${base}/attempts`).send(intent);
expect(started.status).toBe(201);
expect(started.headers["cache-control"]).toBe("no-store");
- expect(started.body.command).toContain(provider === "anthropic" ? "CLAUDE_CONFIG_DIR=" : "login --device-auth");
+ expect(started.body.command).toBeUndefined();
expect((await request(app).post(`${base}/attempts`).send(intent)).body).toEqual(started.body);
const input = { ...intent, localSessionId: started.body.sessionId };
for (const endpoint of [base, `${base}/check`]) {
expect((await request(app).post(endpoint).set("x-test-user", "bob").send(input)).status).toBe(404);
expect((await request(app).post(endpoint.replace(companyId, otherCompanyId)).send(input)).status).toBe(403);
}
+ if (provider === "anthropic") {
+ const codeUrl = `${base}/attempts/${started.body.sessionId}/code`;
+ expect((await request(app).post(codeUrl).set("x-test-user", "bob").send({ browserCode: "fixture-code" })).status).toBe(404);
+ expect((await request(app).post(codeUrl).send({ browserCode: "fixture-code" })).status).toBe(422);
+ }
expect(reader).not.toHaveBeenCalled();
const checked = await request(app).post(`${base}/check`).send(input);
expect(checked.body).toEqual({ status: "ready" });
diff --git a/server/src/__tests__/ai-legacy-compatibility.test.ts b/server/src/__tests__/ai-legacy-compatibility.test.ts
index bc41cd3181e..89c3922b424 100644
--- a/server/src/__tests__/ai-legacy-compatibility.test.ts
+++ b/server/src/__tests__/ai-legacy-compatibility.test.ts
@@ -13,6 +13,16 @@ import { localAiLoginService } from "../services/local-ai-login.js";
import { readVerifiedLocalAiCredential } from "../services/local-ai-credentials.js";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
+const browserLogin = vi.hoisted(() => ({ submitCode: vi.fn(), abort: vi.fn() }));
+vi.mock("../services/local-ai-browser-login.js", () => ({
+ startLocalBrowserLogin: () => ({
+ authorizationUrl: "https://auth.openai.com/codex/device",
+ code: "TEST-CODE",
+ submitCode: browserLogin.submitCode,
+ abort: browserLogin.abort,
+ }),
+}));
+
let database: Awaited>;
let db: ReturnType;
let home: string;
@@ -59,6 +69,35 @@ const loginIntent = () => ({ ...intent, agentIds: [] });
const auth = (mark: string, hour = 10) => JSON.stringify({ tokens: { account_id: "fixture-account", id_token: `id-${mark}`, access_token: `access-${mark}`, refresh_token: `refresh-${mark}` }, last_refresh: `2026-09-10T${hour}:00:00Z` });
const directoryFor = (id: string) => path.join(resolvePaperclipInstanceRoot(), "ai-local-logins", id);
+it("accepts a Claude code once, only from its owner, and audits without the code", async () => {
+ const login = localAiLoginService(db);
+ const intent = { ...loginIntent(), provider: "anthropic" as const };
+ const attempt = await login.start(companyId, owner, intent);
+ browserLogin.submitCode.mockClear();
+ await expect(login.submitCode(companyId, "another-owner", attempt.sessionId, "fixture-secret-code")).rejects.toThrow("not found");
+ await expect(login.submitCode(randomUUID(), owner, attempt.sessionId, "fixture-secret-code")).rejects.toThrow("not found");
+ expect(browserLogin.submitCode).not.toHaveBeenCalled();
+ await login.submitCode(companyId, owner, attempt.sessionId, "fixture-secret-code");
+ expect(browserLogin.submitCode).toHaveBeenCalledExactlyOnceWith("fixture-secret-code");
+ await expect(login.submitCode(companyId, owner, attempt.sessionId, "fixture-secret-code")).rejects.toThrow("cannot accept a code");
+ const events = await db.select().from(activityLog).where(eq(activityLog.entityId, attempt.sessionId));
+ expect(events.filter(event => event.action === "ai_connection.local_login_code_submitted")).toHaveLength(1);
+ expect(JSON.stringify(events)).not.toContain("fixture-secret-code");
+ await login.cancel(companyId, owner, attempt.sessionId);
+});
+
+it.each(["cancelled", "expired"])("rejects code submission to a %s Claude attempt", async state => {
+ const login = localAiLoginService(db);
+ const intent = { ...loginIntent(), provider: "anthropic" as const };
+ const attempt = await login.start(companyId, owner, intent);
+ browserLogin.submitCode.mockClear();
+ if (state === "cancelled") await login.cancel(companyId, owner, attempt.sessionId);
+ else await db.update(adapterAuthSessions).set({ expiresAt: new Date(Date.now() - 1000) }).where(eq(adapterAuthSessions.id, attempt.sessionId));
+ await expect(login.submitCode(companyId, owner, attempt.sessionId, "fixture-code")).rejects.toThrow("expired or was cancelled");
+ expect(browserLogin.submitCode).not.toHaveBeenCalled();
+ await login.reapExpired();
+});
+
it("isolates sign-in and refresh from the host, survives restart, and completes only once", async () => {
const hostHome = path.join(home, "host-codex");
await mkdir(hostHome);
@@ -69,15 +108,17 @@ it("isolates sign-in and refresh from the host, survives restart, and completes
const attempt = await login.start(companyId, owner, loginIntent());
const directory = directoryFor(attempt.sessionId);
expect(await localAiLoginService(db).start(companyId, owner, loginIntent())).toEqual(attempt);
- expect(attempt.command).toContain(`(export CODEX_HOME='${directory}' && mkdir -p "$CODEX_HOME" && codex`);
+ expect(attempt.command).toBeUndefined();
expect(await readFile(path.join(directory, "config.toml"), "utf8")).toContain('cli_auth_credentials_store = "file"');
- expect(await login.check(companyId, owner, loginIntent(), attempt.sessionId)).toEqual({ status: "sign_in_required" });
+ expect(await localAiLoginService(db).check(companyId, owner, loginIntent(), attempt.sessionId)).toEqual({
+ status: "sign_in_required", authorizationUrl: "https://auth.openai.com/codex/device", code: "TEST-CODE",
+ });
// Resuming a valid attempt also repairs a missing directory without changing its ID.
await rm(directory, { recursive: true });
expect(await login.start(companyId, owner, loginIntent())).toEqual(attempt);
expect(await readFile(path.join(directory, "config.toml"), "utf8")).toContain('cli_auth_credentials_store = "file"');
// A valid host login cannot satisfy an unfinished connection-specific login.
- await expect(login.complete(companyId, owner, attempt.sessionId, loginIntent())).rejects.toThrow("sign-in command shown");
+ await expect(login.complete(companyId, owner, attempt.sessionId, loginIntent())).rejects.toThrow("Finish browser sign-in");
expect((await aiConnectionService(db).list(companyId, owner)).filter(c => c.provider === "openai")).toHaveLength(0);
await writeFile(path.join(directory, "auth.json"), auth("independent-login"));
expect(await login.check(companyId, owner, loginIntent(), attempt.sessionId)).toEqual({ status: "ready" });
diff --git a/server/src/__tests__/local-ai-credentials.test.ts b/server/src/__tests__/local-ai-credentials.test.ts
index be138c306cf..2042eb0e2f1 100644
--- a/server/src/__tests__/local-ai-credentials.test.ts
+++ b/server/src/__tests__/local-ai-credentials.test.ts
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { readVerifiedLocalAiCredential } from "../services/local-ai-credentials.js";
-const mocks = vi.hoisted(() => ({ claude: vi.fn(), claudeIsolatedKeychain: vi.fn(), claudeQuota: vi.fn(), codex: vi.fn(), codexQuota: vi.fn(), readFile: vi.fn(), credentialFile: vi.fn() }));
-vi.mock("@paperclipai/adapter-claude-local/server", () => ({ readClaudeToken: mocks.claude, readIsolatedClaudeKeychainToken: mocks.claudeIsolatedKeychain, fetchClaudeQuota: mocks.claudeQuota }));
+const mocks = vi.hoisted(() => ({ claude: vi.fn(), claudeIsolatedKeychain: vi.fn(), codex: vi.fn(), codexQuota: vi.fn(), readFile: vi.fn(), credentialFile: vi.fn() }));
+vi.mock("@paperclipai/adapter-claude-local/server", () => ({ readClaudeToken: mocks.claude, readIsolatedClaudeKeychainToken: mocks.claudeIsolatedKeychain }));
vi.mock("@paperclipai/adapter-codex-local/server", () => ({ readCodexAuthInfo: mocks.codex, fetchCodexQuota: mocks.codexQuota }));
vi.mock("../services/local-ai-credential-file.js", () => ({ readLocalAiCredentialFile: mocks.credentialFile }));
vi.mock("node:fs/promises", () => ({ default: { readFile: mocks.readFile } }));
@@ -11,18 +11,16 @@ describe("explicit local subscription import", () => {
mocks.credentialFile.mockResolvedValue(JSON.stringify({ claudeAiOauth: { accessToken: "isolated-claude" } }));
await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).resolves.toBe("isolated-claude");
expect(mocks.credentialFile).toHaveBeenCalledWith("/isolated/claude/.credentials.json");
- expect(mocks.claudeQuota).toHaveBeenCalledWith("isolated-claude");
expect(mocks.claude).not.toHaveBeenCalled();
});
it("does not fall back to ambient Claude auth when an isolated login is absent or invalid", async () => {
mocks.claude.mockResolvedValue("server-operator-token");
mocks.claudeIsolatedKeychain.mockResolvedValue(null);
mocks.credentialFile.mockRejectedValue(new Error("No file"));
- await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).rejects.toThrow("sign-in command shown");
+ await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).rejects.toThrow("Finish browser sign-in");
mocks.credentialFile.mockResolvedValue("malformed");
- await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).rejects.toThrow("sign-in command shown");
+ await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).rejects.toThrow("Finish browser sign-in");
expect(mocks.claude).not.toHaveBeenCalled();
- expect(mocks.claudeQuota).not.toHaveBeenCalled();
expect(mocks.claudeIsolatedKeychain).toHaveBeenCalledWith("/isolated/claude");
});
it("verifies a macOS isolated login from the home's suffixed Keychain item when no credentials file exists", async () => {
@@ -33,7 +31,6 @@ describe("explicit local subscription import", () => {
mocks.claudeIsolatedKeychain.mockResolvedValue("isolated-keychain-claude");
await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).resolves.toBe("isolated-keychain-claude");
expect(mocks.claudeIsolatedKeychain).toHaveBeenCalledWith("/isolated/claude");
- expect(mocks.claudeQuota).toHaveBeenCalledWith("isolated-keychain-claude");
expect(mocks.claude).not.toHaveBeenCalled();
});
it("prefers the credentials file over the Keychain for an isolated login", async () => {
@@ -48,12 +45,6 @@ describe("explicit local subscription import", () => {
expect(mocks.credentialFile).toHaveBeenLastCalledWith("/isolated/claude/credentials.json");
expect(mocks.claude).not.toHaveBeenCalled();
});
- it("verifies Claude's local credential, including explicit Keychain access", async () => {
- mocks.claude.mockResolvedValue("fixture-claude");
- await expect(readVerifiedLocalAiCredential("anthropic")).resolves.toBe("fixture-claude");
- expect(mocks.claude).toHaveBeenCalledWith({ allowKeychain: true });
- expect(mocks.claudeQuota).toHaveBeenCalledWith("fixture-claude");
- });
it("reads Codex refresh credentials only from the isolated login home", async () => {
mocks.codex.mockResolvedValue({ accessToken: "access", refreshToken: "refresh", idToken: "identity", accountId: "account", lastRefresh: "date" });
const result = JSON.parse(await readVerifiedLocalAiCredential("openai", "/isolated/login"));
@@ -70,17 +61,18 @@ describe("explicit local subscription import", () => {
expect(fetch).toHaveBeenCalledWith("https://api.x.ai/v1/models", expect.objectContaining({ redirect: "error" }));
});
it("rejects missing and invalid logins with actionable, redacted errors", async () => {
- mocks.claude.mockResolvedValue(null);
- await expect(readVerifiedLocalAiCredential("anthropic")).rejects.toThrow("claude auth login");
- mocks.claude.mockResolvedValue("fixture-secret");
- mocks.claudeQuota.mockRejectedValue(new Error("credential fixture-secret rejected"));
- await expect(readVerifiedLocalAiCredential("anthropic")).rejects.toThrow(/^Could not verify the local subscription\. Run claude auth login in a terminal on the machine running Paperclip, then try Connect again\.$/);
+ await expect(readVerifiedLocalAiCredential("anthropic")).rejects.toThrow("Start browser sign-in");
+ mocks.credentialFile.mockResolvedValue(JSON.stringify({ claudeAiOauth: { accessToken: "" } }));
+ mocks.claudeIsolatedKeychain.mockResolvedValue(null);
+ await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).rejects.toThrow(/^Could not verify the local subscription\. Finish browser sign-in for this connection, then try Connect again\.$/);
mocks.codex.mockResolvedValue({ accessToken: "incomplete" });
- await expect(readVerifiedLocalAiCredential("openai", "/isolated/login")).rejects.toThrow("sign-in command shown");
+ await expect(readVerifiedLocalAiCredential("openai", "/isolated/login")).rejects.toThrow("Finish browser sign-in");
expect(mocks.codexQuota).not.toHaveBeenCalled();
});
- it.each(["openai", "xai"] as const)("never clones the ambient rotating %s login", async (provider) => {
- await expect(readVerifiedLocalAiCredential(provider)).rejects.toThrow("separate local sign-in");
+ it.each(["anthropic", "openai", "xai"] as const)("never clones the ambient rotating %s login", async (provider) => {
+ await expect(readVerifiedLocalAiCredential(provider)).rejects.toThrow(provider === "xai"
+ ? "Start local sign-in"
+ : "Start browser sign-in");
expect(mocks.codex).not.toHaveBeenCalled();
expect(mocks.readFile).not.toHaveBeenCalled();
});
diff --git a/server/src/routes/ai-connections.ts b/server/src/routes/ai-connections.ts
index 749562669cf..ef11998e1aa 100644
--- a/server/src/routes/ai-connections.ts
+++ b/server/src/routes/ai-connections.ts
@@ -1,5 +1,4 @@
import { supportsLocalAiLogin } from "../services/local-ai-login-policy.js";
-import { readVerifiedLocalAiCredential } from "../services/local-ai-credentials.js";
import { localAiLoginService } from "../services/local-ai-login.js";
import { z } from "zod";
import { Router, type Request } from "express";
@@ -17,6 +16,7 @@ import {
aiConnectionLoginIntentSchema,
localAiConnectionSchema,
localAiLoginStartSchema,
+ browserCodeSchema,
isAiConnectionCompatible,
type AiConnectionLoginIntent,
type AiProvider,
@@ -174,12 +174,6 @@ export function aiConnectionRoutes(db: Db, options: Parameters {
const companyId = req.params.companyId as string;
const { restart, ...intent } = localAiLoginStartSchema.parse(req.body);
@@ -192,9 +186,6 @@ export function aiConnectionRoutes(db: Db, options: Parameters {
+ const companyId = req.params.companyId as string;
+ assertLocalLoginAvailable();
+ assertBoard(req);
+ assertCompanyAccess(req, companyId);
+ const id = z.string().uuid().parse(req.params.sessionId);
+ await localLogin.submitCode(companyId, getActorInfo(req).actorId, id, browserCodeSchema.parse(req.body.browserCode));
+ res.setHeader("Cache-Control", "no-store");
+ res.json({ ok: true });
+ });
router.get("/companies/:companyId/ai-connections", async (req, res) => {
const companyId = req.params.companyId as string;
assertBoard(req);
@@ -317,16 +318,11 @@ export function aiConnectionRoutes(db: Db, options: Parameters {
+ process.env.PATH = initialPath;
+ if (root) await rm(root, { recursive: true, force: true });
+ root = undefined;
+});
+
+async function fakeCli(name: string, source: string) {
+ root ??= await mkdtemp(path.join(os.tmpdir(), "paperclip-browser-login-"));
+ const bin = path.join(root, "bin");
+ await mkdir(bin, { recursive: true });
+ await writeFile(path.join(bin, name), `#!/bin/sh\n${source}\n`, { mode: 0o700 });
+ process.env.PATH = `${bin}${path.delimiter}${initialPath}`;
+ const home = path.join(root, "credential-home");
+ await mkdir(home, { mode: 0o700 });
+ return home;
+}
+
+describe.skipIf(process.platform === "win32")("local browser subscription login", () => {
+ it("surfaces a Codex device link and code from a local PTY without a user shell command", async () => {
+ const home = await fakeCli("codex", [
+ 'printf "1. Open this link in your browser and sign in to your account\\n"',
+ 'printf "https://auth.openai.com/codex/device\\n"',
+ 'printf "2. Enter this one-time code (expires in 15 minutes)\\n"',
+ 'printf "ABCD-EFGHJ\\n"',
+ ].join("\n"));
+ const login = startLocalBrowserLogin("openai", home);
+ await vi.waitFor(() => expect(login.outcome).toBe("success"), { timeout: 5000 });
+ expect(login.authorizationUrl).toBe("https://auth.openai.com/codex/device");
+ expect(login.code).toBe("ABCD-EFGHJ");
+ });
+
+ it("accepts a Claude browser code and stores its token only in the attempt home", async () => {
+ const url = "https://claude.com/cai/oauth/authorize?client_id=cid&code=abcdefgh&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&redirect_uri=https%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2Fcallback&response_type=code&scope=user&state=0123456789abcdef";
+ const home = await fakeCli("claude", [
+ 'printf "Welcome to Claude Code\\nOpening browser to sign in…\\nBrowser didn\x27t open? Use the url below to sign in (c to copy)\\n"',
+ `printf '%s\\n' '${url}'`,
+ 'printf "Paste code here if prompted >\\n"',
+ 'read -r entered',
+ 'printf "✓ Long-lived authentication token created successfully!\\n\\nYour OAuth token (valid for 1 year):\\n\\nsk-ant-oat01-AAAABBBBCCCCDDDDEEEE11112222FFFFGGGG_HHHH-IIII\\n\\nStore this token securely. You won\x27t be able to see it again.\\n"',
+ ].join("\n"));
+ const login = startLocalBrowserLogin("anthropic", home);
+ await vi.waitFor(() => expect(login.authorizationUrl).toBe(url), { timeout: 5000 });
+ login.submitCode?.("fixture-code");
+ await vi.waitFor(() => expect(login.outcome).toBe("success"), { timeout: 5000 });
+ const credential = JSON.parse(await readFile(path.join(home, ".credentials.json"), "utf8"));
+ expect(credential.claudeAiOauth.accessToken).toMatch(/^sk-ant-oat01-/);
+ expect((await stat(path.join(home, ".credentials.json"))).mode & 0o777).toBe(0o600);
+ });
+
+ it("terminates the local provider process when sign-in is cancelled", async () => {
+ const home = await fakeCli("codex", 'echo $$ > "$CODEX_HOME/login.pid"\nexec sleep 60');
+ const login = startLocalBrowserLogin("openai", home);
+ let pid = 0;
+ await vi.waitFor(async () => { pid = Number(await readFile(path.join(home, "login.pid"), "utf8")); expect(pid).toBeGreaterThan(0); });
+ login.abort();
+ await vi.waitFor(() => expect(login.outcome).toBe("failure"));
+ await vi.waitFor(() => expect(() => process.kill(pid, 0)).toThrow());
+ });
+
+ it("returns a fixed failure without exposing provider error output", async () => {
+ const home = await fakeCli("codex", 'echo "private-provider-error" >&2\nexit 1');
+ const login = startLocalBrowserLogin("openai", home);
+ await vi.waitFor(() => expect(login.outcome).toBe("failure"));
+ expect(JSON.stringify(login)).not.toContain("private-provider-error");
+ expect(login.authorizationUrl).toBeUndefined();
+ });
+});
diff --git a/server/src/services/local-ai-browser-login.ts b/server/src/services/local-ai-browser-login.ts
new file mode 100644
index 00000000000..d79a01555e9
--- /dev/null
+++ b/server/src/services/local-ai-browser-login.ts
@@ -0,0 +1,103 @@
+import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
+import { open } from "node:fs/promises";
+import { constants } from "node:fs";
+import path from "node:path";
+import { runSetupTokenLogin } from "@paperclipai/adapter-claude-local/server";
+import { runDeviceLogin } from "@paperclipai/adapter-codex-local/server";
+
+export type LocalBrowserLoginState = {
+ authorizationUrl?: string;
+ code?: string;
+ outcome?: "success" | "failure";
+ submitCode?: (code: string) => void;
+ abort: () => void;
+};
+
+// Node has no built-in PTY API. Python's standard-library pty module supplies
+// the terminal required by both CLIs. The Docker image already includes
+// python3. This bridge forwards only bytes; provider output is never logged.
+const PYTHON_PTY_BRIDGE = String.raw`
+import errno, os, pty, select, signal, sys
+pid, master = pty.fork()
+if pid == 0:
+ os.execvpe(sys.argv[1], sys.argv[1:], os.environ)
+def stop(_signal, _frame):
+ try: os.killpg(pid, signal.SIGKILL)
+ except ProcessLookupError: pass
+ sys.exit(1)
+signal.signal(signal.SIGTERM, stop)
+while True:
+ readable, _, _ = select.select([master, 0], [], [])
+ if 0 in readable:
+ data = os.read(0, 4096)
+ if data: os.write(master, data)
+ else: stop(None, None)
+ if master in readable:
+ try: data = os.read(master, 4096)
+ except OSError as error:
+ if error.errno == errno.EIO: break
+ raise
+ if not data: break
+ os.write(1, data)
+_, status = os.waitpid(pid, 0)
+sys.exit(os.waitstatus_to_exitcode(status))
+`;
+
+/** Run the same browser-code login used by sandbox connections on the local host. */
+export function startLocalBrowserLogin(provider: "anthropic" | "openai", home: string): LocalBrowserLoginState {
+ const controller = new AbortController();
+ const state: LocalBrowserLoginState = { abort: () => controller.abort() };
+ const executable = provider === "anthropic" ? "claude" : "codex";
+ const args = provider === "anthropic" ? ["setup-token"] : ["login", "--device-auth"];
+ let child: ChildProcessWithoutNullStreams | null = null;
+ const driver = {
+ start(_command: string, onData: (chunk: string) => void): Promise<{ exitCode: number | null }> {
+ const env = { ...process.env,
+ ANTHROPIC_API_KEY: "", ANTHROPIC_AUTH_TOKEN: "", CLAUDE_CODE_OAUTH_TOKEN: "",
+ OPENAI_API_KEY: "", CODEX_API_KEY: "",
+ CLAUDE_CONFIG_DIR: provider === "anthropic" ? home : process.env.CLAUDE_CONFIG_DIR,
+ CODEX_HOME: provider === "openai" ? home : process.env.CODEX_HOME,
+ BROWSER: "true",
+ };
+ return new Promise((resolve, reject) => {
+ child = spawn("python3", ["-u", "-c", PYTHON_PTY_BRIDGE, executable, ...args], { env, stdio: ["pipe", "pipe", "pipe"] });
+ child.stdout.on("data", (bytes: Buffer) => onData(bytes.toString("utf8")));
+ // Provider output may contain secrets. Never log or retain stderr.
+ child.stderr.resume();
+ child.once("error", reject);
+ child.once("close", (exitCode) => resolve({ exitCode }));
+ });
+ },
+ write(input: string) { child?.stdin.write(input); },
+ stop() { child?.kill("SIGTERM"); },
+ async dispose() { child?.kill("SIGTERM"); },
+ async readFile(file: string) { const { readLocalAiCredentialFile } = await import("./local-ai-credential-file.js"); return Buffer.from(await readLocalAiCredentialFile(file)); },
+ };
+ if (provider === "anthropic") {
+ let deliverCode: ((code: string) => void) | undefined;
+ const codeReady = new Promise((resolve) => { deliverCode = resolve; });
+ state.submitCode = (code) => { deliverCode?.(code); deliverCode = undefined; };
+ void runSetupTokenLogin(driver, {
+ timeoutMs: 300_000, signal: controller.signal,
+ onPrompt: (prompt) => { state.authorizationUrl = prompt.url; },
+ provideCode: () => codeReady,
+ onCredential: async (bytes) => {
+ const file = await open(path.join(home, ".credentials.json"),
+ constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
+ try {
+ await file.writeFile(JSON.stringify({ claudeAiOauth: { accessToken: bytes.toString("utf8") } }));
+ } finally {
+ await file.close();
+ }
+ },
+ }).then((result) => { state.outcome = result.outcome === "success" ? "success" : "failure"; })
+ .catch(() => { state.outcome = "failure"; });
+ } else {
+ void runDeviceLogin(driver, {
+ timeoutMs: 300_000, signal: controller.signal,
+ onPrompt: (prompt) => { state.authorizationUrl = prompt.url; state.code = prompt.code; },
+ }).then((result) => { state.outcome = result.outcome === "success" ? "success" : "failure"; })
+ .catch(() => { state.outcome = "failure"; });
+ }
+ return state;
+}
diff --git a/server/src/services/local-ai-credentials.ts b/server/src/services/local-ai-credentials.ts
index 88466b53989..8fa10626d6a 100644
--- a/server/src/services/local-ai-credentials.ts
+++ b/server/src/services/local-ai-credentials.ts
@@ -1,40 +1,40 @@
import { readLocalAiCredentialFile } from "./local-ai-credential-file.js";
import fs from "node:fs/promises";
import path from "node:path";
-import { readClaudeToken, readIsolatedClaudeKeychainToken, fetchClaudeQuota } from "@paperclipai/adapter-claude-local/server";
+import { readIsolatedClaudeKeychainToken } from "@paperclipai/adapter-claude-local/server";
import { readCodexAuthInfo, fetchCodexQuota } from "@paperclipai/adapter-codex-local/server";
import { parseGrokAuthPayload, hasUsableGrokAuthValue } from "@paperclipai/adapter-grok-local/server";
import type { AiProvider } from "@paperclipai/shared";
import { unprocessable } from "../errors.js";
-/** Read an owned login home, or an explicitly authorized local-operator import. */
+/** Read and verify a connection-specific login home. */
export async function readVerifiedLocalAiCredential(provider: AiProvider, loginHome?: string): Promise {
if (provider === "openrouter") throw unprocessable("OpenRouter requires an API key.");
- if ((provider === "openai" || provider === "xai") && !loginHome)
- throw unprocessable("Start a separate local sign-in for this connection before connecting.");
+ if (!loginHome) throw unprocessable(provider === "xai"
+ ? "Start local sign-in for this connection before connecting."
+ : "Start browser sign-in for this connection before connecting.");
try {
if (provider === "anthropic") {
// Never change process.env or fall back to the server account when an
// authenticated user's isolated login is missing or invalid.
let token: string | null = null;
- if (loginHome) {
- for (const name of [".credentials.json", "credentials.json"]) {
- const raw = await readLocalAiCredentialFile(path.join(loginHome, name)).catch(() => null);
- if (!raw) continue;
- let parsed;
- try { parsed = JSON.parse(raw); } catch { continue; }
- const value = parsed?.claudeAiOauth?.accessToken;
- if (typeof value === "string" && value.length) { token = value; break; }
- }
- // On macOS the CLI stores the isolated login in the auth home's own
- // suffixed Keychain item rather than a credentials file. The helper
- // never consults the unsuffixed operator item.
- if (!token) token = await readIsolatedClaudeKeychainToken(loginHome);
- } else {
- token = await readClaudeToken({ allowKeychain: true });
+ for (const name of [".credentials.json", "credentials.json"]) {
+ const raw = await readLocalAiCredentialFile(path.join(loginHome, name)).catch(() => null);
+ if (!raw) continue;
+ let parsed;
+ try { parsed = JSON.parse(raw); } catch { continue; }
+ const value = parsed?.claudeAiOauth?.accessToken;
+ if (typeof value === "string" && value.length) { token = value; break; }
}
+ // On macOS the CLI stores the isolated login in the auth home's own
+ // suffixed Keychain item rather than a credentials file. The helper
+ // never consults the unsuffixed operator item.
+ if (!token) token = await readIsolatedClaudeKeychainToken(loginHome);
if (!token) throw new Error("Missing login");
- await fetchClaudeQuota(token);
+ // Claude's setup-token OAuth flow requests user:inference. The usage API
+ // requires user:profile and returns 403 for that valid token, so it cannot
+ // serve as a sign-in check. This isolated credential is written only after
+ // the provider CLI completes the code exchange successfully.
return token;
}
if (provider === "openai") {
@@ -55,8 +55,8 @@ export async function readVerifiedLocalAiCredential(provider: AiProvider, loginH
return raw;
} catch {
// Provider/CLI errors may contain credential material; never return them.
- throw unprocessable(provider === "anthropic" && !loginHome
- ? "Could not verify the local subscription. Run claude auth login in a terminal on the machine running Paperclip, then try Connect again."
- : "Could not verify the local subscription. Run the sign-in command shown for this connection, finish signing in, then try Connect again.");
+ throw unprocessable(provider === "xai"
+ ? "Could not verify the local subscription. Run the sign-in command shown for this connection, finish signing in, then try Connect again."
+ : "Could not verify the local subscription. Finish browser sign-in for this connection, then try Connect again.");
}
}
diff --git a/server/src/services/local-ai-login.ts b/server/src/services/local-ai-login.ts
index 8f29a7164ae..140d64480f6 100644
--- a/server/src/services/local-ai-login.ts
+++ b/server/src/services/local-ai-login.ts
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
+import { realpathSync } from "node:fs";
import { mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { and, eq, inArray, lte, sql } from "drizzle-orm";
@@ -9,22 +10,26 @@ import { notFound, unprocessable } from "../errors.js";
import { aiConnectionService } from "./ai-connections.js";
import { readVerifiedLocalAiCredential } from "./local-ai-credentials.js";
import { logActivity } from "./activity-log.js";
+import { startLocalBrowserLogin, type LocalBrowserLoginState } from "./local-ai-browser-login.js";
const LOCAL_LOGIN_METHOD = "local_subscription";
const ATTEMPT_DURATION_MS = 30 * 60 * 1000;
function loginHome(id: string) {
- return path.join(resolvePaperclipInstanceRoot(), "ai-local-logins", id);
+ const root = resolvePaperclipInstanceRoot();
+ // macOS exposes /var through a system symlink to /private/var. Credential
+ // reads use O_NOFOLLOW_ANY, so generate login homes from the canonical root.
+ // A fresh instance root may not exist yet; prepareHome creates it first.
+ let canonicalRoot = root;
+ try { canonicalRoot = realpathSync(root); }
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
+ return path.join(canonicalRoot, "ai-local-logins", id);
}
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
function presentAttempt(id: string, expiresAt: Date, provider: string): LocalAiLoginAttempt {
const directory = loginHome(id);
return {
sessionId: id, expiresAt: expiresAt.toISOString(),
- command: provider === "openai"
- ? `(export CODEX_HOME=${shellQuote(directory)} && mkdir -p "$CODEX_HOME" && codex -c 'cli_auth_credentials_store="file"' login --device-auth)`
- : provider === "anthropic"
- ? `(export CLAUDE_CONFIG_DIR=${shellQuote(directory)} && mkdir -p "$CLAUDE_CONFIG_DIR" && claude auth login)`
- : `(export GROK_HOME=${shellQuote(directory)} && mkdir -p "$GROK_HOME" && grok login --device-auth)`,
+ ...(provider === "xai" ? { command: `(export GROK_HOME=${shellQuote(directory)} && mkdir -p "$GROK_HOME" && grok login --device-auth)` } : {}),
};
}
async function prepareHome(id: string, provider: string) {
@@ -41,9 +46,21 @@ function sameTarget(a: AiConnectionLoginIntent, b: AiConnectionLoginIntent) {
JSON.stringify([...a.agentIds].sort()) === JSON.stringify([...b.agentIds].sort());
}
-/** Local terminal sign-ins share the durable attempt/credential lifecycle, but
- * never seed their home from the operator's rotating CLI credential. */
+// Route handlers construct this service per request. Keep live browser processes
+// shared so status polling and code submission can reach the original process.
+const browserLogins = new Map();
+
+/** Local sign-ins share the durable attempt/credential lifecycle, but never seed
+ * their home from the operator's rotating CLI credential. */
export function localAiLoginService(db: Db) {
+ function stopBrowserLogin(id: string) {
+ browserLogins.get(id)?.abort();
+ browserLogins.delete(id);
+ }
+ function ensureBrowserLogin(id: string, provider: string) {
+ if (provider === "xai" || browserLogins.has(id)) return;
+ browserLogins.set(id, startLocalBrowserLogin(provider as "anthropic" | "openai", loginHome(id)));
+ }
async function reapExpired() {
// Bounded batches use the existing expires-at index. Replaying is safe.
const rows = await db.select({ id: adapterAuthSessions.id }).from(adapterAuthSessions)
@@ -55,6 +72,7 @@ export function localAiLoginService(db: Db) {
const [session] = await tx.select().from(adapterAuthSessions)
.where(eq(adapterAuthSessions.id, row.id)).for("update");
if (!session || !session.expiresAt || session.expiresAt.getTime() > Date.now()) return;
+ stopBrowserLogin(row.id);
await rm(loginHome(row.id), { recursive: true, force: true });
await tx.update(adapterAuthSessions).set({
status: session.connectionId ? "authenticated" : "timed_out",
@@ -68,7 +86,7 @@ export function localAiLoginService(db: Db) {
if (intent.provider !== "openai" && intent.provider !== "xai" && intent.provider !== "anthropic")
throw unprocessable("This provider does not use a separate local login home.");
await reapExpired();
- return db.transaction(async (tx) => {
+ const attempt = await db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`ai-local-login:${companyId}:${userId}:${intent.provider}`}, 0))`);
const adapterType = intent.provider === "openai" ? "codex_local" : intent.provider === "anthropic" ? "claude_local" : "grok_local";
const [existing] = await tx.select().from(adapterAuthSessions).where(and(
@@ -85,6 +103,7 @@ export function localAiLoginService(db: Db) {
}
if (!restart || existing.connectionMethod !== LOCAL_LOGIN_METHOD)
throw unprocessable("Another sign-in is still open. Finish it, or choose Start sign-in again to replace a local attempt.");
+ stopBrowserLogin(existing.id);
await rm(loginHome(existing.id), { recursive: true, force: true });
await tx.update(adapterAuthSessions).set({ status: "cancelled", finishedAt: new Date(), updatedAt: new Date() })
.where(eq(adapterAuthSessions.id, existing.id));
@@ -120,14 +139,18 @@ export function localAiLoginService(db: Db) {
}
return presentAttempt(id, expiresAt, intent.provider);
});
+ ensureBrowserLogin(attempt.sessionId, intent.provider);
+ return attempt;
}
// Read-only credential detection: never saves a connection or refreshes another
// login. Scope and intent are checked before touching an attempt's directory.
async function check(companyId: string, userId: string, intent: AiConnectionLoginIntent, id?: string): Promise {
let directory: string | undefined;
- if (id || intent.provider !== "anthropic") {
- if (!id) throw unprocessable("Start local sign-in before checking this account.");
+ {
+ if (!id) throw unprocessable(intent.provider === "xai"
+ ? "Start local sign-in before checking this account."
+ : "Start browser sign-in before checking this account.");
const [session] = await db.select().from(adapterAuthSessions).where(and(
eq(adapterAuthSessions.id, id), eq(adapterAuthSessions.companyId, companyId),
eq(adapterAuthSessions.startedByUserId, userId),
@@ -143,10 +166,35 @@ export function localAiLoginService(db: Db) {
await readVerifiedLocalAiCredential(intent.provider, directory);
return { status: "ready" };
} catch {
- return { status: "sign_in_required" };
+ const login = id ? browserLogins.get(id) : undefined;
+ return { status: "sign_in_required", authorizationUrl: login?.authorizationUrl, code: login?.code,
+ ...(login?.outcome === "failure" ? { error: "Sign-in ended before the account was connected. Start sign-in again." } : {}) };
}
}
+ async function submitCode(companyId: string, userId: string, id: string, code: string) {
+ await db.transaction(async (tx) => {
+ const [session] = await tx.select().from(adapterAuthSessions).where(and(
+ eq(adapterAuthSessions.id, id), eq(adapterAuthSessions.companyId, companyId),
+ eq(adapterAuthSessions.startedByUserId, userId), eq(adapterAuthSessions.connectionMethod, LOCAL_LOGIN_METHOD),
+ eq(adapterAuthSessions.adapterType, "claude_local"),
+ )).for("update");
+ if (!session) throw notFound("Local sign-in attempt not found.");
+ if (session.status !== "waiting_for_user" || !session.expiresAt || session.expiresAt.getTime() <= Date.now())
+ throw unprocessable("This sign-in attempt has expired or was cancelled. Start sign-in again.");
+ const login = browserLogins.get(id);
+ if (!login?.authorizationUrl || !login.submitCode || login.outcome)
+ throw unprocessable("This sign-in attempt cannot accept a code. Start sign-in again.");
+ const submit = login.submitCode;
+ login.submitCode = undefined;
+ submit(code);
+ await logActivity(tx as unknown as Db, {
+ companyId, actorType: "user", actorId: userId, action: "ai_connection.local_login_code_submitted",
+ entityType: "adapter_auth_session", entityId: id, details: { provider: "anthropic" },
+ });
+ });
+ }
+
async function complete(companyId: string, userId: string, id: string, intent: AiConnectionLoginIntent) {
const result = await db.transaction(async (tx) => {
const [session] = await tx.select().from(adapterAuthSessions).where(and(
@@ -176,6 +224,7 @@ export function localAiLoginService(db: Db) {
return saved;
});
// Failed cleanup can be retried by the same completed attempt or reaper.
+ stopBrowserLogin(id);
await rm(loginHome(id), { recursive: true, force: true });
return result;
}
@@ -188,6 +237,7 @@ export function localAiLoginService(db: Db) {
eq(adapterAuthSessions.connectionMethod, LOCAL_LOGIN_METHOD),
)).for("update");
if (!session) throw notFound("Local sign-in attempt not found.");
+ stopBrowserLogin(id);
await rm(loginHome(id), { recursive: true, force: true });
if (!session.connectionId && session.status !== "cancelled") {
await tx.update(adapterAuthSessions).set({
@@ -201,5 +251,5 @@ export function localAiLoginService(db: Db) {
}
});
}
- return { start, check, complete, cancel, reapExpired };
+ return { start, check, submitCode, complete, cancel, reapExpired };
}
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 0626c4cf690..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" })),
@@ -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,7 +3338,7 @@ 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()); }
});
});
@@ -3351,14 +3358,14 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
} finally { await act(async () => root.unmount()); }
});
- it("shows local Claude instructions and saves its connection before hiring", async () => {
+ 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 5b254215828..8749e63cf4d 100644
--- a/ui/src/components/OnboardingWizard.tsx
+++ b/ui/src/components/OnboardingWizard.tsx
@@ -1022,7 +1022,7 @@ function OnboardingWizardInner({
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.
@@ -2991,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" ? (
@@ -3011,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"
@@ -3022,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/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..b0de9683f5d 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) {
+ 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 b98add889f0..9805ebeb229 100644
--- a/ui/src/components/new-agent/AgentProviderConnection.test.tsx
+++ b/ui/src/components/new-agent/AgentProviderConnection.test.tsx
@@ -17,8 +17,8 @@ const managedApi = vi.hoisted(() => ({
list: vi.fn(async () => ({ currentUserId: "user-1", connections: [] })),
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 })),
+ startLocalLogin: vi.fn(async () => ({ sessionId: "local-attempt", expiresAt: "2099-01-01T00:00:00Z" })),
+ checkLocalLogin: vi.fn(async () => ({ status: "sign_in_required" as "ready" | "sign_in_required" })),
cancelLocalLogin: vi.fn(async () => ({})),
create: vi.fn(async () => ({ connectionId: "managed-connection", grantId: "managed-grant" })),
}));
@@ -304,14 +304,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");
@@ -320,33 +318,38 @@ 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 () => {
+ managedApi.checkLocalLogin.mockResolvedValue({ status: "ready" });
let finish!: (result: { connectionId: string; grantId: string }) => void;
managedApi.connectLocal.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
const onComplete = vi.fn();
await mount("claude_local", false, false, false, false, false, { intent: { provider: "anthropic", method: "subscription", name: "My account", ownership: "personal", agentIds: [], allAgents: false }, onComplete }, true);
- openProvider(); click("Connect"); flushSync(() => root.unmount());
+ openProvider();
+ await vi.waitFor(() => expect(host.textContent).toContain("is signed in"));
+ click("Connect"); flushSync(() => root.unmount());
finish({ connectionId: "saved", grantId: "saved-grant" });
await new Promise(resolve => setTimeout(resolve, 0));
expect(onComplete).not.toHaveBeenCalled();
diff --git a/ui/src/components/new-agent/AgentProviderConnection.tsx b/ui/src/components/new-agent/AgentProviderConnection.tsx
index 972016fdb01..f475b715f9a 100644
--- a/ui/src/components/new-agent/AgentProviderConnection.tsx
+++ b/ui/src/components/new-agent/AgentProviderConnection.tsx
@@ -141,7 +141,7 @@ export function AgentProviderConnection({
provider: aiProvider, method: "subscription", name: `My ${provider} subscription`,
ownership: "personal", agentIds: [], allAgents: true,
}, !advanced && canUseLocalLogin && method === "subscription" && !savedSubscription && !storedLogin.data,
- { allowHostClaude: health.data?.deploymentMode === "local_trusted" });
+ );
const auth = useQuery({
queryKey: queryKeys.agents.authSignal(
companyId,
@@ -441,6 +441,7 @@ export function AgentProviderConnection({
managedAccount?.disabled ||
(Boolean(managedAccount) && method === "subscription" && !canLogin && !canUseLocalLogin) ||
(localEnvironment && health.isPending) || localLogin.preparing || Boolean(localLogin.error) ||
+ (method === "subscription" && canUseLocalLogin && !savedSubscription && !storedLogin.data && localLogin.status !== "ready") ||
(!managedAccount && auth.isPending) ||
savedKeys.loading ||
(adapterType === "claude_local" && storedLogin.isPending) ||
diff --git a/ui/storybook/stories/local-ai-subscription.stories.tsx b/ui/storybook/stories/local-ai-subscription.stories.tsx
new file mode 100644
index 00000000000..b5c8ddb8dda
--- /dev/null
+++ b/ui/storybook/stories/local-ai-subscription.stories.tsx
@@ -0,0 +1,61 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, userEvent, waitFor, within } from "storybook/test";
+import { LocalProviderLoginInstructions } from "@/components/AdapterLoginChrome";
+
+const meta = {
+ title: "AI Connections/Provider routing/07 Subscription sign-in",
+ component: LocalProviderLoginInstructions,
+ parameters: { layout: "centered" },
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+
+export const ClaudeBrowserCode: Story = {
+ args: {
+ adapterType: "claude_local",
+ login: {
+ isolated: true, preparing: false, status: "sign_in_required", error: null,
+ authorizationUrl: "#storybook-provider-simulator",
+ submitCode: async () => {}, retry: () => {},
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await waitFor(() => expect(canvas.getByRole("link", { name: "Sign in to Claude" })).toBeVisible());
+ await waitFor(() => expect(canvas.getByLabelText("Authorization code")).toBeVisible());
+ await expect(canvas.queryByText(/claude auth login|CLAUDE_CONFIG_DIR/)).not.toBeInTheDocument();
+ await userEvent.type(canvas.getByLabelText("Authorization code"), "fixture-code");
+ await userEvent.click(canvas.getByRole("button", { name: "Submit code" }));
+ await expect(canvas.getByLabelText("Authorization code")).toHaveValue("");
+ },
+};
+
+export const CodexDeviceCode: Story = {
+ args: {
+ adapterType: "codex_local",
+ login: {
+ isolated: true, preparing: false, status: "sign_in_required", error: null,
+ authorizationUrl: "#storybook-provider-simulator", code: "ABCD-12345",
+ retry: () => {},
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await waitFor(() => expect(canvas.getByRole("link", { name: "Sign in to OpenAI" })).toBeVisible());
+ await waitFor(() => expect(canvas.getByText("ABCD-12345")).toBeVisible());
+ await expect(canvas.queryByText(/codex login|CODEX_HOME/)).not.toBeInTheDocument();
+ },
+};
+
+export const Connected: Story = {
+ args: { adapterType: "codex_local", login: { isolated: true, preparing: false, status: "ready", error: null, retry: () => {} } },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(canvas.getByRole("status")).toHaveTextContent("is signed in");
+ await userEvent.click(canvas.getByRole("button", { name: "Use a different account" }));
+ },
+};
+
+export const Expired: Story = {
+ args: { adapterType: "claude_local", login: { isolated: true, preparing: false, status: "expired", error: "This sign-in attempt expired. Start sign-in again.", retry: () => {} } },
+};