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:
parent
8b84ae5b35
commit
b2acc674be
|
|
@ -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 <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.
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue