mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 22:24:16 +01:00
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:
1 parent
ab56046a8e
commit
fa9b3d22a1
23 files changed
+229
-290
No files matched your search
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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)"
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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`),
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user