Files
PaperClipAI/ui/src/api/health.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

76 lines
2.4 KiB
TypeScript

import type { ServerInfoSnapshot } from "@paperclipai/shared";
import { tenantSessionRecovery } from "@/lib/tenant-session-recovery";
export type DevServerHealthStatus = {
enabled: true;
restartRequired: boolean;
reason: "backend_changes" | "pending_migrations" | "backend_changes_and_pending_migrations" | null;
lastChangedAt: string | null;
changedPathCount: number;
changedPathsSample: string[];
pendingMigrations: string[];
autoRestartEnabled: boolean;
activeRunCount: number;
waitingForIdle: boolean;
lastRestartAt: string | null;
};
export type CloudInstanceHealthStatus = {
managed: true;
managedBy: "paperclip-cloud";
stackSlug: string | null;
stackDisplayName?: string;
cloudBaseUrl: string | null;
};
export type HealthStatus = {
status: "ok";
version?: string;
deploymentMode?: "local_trusted" | "authenticated";
deploymentExposure?: "private" | "public";
localAiLoginSupported?: boolean;
authReady?: boolean;
bootstrapStatus?: "ready" | "bootstrap_pending";
bootstrapInviteActive?: boolean;
features?: {
companyDeletionEnabled?: boolean;
};
serverInfo?: ServerInfoSnapshot;
devServer?: DevServerHealthStatus;
cloud?: CloudInstanceHealthStatus;
/**
* Settings surfaces hidden by the hosting operator (keys from the shared
* settings-visibility registry). Absent when nothing is hidden.
*/
hiddenSettings?: string[];
};
export const healthApi = {
get: async (): Promise<HealthStatus> => {
const res = await fetch("/api/health", {
credentials: "include",
headers: { Accept: "application/json" },
});
if (!res.ok) {
const payload = await res.json().catch(() => null) as { error?: string } | null;
const recovery = tenantSessionRecovery.recoverIfNeeded(res.status, payload);
if (recovery) return recovery;
throw new Error(payload?.error ?? `Failed to load health (${res.status})`);
}
return res.json();
},
requestDevServerRestart: async (): Promise<void> => {
const res = await fetch("/api/health/dev-server/restart", {
method: "POST",
credentials: "include",
headers: { Accept: "application/json" },
});
if (!res.ok) {
const payload = await res.json().catch(() => null) as { error?: string } | null;
const recovery = tenantSessionRecovery.recoverIfNeeded(res.status, payload);
if (recovery) return recovery;
throw new Error(payload?.error ?? `Failed to request restart (${res.status})`);
}
},
};