mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
fix(connections): secure Grok history and repair no-auth and login cancellation
Store Grok history encrypted on the authorized task checkpoint. Restore regular files into a temporary home without a shared host symlink. Permit no-auth routes without a vault credential and keep their session identity stable. Abort pending Claude browser-code input so cancellation and timeout can dispose the login process. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
88e35f27fe
commit
c00dbd8ffe
7 files changed
+183
-38
No files matched your search
@@ -26,7 +26,7 @@ export async function grokHomeHasSession(home: string, sessionId: string): Promi
|
||||
throw error;
|
||||
}
|
||||
for await (const entry of entries) {
|
||||
if (--remaining < 0) throw new Error("Grok session history exceeds its lookup bound");
|
||||
if (--remaining < 0) return false;
|
||||
if (entry.isSymbolicLink()) continue;
|
||||
if ((entry.isFile() || entry.isDirectory()) &&
|
||||
(entry.name === sessionId || entry.name.startsWith(`${sessionId}.`))) return true;
|
||||
|
||||
@@ -4,11 +4,11 @@ import { issueRecoveryActionService } from "../services/issue-recovery-actions.j
|
||||
import * as localCredentials from "../services/local-ai-credentials.js";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, realpath, rm, access, readFile, writeFile, stat, readlink, readdir } from "node:fs/promises";
|
||||
import { mkdtemp, realpath, rm, access, readFile, writeFile, stat, readdir, symlink } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { createDb, companies, agents, heartbeatRuns, companyMemberships, connectionGrants, toolApplications, connectionGrantDelegations, connectionGrantMembers, toolConnections, toolConnectionInstalls, aiConnectionDefaults, aiProviderDefaults, adapterAuthSessions, environments, issues, issueThreadInteractions, issueRecoveryActions, connectionIntentDeliveries, agentWakeupRequests, companySecrets, principalPermissionGrants } from "@paperclipai/db";
|
||||
import { createDb, companies, agents, agentTaskSessions, heartbeatRuns, companyMemberships, connectionGrants, toolApplications, connectionGrantDelegations, connectionGrantMembers, toolConnections, toolConnectionInstalls, aiConnectionDefaults, aiProviderDefaults, adapterAuthSessions, environments, issues, issueThreadInteractions, issueRecoveryActions, connectionIntentDeliveries, agentWakeupRequests, companySecrets, principalPermissionGrants } from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "@paperclipai/db/test-embedded-postgres";
|
||||
import { aiConnectionService } from "../services/ai-connections.js";
|
||||
import * as executionTarget from "@paperclipai/adapter-utils/execution-target";
|
||||
@@ -50,29 +50,68 @@ beforeAll(async () => {
|
||||
afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); if (home) await rm(home, { recursive: true, force: true }); });
|
||||
|
||||
describe("managed AI connections", () => {
|
||||
it("retains only Grok sessions across private runtime homes and isolates agents and accounts", async () => {
|
||||
it.each([
|
||||
["openai", "codex_local", "responses"],
|
||||
["anthropic", "claude_local", "anthropic_messages"],
|
||||
["openrouter", "opencode_local", "chat_completions"],
|
||||
] as const)("prepares a no-auth %s endpoint without a vault secret", async (provider, adapterType, protocol) => {
|
||||
const account = await service.save(companyId, "alice", {
|
||||
provider, method: "api_key", ownership: "personal", name: `No-auth ${provider}`,
|
||||
agentIds: [], allAgents: true,
|
||||
routing: { kind: "local", protocol, baseUrl: "http://127.0.0.1:9000/v1", auth: "none" },
|
||||
}, "");
|
||||
const binding = { provider, method: "api_key", mode: "connection", connectionId: account.connectionId, grantId: account.grantId } as const;
|
||||
const config = { model: "fixture-model", env: { OPENAI_API_KEY: "host-key", ANTHROPIC_API_KEY: "host-key" } };
|
||||
const first = await prepareManagedAiRuntime(db, { ...input, adapterType, responsibleUserId: "alice", binding, config });
|
||||
const second = await prepareManagedAiRuntime(db, { ...input, adapterType, responsibleUserId: "alice", binding, config });
|
||||
try {
|
||||
expect(first.sessionIdentity).toBe(second.sessionIdentity);
|
||||
expect(first.sessionIdentity).toContain("no-auth");
|
||||
expect(JSON.stringify(first.config)).not.toContain("host-key");
|
||||
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, account.grantId));
|
||||
expect(grant.credentialSecretRefs).toEqual([]);
|
||||
} finally { await Promise.all([first.cleanup(), second.cleanup()]); }
|
||||
});
|
||||
it("encrypts retained Grok history and restores only the authorized agent, task, and account", async () => {
|
||||
const save = (name: string) => service.save(companyId, "alice", { provider: "xai", method: "api_key", ownership: "personal", name, agentIds: [], allAgents: true }, `fixture-${name}`);
|
||||
const account = await save("Grok history");
|
||||
const other = await save("Other Grok history");
|
||||
const runInput = { ...input, adapterType: "grok_local", responsibleUserId: "alice", config: {}, binding: { provider: "xai", method: "api_key", mode: "connection", connectionId: account.connectionId, grantId: account.grantId } as const };
|
||||
const taskKey = `grok-history-${randomUUID()}`;
|
||||
await db.insert(agentTaskSessions).values({ companyId, agentId, adapterType: "grok_local", taskKey, sessionParamsJson: {} });
|
||||
const runInput = { ...input, taskKey, adapterType: "grok_local", responsibleUserId: "alice", config: {}, binding: { provider: "xai", method: "api_key", mode: "connection", connectionId: account.connectionId, grantId: account.grantId } as const };
|
||||
const first = await prepareManagedAiRuntime(db, runInput);
|
||||
const sessions = await readlink(path.join(String(first.config.env.GROK_HOME), "sessions"));
|
||||
const sessions = path.join(String(first.config.env.GROK_HOME), "sessions");
|
||||
expect((await stat(sessions)).isDirectory()).toBe(true);
|
||||
await writeFile(path.join(sessions, "history.json"), '{"fixture":"history"}', { mode: 0o600 });
|
||||
// Never retain a symlink to another agent's transcript or a credential file.
|
||||
const outside = path.join(home, "sibling-transcript.json");
|
||||
await writeFile(outside, "sibling-private-history", { mode: 0o600 });
|
||||
await symlink(outside, path.join(sessions, "sibling-link.json"));
|
||||
await writeFile(path.join(String(first.config.env.GROK_HOME), "auth.json"), "disposable-auth", { mode: 0o600 });
|
||||
const checkpoint = await first.checkpointSessionHistory!();
|
||||
await db.update(agentTaskSessions).set({ sessionParamsJson: { sessionId: "history", ...checkpoint } }).where(eq(agentTaskSessions.taskKey, taskKey));
|
||||
await first.cleanup();
|
||||
await expect(access(first.home!)).rejects.toThrow();
|
||||
expect(await readdir(sessions)).toEqual(["history.json"]);
|
||||
expect((await stat(sessions)).mode & 0o777).toBe(0o700);
|
||||
await expect(access(sessions)).rejects.toThrow();
|
||||
const [stored] = await db.select().from(agentTaskSessions).where(eq(agentTaskSessions.taskKey, taskKey));
|
||||
expect(JSON.stringify(stored.sessionParamsJson)).not.toContain("fixture");
|
||||
expect(JSON.stringify(stored.sessionParamsJson)).not.toContain("sibling-private-history");
|
||||
expect(stored.sessionParamsJson?.paperclipGrokHistory).toMatchObject({ material: { scheme: "local_encrypted_v1" } });
|
||||
const resumed = await prepareManagedAiRuntime(db, runInput);
|
||||
const otherAgent = await prepareManagedAiRuntime(db, { ...runInput, agentId: randomUUID() });
|
||||
const otherAccount = await prepareManagedAiRuntime(db, { ...runInput, binding: { ...runInput.binding, connectionId: other.connectionId, grantId: other.grantId } });
|
||||
const otherTask = await prepareManagedAiRuntime(db, { ...runInput, taskKey: "another-task" });
|
||||
try {
|
||||
expect(resumed.home).not.toBe(first.home);
|
||||
expect(await readlink(path.join(String(resumed.config.env.GROK_HOME), "sessions"))).toBe(sessions);
|
||||
expect(await readlink(path.join(String(otherAgent.config.env.GROK_HOME), "sessions"))).not.toBe(sessions);
|
||||
expect(await readlink(path.join(String(otherAccount.config.env.GROK_HOME), "sessions"))).not.toBe(sessions);
|
||||
const restored = path.join(String(resumed.config.env.GROK_HOME), "sessions");
|
||||
expect(await readdir(restored)).toEqual(["history.json"]);
|
||||
expect(await readFile(path.join(restored, "history.json"), "utf8")).toBe('{"fixture":"history"}');
|
||||
expect((await stat(restored)).mode & 0o777).toBe(0o700);
|
||||
for (const runtime of [otherAgent, otherAccount, otherTask]) {
|
||||
expect(await readdir(path.join(String(runtime.config.env.GROK_HOME), "sessions"))).toEqual([]);
|
||||
}
|
||||
await expect(access(path.join(String(resumed.config.env.GROK_HOME), "auth.json"))).rejects.toThrow();
|
||||
} finally { await Promise.all([resumed.cleanup(), otherAgent.cleanup(), otherAccount.cleanup()]); }
|
||||
} finally { await Promise.all([resumed.cleanup(), otherAgent.cleanup(), otherAccount.cleanup(), otherTask.cleanup()]); }
|
||||
});
|
||||
it("authenticates local Gemini probes and runs with the saved key in an isolated home", async () => {
|
||||
const root = await mkdtemp(path.join(home, "gemini-auth-"));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { HttpError, unprocessable } from "../errors.js";
|
||||
import { mkdtemp, mkdir, symlink, writeFile, readFile, rm } from "node:fs/promises";
|
||||
import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
@@ -17,7 +17,7 @@ import { decideCodexAuthMerge } from "@paperclipai/adapter-codex-local/server";
|
||||
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
import { runAdapterExecutionTargetProcess } from "@paperclipai/adapter-utils/execution-target";
|
||||
import { decideGrokAuthMerge } from "@paperclipai/adapter-grok-local/server";
|
||||
import { resolvePaperclipInstanceRootForAdapter } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { prepareGrokSessionHistory } from "./grok-session-history.js";
|
||||
|
||||
export function isAiConnectionBusy(error: unknown): error is HttpError {
|
||||
return error instanceof HttpError && error.status === 422 &&
|
||||
@@ -219,6 +219,7 @@ export async function prepareManagedAiRuntime(
|
||||
allowUninstalledShared?: boolean;
|
||||
allowLegacyValidation?: boolean;
|
||||
config: Record<string, unknown>;
|
||||
taskKey?: string | null;
|
||||
},
|
||||
) {
|
||||
const configuredEnv =
|
||||
@@ -269,13 +270,15 @@ export async function prepareManagedAiRuntime(
|
||||
"The selected default changed. Retry this execution.",
|
||||
);
|
||||
const credentialRef = selection.grant.credentialSecretRefs.find((ref) => ref.configPath === "ai.credential");
|
||||
if (!credentialRef) throw unprocessable("The selected AI credential is unavailable");
|
||||
const readFreshness = async () => (await db.select({ epoch: companySecrets.aiSessionEpoch, version: companySecrets.latestVersion })
|
||||
.from(companySecrets).where(and(eq(companySecrets.companyId, input.companyId), eq(companySecrets.id, credentialRef.secretId))).limit(1))[0];
|
||||
const freshness = await readFreshness();
|
||||
const value = await service.credential(selection);
|
||||
const afterRead = await readFreshness();
|
||||
if (!freshness || freshness.version !== afterRead?.version || freshness.epoch !== afterRead.epoch) throw unprocessable("The AI credential changed during preparation; retry this execution");
|
||||
const routing = aiConnectionMetadataSchema.parse(selection.connection.config.ai).routing;
|
||||
const noAuth = routing?.auth === "none";
|
||||
if (!noAuth && !credentialRef) throw unprocessable("The selected AI credential is unavailable");
|
||||
const readFreshness = async () => credentialRef ? (await db.select({ epoch: companySecrets.aiSessionEpoch, version: companySecrets.latestVersion })
|
||||
.from(companySecrets).where(and(eq(companySecrets.companyId, input.companyId), eq(companySecrets.id, credentialRef.secretId))).limit(1))[0] : undefined;
|
||||
const freshness = noAuth ? undefined : await readFreshness();
|
||||
const value = noAuth ? "" : await service.credential(selection);
|
||||
const afterRead = noAuth ? undefined : await readFreshness();
|
||||
if (!noAuth && (!freshness || freshness.version !== afterRead?.version || freshness.epoch !== afterRead.epoch)) throw unprocessable("The AI credential changed during preparation; retry this execution");
|
||||
home = await mkdtemp(
|
||||
path.join(
|
||||
os.tmpdir(),
|
||||
@@ -289,7 +292,6 @@ export async function prepareManagedAiRuntime(
|
||||
...Object.fromEntries(AI_AUTH_ENV_KEYS.map((key) => [key, ""])),
|
||||
...managedAiHomeEnvironment(home),
|
||||
};
|
||||
const routing = aiConnectionMetadataSchema.parse(selection.connection.config.ai).routing;
|
||||
const capability =
|
||||
AI_CONNECTION_CAPABILITIES[input.binding.provider].methods[
|
||||
selection.attribution.method
|
||||
@@ -339,18 +341,10 @@ export async function prepareManagedAiRuntime(
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
const identity = `${selection.grant.id}:${input.responsibleUserId ?? "shared"}:${generation}`;
|
||||
const sessionIdentity = `${selection.grant.id}:${input.responsibleUserId ?? "shared"}:${credentialRef.secretId}:${freshness.epoch}`;
|
||||
if (input.adapterType === "grok_local") {
|
||||
// Retain only transcripts across disposable credential homes. Scope them
|
||||
// to the company, agent and credential identity, including rotations.
|
||||
// Removing the runtime home removes the link, never its private target.
|
||||
const scope = createHash("sha256")
|
||||
.update(JSON.stringify([input.companyId, input.agentId, sessionIdentity]))
|
||||
.digest("hex");
|
||||
const sessions = path.join(resolvePaperclipInstanceRootForAdapter(), "companies", input.companyId, "grok-sessions", scope);
|
||||
await mkdir(sessions, { recursive: true, mode: 0o700 });
|
||||
await symlink(sessions, path.join(providerHome, "sessions"), "dir");
|
||||
}
|
||||
const sessionIdentity = `${selection.grant.id}:${input.responsibleUserId ?? "shared"}:${noAuth ? "no-auth" : `${credentialRef!.secretId}:${freshness!.epoch}`}`;
|
||||
const retainGrokHistory = input.adapterType === "grok_local"
|
||||
? await prepareGrokSessionHistory(db, { ...input, sessionIdentity, providerHome })
|
||||
: undefined;
|
||||
return {
|
||||
sessionIdentity,
|
||||
config: {
|
||||
@@ -365,6 +359,7 @@ export async function prepareManagedAiRuntime(
|
||||
accountOwnerUserId: selection.grant.subjectUserId,
|
||||
identity,
|
||||
home,
|
||||
checkpointSessionHistory: retainGrokHistory,
|
||||
cleanup: async () => {
|
||||
try {
|
||||
if (subscriptionFile) {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { mkdir, open, readdir, realpath, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { agentTaskSessions, type Db } from "@paperclipai/db";
|
||||
import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js";
|
||||
import type { StoredSecretVersionMaterial } from "../secrets/types.js";
|
||||
|
||||
const MAX_BYTES = 16 * 1024 * 1024;
|
||||
const MAX_ENTRIES = 5000;
|
||||
const FIELD = "paperclipGrokHistory";
|
||||
type Entry = { name: string; bytes: string };
|
||||
|
||||
function validName(name: string) {
|
||||
return name.split("/").length <= 3 && name.split("/").every(part =>
|
||||
/^[a-zA-Z0-9_.-]+$/.test(part) && part !== "." && part !== "..");
|
||||
}
|
||||
|
||||
/** Called only after managed connection authorization. No retained host path is exposed to a CLI. */
|
||||
export async function prepareGrokSessionHistory(db: Db, input: {
|
||||
companyId: string; agentId: string; taskKey?: string | null;
|
||||
sessionIdentity: string; providerHome: string;
|
||||
}) {
|
||||
const directory = path.join(input.providerHome, "sessions");
|
||||
await mkdir(directory, { mode: 0o700 });
|
||||
if (!input.taskKey) return async (): Promise<Record<string, unknown> | undefined> => undefined;
|
||||
const scope = createHash("sha256").update(JSON.stringify([
|
||||
input.companyId, input.agentId, input.taskKey, input.sessionIdentity,
|
||||
])).digest("hex");
|
||||
const where = and(eq(agentTaskSessions.companyId, input.companyId),
|
||||
eq(agentTaskSessions.agentId, input.agentId), eq(agentTaskSessions.adapterType, "grok_local"),
|
||||
eq(agentTaskSessions.taskKey, input.taskKey));
|
||||
const [row] = await db.select({ params: agentTaskSessions.sessionParamsJson }).from(agentTaskSessions).where(where).limit(1);
|
||||
const retained = row?.params?.[FIELD] as { scope?: unknown; material?: StoredSecretVersionMaterial } | undefined;
|
||||
if (retained?.scope === scope && retained.material) {
|
||||
const value = await localEncryptedProvider.resolveVersion({ material: retained.material, externalRef: null });
|
||||
if (Buffer.byteLength(value) > MAX_BYTES * 2) throw new Error("Grok history exceeds its storage bound");
|
||||
const archive = JSON.parse(value) as { scope: string; entries: Entry[] };
|
||||
// Authenticate the scope inside the ciphertext as well as in the row metadata.
|
||||
if (archive.scope !== scope || !Array.isArray(archive.entries) || archive.entries.length > MAX_ENTRIES)
|
||||
throw new Error("Grok history scope is invalid");
|
||||
let size = 0;
|
||||
for (const entry of archive.entries) {
|
||||
if (typeof entry.name !== "string" || !validName(entry.name) || typeof entry.bytes !== "string")
|
||||
throw new Error("Grok history entry is invalid");
|
||||
const bytes = Buffer.from(entry.bytes, "base64");
|
||||
size += bytes.length;
|
||||
if (size > MAX_BYTES) throw new Error("Grok history exceeds its storage bound");
|
||||
const target = path.join(directory, entry.name);
|
||||
await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||
await writeFile(target, bytes, { mode: 0o600, flag: "wx" });
|
||||
}
|
||||
}
|
||||
return async (): Promise<Record<string, unknown> | undefined> => {
|
||||
const root = await realpath(directory);
|
||||
const entries: Entry[] = [];
|
||||
let size = 0;
|
||||
let count = 0;
|
||||
async function collect(relative = "") {
|
||||
for (const entry of await readdir(path.join(root, relative), { withFileTypes: true })) {
|
||||
if (++count > MAX_ENTRIES) throw new Error("Grok history exceeds its entry bound");
|
||||
const name = relative ? `${relative}/${entry.name}` : entry.name;
|
||||
if (!validName(name) || entry.isSymbolicLink()) continue;
|
||||
const source = path.join(root, name);
|
||||
if (entry.isDirectory()) { await collect(name); continue; }
|
||||
if (!entry.isFile() || !(await realpath(source)).startsWith(`${root}${path.sep}`)) continue;
|
||||
const file = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
const stat = await file.stat();
|
||||
if (!stat.isFile() || stat.nlink !== 1) continue;
|
||||
if (size + stat.size > MAX_BYTES) throw new Error("Grok history exceeds its storage bound");
|
||||
const bytes = await file.readFile();
|
||||
size += bytes.length;
|
||||
if (size > MAX_BYTES) throw new Error("Grok history exceeds its storage bound");
|
||||
entries.push({ name, bytes: bytes.toString("base64") });
|
||||
} finally { await file.close(); }
|
||||
}
|
||||
}
|
||||
await collect();
|
||||
if (entries.length === 0) return;
|
||||
const prepared = await localEncryptedProvider.createVersion({ value: JSON.stringify({ scope, entries }) });
|
||||
// The heartbeat writes this with the provider session's checkpoint before
|
||||
// releasing execution ownership. Cleanup must not race a subsequent resume.
|
||||
return { [FIELD]: { scope, material: prepared.material } };
|
||||
};
|
||||
}
|
||||
@@ -21764,7 +21764,7 @@ export function heartbeatService(
|
||||
readFailureReportSecrets = () => collectRunFailureSecretValues(resolvedConfig.env, secretKeys);
|
||||
if (aiBinding) {
|
||||
try {
|
||||
managedAiRuntime = await prepareManagedAiRuntime(db, { companyId: agent.companyId, agentId: agent.id, responsibleUserId, adapterType: agent.adapterType, binding: aiBinding, config: resolvedConfig });
|
||||
managedAiRuntime = await prepareManagedAiRuntime(db, { companyId: agent.companyId, agentId: agent.id, responsibleUserId, adapterType: agent.adapterType, binding: aiBinding, config: resolvedConfig, taskKey });
|
||||
} catch (error) {
|
||||
// Only fresh executions can receive a pre-provider wait receipt. A
|
||||
// persisted native input may already have provider effects to recover.
|
||||
@@ -26069,12 +26069,14 @@ export function heartbeatService(
|
||||
agentId: agent.id,
|
||||
adapterType: agent.adapterType,
|
||||
taskKey,
|
||||
sessionParamsJson:
|
||||
attachPaperclipSessionMetadataToSessionParams(
|
||||
sessionParamsJson: {
|
||||
...attachPaperclipSessionMetadataToSessionParams(
|
||||
nextSessionState.params,
|
||||
configuredModel,
|
||||
sessionConfigMetadata,
|
||||
),
|
||||
...await managedAiRuntime?.checkpointSessionHistory?.(),
|
||||
},
|
||||
sessionDisplayId: nextSessionState.displayId,
|
||||
lastRunId: finalizedRun.id,
|
||||
lastError: runErrorMessage,
|
||||
|
||||
@@ -65,6 +65,23 @@ describe.skipIf(process.platform === "win32")("local browser subscription login"
|
||||
await vi.waitFor(() => expect(() => process.kill(pid, 0)).toThrow());
|
||||
});
|
||||
|
||||
it("cancels Claude while waiting for its browser code and terminates the provider", async () => {
|
||||
const home = await fakeCli("claude", [
|
||||
'echo $$ > "$CLAUDE_CONFIG_DIR/login.pid"',
|
||||
'printf "Welcome to Claude Code\\nOpening browser to sign in…\\nBrowser didn\x27t open? Use the url below to sign in (c to copy)\\n"',
|
||||
'printf "https://claude.com/cai/oauth/authorize?client_id=cid&code=abcdefgh&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&redirect_uri=https%%3A%%2F%%2Fplatform.claude.com%%2Foauth%%2Fcode%%2Fcallback&response_type=code&scope=user&state=0123456789abcdef\\n"',
|
||||
'printf "Paste code here if prompted >\\n"',
|
||||
'read -r entered',
|
||||
].join("\n"));
|
||||
const login = startLocalBrowserLogin("anthropic", home);
|
||||
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 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);
|
||||
|
||||
@@ -80,7 +80,12 @@ export function startLocalBrowserLogin(provider: "anthropic" | "openai", home: s
|
||||
void runSetupTokenLogin(driver, {
|
||||
timeoutMs: 300_000, signal: controller.signal,
|
||||
onPrompt: (prompt) => { state.authorizationUrl = prompt.url; },
|
||||
provideCode: () => codeReady,
|
||||
provideCode: (signal) => new Promise<string>((resolve, reject) => {
|
||||
const abort = () => reject(new Error("Local sign-in cancelled"));
|
||||
if (signal.aborted) { abort(); return; }
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
codeReady.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
||||
}),
|
||||
onCredential: async (bytes) => {
|
||||
const file = await open(path.join(home, ".credentials.json"),
|
||||
constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
|
||||
|
||||
Reference in new issue
Block a user