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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-12 18:06:55 -05:00 committed by GitHub
parent 8b84ae5b35
commit b2acc674be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 285 additions and 45 deletions

View File

@ -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 operators 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
attempts credential files, never the server operators 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 users 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 operators 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 <marke
provider key with `AI_REPAIR_TEST_KEY`. The test verifies these boundaries before
revoking credentials or submitting work. Delete the disposable instance and revoke
its provider key after the test; failed tests may leave a paused task for inspection.
Authenticated public deployments must configure a trusted runtime host (`PAPERCLIP_TRUSTED_MCP_RUNTIME_HOST` or `PAPERCLIP_TOOL_RUNTIME_TRUSTED_HOST`) before offering server-host subscription login, matching the local stdio runtime boundary. Health reports this capability so setup can offer a supported environment or API key instead of an unusable terminal command. Private authenticated self-hosted instances support isolated local login without that extra setting. Isolated Claude credential files must be private, owned by the server user, bounded, and free of symlinks.

View File

@ -35,6 +35,7 @@ const create = (userId: string, name: string, ownership: "personal" | "shared" =
beforeAll(async () => {
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");

View File

@ -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,

View File

@ -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,

View File

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

View File

@ -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");

View File

@ -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);
});
});

View File

@ -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,

View File

@ -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<typeof supportsLocalAiLogin>[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;

View File

@ -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,

View File

@ -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<string> {
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();
}
}

View File

@ -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<string> {
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.");
}

View File

@ -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,
);
}

View File

@ -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<LocalAiLoginAttempt> {
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<LocalAiLoginStatus> {
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),

View File

@ -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;

View File

@ -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 <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin" />Checking local {provider} sign-in</p>;
const ready = login?.status === "ready";

View File

@ -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 ? (
<p className="text-sm text-muted-foreground">Use your saved Claude subscription for this agent.</p>
) : connectStepHasNoSandbox ? (
resolvedLoginEnvironment?.driver === "local" && managedProvider ? (
canUseLocalLogin && managedProvider ? (
<LocalProviderLoginInstructions adapterType={adapterType} login={{ ...localLogin, retry: () => { setError(null); localLogin.retry(); } }} />
) : <p className="text-xs text-muted-foreground">This environment does not support browser sign-in. Choose another sign-in environment or connect with an API key.</p>
) : null}

View File

@ -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"
/>
<LocalProviderLoginInstructions
adapterType="claude_local"
login={{ isolated: true, preparing: false, status: "sign_in_required", error: null,
command: "CLAUDE_CONFIG_DIR='/example/connection-login' claude auth login", retry: () => {} }}
/>
</div>
);
}

View File

@ -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 <><LocalProviderLoginInstructions adapterType={provider === "anthropic" ? "claude_local" : "codex_local"} login={login} /><button onClick={() => void login.connect()}>Connect</button></>;
}
it("checks once under StrictMode, preserves renaming and navigation, and cancels only on explicit retry", async () => {

View File

@ -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<LocalAiLoginAttempt | null>(null);
const [status, setStatus] = useState<LocalAiLoginStatus["status"] | null>(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,

View File

@ -52,6 +52,8 @@ async function mount(
cachedClaudeLogin = false,
managedAccount?: Parameters<typeof AgentProviderConnection>[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 };

View File

@ -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 ? (
<LocalProviderLoginInstructions adapterType={adapterType} login={{ ...localLogin, retry: () => { setError(null); localLogin.retry(); } }} />
) : (
<p className="text-sm text-muted-foreground">
@ -376,6 +377,9 @@ export function AgentProviderConnection({
{testError ?? error}
</p>
)}
{localEnvironment && health.isError && (
<p role="alert" className="mt-4 text-sm text-destructive">Could not prepare sign-in. Reload this page to try again.</p>
)}
<FooterNav
onBack={() => {
if (opened) cancel();