From b2acc674be1d478828cb69f0399e8446115c5cb8 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:06:55 -0500 Subject: [PATCH] fix: enable isolated subscription login on authenticated self-hosted instances (#13344) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - AI Connections store reusable provider credentials with company and owner boundaries. > - Self-hosted instances can require board authentication while running agents on the local server. > - The login UI treated those instances as unsupported and displayed instructions without a command. > - Removing that restriction must not expose the server operator's existing CLI account. > - This pull request enables owner-scoped login attempts and reuses the existing login UI. > - Users can connect Codex and Claude subscriptions on an authenticated self-hosted instance. ## Linked Issues or Issue Description **What happened?** On an authenticated self-hosted instance, OpenAI subscription setup displayed terminal instructions with no command and a disabled Connect button. Claude also could not complete the local connection flow. **Expected behavior** An authorized board user can prepare an isolated sign-in attempt, sign in on the server, and save the verified account as a Connection. This must not import another user's or the operator's ambient credentials. **Steps to reproduce** Run Paperclip in authenticated mode with a local environment. Open Connections, choose OpenAI or Anthropic, and select Subscription. The previous UI never enabled local login preparation. Related: #13247, #13248, and #10751. This fix preserves the restriction on remote access to the operator's ambient Claude login. ## What Changed - Allow company-authorized users to create, check, cancel, and complete their own isolated local login attempts. - Keep ambient Claude credential import restricted to the local operator. - Support isolated Claude credential files without falling back to the host account or mutating process-wide environment variables. - Use Codex device authorization so sign-in does not depend on a browser callback to the remote server's localhost. - Gate server-host login on authenticated public deployments unless a trusted runtime host is configured. Publish the capability through health so setup shows supported alternatives. - Read isolated Claude credential files through bounded, descriptor-bound opens with ownership, permission, and symlink checks. Try the alternate filename after malformed JSON. - Reuse shared login instructions and lifecycle hooks in onboarding, agent setup, and Connections. Show health-query failures explicitly. - Document authenticated self-hosted behavior and add authorization, isolation, lifecycle, and UI regression tests. ## Verification - Passed 71 focused tests across connection routes, credential isolation, legacy compatibility, the shared login hook, and agent setup. - Passed 89 onboarding regression tests. - Passed `pnpm -r typecheck`, `pnpm build`, Storybook build, and `pnpm check:token-gates`. - Completed real Codex device authorization and Claude browser authorization on an authenticated Linux self-hosted instance. Both accounts were detected automatically and saved as Connected. Both completed attempt directories were removed. - These live checks cover login, credential validation, and connection creation. They do not establish a new model execution or long-running refresh result. - Review follow-up: 68 focused checks passed after rerunning one route socket error; the full route/health rerun passed all 49 tests. The 70-test onboarding suite also passed. Final workspace typecheck, production build, and Storybook build passed again. - The broad local run exposed an instance-name assumption in two new assertions. The fixture now uses an explicit non-default instance, and all 32 connection tests passed with a different inherited instance name. The superseded broad run was stopped; this is not a claim that the full local suite completed. Full CI results will be recorded before merge. ## Risks - The server must have the provider CLI installed. Users still run the displayed command on the server that hosts Paperclip. - Authorization checks must keep login attempts scoped to the company, owner, provider, and reconnect target. Regression tests cover cross-user and cross-company access. - Existing local-trusted Claude behavior stays available. Authenticated remote users cannot use its ambient import path. - No database migration, dependency change, agent binding change, or provider routing change is included. ## Model Used OpenAI GPT-6 through Codex, with reasoning, code execution, and browser tools. The runtime does not expose a more specific model version or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/connections/AI-CONNECTIONS.md | 27 +++++--- server/src/__tests__/ai-connections.test.ts | 67 +++++++++++++++++-- .../__tests__/health-dev-server-token.test.ts | 1 + server/src/__tests__/health.test.ts | 3 + .../local-ai-credential-file.test.ts | 29 ++++++++ .../__tests__/local-ai-credentials.test.ts | 25 ++++++- .../__tests__/local-ai-login-policy.test.ts | 10 +++ server/src/app.ts | 2 +- server/src/routes/ai-connections.ts | 23 +++++-- server/src/routes/health.ts | 3 + .../src/services/local-ai-credential-file.ts | 25 +++++++ server/src/services/local-ai-credentials.ts | 21 +++++- server/src/services/local-ai-login-policy.ts | 12 ++++ server/src/services/local-ai-login.ts | 14 ++-- ui/src/api/health.ts | 1 + ui/src/components/AdapterLoginChrome.tsx | 4 +- ui/src/components/OnboardingWizard.tsx | 7 +- .../AiConnectionDesignExamples.tsx | 7 +- .../ai-connections/useLocalAiLogin.test.tsx | 2 +- .../ai-connections/useLocalAiLogin.ts | 5 +- .../AgentProviderConnection.test.tsx | 32 ++++++++- .../new-agent/AgentProviderConnection.tsx | 10 ++- 22 files changed, 285 insertions(+), 45 deletions(-) create mode 100644 server/src/__tests__/local-ai-credential-file.test.ts create mode 100644 server/src/__tests__/local-ai-login-policy.test.ts create mode 100644 server/src/services/local-ai-credential-file.ts create mode 100644 server/src/services/local-ai-login-policy.ts diff --git a/doc/connections/AI-CONNECTIONS.md b/doc/connections/AI-CONNECTIONS.md index 9515797d8b..3cefc7aaad 100644 --- a/doc/connections/AI-CONNECTIONS.md +++ b/doc/connections/AI-CONNECTIONS.md @@ -27,7 +27,7 @@ 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. Local trusted installations use the shared terminal sign-in +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. API keys are validated against fixed provider endpoints; redirects @@ -139,12 +139,15 @@ 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`. Claude uses `claude auth login` on the machine running -Paperclip; the explicit local operator import reads and verifies the access token. +`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. The home is never +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 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 @@ -158,9 +161,10 @@ periodic cleanup sweep retries expired directories. Successful completion persis credentials to the encrypted grant and removes the temporary login home. Refreshes subsequently update only that grant. Reconnect preserves IDs and access settings. -The local endpoints require the local trusted operator; remote authenticated users -cannot import host credentials or start local attempts. Claude Keychain reads are -allowed only for the explicit default-home import. A failed verification creates +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 isolated-subscription marker require reconnect before another managed execution; unmanaged legacy agents retain their existing authentication paths. @@ -200,9 +204,10 @@ 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. -Claude checks the local operator's Claude Code login. Codex and Grok check only their -connection-specific login home, preserving the operator's separate rotating CLI login. -These checks are restricted to the local operator in local-trusted deployments. +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. 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** @@ -222,3 +227,5 @@ personal OpenAI API connection, and issue must all be named `AI Repair QA { home = 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-"); db = createDb(database.connectionString); service = aiConnectionService(db); @@ -271,7 +272,7 @@ describe("managed AI connections", () => { const app = express(); app.use(express.json()); app.use((req, _res, next) => { - req.actor = { type: "board", source: req.headers["x-local"] === "yes" ? "local_implicit" : "session", userId: "alice", companyIds: [companyId], memberships: [{ companyId, status: "active", membershipRole: "member" }] }; + req.actor = { type: "board", source: req.headers["x-local"] === "yes" ? "local_implicit" : "session", userId: String(req.headers["x-test-user"] ?? "alice"), companyIds: [companyId], memberships: [{ companyId, status: "active", membershipRole: "member" }] }; next(); }); app.use("/api", aiConnectionRoutes(db)); @@ -303,17 +304,75 @@ describe("managed AI connections", () => { expect((await service.list(companyId, "alice")).some(c => c.name === "Unsuccessful local login")).toBe(false); const codex = { ...payload, provider: "openai", name: "Isolated terminal login" }; const attempts = `${url}/attempts`; - expect((await request(app).post(attempts).send(codex)).status).toBe(403); + 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\)$/); + expect(prepared.body.command).toMatch(/^\(export CODEX_HOME=.* && mkdir -p .* && codex -c .* login --device-auth\)$/); 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}`).send()).status).toBe(403); + 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); expect((await request(app).post(url).set("x-local", "yes").send({ ...codex, localSessionId: prepared.body.sessionId })).status).toBe(422); } finally { reader.mockRestore(); } }); + it.each(["anthropic", "openai"] as const)("blocks server-host %s login on a public deployment without a trusted host", async provider => { + const reader = vi.spyOn(localCredentials, "readVerifiedLocalAiCredential"); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = { type: "board", source: "session", userId: "alice", companyIds: [companyId], memberships: [{ companyId, status: "active", membershipRole: "member" }] }; + next(); + }); + app.use("/api", aiConnectionRoutes(db, { deploymentMode: "authenticated", deploymentExposure: "public", trustedLocalStdioRuntimeHost: "" })); + app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); }); + const base = `/api/companies/${companyId}/ai-connections/local`; + const intent = { provider, method: "subscription", ownership: "personal", name: "Hosted account", allAgents: false, agentIds: [] }; + try { + for (const endpoint of [base, `${base}/attempts`, `${base}/check`]) { + const result = await request(app).post(endpoint).send(intent); + expect(result.status).toBe(422); + expect(result.body.error).toContain("unavailable on this hosted instance"); + } + expect(reader).not.toHaveBeenCalled(); + } finally { reader.mockRestore(); } + }); + it.each(["anthropic", "openai"] as const)("lets authenticated users connect only their own isolated %s login", async provider => { + const owner = `self-hosted-${provider}`; + await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" }); + const reader = vi.spyOn(localCredentials, "readVerifiedLocalAiCredential").mockResolvedValue("isolated-fixture-token"); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = { type: "board", source: "session", userId: String(req.headers["x-test-user"] ?? owner), companyIds: [companyId], memberships: [{ companyId, status: "active", membershipRole: req.headers["x-viewer"] ? "viewer" : "member" }] }; + next(); + }); + app.use("/api", aiConnectionRoutes(db)); + app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); }); + const base = `/api/companies/${companyId}/ai-connections/local`; + const intent = { provider, method: "subscription", ownership: "personal", name: `Self-hosted ${provider}`, allAgents: false, agentIds: [] }; + try { + expect((await request(app).post(`${base}/attempts`).set("x-viewer", "yes").send(intent)).status).toBe(403); + 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((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); + } + expect(reader).not.toHaveBeenCalled(); + const checked = await request(app).post(`${base}/check`).send(input); + expect(checked.body).toEqual({ status: "ready" }); + expect(reader).toHaveBeenLastCalledWith(provider, path.join(home, "instances/ai-connection-fixture/ai-local-logins", started.body.sessionId)); + const saved = await request(app).post(base).send(input); + expect(saved.status).toBe(201); + expect((await request(app).post(base).send(input)).body).toEqual(saved.body); + expect(JSON.stringify(saved.body)).not.toContain("isolated-fixture-token"); + expect((await service.list(companyId, owner)).filter(c => c.name === intent.name)).toHaveLength(1); + } finally { reader.mockRestore(); } + }); it("rejects invalid credentials without exposing the provider response", async () => { const request = vi.fn().mockResolvedValue(new Response("secret-provider-body", { status: 401 })); await expect(validateAiApiKey("anthropic", "fixture", request)).rejects.toThrow("rejected"); diff --git a/server/src/__tests__/health-dev-server-token.test.ts b/server/src/__tests__/health-dev-server-token.test.ts index 4f96249ea8..d9b1f8ddfd 100644 --- a/server/src/__tests__/health-dev-server-token.test.ts +++ b/server/src/__tests__/health-dev-server-token.test.ts @@ -106,6 +106,7 @@ describe("GET /health dev-server supervisor access", () => { status: "ok", deploymentMode: "authenticated", deploymentExposure: "private", + localAiLoginSupported: true, commit: null, bootstrapStatus: "ready", bootstrapInviteActive: false, diff --git a/server/src/__tests__/health.test.ts b/server/src/__tests__/health.test.ts index 2e5faaa77c..1d97f90e0f 100644 --- a/server/src/__tests__/health.test.ts +++ b/server/src/__tests__/health.test.ts @@ -343,6 +343,7 @@ describe("GET /health", () => { status: "ok", deploymentMode: "authenticated", deploymentExposure: "public", + localAiLoginSupported: false, commit: testServerInfo.git.fullSha, bootstrapStatus: "ready", bootstrapInviteActive: false, @@ -400,6 +401,7 @@ describe("GET /health", () => { status: "ok", deploymentMode: "authenticated", deploymentExposure: "public", + localAiLoginSupported: false, commit: testServerInfo.git.fullSha, bootstrapStatus: "ready", bootstrapInviteActive: false, @@ -438,6 +440,7 @@ describe("GET /health", () => { status: "ok", deploymentMode: "authenticated", deploymentExposure: "public", + localAiLoginSupported: false, commit: testServerInfo.git.fullSha, bootstrapStatus: "ready", bootstrapInviteActive: false, diff --git a/server/src/__tests__/local-ai-credential-file.test.ts b/server/src/__tests__/local-ai-credential-file.test.ts new file mode 100644 index 0000000000..c2db45bf86 --- /dev/null +++ b/server/src/__tests__/local-ai-credential-file.test.ts @@ -0,0 +1,29 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { chmod, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { readLocalAiCredentialFile } from "../services/local-ai-credential-file.js"; +let home: string; +beforeEach(async () => { home = await realpath(await mkdtemp(path.join(os.tmpdir(), "ai-auth-read-"))); }); +afterEach(async () => { await rm(home, { recursive: true, force: true }); }); +describe("isolated credential file safety", () => { + it("reads only bounded private regular files", async () => { + const filename = path.join(home, "credentials.json"); + await writeFile(filename, "fixture", { mode: 0o600 }); + await expect(readLocalAiCredentialFile(filename)).resolves.toBe("fixture"); + await chmod(filename, 0o644); + await expect(readLocalAiCredentialFile(filename)).rejects.toThrow(); + await chmod(filename, 0o600); + await writeFile(filename, Buffer.alloc(64 * 1024 + 1)); + await expect(readLocalAiCredentialFile(filename)).rejects.toThrow(); + await expect(readLocalAiCredentialFile(home)).rejects.toThrow(); + }); + it("rejects file and ancestor symlinks", async () => { + const filename = path.join(home, "credentials.json"); + await writeFile(filename, "fixture", { mode: 0o600 }); + await symlink(filename, path.join(home, "linked.json")); + await expect(readLocalAiCredentialFile(path.join(home, "linked.json"))).rejects.toThrow(); + await symlink(home, path.join(home, "linked-home")); + await expect(readLocalAiCredentialFile(path.join(home, "linked-home", "credentials.json"))).rejects.toThrow(); + }); +}); diff --git a/server/src/__tests__/local-ai-credentials.test.ts b/server/src/__tests__/local-ai-credentials.test.ts index 80b5fdf719..642ac5e7ae 100644 --- a/server/src/__tests__/local-ai-credentials.test.ts +++ b/server/src/__tests__/local-ai-credentials.test.ts @@ -1,11 +1,34 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { readVerifiedLocalAiCredential } from "../services/local-ai-credentials.js"; -const mocks = vi.hoisted(() => ({ claude: vi.fn(), claudeQuota: vi.fn(), codex: vi.fn(), codexQuota: vi.fn(), readFile: vi.fn() })); +const mocks = vi.hoisted(() => ({ claude: 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, fetchClaudeQuota: mocks.claudeQuota })); 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 } })); afterEach(() => { vi.resetAllMocks(); vi.unstubAllGlobals(); }); describe("explicit local subscription import", () => { + it("verifies Claude only from the selected isolated home, never the host account", async () => { + 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.credentialFile.mockRejectedValue(new Error("No file")); + await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).rejects.toThrow("sign-in command shown"); + mocks.credentialFile.mockResolvedValue("malformed"); + await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).rejects.toThrow("sign-in command shown"); + expect(mocks.claude).not.toHaveBeenCalled(); + expect(mocks.claudeQuota).not.toHaveBeenCalled(); + }); + it("tries the alternate Claude filename after malformed JSON", async () => { + mocks.credentialFile.mockResolvedValueOnce("malformed").mockResolvedValueOnce(JSON.stringify({ claudeAiOauth: { accessToken: "alternate-token" } })); + await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).resolves.toBe("alternate-token"); + 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"); diff --git a/server/src/__tests__/local-ai-login-policy.test.ts b/server/src/__tests__/local-ai-login-policy.test.ts new file mode 100644 index 0000000000..e7630a6151 --- /dev/null +++ b/server/src/__tests__/local-ai-login-policy.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { supportsLocalAiLogin } from "../services/local-ai-login-policy.js"; +describe("server-host subscription login policy", () => { + it("permits private self-hosted instances and explicit trusted hosts", () => { + expect(supportsLocalAiLogin({ deploymentMode: "local_trusted", deploymentExposure: "private" })).toBe(true); + expect(supportsLocalAiLogin({ deploymentMode: "authenticated", deploymentExposure: "private" })).toBe(true); + expect(supportsLocalAiLogin({ deploymentMode: "authenticated", deploymentExposure: "public", trustedLocalStdioRuntimeHost: "trusted-host" })).toBe(true); + expect(supportsLocalAiLogin({ deploymentMode: "authenticated", deploymentExposure: "public", trustedLocalStdioRuntimeHost: "" })).toBe(false); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 6fa667cda7..003183d8d7 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -825,7 +825,7 @@ export async function createApp( app.locals.toolGateway = toolGateway; app.locals.toolActionDeliveries = toolActionDeliveries; app.use(mcpGatewayProtocolRoutes(toolGateway)); - api.use(aiConnectionRoutes(db)); + api.use(aiConnectionRoutes(db, { deploymentMode: opts.deploymentMode, deploymentExposure: opts.deploymentExposure, trustedLocalStdioRuntimeHost })); api.use( toolAccessRoutes(db, { deploymentMode: opts.deploymentMode, diff --git a/server/src/routes/ai-connections.ts b/server/src/routes/ai-connections.ts index c54fe80710..da293a37a1 100644 --- a/server/src/routes/ai-connections.ts +++ b/server/src/routes/ai-connections.ts @@ -1,3 +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"; @@ -165,7 +166,10 @@ export async function validateAiApiKey( ); } -export function aiConnectionRoutes(db: Db) { +export function aiConnectionRoutes(db: Db, options: Parameters[0] = {}) { + function assertLocalLoginAvailable() { + if (!supportsLocalAiLogin(options)) throw unprocessable("Server-host subscription sign-in is unavailable on this hosted instance. Choose a supported sign-in environment or use an API key."); + } const router = Router(); const service = aiConnectionService(db); const localLogin = localAiLoginService(db); @@ -176,22 +180,27 @@ export function aiConnectionRoutes(db: Db) { throw forbidden("Only the local operator can connect this machine's CLI account."); } router.post("/companies/:companyId/ai-connections/local/attempts", validate(localAiLoginStartSchema), async (req, res) => { - assertLocalOperator(req); const companyId = req.params.companyId as string; const { restart, ...intent } = localAiLoginStartSchema.parse(req.body); + assertLocalLoginAvailable(); const userId = await assertAiConnectionCreateAccess(db, req, companyId, intent); + res.setHeader("Cache-Control", "no-store"); res.status(201).json(await localLogin.start(companyId, userId, intent, restart)); }); router.post("/companies/:companyId/ai-connections/local/check", validate(localAiConnectionSchema), async (req, res) => { - assertLocalOperator(req); const companyId = req.params.companyId as string; const { localSessionId, ...intent } = localAiConnectionSchema.parse(req.body); + assertLocalLoginAvailable(); + // Only implicit local operators may inspect ambient Claude credentials. + // Authenticated users sign in to their own company/user-scoped attempt. + if (intent.provider === "anthropic" && !localSessionId) assertLocalOperator(req); const userId = await assertAiConnectionCreateAccess(db, req, companyId, intent); res.setHeader("Cache-Control", "no-store"); res.json(await localLogin.check(companyId, userId, intent, localSessionId)); }); router.delete("/companies/:companyId/ai-connections/local/attempts/:sessionId", async (req, res) => { - assertLocalOperator(req); + assertBoard(req); + assertCompanyAccess(req, req.params.companyId as string); const id = z.string().uuid().parse(req.params.sessionId); await localLogin.cancel(req.params.companyId as string, getActorInfo(req).actorId, id); res.json({ ok: true }); @@ -288,12 +297,12 @@ export function aiConnectionRoutes(db: Db) { "/companies/:companyId/ai-connections/local", validate(localAiConnectionSchema), async (req, res) => { - // A signed-in remote user must never claim the server operator's account. - assertLocalOperator(req); const companyId = req.params.companyId as string; const { localSessionId, ...input } = localAiConnectionSchema.parse(req.body); + assertLocalLoginAvailable(); + if (input.provider === "anthropic" && !localSessionId) assertLocalOperator(req); const userId = await assertAiConnectionCreateAccess(db, req, companyId, input); - if (input.provider === "openai" || input.provider === "xai") { + if (localSessionId || input.provider === "openai" || input.provider === "xai") { if (!localSessionId) throw unprocessable("Start a separate local sign-in for this connection before connecting."); res.status(201).json(await localLogin.complete(companyId, userId, localSessionId, input)); return; diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index 0e4d954722..bf7dc4a4a0 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -1,3 +1,4 @@ +import { supportsLocalAiLogin } from "../services/local-ai-login-policy.js"; import { randomUUID, timingSafeEqual } from "node:crypto"; import { Router } from "express"; import type { Db } from "@paperclipai/db"; @@ -391,6 +392,7 @@ export function healthRoutes( status: healthStatus, deploymentMode: opts.deploymentMode, deploymentExposure: opts.deploymentExposure, + localAiLoginSupported: supportsLocalAiLogin(opts), commit, bootstrapStatus, bootstrapInviteActive, @@ -414,6 +416,7 @@ export function healthRoutes( commit, deploymentMode: opts.deploymentMode, deploymentExposure: opts.deploymentExposure, + localAiLoginSupported: supportsLocalAiLogin(opts), authReady: opts.authReady, bootstrapStatus, bootstrapInviteActive, diff --git a/server/src/services/local-ai-credential-file.ts b/server/src/services/local-ai-credential-file.ts new file mode 100644 index 0000000000..4641dde599 --- /dev/null +++ b/server/src/services/local-ai-credential-file.ts @@ -0,0 +1,25 @@ +import { openRunnerApiWorkspaceFile } from "./native-runtime/runner-api-files.js"; + +const MAX_CREDENTIAL_BYTES = 64 * 1024; + +/** Bounded descriptor read; never follows symlinks or reopens a checked path. */ +export async function readLocalAiCredentialFile(filename: string): Promise { + const file = await openRunnerApiWorkspaceFile(filename); + try { + const stat = await file.stat(); + if (!stat.isFile() || stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o600 || stat.size > MAX_CREDENTIAL_BYTES) { + throw new Error("Invalid credential file"); + } + const bytes = Buffer.alloc(MAX_CREDENTIAL_BYTES + 1); + let size = 0; + while (size < bytes.length) { + const read = await file.read(bytes, size, bytes.length - size, size); + if (!read.bytesRead) break; + size += read.bytesRead; + } + if (size > MAX_CREDENTIAL_BYTES) throw new Error("Invalid credential file"); + return bytes.subarray(0, size).toString("utf8"); + } finally { + await file.close(); + } +} diff --git a/server/src/services/local-ai-credentials.ts b/server/src/services/local-ai-credentials.ts index 528fbb596e..ece03e792e 100644 --- a/server/src/services/local-ai-credentials.ts +++ b/server/src/services/local-ai-credentials.ts @@ -1,3 +1,4 @@ +import { readLocalAiCredentialFile } from "./local-ai-credential-file.js"; import fs from "node:fs/promises"; import path from "node:path"; import { readClaudeToken, fetchClaudeQuota } from "@paperclipai/adapter-claude-local/server"; @@ -6,14 +7,28 @@ import { parseGrokAuthPayload, hasUsableGrokAuthValue } from "@paperclipai/adapt import type { AiProvider } from "@paperclipai/shared"; import { unprocessable } from "../errors.js"; -/** Explicit local-operator import only. Callers must authorize before reading. */ +/** Read an owned login home, or an explicitly authorized local-operator import. */ 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."); try { if (provider === "anthropic") { - const token = await readClaudeToken({ allowKeychain: true }); + // 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; } + } + } else { + token = await readClaudeToken({ allowKeychain: true }); + } if (!token) throw new Error("Missing login"); await fetchClaudeQuota(token); return token; @@ -36,7 +51,7 @@ export async function readVerifiedLocalAiCredential(provider: AiProvider, loginH return raw; } catch { // Provider/CLI errors may contain credential material; never return them. - throw unprocessable(provider === "anthropic" + 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."); } diff --git a/server/src/services/local-ai-login-policy.ts b/server/src/services/local-ai-login-policy.ts new file mode 100644 index 0000000000..51370b9a1b --- /dev/null +++ b/server/src/services/local-ai-login-policy.ts @@ -0,0 +1,12 @@ +import type { DeploymentMode, DeploymentExposure } from "@paperclipai/shared"; + +/** Same server-host boundary as local stdio runtimes. */ +export function supportsLocalAiLogin(options: { + deploymentMode?: DeploymentMode; + deploymentExposure?: DeploymentExposure; + trustedLocalStdioRuntimeHost?: string | null; +}) { + return options.deploymentMode !== "authenticated" || options.deploymentExposure !== "public" || Boolean( + options.trustedLocalStdioRuntimeHost ?? process.env.PAPERCLIP_TRUSTED_MCP_RUNTIME_HOST ?? process.env.PAPERCLIP_TOOL_RUNTIME_TRUSTED_HOST, + ); +} diff --git a/server/src/services/local-ai-login.ts b/server/src/services/local-ai-login.ts index dca0eb0da9..8f29a7164a 100644 --- a/server/src/services/local-ai-login.ts +++ b/server/src/services/local-ai-login.ts @@ -21,8 +21,10 @@ function presentAttempt(id: string, expiresAt: Date, provider: string): LocalAiL 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)` - : `(export GROK_HOME=${shellQuote(directory)} && mkdir -p "$GROK_HOME" && grok login --device-auth)`, + ? `(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)`, }; } async function prepareHome(id: string, provider: string) { @@ -63,12 +65,12 @@ export function localAiLoginService(db: Db) { } async function start(companyId: string, userId: string, intent: AiConnectionLoginIntent, restart = false): Promise { - if (intent.provider !== "openai" && intent.provider !== "xai") + 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) => { 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" : "grok_local"; + const adapterType = intent.provider === "openai" ? "codex_local" : intent.provider === "anthropic" ? "claude_local" : "grok_local"; const [existing] = await tx.select().from(adapterAuthSessions).where(and( eq(adapterAuthSessions.companyId, companyId), eq(adapterAuthSessions.startedByUserId, userId), eq(adapterAuthSessions.adapterType, adapterType), @@ -101,7 +103,7 @@ export function localAiLoginService(db: Db) { try { await tx.insert(adapterAuthSessions).values({ id, publicSessionId: id, companyId, environmentId: environment.id, - adapterType: intent.provider === "openai" ? "codex_local" : "grok_local", + adapterType, startedByUserId: userId, aiConnection: intent, connectionMethod: LOCAL_LOGIN_METHOD, status: "waiting_for_user", expiresAt, }); @@ -124,7 +126,7 @@ export function localAiLoginService(db: Db) { // 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 (intent.provider !== "anthropic") { + if (id || intent.provider !== "anthropic") { if (!id) throw unprocessable("Start local sign-in before checking this account."); const [session] = await db.select().from(adapterAuthSessions).where(and( eq(adapterAuthSessions.id, id), eq(adapterAuthSessions.companyId, companyId), diff --git a/ui/src/api/health.ts b/ui/src/api/health.ts index 8068d2614a..ac8cbc00a2 100644 --- a/ui/src/api/health.ts +++ b/ui/src/api/health.ts @@ -28,6 +28,7 @@ export type HealthStatus = { version?: string; deploymentMode?: "local_trusted" | "authenticated"; deploymentExposure?: "private" | "public"; + localAiLoginSupported?: boolean; authReady?: boolean; bootstrapStatus?: "ready" | "bootstrap_pending"; bootstrapInviteActive?: boolean; diff --git a/ui/src/components/AdapterLoginChrome.tsx b/ui/src/components/AdapterLoginChrome.tsx index 0eadf5bb97..6d7aa7e63e 100644 --- a/ui/src/components/AdapterLoginChrome.tsx +++ b/ui/src/components/AdapterLoginChrome.tsx @@ -469,11 +469,11 @@ export function ProviderApiKeyCard({ /** Shared instructions for local subscription setup in every authentication host. */ export function LocalProviderLoginInstructions({ adapterType, login }: { adapterType: string; - login?: { command?: string; preparing: boolean; status?: "ready" | "sign_in_required" | "expired" | null; error: string | null; retry: () => void }; + login?: { isolated?: boolean; command?: string; preparing: boolean; status?: "ready" | "sign_in_required" | "expired" | null; error: string | null; retry: () => void }; }) { const [showCommand, setShowCommand] = useState(false); const provider = adapterType === "claude_local" ? "Claude Code" : adapterType === "grok_local" ? "Grok CLI" : "Codex CLI"; - const isolated = adapterType === "codex_local" || adapterType === "grok_local"; + 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 ready = login?.status === "ready"; diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 87a829f936..7641d62b83 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -992,13 +992,14 @@ function OnboardingWizardInner({ // input here, so this gate alone only decides whether the login mechanism // could ever apply to the current adapter and environment. const localLoginHealth = useQuery({ queryKey: queryKeys.health, queryFn: healthApi.get }); - const canUseLocalLogin = resolvedLoginEnvironment?.driver === "local" && localLoginHealth.data?.deploymentMode === "local_trusted"; + const canUseLocalLogin = resolvedLoginEnvironment?.driver === "local" && (localLoginHealth.data?.localAiLoginSupported ?? localLoginHealth.data?.deploymentMode === "local_trusted"); const localLogin = useLocalAiLogin(createdCompanyId, { provider: managedProvider ?? "anthropic", method: "subscription", name: `My ${CONNECT_SOURCE_NAMES[adapterType] ?? managedProvider} subscription`, ownership: "personal", agentIds: [], allAgents: true, }, effectiveOnboardingOpen && step === 4 && canUseLocalLogin && credentialMode !== "api" && - Boolean(managedProvider) && !savedSubscription && !savedKeys.storedLogin.data && !managedBindingForStep()); + Boolean(managedProvider) && !savedSubscription && !savedKeys.storedLogin.data && !managedBindingForStep(), + { allowHostClaude: localLoginHealth.data?.deploymentMode === "local_trusted" }); const canShowAdapterLogin = Boolean( adapterCaps.login != null && resolvedLoginEnvironment?.driver === "sandbox" && @@ -2778,7 +2779,7 @@ function OnboardingWizardInner({ ) : adapterType === "claude_local" && savedKeys.storedLogin.data ? (

Use your saved Claude subscription for this agent.

) : connectStepHasNoSandbox ? ( - resolvedLoginEnvironment?.driver === "local" && managedProvider ? ( + canUseLocalLogin && managedProvider ? ( { setError(null); localLogin.retry(); } }} /> ) :

