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

{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 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 <>; } 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: () => {} } }, +};