UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

1,060 lines (1,059 loc) 38.2 kB
import { i as getOrCreatePromise } from "./lazy-promise-DGqyc4Y4.js"; import { t as hasErrnoCode } from "./errno-CkbDOfLk.js"; import { c as resolveAgentConfig, o as listAgentIds } from "./agent-scope-config-DcbEhP0R.js"; import { t as formatErrorMessage } from "./errors-Db3Ymjlb.js"; import "./agent-scope-DbtJyKUL.js"; import { n as withOpenClawStateLease } from "./openclaw-state-lease-Ciyr2uVu.js"; import { a as observeUserGitHubProfileRetirement, c as updateUserGitHubConnection, l as updateUserGitHubRefresh, n as disconnectedUserGitHubConnection, o as readUserGitHubConnection, r as listUserGitHubConnections, s as resolvePersonalGitHubOwner, t as disconnectUserGitHubConnection } from "./user-github-connections-BGiRrMav.js"; import { i as captureAgentLifecycleBinding, s as matchesAgentLifecycleBinding } from "./agent-lifecycle-registry-WgCc3vx1.js"; import { A as refreshGitHubOAuthToken, C as listGitHubDeviceAuthorizationRecords, D as writeGitHubOAuthRecord, E as writeGitHubDeviceAuthorizationRecord, O as clearGitHubCredentialVerificationCache, S as inspectGitHubOAuthRecord, T as readGitHubDeviceAuthorizationRecord, _ as resolveManagedGitHubProfileRoot, b as deleteGitHubDeviceAuthorizationRecord, d as refreshManagedGitHubProfile, f as removeManagedGitHubProfile, g as resolveManagedGitHubProfileDir, i as installManagedGitHubProfile, j as requestGitHubOAuthDeviceCode, k as pollGitHubOAuthDeviceToken, m as resolveGitHubToolIdentityStatus, p as resolveConfiguredGitHubToolIdentity, r as createManagedGitHubProfileId, t as GitHubAccountMismatchError, u as preparePersonalGitHubPublicationIdentity, w as listGitHubOAuthRecords, x as deleteGitHubOAuthRecord, y as createGitHubOAuthRecord } from "./github-tool-identity-CsNyDjEx.js"; import { r as assertGitHubCliAvailable, t as updateGitHubToolIdentityConfig } from "./github-tool-identity-config-DHi7zLE0.js"; import { isDeepStrictEqual } from "node:util"; import fs from "node:fs/promises"; import { randomBytes, randomUUID } from "node:crypto"; //#region src/gateway/github-oauth-device-flow.ts /** Transport and scheduling are shared; the caller owns identity, TTL/CAS, and installation. */ async function startGitHubDeviceFlow(signal) { const startedAt = Date.now(); const authorization = await requestGitHubOAuthDeviceCode({ signal }); if (authorization.expiresInSeconds > 900 || authorization.intervalSeconds > 60) throw new Error("GitHub device authorization timing is outside the supported bounds."); const pollIntervalMs = authorization.intervalSeconds * 1e3; return { deviceCode: authorization.deviceCode, userCode: authorization.userCode, verificationUri: authorization.verificationUri, createdAtMs: startedAt, expiresAtMs: startedAt + authorization.expiresInSeconds * 1e3, pollIntervalMs, nextPollAtMs: startedAt + pollIntervalMs }; } async function pollGitHubDeviceFlow(record, signal) { const now = Date.now(); if (record.expiresAtMs <= now) return { kind: "terminal", result: { status: "expired" } }; if (now < record.nextPollAtMs) return { kind: "waiting", result: { status: "pending", retryAfterMs: record.nextPollAtMs - now }, pollIntervalMs: record.pollIntervalMs, nextPollAtMs: record.nextPollAtMs }; let result; try { result = await pollGitHubOAuthDeviceToken({ deviceCode: record.deviceCode, signal }); } catch { const nextPollAtMs = Math.min(record.expiresAtMs, Date.now() + record.pollIntervalMs); return { kind: "waiting", result: { status: "network_error", retryAfterMs: Math.max(1, nextPollAtMs - Date.now()) }, pollIntervalMs: record.pollIntervalMs, nextPollAtMs }; } if (result.status === "authorized") return { kind: "authorized", tokens: result.tokens }; if (result.status === "authorization_pending" || result.status === "slow_down") { const pollIntervalMs = result.status === "slow_down" ? Math.min(6e4, Math.max(record.pollIntervalMs + 5e3, (result.intervalSeconds ?? 0) * 1e3)) : record.pollIntervalMs; const nextPollAtMs = Math.min(record.expiresAtMs, Date.now() + pollIntervalMs); return { kind: "waiting", pollIntervalMs, nextPollAtMs, result: { status: result.status === "slow_down" ? "slow_down" : "pending", retryAfterMs: Math.max(1, nextPollAtMs - Date.now()) } }; } if (result.status === "access_denied") return { kind: "terminal", result: { status: "access_denied" } }; if (result.status === "expired_token") return { kind: "terminal", result: { status: "expired" } }; return { kind: "terminal", result: result.code === "incorrect_device_code" || result.code === "bad_verification_code" ? { status: "incorrect_device_code" } : { status: "failed", reason: "setup_failed" } }; } const MAINTENANCE_INTERVAL_MS = 6e4; const SHUTDOWN_DRAIN_TIMEOUT_MS = 31e3; const defaultGitAuthor = (account) => ({ name: account.login, email: `${account.accountId}+${account.login}@users.noreply.github.com` }); function identityStillSelected(config, location, expected) { const current = resolveConfiguredGitHubToolIdentity({ config, ...location }); return isDeepStrictEqual(current ?? null, expected); } function authorizationStillOwned(config, record) { return identityStillSelected(config, record, record.expectedIdentity) && (record.scope === "system" || record.agentLifecycleBinding !== void 0 && matchesAgentLifecycleBinding(config, record.agentLifecycleBinding)); } function configuredOAuthIdentities(config) { const identities = []; const system = config.tools?.github; if (system?.kind === "oauth") identities.push({ scope: "system", agentId: "system", identity: { ...system, kind: "oauth" } }); for (const agentId of listAgentIds(config).toSorted()) { const identity = resolveAgentConfig(config, agentId)?.tools?.github; if (identity?.kind === "oauth") identities.push({ scope: "agent", agentId, identity: { ...identity, kind: "oauth" } }); } return identities; } function currentIdentityForRecord(config, record) { return resolveConfiguredGitHubToolIdentity({ config, ...record }); } //#endregion //#region src/gateway/github-personal-oauth.ts const profileDir = (profileId) => resolveManagedGitHubProfileDir({ agentId: "", scope: "personal", profileId }); const withProfileLease = (profileId, run) => withOpenClawStateLease({ scope: "personal-github-profile", key: profileId, database: { scope: "shared" }, leaseMs: 6e4, waitMs: 3e4 }, async (lease) => await run(() => lease.assertOwned())); function projectPending(pending) { return { requestId: pending.requestId, userCode: pending.userCode, verificationUri: pending.verificationUri, expiresInMs: Math.max(0, pending.expiresAtMs - Date.now()), pollAfterMs: Math.max(1, Math.min(6e4, pending.nextPollAtMs - Date.now())) }; } function personalGitHubStatus(action) { action.assertCurrent(); let record; try { record = readUserGitHubConnection(action.owner); } catch { action.assertCurrent(); return { state: "unavailable", generation: null, account: null, accessExpiresAtMs: null, refreshState: "failed", pending: null }; } const selection = record?.selection; const connected = selection?.kind === "connected" ? selection : void 0; return { state: connected ? "connected" : "disconnected", generation: record?.generation ?? null, account: connected ? { accountId: connected.accountId, login: connected.login } : null, accessExpiresAtMs: connected?.accessExpiresAtMs ?? null, refreshState: !connected ? "not_applicable" : connected.refresh ? "refreshing" : connected.refreshFailure ?? (connected.refreshExpiresAtMs <= Date.now() ? "expired" : "available"), pending: record?.pending?.kind === "device" && record.pending.expiresAtMs > Date.now() ? projectPending(record.pending) : null }; } async function resolvePersonalGitHubStatus(action) { const status = personalGitHubStatus(action); if (status.state !== "connected") return status; const record = readUserGitHubConnection(action.owner); if (record?.selection.kind !== "connected") return { ...status, state: "unavailable" }; const assertCurrent = () => { action.assertCurrent(); if (readUserGitHubConnection(action.owner)?.generation !== record.generation) throw new Error("My GitHub connection changed; reload its status."); }; try { await preparePersonalGitHubPublicationIdentity({ profileId: record.selection.profileId, accountId: record.selection.accountId, assertCurrent }); assertCurrent(); return status; } catch { assertCurrent(); return { ...status, state: "unavailable" }; } } function requirePending(record, generation, requestId) { if (!record?.pending || record.generation !== generation || record.pending.requestId !== requestId || record.pending.expiresAtMs <= Date.now()) throw new Error("My GitHub authorization changed or expired; start again."); return { ...record, pending: record.pending }; } function rotatedSelection(selection, tokens, receivedAtMs) { return { ...selection, refreshToken: tokens.refreshToken, scopes: tokens.scopes, accessExpiresAtMs: receivedAtMs + tokens.expiresInSeconds * 1e3, refreshExpiresAtMs: receivedAtMs + tokens.refreshTokenExpiresInSeconds * 1e3, refreshFailure: void 0 }; } /** Personal adapters share device transport and profile materialization with System/agent OAuth. */ function createPersonalGitHubOAuthLifecycle() { const abort = new AbortController(); const polls = /* @__PURE__ */ new Map(); const refreshes = /* @__PURE__ */ new Map(); const rotated = /* @__PURE__ */ new Map(); const retirements = /* @__PURE__ */ new Set(); const cleanups = /* @__PURE__ */ new Map(); let stopped = false; let inspectedProfiles = false; const profileIsReferenced = (id) => listUserGitHubConnections().some(({ connection }) => connection.selection.kind === "connected" && connection.selection.profileId === id || connection.pending?.kind === "device" && connection.pending.candidate?.profileId === id); const assertRunning = () => { if (stopped) throw new Error("GitHub authorization is stopping."); }; const retire = async (id) => { await getOrCreatePromise(cleanups, id, () => withProfileLease(id, async (assertOwned) => { assertOwned(); if (profileIsReferenced(id)) return; await removeManagedGitHubProfile(profileDir(id)); }).then(() => { retirements.delete(id); }, () => { retirements.add(id); }), { evictOnSettled: true }); }; const unobserve = observeUserGitHubProfileRetirement((ids) => { for (const id of ids) { retirements.add(id); retire(id); } }); const guard = (action) => { assertRunning(); action.assertCurrent(); }; const install = async (action, generation, pending) => { const candidate = pending.candidate; if (!candidate) throw new Error("My GitHub authorization has no candidate."); const assertCurrent = () => { guard(action); const record = requirePending(readUserGitHubConnection(action.owner), generation, pending.requestId); if (record.pending.kind !== "device" || record.pending.candidate?.profileId !== candidate.profileId) throw new Error("My GitHub authorization changed."); }; try { await withProfileLease(candidate.profileId, async (assertOwned) => { const assertInstall = () => { assertOwned(); assertCurrent(); }; assertInstall(); await removeManagedGitHubProfile(profileDir(candidate.profileId)); assertInstall(); await installManagedGitHubProfile({ profileDir: profileDir(candidate.profileId), token: candidate.tokens.accessToken, assertCurrent: assertInstall, commitConfig: async (account) => { updateUserGitHubConnection(action.owner, (current) => { return { ...requirePending(current, generation, pending.requestId), generation: randomUUID(), pending: void 0, selection: { kind: "connected", profileId: candidate.profileId, accountId: account.accountId, login: account.login, refreshToken: candidate.tokens.refreshToken, scopes: candidate.tokens.scopes, accessExpiresAtMs: candidate.receivedAtMs + candidate.tokens.expiresInSeconds * 1e3, refreshExpiresAtMs: candidate.receivedAtMs + candidate.tokens.refreshTokenExpiresInSeconds * 1e3 } }; }, assertInstall); } }); }); guard(action); return { status: "success", personal: personalGitHubStatus(action) }; } catch { guard(action); return { status: "failed", reason: "setup_failed" }; } }; const pollOnce = async (action, initial, requestId) => { const pending = initial.pending; if (!pending || pending.requestId !== requestId || pending.expiresAtMs <= Date.now()) return { status: "expired" }; if (pending.kind === "starting") return { status: "pending", retryAfterMs: 1e3 }; if (pending.candidate) return await install(action, initial.generation, pending); const polled = await pollGitHubDeviceFlow(pending, abort.signal); guard(action); let next; try { next = updateUserGitHubConnection(action.owner, (current) => { const owned = requirePending(current, initial.generation, requestId); if (owned.pending.kind !== "device" || owned.pending.deviceCode !== pending.deviceCode) throw new Error("My GitHub authorization changed."); return { ...owned, pending: polled.kind === "terminal" ? void 0 : { ...owned.pending, ...polled.kind === "authorized" ? { candidate: { receivedAtMs: Date.now(), profileId: createManagedGitHubProfileId(), tokens: polled.tokens } } : { pollIntervalMs: polled.pollIntervalMs, nextPollAtMs: polled.nextPollAtMs } } }; }, () => guard(action)); } catch { guard(action); return { status: "failed", reason: "identity_changed" }; } if (polled.kind !== "authorized") return polled.result; if (next.pending?.kind !== "device") throw new Error("My GitHub authorization changed."); return await install(action, next.generation, next.pending); }; const persistRotation = (pending) => updateUserGitHubRefresh({ ...pending, update: (selection) => ({ ...rotatedSelection(selection, pending.tokens, pending.receivedAtMs), refresh: { operationId: pending.operationId, tokens: pending.tokens, receivedAtMs: pending.receivedAtMs } }) }); const materializeRefresh = async (owner, id, operationId, assertOwned) => { const readExact = () => { assertOwned(); const canonical = resolvePersonalGitHubOwner(owner); const selection = canonical ? readUserGitHubConnection(canonical)?.selection : void 0; if (selection?.kind !== "connected" || selection.profileId !== id || selection.refresh?.operationId !== operationId || !selection.refresh.tokens) throw new Error("My GitHub refresh ownership changed."); return selection; }; const current = readExact(); const account = await refreshManagedGitHubProfile({ profileDir: profileDir(id), token: current.refresh.tokens.accessToken, expectedAccountId: current.accountId, assertCurrent: () => { readExact(); } }); assertOwned(); updateUserGitHubRefresh({ owner, profileId: id, operationId, update: (selection) => ({ ...selection, login: account.login, refresh: void 0, refreshFailure: void 0 }) }); }; const refresh = async (owner) => { assertRunning(); const initial = readUserGitHubConnection(owner)?.selection; if (initial?.kind !== "connected") return; const id = initial.profileId; await getOrCreatePromise(refreshes, id, () => withProfileLease(id, async (assertOwned) => { const memory = rotated.get(id); if (memory) { if (!persistRotation(memory)) { rotated.delete(id); return; } rotated.delete(id); } const record = readUserGitHubConnection(owner); const selection = record?.selection; if (!record || selection?.kind !== "connected" || selection.profileId !== id) return; if (selection.refresh?.tokens) { await materializeRefresh(owner, id, selection.refresh.operationId, assertOwned); return; } if (selection.refreshFailure === "expired" || selection.refreshExpiresAtMs <= Date.now() || !selection.refresh && selection.accessExpiresAtMs > Date.now() + 6e5) return; const operationId = selection.refresh?.operationId ?? randomUUID(); updateUserGitHubConnection(owner, (current) => { if (current?.generation !== record.generation || current.selection.kind !== "connected" || current.selection.profileId !== id) throw new Error("My GitHub selection changed."); return { ...current, selection: { ...current.selection, refresh: { operationId } } }; }, assertOwned); let result; try { result = await refreshGitHubOAuthToken({ refreshToken: selection.refreshToken }); } catch { updateUserGitHubRefresh({ owner, profileId: id, operationId, update: (current) => ({ ...current, refresh: void 0, refreshFailure: "failed" }) }); return; } if (result.status === "error") { updateUserGitHubRefresh({ owner, profileId: id, operationId, update: (current) => ({ ...current, refresh: void 0, refreshFailure: result.code === "bad_refresh_token" ? "expired" : "failed" }) }); return; } const pending = { owner, profileId: id, operationId, tokens: result.tokens, receivedAtMs: Date.now() }; rotated.set(id, pending); if (!persistRotation(pending)) { rotated.delete(id); return; } rotated.delete(id); await materializeRefresh(owner, id, operationId, assertOwned); }), { evictOnSettled: true }); }; let maintenance; const runMaintenance = async () => { if (!inspectedProfiles) { const root = resolveManagedGitHubProfileRoot({ agentId: "", scope: "personal" }); const entries = await fs.readdir(root, { withFileTypes: true }).catch((error) => { if (hasErrnoCode(error, "ENOENT")) return []; throw error; }); for (const entry of entries) if (entry.isDirectory() && /^ghp_[a-f0-9]{32}$/u.test(entry.name) && !profileIsReferenced(entry.name)) retirements.add(entry.name); inspectedProfiles = true; } for (const pending of rotated.values()) try { persistRotation(pending); rotated.delete(pending.profileId); } catch {} for (const id of retirements) await retire(id); for (const { owner, connection } of listUserGitHubConnections()) { if (stopped) break; if (connection.pending && connection.pending.expiresAtMs <= Date.now()) updateUserGitHubConnection(owner, (current) => { if (!current) return disconnectedUserGitHubConnection(); return current.pending && current.pending.expiresAtMs <= Date.now() ? { ...current, pending: void 0 } : current; }, assertRunning); try { await refresh(owner); } catch {} } }; return { status: resolvePersonalGitHubStatus, async startAuthorization(action) { guard(action); assertGitHubCliAvailable(); const requestId = randomUUID(); const createdAtMs = Date.now(); const initial = updateUserGitHubConnection(action.owner, (current) => ({ ...current ?? disconnectedUserGitHubConnection(), pending: { kind: "starting", requestId, createdAtMs, expiresAtMs: createdAtMs + 9e5 } }), () => guard(action)); const authorization = await startGitHubDeviceFlow(abort.signal); guard(action); if (authorization.expiresAtMs <= Date.now()) throw new Error("My GitHub authorization expired while starting; start again."); const next = updateUserGitHubConnection(action.owner, (current) => ({ ...requirePending(current, initial.generation, requestId), pending: { ...authorization, kind: "device", requestId } }), () => guard(action)); if (next.pending?.kind !== "device") throw new Error("My GitHub authorization changed."); return projectPending(next.pending); }, async pollAuthorization(action, requestId) { guard(action); const current = readUserGitHubConnection(action.owner); if (current?.pending?.requestId !== requestId) return { status: "expired" }; const key = `${action.owner}\0${requestId}`; const result = await getOrCreatePromise(polls, key, () => pollOnce(action, current, requestId), { evictOnSettled: true }); guard(action); return result; }, cancelAuthorization(action, requestId) { guard(action); if (readUserGitHubConnection(action.owner)?.pending?.requestId !== requestId) return false; updateUserGitHubConnection(action.owner, (record) => { if (!record || record.pending?.requestId !== requestId) throw new Error("My GitHub authorization changed."); return { ...record, pending: void 0 }; }, () => guard(action)); return true; }, disconnect(action) { guard(action); disconnectUserGitHubConnection(action.owner, () => guard(action)); }, refresh, maintain() { if (stopped) return Promise.resolve(); maintenance ??= runMaintenance().finally(() => { maintenance = void 0; }); return maintenance; }, async stop() { stopped = true; abort.abort(); unobserve(); await Promise.allSettled([ ...maintenance ? [maintenance] : [], ...polls.values(), ...refreshes.values(), ...cleanups.values() ]); for (const pending of rotated.values()) try { persistRotation(pending); } catch {} } }; } //#endregion //#region src/gateway/github-oauth-lifecycle.ts let activeLifecycle; function installActiveGitHubOAuthLifecycle(lifecycle) { activeLifecycle = lifecycle; return () => { if (activeLifecycle === lifecycle) activeLifecycle = void 0; }; } async function requestCurrentGitHubOAuthRefresh(agentId) { await activeLifecycle?.refreshEffectiveIdentity(agentId); } async function requestCurrentPersonalGitHubRefresh(owner) { if (!activeLifecycle) throw new Error("My GitHub lifecycle is unavailable; retry after Gateway startup."); await activeLifecycle.personal.refresh(owner); } function createGitHubOAuthLifecycle(params) { const personal = createPersonalGitHubOAuthLifecycle(); const deviceController = new AbortController(); const devicePolls = /* @__PURE__ */ new Map(); const committingRequests = /* @__PURE__ */ new Set(); const refreshes = /* @__PURE__ */ new Map(); const pendingRefreshes = /* @__PURE__ */ new Map(); const pendingCleanup = /* @__PURE__ */ new Set(); let maintenance; let interval; let stopping = false; const queueDeviceCleanup = (requestId) => { try { deleteGitHubDeviceAuthorizationRecord(requestId); pendingCleanup.delete(requestId); } catch { pendingCleanup.add(requestId); } }; const queueOAuthCleanup = (profileId) => { try { deleteGitHubOAuthRecord(profileId); } catch {} }; const status = (agentId, selectedScope) => resolveGitHubToolIdentityStatus({ config: params.getConfig(), agentId, selectedScope }); const installDeviceTokens = async (record, tokens) => { const current = params.getConfig(); if (!authorizationStillOwned(current, record)) { queueDeviceCleanup(record.requestId); return { status: "failed", reason: "identity_changed" }; } const profileId = createManagedGitHubProfileId(); const profileDir = resolveManagedGitHubProfileDir({ agentId: record.agentId, scope: record.scope, profileId }); let nextConfig = current; let metadataWritten = false; try { await installManagedGitHubProfile({ profileDir, token: tokens.accessToken, retainProfileOnCommitFailure: true, commitConfig: async (account) => { const pending = readGitHubDeviceAuthorizationRecord(record.requestId); if (!pending || pending.createdAtMs !== record.createdAtMs || pending.deviceCode !== record.deviceCode || !isDeepStrictEqual(pending.expectedIdentity, record.expectedIdentity) || !authorizationStillOwned(params.getConfig(), record)) throw new Error("GitHub authorization is no longer pending."); committingRequests.add(record.requestId); const pendingInitial = { requestId: record.requestId, scope: record.scope, agentId: record.agentId, expectedIdentity: record.expectedIdentity, ...record.agentLifecycleBinding ? { agentLifecycleBinding: record.agentLifecycleBinding } : {} }; writeGitHubOAuthRecord(createGitHubOAuthRecord({ profileId, scope: record.scope, agentId: record.agentId, account, tokens, now: Date.now(), pendingInitial })); metadataWritten = true; const identity = { profileId, kind: "oauth", gitAuthor: record.expectedIdentity?.gitAuthor ? structuredClone(record.expectedIdentity.gitAuthor) : defaultGitAuthor(account) }; nextConfig = await updateGitHubToolIdentityConfig({ scope: record.scope, agentId: record.agentId, identity, expectedIdentity: record.expectedIdentity, ...record.agentLifecycleBinding ? { agentLifecycleBinding: record.agentLifecycleBinding } : {} }); const inspected = inspectGitHubOAuthRecord(profileId); if (inspected.state !== "valid" || !inspected.record.pendingInitial) throw new Error("GitHub OAuth initial record is unavailable."); writeGitHubOAuthRecord({ ...inspected.record, pendingInitial: void 0 }); } }); } catch { if (metadataWritten) try { const persistedConfig = params.getPersistedConfig?.(); if (!persistedConfig) throw new Error("Authoritative persisted config is unavailable."); const persistedIdentity = resolveConfiguredGitHubToolIdentity({ config: persistedConfig, scope: record.scope, agentId: record.agentId }); if (persistedIdentity?.profileId === profileId && persistedIdentity.kind === "oauth") { const inspected = inspectGitHubOAuthRecord(profileId); if (inspected.state === "valid" && inspected.record.pendingInitial) writeGitHubOAuthRecord({ ...inspected.record, pendingInitial: void 0 }); queueDeviceCleanup(record.requestId); if (record.expectedIdentity?.kind === "oauth") queueOAuthCleanup(record.expectedIdentity.profileId); return { status: "success", githubStatus: await resolveGitHubToolIdentityStatus({ config: persistedConfig, agentId: record.agentId, selectedScope: record.scope }) }; } } catch { queueDeviceCleanup(record.requestId); return { status: "failed", reason: "setup_failed" }; } if (metadataWritten) queueOAuthCleanup(profileId); await removeManagedGitHubProfile(profileDir).catch(() => void 0); queueDeviceCleanup(record.requestId); return { status: "failed", reason: "setup_failed" }; } finally { committingRequests.delete(record.requestId); } queueDeviceCleanup(record.requestId); if (record.expectedIdentity?.kind === "oauth") queueOAuthCleanup(record.expectedIdentity.profileId); return { status: "success", githubStatus: await resolveGitHubToolIdentityStatus({ config: nextConfig, agentId: record.agentId, selectedScope: record.scope }) }; }; const pollOnce = async (requestId) => { const record = readGitHubDeviceAuthorizationRecord(requestId); const now = Date.now(); if (!record || record.expiresAtMs <= now) { queueDeviceCleanup(requestId); return { status: "expired" }; } if (!authorizationStillOwned(params.getConfig(), record)) { queueDeviceCleanup(requestId); return { status: "failed", reason: "identity_changed" }; } const result = await pollGitHubDeviceFlow(record, deviceController.signal); const currentRecord = readGitHubDeviceAuthorizationRecord(requestId); if (!currentRecord) return { status: "expired" }; if (currentRecord.deviceCode !== record.deviceCode || currentRecord.createdAtMs !== record.createdAtMs || !isDeepStrictEqual(currentRecord.expectedIdentity, record.expectedIdentity)) { queueDeviceCleanup(requestId); return { status: "failed", reason: "identity_changed" }; } const activeRecord = currentRecord; if (result.kind === "authorized") return await installDeviceTokens(activeRecord, result.tokens); if (result.kind === "waiting") writeGitHubDeviceAuthorizationRecord({ ...activeRecord, pollIntervalMs: result.pollIntervalMs, nextPollAtMs: result.nextPollAtMs }); else queueDeviceCleanup(requestId); return result.result; }; const applyPendingRefresh = async (record, accessToken) => { const profileDir = resolveManagedGitHubProfileDir({ agentId: record.agentId, scope: record.scope, profileId: record.profileId }); let account; try { account = await refreshManagedGitHubProfile({ profileDir, token: accessToken, expectedAccountId: record.accountId }); } catch (error) { if (error instanceof GitHubAccountMismatchError) writeGitHubOAuthRecord({ ...record, pendingRefresh: void 0, refreshFailure: "expired" }); return; } writeGitHubOAuthRecord({ ...record, login: account.login, pendingRefresh: void 0, refreshFailure: void 0 }); }; const refreshOne = async (configured) => { const profileId = configured.identity.profileId; const inspected = inspectGitHubOAuthRecord(profileId); if (inspected.state !== "valid") return; const currentRecord = inspected.record; if (currentRecord.pendingInitial) return; const now = Date.now(); if (currentRecord.refreshFailure === "expired" || currentRecord.refreshExpiresAtMs <= now) return; if (!currentRecord.pendingRefresh && currentRecord.accessExpiresAtMs > now + 6e5) return; const currentIdentity = currentIdentityForRecord(params.getConfig(), currentRecord); if (currentIdentity?.kind !== "oauth" || currentIdentity.profileId !== profileId) return; let refreshed; try { refreshed = await refreshGitHubOAuthToken({ refreshToken: currentRecord.refreshToken }); } catch { if (!currentRecord.pendingRefresh) writeGitHubOAuthRecord({ ...currentRecord, refreshFailure: "failed" }); return; } if (refreshed.status === "error") { const refreshFailure = refreshed.code === "bad_refresh_token" ? "expired" : "failed"; writeGitHubOAuthRecord({ ...currentRecord, pendingRefresh: void 0, refreshFailure }); return; } const rotatedRecord = { ...currentRecord, refreshToken: refreshed.tokens.refreshToken, accessExpiresAtMs: now + refreshed.tokens.expiresInSeconds * 1e3, refreshExpiresAtMs: now + refreshed.tokens.refreshTokenExpiresInSeconds * 1e3, scopes: refreshed.tokens.scopes, pendingRefresh: true, pendingInitial: void 0, refreshFailure: void 0 }; pendingRefreshes.set(profileId, { record: rotatedRecord, accessToken: refreshed.tokens.accessToken }); try { writeGitHubOAuthRecord(rotatedRecord); } catch { return; } pendingRefreshes.delete(profileId); await applyPendingRefresh(rotatedRecord, refreshed.tokens.accessToken); }; const requestRefresh = (configured) => getOrCreatePromise(refreshes, configured.identity.profileId, () => refreshOne(configured).catch((error) => { params.warn(`GitHub OAuth refresh failed; will retry: ${formatErrorMessage(error)}`); }), { evictOnSettled: true }); const reconcileRecords = async () => { for (const { requestId, record } of listGitHubDeviceAuthorizationRecords()) if (!record || record.expiresAtMs <= Date.now()) queueDeviceCleanup(requestId); for (const { profileId, record } of listGitHubOAuthRecords()) { if (!record) { queueOAuthCleanup(profileId); continue; } if (record.pendingInitial) { if (committingRequests.has(record.pendingInitial.requestId)) continue; let persistedConfig; try { const persisted = params.getPersistedConfig?.(); if (!persisted) continue; persistedConfig = persisted; } catch { continue; } const persistedIdentity = currentIdentityForRecord(persistedConfig, record); if ((record.scope === "system" || record.pendingInitial.agentLifecycleBinding !== void 0 && matchesAgentLifecycleBinding(persistedConfig, record.pendingInitial.agentLifecycleBinding)) && persistedIdentity?.profileId === profileId && persistedIdentity.kind === "oauth") { writeGitHubOAuthRecord({ ...record, pendingInitial: void 0 }); if (record.pendingInitial.expectedIdentity?.kind === "oauth") queueOAuthCleanup(record.pendingInitial.expectedIdentity.profileId); continue; } queueOAuthCleanup(profileId); await removeManagedGitHubProfile(resolveManagedGitHubProfileDir({ agentId: record.agentId, scope: record.scope, profileId })).catch(() => void 0); continue; } const current = currentIdentityForRecord(params.getConfig(), record); if (current?.profileId !== profileId || current.kind !== "oauth") { queueOAuthCleanup(profileId); continue; } if (record.pendingRefresh && !stopping) await requestRefresh({ scope: record.scope, agentId: record.agentId, identity: { ...current, kind: "oauth" } }); } }; const runMaintenance = async () => { for (const requestId of pendingCleanup) queueDeviceCleanup(requestId); for (const [profileId, pending] of [...pendingRefreshes].toSorted(([left], [right]) => left.localeCompare(right))) try { writeGitHubOAuthRecord(pending.record); pendingRefreshes.delete(profileId); await applyPendingRefresh(pending.record, pending.accessToken); } catch {} await reconcileRecords(); for (const configured of configuredOAuthIdentities(params.getConfig())) { if (stopping) break; await requestRefresh(configured); } }; const maintain = () => { if (stopping && !maintenance) return Promise.resolve(); if (maintenance) return maintenance; maintenance = runMaintenance().catch((error) => { params.warn(`GitHub OAuth maintenance failed; will retry: ${formatErrorMessage(error)}`); }).finally(() => { maintenance = void 0; }); return maintenance; }; const maintainAll = async () => { await Promise.all([maintain(), personal.maintain()]).catch((error) => { params.warn(`GitHub OAuth maintenance failed; will retry: ${formatErrorMessage(error)}`); }); }; return { personal, startAuthorization: async (input) => { if (stopping) throw new Error("GitHub authorization lifecycle is stopping."); assertGitHubCliAvailable(); const expectedIdentity = structuredClone(resolveConfiguredGitHubToolIdentity({ config: params.getConfig(), ...input }) ?? null); const agentLifecycleBinding = input.scope === "agent" ? captureAgentLifecycleBinding(params.getConfig(), input.agentId) : void 0; if (input.scope === "agent" && !agentLifecycleBinding) throw new Error("GitHub authorization requires an active agent."); const authorization = await startGitHubDeviceFlow(deviceController.signal); if (!identityStillSelected(params.getConfig(), input, expectedIdentity) || agentLifecycleBinding !== void 0 && !matchesAgentLifecycleBinding(params.getConfig(), agentLifecycleBinding)) throw new Error("GitHub identity changed while authorization was starting."); for (const existing of listGitHubDeviceAuthorizationRecords()) if (existing.record?.scope === input.scope && existing.record.agentId === input.agentId) queueDeviceCleanup(existing.requestId); const requestId = `github-device-${randomBytes(16).toString("hex")}`; const { createdAtMs, expiresAtMs, pollIntervalMs, nextPollAtMs } = authorization; writeGitHubDeviceAuthorizationRecord({ version: 1, requestId, deviceCode: authorization.deviceCode, userCode: authorization.userCode, verificationUri: authorization.verificationUri, createdAtMs, expiresAtMs, pollIntervalMs, nextPollAtMs, agentId: input.agentId, scope: input.scope, expectedIdentity, ...agentLifecycleBinding ? { agentLifecycleBinding } : {} }); return { requestId, userCode: authorization.userCode, verificationUri: authorization.verificationUri, expiresInMs: Math.max(0, expiresAtMs - Date.now()), pollAfterMs: pollIntervalMs }; }, pollAuthorization: (requestId) => getOrCreatePromise(devicePolls, requestId, () => pollOnce(requestId), { evictOnSettled: true }), cancelAuthorization: (requestId) => { if (committingRequests.has(requestId)) return false; const existed = readGitHubDeviceAuthorizationRecord(requestId) !== void 0; queueDeviceCleanup(requestId); return existed; }, status, retireProfile: (profileId) => queueOAuthCleanup(profileId), refreshEffectiveIdentity: async (agentId) => { if (stopping) return; const config = params.getConfig(); const agent = resolveAgentConfig(config, agentId)?.tools?.github; const identity = agent ?? config.tools?.github; if (identity?.kind !== "oauth") return; await requestRefresh({ scope: agent ? "agent" : "system", agentId, identity: { ...identity, kind: "oauth" } }); }, maintain: maintainAll, start: () => { if (stopping) return; maintainAll(); interval ??= setInterval(() => void maintainAll(), MAINTENANCE_INTERVAL_MS); interval.unref?.(); }, stop: async () => { clearGitHubCredentialVerificationCache(); stopping = true; if (interval) { clearInterval(interval); interval = void 0; } deviceController.abort(); const drain = (async () => { await Promise.allSettled([ personal.stop(), ...maintenance ? [maintenance] : [], ...devicePolls.values(), ...refreshes.values() ]); if (pendingRefreshes.size > 0) await runMaintenance(); })(); let timeout; try { await Promise.race([drain, new Promise((resolve) => { timeout = setTimeout(resolve, SHUTDOWN_DRAIN_TIMEOUT_MS); timeout.unref?.(); })]); } finally { if (timeout) clearTimeout(timeout); } } }; } //#endregion export { personalGitHubStatus as a, requestCurrentPersonalGitHubRefresh as i, installActiveGitHubOAuthLifecycle as n, requestCurrentGitHubOAuthRefresh as r, createGitHubOAuthLifecycle as t };