This environment does not support browser sign-in. Choose another sign-in environment or connect with an API key.

) : null} diff --git a/ui/src/components/ai-connections/AiConnectionDesignExamples.tsx b/ui/src/components/ai-connections/AiConnectionDesignExamples.tsx index 059494cb18..0e30c4bb31 100644 --- a/ui/src/components/ai-connections/AiConnectionDesignExamples.tsx +++ b/ui/src/components/ai-connections/AiConnectionDesignExamples.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { AiConnectionPicker } from "./AiConnectionPicker"; -import { ProviderApiKeyCard } from "@/components/AdapterLoginChrome"; +import { LocalProviderLoginInstructions, ProviderApiKeyCard } from "@/components/AdapterLoginChrome"; import type { AiConnectionBinding, AiConnectionRequirement, @@ -58,6 +58,11 @@ export function AiConnectionDesignExamples() { onSubmit={() => {}} placeholder="Enter API key here" /> + {} }} + /> ); } diff --git a/ui/src/components/ai-connections/useLocalAiLogin.test.tsx b/ui/src/components/ai-connections/useLocalAiLogin.test.tsx index 67d28d6169..4434632609 100644 --- a/ui/src/components/ai-connections/useLocalAiLogin.test.tsx +++ b/ui/src/components/ai-connections/useLocalAiLogin.test.tsx @@ -20,7 +20,7 @@ beforeEach(() => { }); 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); + const login = useLocalAiLogin("company", { provider, method: "subscription", name, ownership: "personal", agentIds: [], allAgents: true }, enabled, { allowHostClaude: true }); return <>; } it("checks once under StrictMode, preserves renaming and navigation, and cancels only on explicit retry", async () => { diff --git a/ui/src/components/ai-connections/useLocalAiLogin.ts b/ui/src/components/ai-connections/useLocalAiLogin.ts index e4e12afd47..4d84cf7297 100644 --- a/ui/src/components/ai-connections/useLocalAiLogin.ts +++ b/ui/src/components/ai-connections/useLocalAiLogin.ts @@ -3,8 +3,8 @@ 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) { - const isolated = intent.provider === "openai" || intent.provider === "xai"; +export function useLocalAiLogin(companyId: string | null, intent: AiConnectionLoginIntent, enabled: boolean, options: { allowHostClaude?: boolean } = {}) { + const isolated = intent.provider !== "anthropic" || !options.allowHostClaude; const active = Boolean(companyId && enabled); const [attempt, setAttempt] = useState(null); const [status, setStatus] = useState(null); @@ -77,6 +77,7 @@ export function useLocalAiLogin(companyId: string | null, intent: AiConnectionLo }; }, [companyId, active, isolated, target, generation]); return { + isolated, command: attempt?.command, status, preparing: active && !status && !error, diff --git a/ui/src/components/new-agent/AgentProviderConnection.test.tsx b/ui/src/components/new-agent/AgentProviderConnection.test.tsx index a0fdd6bc71..c4301be34f 100644 --- a/ui/src/components/new-agent/AgentProviderConnection.test.tsx +++ b/ui/src/components/new-agent/AgentProviderConnection.test.tsx @@ -52,6 +52,8 @@ async function mount( cachedClaudeLogin = false, managedAccount?: Parameters[0]["managedAccount"], localEnvironment = false, + deploymentMode: "local_trusted" | "authenticated" = "local_trusted", + localAiLoginSupported = true, ) { const key = adapterType === "claude_local" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY"; @@ -104,7 +106,7 @@ async function mount( client.setQueryData(["claude-oauth-token-status", "c1"], { secretId: "cached-claude", latestVersion: 1 }); mocks.auth.mockResolvedValue({ status: "absent" }); } - client.setQueryData(["health"], { deploymentMode: "local_trusted" }); + client.setQueryData(["health"], { deploymentMode, localAiLoginSupported }); client.setQueryDefaults(["health"], { staleTime: Infinity }); host = document.createElement("div"); document.body.appendChild(host); @@ -146,6 +148,34 @@ function openProvider() { ); } describe("AgentProviderConnection reuse", () => { + it.each(["claude_local", "codex_local"] as const)("does not offer a server-host command when health disables local login: %s", async adapterType => { + const onComplete = vi.fn(); + const intent = { provider: adapterType === "claude_local" ? "anthropic" as const : "openai" as const, method: "subscription" as const, name: "Hosted account", ownership: "personal" as const, agentIds: [], allAgents: false }; + await mount(adapterType, false, false, false, false, false, { intent, onComplete }, true, "authenticated", false); + openProvider(); + expect(host.textContent).toContain("This environment does not support browser sign-in"); + expect(host.textContent).not.toContain("Run this in a terminal"); + expect(managedApi.startLocalLogin).not.toHaveBeenCalled(); + click("Connect"); + expect(managedApi.connectLocal).not.toHaveBeenCalled(); + expect(onComplete).not.toHaveBeenCalled(); + }); + 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" }); + 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"); + expect(managedApi.startLocalLogin).toHaveBeenCalledWith("c1", intent); + expect(managedApi.checkLocalLogin).toHaveBeenCalledWith("c1", { ...intent, localSessionId: "local-attempt" }); + click("Connect"); + await vi.waitFor(() => expect(onComplete).toHaveBeenCalled()); + expect(managedApi.connectLocal).toHaveBeenCalledWith("c1", { ...intent, localSessionId: "local-attempt" }); + }); 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(); 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 }; diff --git a/ui/src/components/new-agent/AgentProviderConnection.tsx b/ui/src/components/new-agent/AgentProviderConnection.tsx index 0de551fb5b..d51b1c0422 100644 --- a/ui/src/components/new-agent/AgentProviderConnection.tsx +++ b/ui/src/components/new-agent/AgentProviderConnection.tsx @@ -63,7 +63,7 @@ export function AgentProviderConnection({ }; }) { const health = useQuery({ queryKey: queryKeys.health, queryFn: healthApi.get, enabled: localEnvironment }); - const canUseLocalLogin = localEnvironment && health.data?.deploymentMode === "local_trusted"; + const canUseLocalLogin = localEnvironment && (health.data?.localAiLoginSupported ?? health.data?.deploymentMode === "local_trusted"); const epoch = useRef(0); useEffect( () => () => { @@ -121,7 +121,8 @@ export function AgentProviderConnection({ const localLogin = useLocalAiLogin(companyId, managedAccount?.intent ?? { provider: aiProvider, method: "subscription", name: `My ${provider} subscription`, ownership: "personal", agentIds: [], allAgents: true, - }, canUseLocalLogin && method === "subscription" && !savedSubscription && !storedLogin.data); + }, canUseLocalLogin && method === "subscription" && !savedSubscription && !storedLogin.data, + { allowHostClaude: health.data?.deploymentMode === "local_trusted" }); const auth = useQuery({ queryKey: queryKeys.agents.authSignal( companyId, @@ -352,7 +353,7 @@ export function AgentProviderConnection({ onConnected(connection); }} /> - ) : savedSubscription ? null : localEnvironment && !storedLogin.data ? ( + ) : savedSubscription ? null : canUseLocalLogin && !storedLogin.data ? ( { setError(null); localLogin.retry(); } }} /> ) : (

@@ -376,6 +377,9 @@ export function AgentProviderConnection({ {testError ?? error}

)} + {localEnvironment && health.isError && ( +

Could not prepare sign-in. Reload this page to try again.

+ )} { if (opened) cancel();