From c00dbd8ffee73e0b5d2387c0c4c6e2d5cecd608e Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 6 Oct 2026 09:47:34 -0500 Subject: [PATCH] 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 --- .../grok-local/src/server/grok-home.ts | 2 +- server/src/__tests__/ai-connections.test.ts | 61 ++++++++++--- server/src/services/ai-connection-runtime.ts | 39 ++++----- server/src/services/grok-session-history.ts | 87 +++++++++++++++++++ server/src/services/heartbeat.ts | 8 +- .../services/local-ai-browser-login.test.ts | 17 ++++ server/src/services/local-ai-browser-login.ts | 7 +- 7 files changed, 183 insertions(+), 38 deletions(-) create mode 100644 server/src/services/grok-session-history.ts diff --git a/packages/adapters/grok-local/src/server/grok-home.ts b/packages/adapters/grok-local/src/server/grok-home.ts index fd5ba77f8bd..f1384447ece 100644 --- a/packages/adapters/grok-local/src/server/grok-home.ts +++ b/packages/adapters/grok-local/src/server/grok-home.ts @@ -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; diff --git a/server/src/__tests__/ai-connections.test.ts b/server/src/__tests__/ai-connections.test.ts index 85178cffffc..c4e80951742 100644 --- a/server/src/__tests__/ai-connections.test.ts +++ b/server/src/__tests__/ai-connections.test.ts @@ -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-")); diff --git a/server/src/services/ai-connection-runtime.ts b/server/src/services/ai-connection-runtime.ts index 8d8abfe9bf6..bf937272a48 100644 --- a/server/src/services/ai-connection-runtime.ts +++ b/server/src/services/ai-connection-runtime.ts @@ -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; + 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) { diff --git a/server/src/services/grok-session-history.ts b/server/src/services/grok-session-history.ts new file mode 100644 index 00000000000..113ff683553 --- /dev/null +++ b/server/src/services/grok-session-history.ts @@ -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 | 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 | 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 } }; + }; +} diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 9fdf3199d51..7fdbb74f15f 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -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, diff --git a/server/src/services/local-ai-browser-login.test.ts b/server/src/services/local-ai-browser-login.test.ts index 1b102966ed8..0f8085314c7 100644 --- a/server/src/services/local-ai-browser-login.test.ts +++ b/server/src/services/local-ai-browser-login.test.ts @@ -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); diff --git a/server/src/services/local-ai-browser-login.ts b/server/src/services/local-ai-browser-login.ts index d79a01555e9..ad3ee6dfe7a 100644 --- a/server/src/services/local-ai-browser-login.ts +++ b/server/src/services/local-ai-browser-login.ts @@ -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((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);