refactor(connections): keep runtime and browser-login changes independently usable

Keep the provider runtime PR below the review file limit. Preserve browser sign-in compatibility and provider branding here; ship advanced setup, stories, and the qualification harness in the linked UI PR.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-06 09:21:04 -05:00
1 parent ab56046a8e
commit fa9b3d22a1
23 files changed
+229 -290

No files matched your search

-3
View File
@@ -417,9 +417,6 @@ This starts the UI and API at `http://localhost:3100`. An embedded PostgreSQL da
> **Requirements:** Node.js 24.11+, pnpm 9.15+
Local Claude and Codex subscription sign-in also needs Python 3 and the
corresponding provider CLI on the Paperclip host. The Docker image includes them.
Source development also builds the native Paperclip Runner when enabled (the self-hosted default). Install a Rust toolchain, or set `PAPERCLIP_RUNNER_BINARY` to a compatible prebuilt runner.
<br/>
+5 -6
View File
@@ -212,25 +212,24 @@ The image pre-installs:
- `claude` (Anthropic Claude Code CLI)
- `codex` (OpenAI Codex CLI)
- `python3` (used to run local subscription sign-in in a pseudo-terminal)
Start the container, then open **Apps → Connections** to connect a Claude or
Codex subscription in your browser. You can also enter a provider API key there.
You do not run a shell command or configure a CLI home.
If you want local adapter runs inside the container, pass API keys when starting the container:
```sh
docker run --name paperclip \
-p 3100:3100 \
-e HOST=0.0.0.0 \
-e PAPERCLIP_HOME=/paperclip \
-e OPENAI_API_KEY=... \
-e ANTHROPIC_API_KEY=... \
-v "$(pwd)/data/docker-paperclip:/paperclip" \
paperclip-local
```
Notes:
- The image provides the CLI and Python prerequisites for local subscription sign-in.
- Adapter environment checks in Paperclip surface missing authentication or CLI prerequisites.
- Without API keys, the app still runs normally.
- Adapter environment checks in Paperclip will surface missing auth/CLI prerequisites.
## Podman Quadlet (systemd)
+14 -29
View File
@@ -10,11 +10,6 @@ homes. Managed default/shared accounts are additional choices in that same
selector. Selecting “Sign in to another account” survives background refreshes;
Claude authorization paste keeps upstream's immediate Connecting feedback.
New-agent Connect offers three persistent tiles: the provider's subscription,
the provider's API key, and Advanced. All three stay visible while the selected
mode's form is shown below. Advanced opens the existing compatible-connection
picker and provider setup. Execution environment selection lives in Configure.
Storybook's simulated controllers and page annotations do not run in the app.
## Compatibility and selection
@@ -384,8 +379,8 @@ onboarding, and agent setup share `LocalProviderLoginInstructions` and
`useLocalAiLogin`. Claude and Codex start a local provider process behind the
browser sign-in card. Claude accepts the authorization code in that card; Codex
displays its device code there. The user does not run a shell command. Each
local runner requires Python 3 for its pseudo-terminal (included in the Docker
image) and the corresponding provider CLI on the Paperclip host. Each
local runner requires Python 3 for its pseudo-terminal and the corresponding
provider CLI on the Paperclip host. Each
attempt retains a private credential home. 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
@@ -516,24 +511,15 @@ fingerprint. These checks do not relax current connection authorization.
## Advanced provider routing (2026-10-02)
Use the regular rows on **Connectors** to add OpenRouter, Amazon Bedrock,
Google Gemini, a Responses API, Messages API, Chat Completions API, or local
endpoint connection. Each row has its own Connect action and saved accounts.
The catalog tags these entries `model-provider`; no category UI is shown.
Responses-compatible gateways such as Emissary use the Responses API row.
The native subscription/API-key onboarding remains the default. Provider choices
show the existing local brand artwork and reuse the existing access step. Custom
URLs, protocol, AWS region, and credential fields appear only after choosing the
corresponding connector. New connections default to everyone in the organization
and all agents when the actor has permission; the existing Advanced disclosure
contains the controls to narrow access, without a separate Access step. New-agent setup also offers the connection picker under
**Use another connection**.
The connection API and catalog support OpenRouter, Amazon Bedrock, Google Gemini,
Responses API, Messages API, Chat Completions API, and local endpoints.
The catalog tags these entries `model-provider`. Responses-compatible gateways
such as Emissary use the Responses API definition.
At **Agents → [agent] → Harness / Runtime**, **Connection** is a dropdown of
compatible saved connections, including explicit personal accounts. The existing
model picker uses that connection’s optional model IDs and accepts manual IDs.
Changing connections preserves the model for explicit review. URLs and
credentials belong to the connection; the model belongs to the agent.
Provider routing belongs to the connection; the model belongs to the agent.
The API exposes compatible saved connections and optional model identifiers.
The advanced setup UI and review stories ship in the follow-up UI change.
Native subscription and API-key setup keep their existing controls.
| Harness | Implemented managed routes |
| --- | --- |
@@ -543,7 +529,7 @@ credentials belong to the connection; the model belongs to the agent.
| Hermes local | OpenRouter; custom/local Chat Completions |
| Gemini CLI, Grok | Their native API connections; custom routes are not advertised |
Migration `0295` adds Google to both account-default provider constraints. Local
Migration `0300` adds Google to both account-default provider constraints. Local
Gemini connections seed the API-key auth choice in their disposable home before
environment probes and task execution. The settings file contains no credential.
@@ -555,10 +541,9 @@ catalog discovery for custom gateways are not part of this implementation.
OpenRouter connections without an explicit model list automatically load its public
[model catalog](https://openrouter.ai/docs/api/api-reference/models/list-all-models-and-their-properties),
ordered with `sort=most-popular`. New-agent setup and agent settings share this
discovery path, preserve the provider's ordering, and adapt model IDs to the selected
harness. Explicit connection model lists take precedence. Catalog discovery sends
no credentials; a failed request offers refresh and manual model entry.
ordered with `sort=most-popular`. The company-scoped model discovery API preserves
the provider's ordering and adapts model IDs to the selected harness. Explicit
connection model lists take precedence. Catalog discovery sends no credentials.
`config.ai.routing` stores only kind, protocol, URL, auth method, region, and
optional model IDs/labels. The vault stores provider API keys, including Bedrock API keys.
@@ -1,100 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, expect, it } from "vitest";
import { createAcpRuntime, createAgentRegistry, createRuntimeStore } from "acpx/runtime";
const roots: string[] = [];
const fixture = fileURLToPath(new URL("../../../../scripts/mcp-fixtures/servers/acp-filesystem-agent.mjs", import.meta.url));
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
});
async function workspace() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acp-filesystem-"));
roots.push(root);
const cwd = path.join(root, "workspace");
await fs.mkdir(cwd);
return { root, cwd: await fs.realpath(cwd) };
}
async function drive(input: {
cwd: string;
root: string;
permissionMode?: "approve-all" | "deny-all";
operations: Array<{ method: string; path: string; content?: string }>;
}) {
let stderr = "";
const runtime = createAcpRuntime({
cwd: input.cwd,
sessionStore: createRuntimeStore({ stateDir: path.join(input.root, "state") }),
agentRegistry: createAgentRegistry({ overrides: { custom: `${JSON.stringify(process.execPath.replaceAll("\\", "/"))} ${JSON.stringify(fixture.replaceAll("\\", "/"))}` } }),
permissionMode: input.permissionMode ?? "approve-all",
nonInteractivePermissions: "deny",
inheritProcessEnv: false,
onAgentStderr: (chunk: string) => { stderr += chunk; },
});
const handle = await runtime.ensureSession({
sessionKey: "filesystem-protocol", agent: "custom", mode: "oneshot",
cwd: input.cwd, sessionOptions: { env: {} },
});
try {
for await (const _event of runtime.runTurn({
handle, text: JSON.stringify({ operations: input.operations }),
mode: "prompt", requestId: "filesystem-protocol", timeoutMs: 5_000,
})) { /* Drain the real ACP turn. */ }
return stderr.split("\n").filter((line) => line.startsWith("FS_RESULT "))
.map((line) => JSON.parse(line.slice("FS_RESULT ".length)));
} finally {
await runtime.close({ handle, reason: "done" });
}
}
it("reports a missing file as an ACP resource error and then creates and reads it", async () => {
const input = await workspace();
const file = path.join(input.cwd, "new.json");
const content = '{"proof":"synthetic"}\n';
const receipts = await drive({ ...input, operations: [
{ method: "fs/read_text_file", path: file },
{ method: "fs/write_text_file", path: file, content },
{ method: "fs/read_text_file", path: file },
] });
expect(receipts).toHaveLength(3);
expect(receipts[0].error).toMatchObject({ code: -32002, message: expect.stringContaining("Resource not found") });
expect(receipts[1].error).toBeUndefined();
expect(receipts[2].result.content).toBe(content);
expect(await fs.readFile(file, "utf8")).toBe(content);
});
it("keeps unrelated paths denied for both reads and writes", async () => {
const input = await workspace();
const outside = path.join(await fs.realpath(input.root), "outside.json");
await fs.writeFile(outside, "unchanged");
const receipts = await drive({ ...input, operations: [
{ method: "fs/read_text_file", path: outside },
{ method: "fs/write_text_file", path: outside, content: "changed" },
] });
expect(receipts).toHaveLength(2);
expect(receipts.every((receipt) => receipt.error && receipt.error.code !== -32002)).toBe(true);
expect(await fs.readFile(outside, "utf8")).toBe("unchanged");
});
it("preserves deny-all permissions instead of converting denials into missing files", async () => {
const input = await workspace();
const file = path.join(input.cwd, "denied.json");
const receipts = await drive({ ...input, permissionMode: "deny-all", operations: [
{ method: "fs/read_text_file", path: file },
{ method: "fs/write_text_file", path: file, content: "denied" },
] });
expect(receipts).toHaveLength(2);
expect(receipts.every((receipt) => receipt.error && receipt.error.code !== -32002)).toBe(true);
await expect(fs.stat(file)).rejects.toMatchObject({ code: "ENOENT" });
});
it.skipIf(process.platform === "win32")("keeps other filesystem failures distinct from missing files", async () => {
const input = await workspace();
const receipts = await drive({ ...input, operations: [{ method: "fs/read_text_file", path: input.cwd }] });
expect(receipts).toHaveLength(1);
expect(receipts[0].error.code).not.toBe(-32002);
});
-1
View File
@@ -237,7 +237,6 @@ Available toolsets: `terminal`, `file`, `web`, `browser`, `code_execution`, `vis
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `cwd` | string | *(assigned task workspace)* | Absolute working directory override. Without an override, use Paperclip's resolved task workspace. |
| `persistSession` | boolean | `true` | Resume sessions across heartbeats |
| `worktreeMode` | boolean | `false` | Git worktree isolation |
| `checkpoints` | boolean | `false` | Enable filesystem checkpoints for rollback |
-1
View File
@@ -99,7 +99,6 @@ tools, persistent memory, session persistence, skills, and MCP support.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| cwd | string | (assigned task workspace) | Absolute working directory override; otherwise use Paperclip's resolved task workspace |
| persistSession | boolean | true | Resume sessions across heartbeats |
| worktreeMode | boolean | false | Use git worktree for isolated changes |
| checkpoints | boolean | false | Enable filesystem checkpoints |
@@ -113,18 +113,6 @@ describe("hermes-local adapter onSpawn forwarding", () => {
expect(call[2]).toContainEqual(expect.stringContaining("Current task brief"));
});
it("launches in the assigned task workspace when no cwd was configured", async () => {
const { ctx } = makeCtx();
await execute({ ...ctx, context: { ...ctx.context, paperclipWorkspace: { cwd: "/private/qa/agent-workspace" } } } as any);
expect(vi.mocked(serverUtils.runChildProcess).mock.calls.at(-1)![3].cwd).toBe("/private/qa/agent-workspace");
});
it("preserves an explicitly configured cwd", async () => {
const { ctx } = makeCtx({ cwd: "/private/qa/configured-workspace" });
await execute({ ...ctx, context: { ...ctx.context, paperclipWorkspace: { cwd: "/private/qa/agent-workspace" } } } as any);
expect(vi.mocked(serverUtils.runChildProcess).mock.calls.at(-1)![3].cwd).toBe("/private/qa/configured-workspace");
});
it("runChildProcess opts type includes onSpawn", () => {
// Type-level assertion: if onSpawn were removed from the type,
// this file would fail to compile. The runtime test above catches
@@ -510,12 +510,8 @@ export async function execute(
if (envCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = envCommentId;
// ── Resolve working directory ──────────────────────────────────────────
const workspace = ctx.context?.paperclipWorkspace;
const workspaceCwd = workspace && typeof workspace === "object"
? cfgString((workspace as Record<string, unknown>).cwd)
: undefined;
const cwd =
cfgString(config.cwd) || workspaceCwd || cfgString(ctx.config?.workspaceDir) || ".";
cfgString(config.cwd) || cfgString(ctx.config?.workspaceDir) || ".";
try {
await ensureAbsoluteDirectory(cwd);
} catch {
@@ -1,43 +0,0 @@
#!/usr/bin/env node
// Credential-free ACP filesystem requests, driven through the real client.
import { createInterface } from "node:readline";
const pending = new Map();
let nextId = 100;
const send = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
const request = (method, params) => new Promise((resolve) => {
const id = nextId++;
pending.set(id, resolve);
send({ jsonrpc: "2.0", id, method, params });
});
createInterface({ input: process.stdin }).on("line", async (line) => {
const message = JSON.parse(line);
if (pending.has(message.id)) {
pending.get(message.id)(message);
pending.delete(message.id);
return;
}
let result;
if (message.method === "initialize") {
result = {
protocolVersion: 1,
agentCapabilities: { loadSession: false, sessionCapabilities: { close: {} } },
agentInfo: { name: "paperclip-acp-filesystem-fixture", version: "1.0.0" },
};
} else if (message.method === "session/new") {
result = { sessionId: "filesystem-fixture" };
} else if (message.method === "session/prompt") {
const { operations } = JSON.parse(message.params.prompt.find((entry) => entry.type === "text").text);
for (const { method, ...params } of operations) {
const response = await request(method, { sessionId: "filesystem-fixture", ...params });
process.stderr.write(`FS_RESULT ${JSON.stringify(response)}\n`);
}
result = { stopReason: "end_turn" };
} else if (message.method === "session/cancel") {
return;
} else {
result = {};
}
if (message.id !== undefined) send({ jsonrpc: "2.0", id: message.id, result });
});
-5
View File
@@ -156,8 +156,3 @@ confirms storage and response binding in Paperclip; it does not confirm delivery
to an external chat provider. If registration fails, use the returned error to
resolve the failure or explain the limitation; do not describe a workspace path
as an uploaded file.
The shell helper keeps temporary responses and retry receipts in the assigned
workspace's private `.paperclip/artifact-helper` directory, so CLI sandboxes can
write them even when the controller's `TMPDIR` is inaccessible.
`PAPERCLIP_HELPER_STATE_DIR` overrides that directory when explicitly configured.
@@ -113,17 +113,6 @@ sha256_text() {
fi
}
helper_state_directory() {
printf '%s' "${PAPERCLIP_HELPER_STATE_DIR:-${PAPERCLIP_WORKSPACE_CWD:-$PWD}/.paperclip/artifact-helper}"
}
helper_response_file() {
local directory
directory="$(helper_state_directory)"
# TMPDIR can name a controller-owned directory outside a CLI sandbox.
(umask 077; mkdir -p "$directory" && mktemp "$directory/response.XXXXXX")
}
request_json() {
local method="$1"
local url="$2"
@@ -131,7 +120,7 @@ request_json() {
local response_file
local status_code
response_file="$(helper_response_file)"
response_file="$(mktemp)"
if [[ -n "$body" ]]; then
status_code="$(
curl -sS -X "$method" -w '%{http_code}' -o "$response_file" \
@@ -174,7 +163,7 @@ upload_file() {
escaped_path="${path//\\/\\\\}"
escaped_path="${escaped_path//\"/\\\"}"
response_file="$(helper_response_file)"
response_file="$(mktemp)"
status_code="$(
curl -sS -X POST -w '%{http_code}' -o "$response_file" \
"$url" \
@@ -234,7 +223,7 @@ acquire_operation_lock() {
local attempts=0
umask 077
operation_state_root="$(helper_state_directory)"
operation_state_root="${PAPERCLIP_HELPER_STATE_DIR:-${TMPDIR:-/tmp}/paperclip-upload-artifact}"
mkdir -p "$operation_state_root"
operation_lock_path="$operation_state_root/$operation_key.lock"
operation_lock_owner="$$|$(process_start_identity "$$" || true)"
+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Bedrock</title><defs><linearGradient id="lobe-icons-bedrock-_R_0_" x1="80%" x2="20%" y1="20%" y2="80%"><stop offset="0%" stop-color="#6350FB"></stop><stop offset="50%" stop-color="#3D8FFF"></stop><stop offset="100%" stop-color="#9AD8F8"></stop></linearGradient></defs><path d="M13.05 15.513h3.08c.214 0 .389.177.389.394v1.82a1.704 1.704 0 011.296 1.661c0 .943-.755 1.708-1.685 1.708-.931 0-1.686-.765-1.686-1.708 0-.807.554-1.484 1.297-1.662v-1.425h-2.69v4.663a.395.395 0 01-.188.338l-2.69 1.641a.385.385 0 01-.405-.002l-4.926-3.086a.395.395 0 01-.185-.336V16.3L2.196 14.87A.395.395 0 012 14.555L2 14.528V9.406c0-.14.073-.27.192-.34l2.465-1.462V4.448c0-.129.062-.249.165-.322l.021-.014L9.77 1.058a.385.385 0 01.407 0l2.69 1.675a.395.395 0 01.185.336V7.6h3.856V5.683a1.704 1.704 0 01-1.296-1.662c0-.943.755-1.708 1.685-1.708.931 0 1.685.765 1.685 1.708 0 .807-.553 1.484-1.296 1.662v2.311a.391.391 0 01-.389.394h-4.245v1.806h6.624a1.69 1.69 0 011.64-1.313c.93 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708a1.69 1.69 0 01-1.64-1.314H13.05v1.937h4.953l.915 1.18a1.66 1.66 0 01.84-.227c.931 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708-.93 0-1.685-.765-1.685-1.708 0-.346.102-.668.276-.937l-.724-.935H13.05v1.806zM9.973 1.856L7.93 3.122V6.09h-.778V3.604L5.435 4.669v2.945l2.11 1.36L9.712 7.61V5.334h.778V7.83c0 .136-.07.263-.184.335L7.963 9.638v2.081l1.422 1.009-.446.646-1.406-.998-1.53 1.005-.423-.66 1.605-1.055v-1.99L5.038 8.29l-2.26 1.34v1.676l1.972-1.189.398.677-2.37 1.429V14.3l2.166 1.258 2.27-1.368.397.677-2.176 1.311V19.3l1.876 1.175 2.365-1.426.398.678-2.017 1.216 1.918 1.201 2.298-1.403v-5.78l-4.758 2.893-.4-.675 5.158-3.136V3.289L9.972 1.856zM16.13 18.47a.913.913 0 00-.908.92c0 .507.406.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zm3.63-3.81a.913.913 0 00-.908.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92zm1.555-4.99a.913.913 0 00-.908.92c0 .507.407.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zM17.296 3.1a.913.913 0 00-.907.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92z" fill="url(#lobe-icons-bedrock-_R_0_)" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Google</title><path d="M23 12.245c0-.905-.075-1.565-.236-2.25h-10.54v4.083h6.186c-.124 1.014-.797 2.542-2.294 3.569l-.021.136 3.332 2.53.23.022C21.779 18.417 23 15.593 23 12.245z" fill="#4285F4"></path><path d="M12.225 23c3.03 0 5.574-.978 7.433-2.665l-3.542-2.688c-.948.648-2.22 1.1-3.891 1.1a6.745 6.745 0 01-6.386-4.572l-.132.011-3.465 2.628-.045.124C4.043 20.531 7.835 23 12.225 23z" fill="#34A853"></path><path d="M5.84 14.175A6.65 6.65 0 015.463 12c0-.758.138-1.491.361-2.175l-.006-.147-3.508-2.67-.115.054A10.831 10.831 0 001 12c0 1.772.436 3.447 1.197 4.938l3.642-2.763z" fill="#FBBC05"></path><path d="M12.225 5.253c2.108 0 3.529.892 4.34 1.638l3.167-3.031C17.787 2.088 15.255 1 12.225 1 7.834 1 4.043 3.469 2.197 7.062l3.63 2.763a6.77 6.77 0 016.398-4.572z" fill="#EB4335"></path></svg>

After

Width:  |  Height:  |  Size: 920 B

+36
View File
@@ -32,6 +32,12 @@
"catalogVisible": true,
"localAsset": "/brands/apps/asana.svg"
},
{
"slug": "bedrock",
"provider": "Amazon Bedrock",
"catalogVisible": true,
"localAsset": "/brands/apps/bedrock.svg"
},
{
"slug": "beehiiv",
"provider": "beehiiv",
@@ -154,6 +160,12 @@
"catalogVisible": true,
"localAsset": "/brands/apps/google-sheets.svg"
},
{
"slug": "google",
"provider": "Google",
"catalogVisible": true,
"localAsset": "/brands/apps/google.svg"
},
{
"slug": "google-drive",
"provider": "Google Drive",
@@ -536,6 +548,30 @@
"localAsset": "/brands/apps/browser-use.svg",
"sourceUrl": "https://browser-use.com/logo-primary.svg",
"retrievedAt": "2026-09-29"
},
{
"slug": "chat-completions-api",
"provider": "Chat Completions API",
"catalogVisible": true,
"localAsset": "/brands/apps/api-key-generic.svg"
},
{
"slug": "local",
"provider": "Local endpoint",
"catalogVisible": true,
"localAsset": "/brands/apps/api-key-generic.svg"
},
{
"slug": "messages-api",
"provider": "Messages API",
"catalogVisible": true,
"localAsset": "/brands/apps/api-key-generic.svg"
},
{
"slug": "responses-api",
"provider": "Responses API",
"catalogVisible": true,
"localAsset": "/brands/apps/api-key-generic.svg"
}
]
}
+1
View File
@@ -4,6 +4,7 @@ export const aiConnectionsApi = {
probeUsage: (companyId: string, connectionId: string, grantId?: string) => api.get<AiConnectionUsage>(`/companies/${companyId}/ai-connections/${connectionId}/usage${grantId ? `?grantId=${encodeURIComponent(grantId)}` : ""}`),
startLocalLogin: (companyId: string, input: AiConnectionLoginIntent & { restart?: boolean }) => api.post<LocalAiLoginAttempt>(`/companies/${companyId}/ai-connections/local/attempts`, input),
checkLocalLogin: (companyId: string, input: AiConnectionLoginIntent & { localSessionId?: string }) => api.post<LocalAiLoginStatus>(`/companies/${companyId}/ai-connections/local/check`, input),
submitLocalLoginCode: (companyId: string, sessionId: string, browserCode: string) => api.post(`/companies/${companyId}/ai-connections/local/attempts/${sessionId}/code`, { browserCode }),
cancelLocalLogin: (companyId: string, sessionId: string) => api.delete(`/companies/${companyId}/ai-connections/local/attempts/${sessionId}`),
connectLocal: (companyId: string, input: AiConnectionLoginIntent & { localSessionId?: string }) => api.post<{ connectionId: string; grantId: string }>(`/companies/${companyId}/ai-connections/local`, input),
activeRuns: (companyId: string, connectionId: string) => api.get<Array<{ id: string; agentId: string; agentName: string; status: string }>>(`/companies/${companyId}/ai-connections/${connectionId}/active-runs`),
+38 -7
View File
@@ -469,20 +469,51 @@ export function ProviderApiKeyCard({
/** Shared instructions for local subscription setup in every authentication host. */
export function LocalProviderLoginInstructions({ adapterType, login }: {
adapterType: string;
login?: { isolated?: boolean; command?: string; preparing: boolean; status?: "ready" | "sign_in_required" | "expired" | null; error: string | null; retry: () => void };
login?: { isolated?: boolean; command?: string; authorizationUrl?: string | null; code?: string | null; submitCode?: (code: string) => Promise<void>; preparing: boolean; status?: "ready" | "sign_in_required" | "expired" | null; error: string | null; retry: () => void };
}) {
const [showCommand, setShowCommand] = useState(false);
const [browserCode, setBrowserCode] = useState("");
const [submitting, setSubmitting] = useState(false);
const provider = adapterType === "claude_local" ? "Claude Code" : adapterType === "grok_local" ? "Grok CLI" : "Codex CLI";
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 command = login?.command;
const browserLogin = adapterType === "claude_local" || adapterType === "codex_local";
async function submitBrowserCode() {
if (!browserCode.trim() || !login?.submitCode || submitting) return;
setSubmitting(true);
try {
await login.submitCode(browserCode.trim());
setBrowserCode("");
} catch {
// The login hook presents the request error beside the sign-in card.
} finally {
setSubmitting(false);
}
}
if (login?.preparing) return <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin" />Preparing sign-in…</p>;
const ready = login?.status === "ready";
return <div className="min-w-0 max-w-full space-y-3 text-sm text-muted-foreground">
{ready ? <>
<p role="status" className="flex items-center gap-2 text-foreground"><Check className="size-4 shrink-0 text-(--status-task-icon-done)" />{provider} is signed in. Click Connect to use this account.</p>
{!showCommand && <button type="button" className="underline underline-offset-4" onClick={() => setShowCommand(true)}>Use a different account</button>}
</> : <p>{isolated ? `Sign in to ${provider} for this connection on the machine running Paperclip. Your existing terminal login stays separate.` : `Connect uses your local ${provider} account on the machine running Paperclip.`}</p>}
{(!ready || showCommand) && !login?.error && <>
{!showCommand && <button type="button" className="underline underline-offset-4" onClick={() => browserLogin ? login?.retry() : setShowCommand(true)}>Use a different account</button>}
</> : !browserLogin && <p>{isolated ? `Sign in to ${provider} for this connection on the machine running Paperclip. Your existing terminal login stays separate.` : `Connect uses your local ${provider} account on the machine running Paperclip.`}</p>}
{browserLogin && !ready && !login?.authorizationUrl && !login?.error && <p role="status">Preparing browser sign-in…</p>}
{browserLogin && !ready && !login?.error && login?.authorizationUrl && <ProviderSubscriptionCard
providerName={connectSourceName(adapterType)}
authorizationUrl={login.authorizationUrl}
mode={adapterType === "claude_local" ? "submitted_code" : "displayed_code"}
>
{adapterType === "claude_local" ? <div className="flex flex-col gap-2">
<OnboardingCardField
value={browserCode}
onChange={setBrowserCode}
onSubmit={() => void submitBrowserCode()}
disabled={submitting}
/>
<Button type="button" disabled={!browserCode.trim() || submitting} onClick={() => void submitBrowserCode()}>Submit code</Button>
</div> : <OnboardingLoginCodeRow code={login.code ?? ""} />}
</ProviderSubscriptionCard>}
{!browserLogin && (!ready || showCommand) && !login?.error && <>
<p>Run this in a terminal on that machine and finish signing in in your browser. We’ll check automatically when you return.</p>
{command && <div className="flex min-w-0 max-w-full items-start gap-2 rounded-md border bg-muted p-3 text-foreground">
<pre className="min-w-0 flex-1 whitespace-pre-wrap break-all font-mono text-xs"><code>{command}</code></pre>
@@ -490,6 +521,6 @@ export function LocalProviderLoginInstructions({ adapterType, login }: {
</div>}
</>}
{login?.error && <p role="alert">{login.error}</p>}
{login && !login.preparing && (isolated || login.error) && <button type="button" className="underline underline-offset-4" onClick={login.retry}>{isolated ? "Start sign-in again" : "Check again"}</button>}
{login && !login.preparing && !ready && (isolated || login.error) && <button type="button" className="underline underline-offset-4" onClick={login.retry}>{isolated ? "Start sign-in again" : "Check again"}</button>}
</div>;
}
+38 -16
View File
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { act } from "react";
import type { LocalAiLoginStatus } from "@paperclipai/shared";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -16,8 +17,9 @@ const localHealth = vi.hoisted(() => ({ get: vi.fn() }));
vi.mock("@/api/health", () => ({ healthApi: localHealth }));
const managedApi = vi.hoisted(() => ({
list: vi.fn(async () => ({ currentUserId: "user-1", connections: [] })),
startLocalLogin: vi.fn(async () => ({ sessionId: "local-attempt", command: "CODEX_HOME='/fixture/login' codex login", expiresAt: "2026-09-11T20:00:00Z" })),
checkLocalLogin: vi.fn(async () => ({ status: "sign_in_required" as "sign_in_required" | "ready" })),
startLocalLogin: vi.fn(async () => ({ sessionId: "local-attempt", expiresAt: "2099-01-01T00:00:00Z" })),
submitLocalLoginCode: vi.fn(async () => ({ ok: true })),
checkLocalLogin: vi.fn(async (): Promise<LocalAiLoginStatus> => ({ status: "sign_in_required" })),
cancelLocalLogin: vi.fn(async () => ({})),
connectLocal: vi.fn(async () => ({ connectionId: "local-connection", grantId: "local-grant" })),
create: vi.fn(async () => ({ connectionId: "managed-connection", grantId: "managed-grant" })),
@@ -161,7 +163,7 @@ const mockProjectsApi = vi.hoisted(() => ({
// model/harness picker internals are out of scope here, so stub the adapter
// layer entirely and drive it through this knob.
const mockAdapterRegistry = vi.hoisted(() => ({
list: [] as Array<{ type: string }>,
list: [] as Array<{ type: string; recommended?: boolean }>,
disabled: new Set<string>(),
}));
@@ -198,7 +200,7 @@ vi.mock("../adapters/adapter-display-registry", () => ({
// then sat in the "Advanced settings" disclosure and was reachable anyway;
// with the step down to a tile row built from this flag, it made that row
// empty in every test and hid the surface under it.
recommended: type === "claude_local" || type === "codex_local",
recommended: type === "claude_local" || type === "codex_local" || mockAdapterRegistry.list.some(entry => entry.type === type && entry.recommended),
label: type,
description: "",
icon: () => null,
@@ -324,7 +326,11 @@ function isArcPrimary(text: string): boolean {
describe("OnboardingWizard restore-gate (stale localStorage across accounts)", () => {
beforeEach(() => {
localHealth.get.mockResolvedValue({ deploymentMode: "authenticated" });
managedApi.checkLocalLogin.mockReset().mockResolvedValue({ status: "sign_in_required" });
managedApi.checkLocalLogin.mockReset().mockResolvedValue({
status: "sign_in_required",
authorizationUrl: "https://claude.ai/oauth/authorize?code=true",
code: "TEST-CODE",
});
managedApi.connectLocal.mockReset().mockResolvedValue({ connectionId: "local-connection", grantId: "local-grant" });
mockAuthApi.getSession.mockResolvedValue({
session: { id: "session-b", userId: SESSION_USER_ID },
@@ -430,7 +436,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
async function clickByText(match: (text: string) => boolean) {
const el = [...document.body.querySelectorAll("button")].find((b) =>
match(b.textContent?.trim() ?? ""),
match(b.getAttribute("aria-label")?.startsWith("Use ") ? b.getAttribute("aria-label")! : b.textContent?.trim() ?? ""),
)!;
await act(async () => {
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@@ -521,7 +527,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
const clickText = async (match: (t: string) => boolean) => {
const el = [...document.body.querySelectorAll("button")].find((b) =>
match(b.textContent?.trim() ?? ""),
match(b.getAttribute("aria-label")?.startsWith("Use ") ? b.getAttribute("aria-label")! : b.textContent?.trim() ?? ""),
)!;
await act(async () => {
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@@ -598,7 +604,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
const clickText = async (match: (t: string) => boolean) => {
const el = [...document.body.querySelectorAll("button")].find((b) =>
match(b.textContent?.trim() ?? ""),
match(b.getAttribute("aria-label")?.startsWith("Use ") ? b.getAttribute("aria-label")! : b.textContent?.trim() ?? ""),
)!;
await act(async () => {
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@@ -777,7 +783,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
const clickByText = async (match: (text: string) => boolean) => {
const el = [...document.body.querySelectorAll("button")].find((b) =>
match(b.textContent?.trim() ?? ""),
match(b.getAttribute("aria-label")?.startsWith("Use ") ? b.getAttribute("aria-label")! : b.textContent?.trim() ?? ""),
)!;
await act(async () => {
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@@ -3063,7 +3069,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
const clickByText = async (match: (text: string) => boolean) => {
const el = [...document.body.querySelectorAll("button")].find((b) =>
match(b.textContent?.trim() ?? ""),
match(b.getAttribute("aria-label")?.startsWith("Use ") ? b.getAttribute("aria-label")! : b.textContent?.trim() ?? ""),
)!;
await act(async () => {
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@@ -3136,7 +3142,8 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
const { root, queryClient } = await openStep4({ adapterType });
try {
await pickSource(label);
expect(document.body.textContent).toContain("Run this in a terminal");
expect(document.body.textContent).toContain("Sign in to");
expect(document.body.textContent).not.toContain("Run this in a terminal");
expect(button("Connect").disabled).toBe(false);
await detected();
expectTesting();
@@ -3208,7 +3215,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
await settle();
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Run this in a terminal");
expect(document.body.textContent).toContain("Sign in to");
expect(button("Connect").disabled).toBe(false);
} finally { await act(async () => root.unmount()); }
});
@@ -3331,19 +3338,34 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
await settle();
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(document.body.textContent).not.toContain("is ready to work!");
if (navigation === "provider") expect(document.body.textContent).toContain("claude auth login");
if (navigation === "provider") expect(document.body.textContent).toContain("Preparing browser sign-in");
} finally { if (mounted) await act(async () => root.unmount()); }
});
});
it("shows local Claude instructions and saves its connection before hiring", async () => {
it("keeps Gemini local login out of managed Claude subscription setup", async () => {
localHealth.get.mockResolvedValue({ deploymentMode: "local_trusted" });
mockEnvironmentsApi.list.mockResolvedValue([LOCAL_ENVIRONMENT]);
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null });
mockAdapterRegistry.list.push({ type: "gemini_local", recommended: true });
const { root } = await openStep4({ adapterType: "gemini_local" });
try {
await pickSource(/Gemini|Google|gemini_local/);
expect(managedApi.startLocalLogin).not.toHaveBeenCalled();
expect(managedApi.connectLocal).not.toHaveBeenCalled();
expect(document.body.textContent).not.toContain("claude auth login");
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
} finally { await act(async () => root.unmount()); }
});
it("shows the Claude browser sign-in card and saves its connection before hiring", async () => {
localHealth.get.mockResolvedValue({ deploymentMode: "local_trusted" });
mockEnvironmentsApi.list.mockResolvedValue([LOCAL_ENVIRONMENT]);
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null });
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
expect(document.body.textContent).toContain("claude auth login");
expect(document.body.textContent).toContain("machine running Paperclip");
expect(document.body.textContent).toContain("Sign in to Claude then come back and enter authorization code");
expect(document.body.textContent).not.toContain("claude auth login");
expect(document.body.textContent).not.toContain("No managed sandbox");
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
const connect = [...document.body.querySelectorAll("button")].find(b => b.textContent?.trim().startsWith("Connect"));
+13 -15
View File
@@ -762,6 +762,7 @@ function OnboardingWizardInner({
const apiKeySecretRef = useRef<{ key: string; companyId: string; envKey: string; binding?: Awaited<ReturnType<typeof storeProviderApiKey>>["binding"]; aiConnection?: AiConnectionBinding } | null>(null);
const managedSubscriptionRef = useRef<{ companyId: string; binding: AiConnectionBinding } | null>(null);
const managedProvider = aiProviderForAdapter(adapterType);
const managedSubscriptionProvider = managedProvider === "anthropic" || managedProvider === "openai" || managedProvider === "xai" ? managedProvider : undefined;
function managedBindingForStep(): AiConnectionBinding | undefined {
if (credentialMode === "api") return selectedApiKey?.aiConnection ?? (
!selectedApiKey && apiKeySecretRef.current?.companyId === createdCompanyId && apiKeySecretRef.current.envKey === apiKeyEnvKeyFor(adapterType)
@@ -1014,14 +1015,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?.localAiLoginSupported ?? localLoginHealth.data?.deploymentMode === "local_trusted");
const canUseLocalLogin = Boolean(managedSubscriptionProvider) && resolvedLoginEnvironment?.driver === "local" && (localLoginHealth.data?.localAiLoginSupported ?? localLoginHealth.data?.deploymentMode === "local_trusted");
const localLogin = useLocalAiLogin(createdCompanyId, {
provider: managedProvider ?? "anthropic", method: "subscription",
provider: managedSubscriptionProvider ?? "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(),
{ allowHostClaude: localLoginHealth.data?.deploymentMode === "local_trusted" });
);
// A result from a previous selection must not hire or advance this wizard.
// Environment query updates are not user navigation: the test resolves its
// own environment, and those updates must not interrupt the pending attempt.
@@ -2815,7 +2816,7 @@ function OnboardingWizardInner({
adapterType={adapterType}
environmentId={resolvedLoginEnvironmentId}
chrome="onboarding"
aiConnection={managedProvider ? { provider: managedProvider, method: "subscription", name: `My ${CONNECT_SOURCE_NAMES[adapterType] ?? managedProvider} subscription`, ownership: "personal", agentIds: [], allAgents: true } : undefined}
aiConnection={managedProvider === "anthropic" || managedProvider === "openai" || managedProvider === "xai" ? { provider: managedProvider, method: "subscription", name: `My ${CONNECT_SOURCE_NAMES[adapterType] ?? managedProvider} subscription`, ownership: "personal", agentIds: [], allAgents: true } : undefined}
autoStart
onPromptReady={(url) => {
setConnectAuthUrl(url);
@@ -2850,7 +2851,7 @@ function OnboardingWizardInner({
);
}}
onConnected={() => {
if (managedProvider) managedSubscriptionRef.current = { companyId: createdCompanyId, binding: { provider: managedProvider, method: "subscription", mode: "responsible_user" } };
if (managedSubscriptionProvider) managedSubscriptionRef.current = { companyId: createdCompanyId, binding: { provider: managedSubscriptionProvider, method: "subscription", mode: "responsible_user" } };
setConnectAuthUrl(null);
// Not into a card the customer has left. The panel is
// still mounted through Back's exit, and a login that
@@ -2990,8 +2991,11 @@ function OnboardingWizardInner({
Prompt:{" "}
<span className="font-mono">Respond with hello.</span>
</p>
{adapterType === "cursor" ||
adapterType === "codex_local" ||
{adapterType === "claude_local" || adapterType === "codex_local" ? (
<p className="text-muted-foreground">
If authentication fails, connect your subscription in the browser above or use an API key.
</p>
) : adapterType === "cursor" ||
adapterType === "gemini_local" ||
adapterType === "kimi_local" ||
adapterType === "opencode_local" ? (
@@ -3010,9 +3014,7 @@ function OnboardingWizardInner({
<span className="font-mono">
{adapterType === "cursor"
? "agent login"
: adapterType === "codex_local"
? "codex login"
: adapterType === "gemini_local"
: adapterType === "gemini_local"
? "gemini auth"
: adapterType === "kimi_local"
? "kimi login"
@@ -3021,11 +3023,7 @@ function OnboardingWizardInner({
.
</p>
) : (
<p className="text-muted-foreground">
If login is required, run{" "}
<span className="font-mono">claude login</span>{" "}
and retry.
</p>
<p className="text-muted-foreground">If login is required, connect the provider above and retry.</p>
)}
</div>
)}
@@ -74,7 +74,7 @@ function SubscriptionConnectionStep({ companyId, provider, initialMethod, fixedM
const canLogin = environment?.driver === "sandbox" && caps.data?.sandboxProviders?.[sandboxProvider]?.supportsLoginPty === true;
const loading = [envs, caps, settings, experimental, general].some((query) => query.isPending);
const error = environmentError ?? [envs, caps, settings, experimental, general].find((query) => query.error)?.error?.message;
const intent: AiConnectionLoginIntent = { provider, method: "subscription", name, ownership, agentIds, allAgents, connectionId };
const intent: AiConnectionLoginIntent = { provider: provider as AiConnectionLoginIntent["provider"], method: "subscription", name, ownership, agentIds, allAgents, connectionId };
return <div className="mx-auto w-full min-w-0 max-w-xl space-y-6">
{!hideName && <label className="block space-y-2 text-sm">Connection name<Input value={name} onChange={(event) => setName(event.target.value)} disabled={Boolean(connectionId)} /></label>}
{!suppliedEnvironmentId && !forced.forced && loginEnvironments.length > 1 && <Select value={environmentId ?? ""} onValueChange={setChosenEnvironment}>
@@ -7,6 +7,7 @@ export const AI_PROVIDERS: Record<
AiProvider,
{ name: string; subscriptionName?: string; logo?: string }
> = {
google: { name: "Google", logo: "/brands/apps/google.svg" },
anthropic: {
name: "Claude",
subscriptionName: "Claude subscription",
@@ -6,26 +6,27 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { useLocalAiLogin } from "./useLocalAiLogin";
import { LocalProviderLoginInstructions } from "../AdapterLoginChrome";
const api = vi.hoisted(() => ({ startLocalLogin: vi.fn(), checkLocalLogin: vi.fn(), cancelLocalLogin: vi.fn(), connectLocal: vi.fn() }));
const api = vi.hoisted(() => ({ startLocalLogin: vi.fn(), checkLocalLogin: vi.fn(), submitLocalLoginCode: vi.fn(), cancelLocalLogin: vi.fn(), connectLocal: vi.fn() }));
vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: api }));
let root: ReturnType<typeof createRoot>;
let host: HTMLDivElement;
beforeEach(() => {
vi.resetAllMocks();
api.startLocalLogin.mockImplementation(async () => ({ sessionId: "attempt-1", command: "isolated codex login", expiresAt: "2099-01-01T00:00:00Z" }));
api.checkLocalLogin.mockResolvedValue({ status: "sign_in_required" });
api.startLocalLogin.mockImplementation(async () => ({ sessionId: "attempt-1", expiresAt: "2099-01-01T00:00:00Z" }));
api.checkLocalLogin.mockResolvedValue({ status: "sign_in_required", authorizationUrl: "https://auth.openai.com/codex/device", code: "ABCD-12345" });
api.submitLocalLoginCode.mockResolvedValue({ ok: true });
api.cancelLocalLogin.mockResolvedValue({});
api.connectLocal.mockResolvedValue({ connectionId: "connection", grantId: "grant" });
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
});
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, { allowHostClaude: true });
const login = useLocalAiLogin("company", { provider, method: "subscription", name, ownership: "personal", agentIds: [], allAgents: true }, enabled);
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 () => {
flushSync(() => root.render(<StrictMode><Harness name="First name" /></StrictMode>));
await vi.waitFor(() => expect(host.textContent).toContain("isolated codex login"));
await vi.waitFor(() => expect(host.textContent).toContain("ABCD-12345"));
expect(api.startLocalLogin).toHaveBeenCalledTimes(1);
expect(api.checkLocalLogin).toHaveBeenCalledTimes(1);
expect(api.cancelLocalLogin).not.toHaveBeenCalled();
@@ -33,7 +34,7 @@ it("checks once under StrictMode, preserves renaming and navigation, and cancels
expect(api.startLocalLogin).toHaveBeenCalledTimes(1);
flushSync(() => root.render(<StrictMode><Harness name="Renamed" enabled={false} /></StrictMode>));
flushSync(() => root.render(<StrictMode><Harness name="Renamed" /></StrictMode>));
await vi.waitFor(() => expect(host.textContent).toContain("isolated codex login"));
await vi.waitFor(() => expect(host.textContent).toContain("ABCD-12345"));
expect(api.cancelLocalLogin).not.toHaveBeenCalled();
flushSync(() => Array.from(host.querySelectorAll('button')).find(b => b.textContent === 'Connect')!.click());
await vi.waitFor(() => expect(api.connectLocal).toHaveBeenCalledWith("company", expect.objectContaining({ name: "Renamed", localSessionId: "attempt-1" })));
@@ -45,24 +46,24 @@ it("checks once under StrictMode, preserves renaming and navigation, and cancels
it.each(["anthropic", "openai"] as const)("detects an already-signed-in %s account before showing instructions, and does not save it until Connect", async provider => {
api.checkLocalLogin.mockResolvedValue({ status: "ready" });
flushSync(() => root.render(<Harness provider={provider} />));
expect(host.textContent).toContain("Checking local");
expect(host.textContent).toContain("Preparing sign-in");
await vi.waitFor(() => expect(host.textContent).toContain("is signed in"));
expect(host.textContent).not.toContain("Run this in a terminal");
expect(api.connectLocal).not.toHaveBeenCalled();
if (provider === "anthropic") expect(api.startLocalLogin).not.toHaveBeenCalled();
expect(api.startLocalLogin).toHaveBeenCalledTimes(1);
});
it("detects terminal completion on focus without needing a Connect attempt", async () => {
it("detects browser completion on focus without needing a Connect attempt", async () => {
flushSync(() => root.render(<Harness />));
await vi.waitFor(() => expect(host.textContent).toContain("isolated codex login"));
await vi.waitFor(() => expect(host.textContent).toContain("ABCD-12345"));
api.checkLocalLogin.mockResolvedValue({ status: "ready" });
window.dispatchEvent(new Event('focus'));
await vi.waitFor(() => expect(host.textContent).toContain("is signed in"));
expect(host.textContent).not.toContain("isolated codex login");
expect(host.textContent).not.toContain("ABCD-12345");
expect(api.connectLocal).not.toHaveBeenCalled();
});
it("keeps a copied command's attempt alive after leaving the page", async () => {
it("keeps a browser sign-in attempt alive after leaving the page", async () => {
flushSync(() => root.render(<Harness />));
await vi.waitFor(() => expect(host.textContent).toContain("isolated codex login"));
await vi.waitFor(() => expect(host.textContent).toContain("ABCD-12345"));
flushSync(() => root.render(<div>Another page</div>));
await new Promise(resolve => setTimeout(resolve, 10));
expect(api.cancelLocalLogin).not.toHaveBeenCalled();
@@ -76,6 +77,32 @@ it("explicit retry can replace an attempt opened in another authentication host"
flushSync(() => root.render(<Harness />));
await vi.waitFor(() => expect(host.textContent).toContain("Another sign-in"));
flushSync(() => Array.from(host.querySelectorAll('button')).find(b => b.textContent === 'Start sign-in again')!.click());
await vi.waitFor(() => expect(host.textContent).toContain("isolated codex login"));
await vi.waitFor(() => expect(host.textContent).toContain("ABCD-12345"));
expect(api.startLocalLogin).toHaveBeenLastCalledWith("company", expect.objectContaining({ restart: true }));
});
it("submits a Claude browser code through the owned local attempt", async () => {
api.checkLocalLogin.mockResolvedValue({ status: "sign_in_required", authorizationUrl: "https://claude.ai/oauth/authorize?client_id=test" });
flushSync(() => root.render(<Harness provider="anthropic" />));
await vi.waitFor(() => expect(host.querySelector('input[aria-label="Authorization code"]')).not.toBeNull());
const input = host.querySelector('input[aria-label="Authorization code"]') as HTMLInputElement;
flushSync(() => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "fixture-code");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
flushSync(() => Array.from(host.querySelectorAll("button")).find((button) => button.textContent === "Submit code")?.click());
await vi.waitFor(() => expect(api.submitLocalLoginCode).toHaveBeenCalledWith("company", "attempt-1", "fixture-code"));
expect(host.textContent).not.toContain("claude auth login");
});
it("hides a failed device prompt and lets the user restart sign-in", async () => {
api.checkLocalLogin.mockResolvedValue({ status: "sign_in_required", authorizationUrl: "https://auth.openai.com/codex/device", code: "OLD-CODE", error: "Sign-in ended. Start sign-in again." });
flushSync(() => root.render(<Harness />));
await vi.waitFor(() => expect(host.querySelector('[role="alert"]')?.textContent).toContain("Sign-in ended"));
expect(host.textContent).not.toContain("OLD-CODE");
expect(host.querySelector('a[href="https://auth.openai.com/codex/device"]')).toBeNull();
api.checkLocalLogin.mockResolvedValue({ status: "sign_in_required", authorizationUrl: "https://auth.openai.com/codex/device", code: "NEW-CODE" });
flushSync(() => Array.from(host.querySelectorAll("button")).find(b => b.textContent === "Start sign-in again")!.click());
await vi.waitFor(() => expect(host.textContent).toContain("NEW-CODE"));
expect(host.querySelector('[role="alert"]')).toBeNull();
});
@@ -3,11 +3,13 @@ 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, options: { allowHostClaude?: boolean } = {}) {
const isolated = intent.provider !== "anthropic" || !options.allowHostClaude;
export function useLocalAiLogin(companyId: string | null, intent: AiConnectionLoginIntent, enabled: boolean, _legacyOptions?: { allowHostClaude?: boolean }) {
const isolated = true;
const active = Boolean(companyId && enabled);
const [attempt, setAttempt] = useState<LocalAiLoginAttempt | null>(null);
const [status, setStatus] = useState<LocalAiLoginStatus["status"] | null>(null);
const [authorizationUrl, setAuthorizationUrl] = useState<string | null>(null);
const [code, setCode] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [generation, setGeneration] = useState(0);
const latestIntent = useRef(intent);
@@ -27,6 +29,8 @@ export function useLocalAiLogin(companyId: string | null, intent: AiConnectionLo
setAttempt(null);
setError(null);
setStatus(null);
setAuthorizationUrl(null);
setCode(null);
if (!active || !companyId) return;
let cancelled = false;
let checking = false;
@@ -54,9 +58,11 @@ export function useLocalAiLogin(companyId: string | null, intent: AiConnectionLo
});
if (cancelled) return;
setStatus(next.status);
setError(next.status === "expired" ? "This sign-in attempt expired. Start sign-in again." : null);
// Stop polling a verified account. Focus still rechecks after a terminal
// visit; awaiting terminal login never requires repeated Connect clicks.
setAuthorizationUrl(next.authorizationUrl ?? null);
setCode(next.code ?? null);
setError(next.error ?? (next.status === "expired" ? "This sign-in attempt expired. Start sign-in again." : null));
// Stop polling a verified account. Focus rechecks after the provider
// browser visit; sign-in never requires repeated Connect clicks.
if (next.status === "sign_in_required") timer = setTimeout(() => void check(), 5000);
} catch (cause) {
if (!cancelled) setError(cause instanceof Error ? cause.message : "Could not check local sign-in.");
@@ -72,17 +78,27 @@ export function useLocalAiLogin(companyId: string | null, intent: AiConnectionLo
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onFocus);
// Navigation is not cancellation. The server resumes this bounded attempt
// when the user returns and reaps abandoned attempts after expiry. Deleting
// here made copied CODEX_HOME commands point at nonexistent directories.
// when the user returns and reaps abandoned attempts after expiry.
};
}, [companyId, active, isolated, target, generation]);
return {
isolated,
command: attempt?.command,
authorizationUrl,
code,
status,
preparing: active && !status && !error,
error,
retry: () => { restartRequested.current = true; cancelCurrent(); setGeneration((value) => value + 1); },
submitCode: async (browserCode: string) => {
if (!companyId || !attempt) throw new Error("Start sign-in before submitting a code.");
try {
await aiConnectionsApi.submitLocalLoginCode(companyId, attempt.sessionId, browserCode);
} catch (cause) {
setError("Could not submit the authorization code. Start sign-in again.");
throw cause;
}
},
connect: (input = intent) => {
if (!companyId) throw new Error("Choose a company before connecting.");
if (isolated && !attempt) throw new Error("Prepare local sign-in before connecting.");
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import type { LocalAiLoginStatus } from "@paperclipai/shared";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -18,7 +19,7 @@ const managedApi = vi.hoisted(() => ({
loginResult: vi.fn(async () => ({ connectionId: "login-account", grantId: "login-grant" })),
connectLocal: vi.fn(async () => ({ connectionId: "local-account", grantId: "local-grant" })),
startLocalLogin: vi.fn(async () => ({ sessionId: "local-attempt", command: "CODEX_HOME='/fixture/isolated-login' codex login", expiresAt: "2026-09-11T20:00:00Z" })),
checkLocalLogin: vi.fn(async () => ({ status: "sign_in_required" as const })),
checkLocalLogin: vi.fn(async (): Promise<LocalAiLoginStatus> => ({ status: "sign_in_required" })),
cancelLocalLogin: vi.fn(async () => ({})),
create: vi.fn(async () => ({ connectionId: "managed-connection", grantId: "managed-grant" })),
}));
@@ -234,14 +235,12 @@ describe("AgentProviderConnection reuse", () => {
});
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" });
managedApi.checkLocalLogin.mockResolvedValue({ status: "ready" });
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");
await vi.waitFor(() => expect(host.textContent).toContain("is signed in"));
expect(host.textContent).not.toMatch(/CLAUDE_CONFIG_DIR|CODEX_HOME|Run this in a terminal/);
expect(managedApi.startLocalLogin).toHaveBeenCalledWith("c1", intent);
expect(managedApi.checkLocalLogin).toHaveBeenCalledWith("c1", { ...intent, localSessionId: "local-attempt" });
click("Connect");
@@ -250,25 +249,27 @@ describe("AgentProviderConnection reuse", () => {
});
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();
managedApi.checkLocalLogin.mockResolvedValue({ status: "ready" });
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 };
await mount(adapterType, false, false, false, false, false, { intent, onComplete }, true);
openProvider();
await vi.waitFor(() => expect(host.textContent).toContain(adapterType === "claude_local" ? "claude auth login" : "codex login"));
expect(host.textContent).toContain("machine running Paperclip");
await vi.waitFor(() => expect(host.textContent).toContain("is signed in"));
expect(host.textContent).not.toMatch(/claude auth login|codex login|CODEX_HOME/);
expect(host.textContent).not.toContain("sandbox");
managedApi.connectLocal.mockRejectedValueOnce(new Error("Run local login and try again"));
click("Connect");
await vi.waitFor(() => expect(host.textContent).toContain("Run local login and try again"));
expect(onComplete).not.toHaveBeenCalled();
if (adapterType === "codex_local") {
click("Start sign-in again");
expect(host.textContent).not.toContain("Start sign-in again");
click("Use a different account");
await vi.waitFor(() => expect(host.textContent).not.toContain("Run local login and try again"));
await vi.waitFor(() => expect(managedApi.cancelLocalLogin).toHaveBeenCalledWith("c1", "local-attempt"));
await vi.waitFor(() => expect(host.textContent).toContain("codex login"));
await vi.waitFor(() => expect(host.textContent).toContain("is signed in"));
}
click("Connect");
await vi.waitFor(() => expect(onComplete).toHaveBeenCalledWith({ connectionId: "local-account", grantId: "local-grant", method: "subscription" }));
expect(managedApi.connectLocal).toHaveBeenCalledWith("c1", adapterType === "codex_local" ? { ...intent, localSessionId: "local-attempt" } : intent);
expect(managedApi.connectLocal).toHaveBeenCalledWith("c1", { ...intent, localSessionId: "local-attempt" });
expect(mocks.loginPanel).not.toHaveBeenCalled();
});
it("leaves a completed local account saved when its host is cancelled", async () => {