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();