fix(connections): recover browser logins and isolate Hermes gateway keys

Allow a personal gateway to be saved before a new agent exists. Update the new-agent connection selector regression.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-06 10:39:50 -05:00
1 parent 586950c9c9
commit fe1da1ca25
8 files changed
+78 -12

No files matched your search

@@ -1,6 +1,7 @@
import { connectionIntentService } from "../services/connection-intents.js";
import { connectionIntentDeliveryService } from "../services/connection-intent-delivery.js";
import { issueRecoveryActionService } from "../services/issue-recovery-actions.js";
import { localAiLoginService } from "../services/local-ai-login.js";
import * as localCredentials from "../services/local-ai-credentials.js";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
@@ -793,6 +794,21 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
expect(network).not.toHaveBeenCalled();
} finally { network.mockRestore(); }
});
it.each(["anthropic", "openai"] as const)("reports a missing %s browser process after a server restart", async provider => {
const [environment] = await db.select().from(environments).where(eq(environments.driver, "local")).limit(1);
const id = randomUUID();
const owner = `restart-${provider}`;
const intent = { provider, method: "subscription", ownership: "personal", name: "Interrupted sign-in", allAgents: false, agentIds: [] } as const;
await db.insert(adapterAuthSessions).values({ id, publicSessionId: id, companyId, environmentId: environment.id, startedByUserId: owner, adapterType: provider === "anthropic" ? "claude_local" : "codex_local", aiConnection: { ...intent, agentIds: [] }, connectionMethod: "local_subscription", status: "waiting_for_user", expiresAt: new Date(Date.now() + 60_000) });
const reader = vi.spyOn(localCredentials, "readVerifiedLocalAiCredential").mockRejectedValue(new Error("No credential yet"));
try {
const login = localAiLoginService(db);
await expect(login.check(companyId, owner, { ...intent, agentIds: [] }, id)).resolves.toEqual({ status: "sign_in_required", error: "The server restarted during sign-in. Start sign-in again." });
await expect(login.check(companyId, "bob", { ...intent, agentIds: [] }, id)).rejects.toThrow("not found");
reader.mockResolvedValue("completed-before-restart");
await expect(login.check(companyId, owner, { ...intent, agentIds: [] }, id)).resolves.toEqual({ status: "ready" });
} finally { reader.mockRestore(); await db.delete(adapterAuthSessions).where(eq(adapterAuthSessions.id, id)); }
});
it("requires an owned browser sign-in attempt for local subscriptions", async () => {
const reader = vi.spyOn(localCredentials, "readVerifiedLocalAiCredential").mockResolvedValue("fixture-local-token");
const app = express();
@@ -84,6 +84,15 @@ describe("provider routing", () => {
),
).toBe(false);
});
it.each(["gateway", "openrouter"] as const)("uses a terminal-filtered secret variable for Hermes %s authentication", kind => {
const route = aiProviderRoutingSchema.parse({ kind, protocol: "chat", auth: "bearer", ...(kind === "gateway" ? { baseUrl: "https://gateway.example/v1" } : {}) });
const projected = managedProviderRouting(route, "hermes_local", "selected-key", "fixture-model");
expect(projected.env.OPENAI_API_KEY).toBe("selected-key");
expect(projected.env.PAPERCLIP_AI_PROVIDER_KEY).toBeUndefined();
expect(projected.hermesConfig).toContain('api_key: "${OPENAI_API_KEY}"');
expect(projected.hermesConfig).not.toContain("selected-key");
expect(Object.entries(projected.env).filter(([, value]) => value === "selected-key").map(([name]) => name)).toEqual(kind === "openrouter" ? ["OPENAI_API_KEY", "OPENROUTER_API_KEY"] : ["OPENAI_API_KEY"]);
});
it("routes OpenRouter through each harness's protocol without putting secrets in Codex config", () => {
const routing = aiProviderRoutingSchema.parse({
kind: "openrouter",
+3 -2
View File
@@ -68,8 +68,9 @@ export function managedProviderRouting(
env.OPENROUTER_API_KEY = route.kind === "openrouter" ? credential : "";
config.provider = route.kind === "openrouter" ? "openrouter" : "auto";
// Hermes now reads custom endpoint routing from config.yaml, not OPENAI_BASE_URL.
env.PAPERCLIP_AI_PROVIDER_KEY = credential;
hermesConfig = `model:\n provider: ${route.kind === "openrouter" ? '"openrouter"' : '"custom"'}\n default: ${JSON.stringify(model)}\n base_url: ${JSON.stringify(baseUrl)}\n api_mode: "chat_completions"\n${route.auth === "none" ? "" : ' api_key: "${PAPERCLIP_AI_PROVIDER_KEY}"\n'}`;
// Hermes strips OPENAI_API_KEY from terminal children. A custom variable
// would expose the reusable gateway key to task-controlled shell commands.
hermesConfig = `model:\n provider: ${route.kind === "openrouter" ? '"openrouter"' : '"custom"'}\n default: ${JSON.stringify(model)}\n base_url: ${JSON.stringify(baseUrl)}\n api_mode: "chat_completions"\n${route.auth === "none" ? "" : ' api_key: "${OPENAI_API_KEY}"\n'}`;
}
return { env, config, codexConfig, hermesConfig };
}
@@ -61,8 +61,8 @@ describe.skipIf(process.platform === "win32")("local browser subscription login"
let pid = 0;
await vi.waitFor(async () => { pid = Number(await readFile(path.join(home, "login.pid"), "utf8")); expect(pid).toBeGreaterThan(0); });
login.abort();
await vi.waitFor(() => expect(login.outcome).toBe("failure"));
await vi.waitFor(() => expect(() => process.kill(pid, 0)).toThrow());
await vi.waitFor(() => expect(login.outcome).toBe("failure"), { timeout: 5000 });
await vi.waitFor(() => expect(() => process.kill(pid, 0)).toThrow(), { timeout: 5000 });
});
it("cancels Claude while waiting for its browser code and terminates the provider", async () => {
@@ -77,15 +77,15 @@ describe.skipIf(process.platform === "win32")("local browser subscription login"
await vi.waitFor(() => expect(login.authorizationUrl).toBeDefined());
const pid = Number(await readFile(path.join(home, "login.pid"), "utf8"));
login.abort();
await vi.waitFor(() => expect(login.outcome).toBe("failure"));
await vi.waitFor(() => expect(() => process.kill(pid, 0)).toThrow());
await vi.waitFor(() => expect(login.outcome).toBe("failure"), { timeout: 5000 });
await vi.waitFor(() => expect(() => process.kill(pid, 0)).toThrow(), { timeout: 5000 });
await expect(readFile(path.join(home, ".credentials.json"))).rejects.toThrow();
});
it("returns a fixed failure without exposing provider error output", async () => {
const home = await fakeCli("codex", 'echo "private-provider-error" >&2\nexit 1');
const login = startLocalBrowserLogin("openai", home);
await vi.waitFor(() => expect(login.outcome).toBe("failure"));
await vi.waitFor(() => expect(login.outcome).toBe("failure"), { timeout: 5000 });
expect(JSON.stringify(login)).not.toContain("private-provider-error");
expect(login.authorizationUrl).toBeUndefined();
});
+5 -1
View File
@@ -168,7 +168,11 @@ export function localAiLoginService(db: Db) {
} catch {
const login = id ? browserLogins.get(id) : undefined;
return { status: "sign_in_required", authorizationUrl: login?.authorizationUrl, code: login?.code,
...(login?.outcome === "failure" ? { error: "Sign-in ended before the account was connected. Start sign-in again." } : {}) };
...(!login && intent.provider !== "xai"
? { error: "The server restarted during sign-in. Start sign-in again." }
: login?.outcome === "failure"
? { error: "Sign-in ended before the account was connected. Start sign-in again." }
: {}) };
}
}
@@ -97,3 +97,28 @@ it("saves the default organization grant and all-agent installation from the dir
await act(async () => root.unmount()); client.clear();
}
});
it("allows an ordinary member to save a personal gateway before the agent exists", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
client.setQueryData(["ai-connections", "company", undefined], { canManageConnections: false, connections: [] });
client.setQueryData(["agents", "company", "provider-access"], []);
const container = document.createElement("div");
const root = createRoot(container);
const onComplete = vi.fn();
try {
await act(async () => root.render(<QueryClientProvider client={client}><AiProviderSetup companyId="company" initialProvider="openrouter" onCancel={() => {}} onComplete={onComplete} /></QueryClientProvider>));
const input = container.querySelector('input[aria-label="API key"]')!;
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "member-key");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
const connect = container.querySelector('button[type="submit"]') as HTMLButtonElement;
expect(connect.disabled).toBe(false);
await act(async () => container.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })));
expect(aiConnectionsApi.create).toHaveBeenCalledWith("company", expect.objectContaining({ ownership: "personal", allAgents: false, agentIds: [], apiKey: "member-key" }));
expect(onComplete).toHaveBeenCalledWith(expect.objectContaining({ mode: "delegated", connectionId: "connection", grantId: "grant" }));
} finally {
await act(async () => root.unmount()); client.clear();
}
});
@@ -450,7 +450,7 @@ export function AiProviderSetup({
type="submit"
disabled={
save.isPending || accounts.isPending || accounts.isError ||
(!reconnect && !allAgents && agentIds.size === 0) ||
(!reconnect && ownership === "shared" && !allAgents && agentIds.size === 0) ||
(auth !== "none" && !apiKey.trim())
}
>
+14 -3
View File
@@ -528,11 +528,22 @@ describe("New agent setup", () => {
await render("opencode_local");
const model = "openrouter/anthropic/claude-sonnet-4.6";
await fill("Model", model);
await click("Connect another account");
const connectionSelect = container.querySelector('[role="combobox"][aria-label="Connection"]')!;
await act(async () => connectionSelect.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })));
await settle();
const connectOption = [...document.querySelectorAll('[role="option"]')].find(option => option.textContent?.includes("Connect an account"))!;
expect(connectOption).toBeTruthy();
await act(async () => (connectOption as HTMLElement).click());
await settle();
const dialog = document.querySelector('[role="dialog"]')!;
expect(dialog).toBeTruthy();
expect(api.hire).not.toHaveBeenCalled();
expect(api.testEnvironment).not.toHaveBeenCalled();
const advanced = [...dialog.querySelectorAll("summary")].find(node => node.textContent?.includes("Advanced providers"))!;
await act(async () => advanced.click());
const openrouter = [...dialog.querySelectorAll("button")].find(node => node.textContent?.includes("OpenRouter"))!;
await act(async () => openrouter.click());
await settle();
const input = dialog.querySelector('[aria-label="API key"]') as HTMLInputElement;
expect(input).toBeTruthy();
await act(async () => {
@@ -544,11 +555,11 @@ describe("New agent setup", () => {
await act(async () => connectButton.click());
await settle();
expect(document.querySelector('[role="dialog"]')).toBeNull();
expect(managedApi.setDefault).toHaveBeenCalledWith("company-1", "managed-grant");
expect(managedApi.setDefault).not.toHaveBeenCalled();
expect(managedApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
provider: "openrouter", method: "api_key", apiKey: "example-test-secret",
}));
const binding = { provider: "openrouter", method: "api_key", mode: "responsible_user" };
const binding = { provider: "openrouter", method: "api_key", mode: "delegated", connectionId: "managed-connection", grantId: "managed-grant" };
await click("Run test");
expect(api.testEnvironment.mock.calls[0][2]).toEqual(expect.objectContaining({
aiConnection: binding, testCredentials: {},