Files
PaperClipAI/server/src/services/local-ai-credentials.ts
T
DottaandPaperclip b2acc674be 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>
2026-09-12 23:06:55 +00:00

59 lines
3.2 KiB
TypeScript

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";
import { readCodexAuthInfo, fetchCodexQuota } from "@paperclipai/adapter-codex-local/server";
import { parseGrokAuthPayload, hasUsableGrokAuthValue } from "@paperclipai/adapter-grok-local/server";
import type { AiProvider } from "@paperclipai/shared";
import { unprocessable } from "../errors.js";
/** Read an owned login home, or an explicitly authorized local-operator import. */
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") {
// 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;
}
if (provider === "openai") {
const auth = await readCodexAuthInfo(loginHome);
if (!auth?.accessToken || !auth.refreshToken || !auth.idToken) throw new Error("Missing login");
await fetchCodexQuota(auth.accessToken, auth.accountId);
return JSON.stringify({ tokens: { access_token: auth.accessToken, refresh_token: auth.refreshToken, id_token: auth.idToken, account_id: auth.accountId }, last_refresh: auth.lastRefresh });
}
const raw = await fs.readFile(path.join(loginHome!, "auth.json"), "utf8");
const payload = parseGrokAuthPayload(JSON.parse(raw));
if (!payload || !hasUsableGrokAuthValue(payload.value)) throw new Error("Missing login");
const response = await fetch("https://api.x.ai/v1/models", {
headers: { Authorization: `Bearer ${payload.value.key}` },
redirect: "error", signal: AbortSignal.timeout(15000),
});
await response.body?.cancel();
if (!response.ok) throw new Error("Invalid login");
return raw;
} catch {
// Provider/CLI errors may contain credential material; never return them.
throw unprocessable(provider === "anthropic" && !loginHome
? "Could not verify the local subscription. Run claude auth login in a terminal on the machine running Paperclip, then try Connect again."
: "Could not verify the local subscription. Run the sign-in command shown for this connection, finish signing in, then try Connect again.");
}
}