UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

1,073 lines (1,072 loc) • 40 kB
import { t as hasErrnoCode } from "./errno-FR4CRyya.mjs"; import { t as formatErrorMessage } from "./errors-BvOkmQM5.mjs"; import { i as getOrCreatePromise } from "./lazy-promise-DGqyc4Y4.mjs"; import { i as listAgentIds } from "./agent-roster-DH6r_8f4.mjs"; import { D as withAgentRosterFactsBatch, r as resolveAgentConfig } from "./agent-scope-config-BEk6-ThS.mjs"; import "./agent-scope-bJOokm5r.mjs"; import { t as settlesWithin } from "./settle-within-CTpJLoBG.mjs"; import { t as withOpenClawStateLease } from "./openclaw-state-lease-BVIgxmmg.mjs"; import { a as matchesAgentLifecycleBindingAsync, i as matchesAgentLifecycleBinding, n as captureAgentLifecycleBinding } from "./agent-lifecycle-registry-CxGB4gfC.mjs"; import { r as clearNativeGitHubTokenCache } from "./github-read-identity-CnnDW8pa.mjs"; import { A as pollGitHubOAuthDeviceToken, C as inspectGitHubOAuthRecord, D as writeGitHubDeviceAuthorizationRecord, E as readGitHubDeviceAuthorizationRecord, M as requestGitHubOAuthDeviceCode, O as writeGitHubOAuthRecord, S as deleteGitHubOAuthRecord, T as listGitHubOAuthRecords, _ as resolveManagedGitHubProfileRoot, b as createGitHubOAuthRecord, d as refreshManagedGitHubProfile, f as removeManagedGitHubProfile, g as resolveManagedGitHubProfileDir, j as refreshGitHubOAuthToken, k as clearGitHubCredentialVerificationCache, m as resolveGitHubToolIdentityStatus, n as createManagedGitHubProfileId, p as resolveConfiguredGitHubToolIdentity, r as installManagedGitHubProfile, t as GitHubAccountMismatchError, u as preparePersonalGitHubPublicationIdentity, w as listGitHubDeviceAuthorizationRecords, x as deleteGitHubDeviceAuthorizationRecord } from "./github-tool-identity-DbK12gfx.mjs"; import { r as assertGitHubCliAvailable, t as updateGitHubToolIdentityConfig } from "./github-tool-identity-config-CYlmno8Y.mjs"; 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-C7zHkhOS.mjs"; import fs from "node:fs/promises"; import { isDeepStrictEqual } from "node:util"; 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; 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)); } async function authorizationStillOwnedAsync(getConfig, record) { if (record.scope !== "system" && (!record.agentLifecycleBinding || !await matchesAgentLifecycleBindingAsync(getConfig, record.agentLifecycleBinding))) return false; return identityStillSelected(getConfig(), record, record.expectedIdentity); } function configuredOAuthIdentities(config) { return withAgentRosterFactsBatch(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; }); } //#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 }; } function revalidatePersonalGitHubStatus(action, prepared) { const current = personalGitHubStatus(action); if (current.generation !== prepared.generation || current.account?.accountId !== prepared.account?.accountId || current.account?.login.toLowerCase() !== prepared.account?.login.toLowerCase()) throw new Error("My GitHub connection changed; reload its status."); return prepared.state === "unavailable" ? { ...current, state: "unavailable" } : current; } 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 = () => { revalidatePersonalGitHubStatus(action, status); }; try { await preparePersonalGitHubPublicationIdentity({ profileId: record.selection.profileId, accountId: record.selection.accountId, assertCurrent }); return revalidatePersonalGitHubStatus(action, status); } catch { return { ...revalidatePersonalGitHubStatus(action, 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 needsRefresh(selection) { return Boolean(selection.refresh?.tokens) || selection.refreshFailure !== "expired" && selection.refreshExpiresAtMs > Date.now() && (Boolean(selection.refresh) || selection.accessExpiresAtMs <= Date.now() + 6e5); } /** 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) => ({ ...selection, refreshToken: pending.tokens.refreshToken, scopes: pending.tokens.scopes, accessExpiresAtMs: pending.receivedAtMs + pending.tokens.expiresInSeconds * 1e3, refreshExpiresAtMs: pending.receivedAtMs + pending.tokens.refreshTokenExpiresInSeconds * 1e3, refreshFailure: void 0, 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; if (!rotated.has(id) && !needsRefresh(initial)) return; 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 || !needsRefresh(selection)) return; if (selection.refresh?.tokens) { await materializeRefresh(owner, id, selection.refresh.operationId, assertOwned); 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, revalidateStatus: revalidatePersonalGitHubStatus, 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)); clearNativeGitHubTokenCache(); }, 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 maintenanceScope; let stopping = false; const warnMaintenanceError = (error) => { params.warn(`GitHub OAuth maintenance failed; will retry: ${formatErrorMessage(error)}`); }; const queueDeviceCleanup = (requestId) => { try { deleteGitHubDeviceAuthorizationRecord(requestId); pendingCleanup.delete(requestId); } catch { pendingCleanup.add(requestId); } }; const queueOAuthCleanup = (profileId) => { clearNativeGitHubTokenCache(); try { deleteGitHubOAuthRecord(profileId); } catch {} }; const status = (agentId, selectedScope) => resolveGitHubToolIdentityStatus({ config: params.getConfig(), agentId, selectedScope }); const assertInitialRecordCurrent = (expected, runtimeConfig, runtimeConfigValue) => { const current = inspectGitHubOAuthRecord(expected.profileId); if (params.getConfig() !== runtimeConfig || !isDeepStrictEqual(runtimeConfig, runtimeConfigValue) || current.state !== "valid" || !isDeepStrictEqual(current.record, expected)) throw new Error("GitHub OAuth initial reconciliation was superseded."); }; const installDeviceTokens = async (record, tokens) => { if (!await authorizationStillOwnedAsync(params.getConfig, record)) { queueDeviceCleanup(record.requestId); return { status: "failed", reason: "identity_changed" }; } const current = params.getConfig(); const profileId = createManagedGitHubProfileId(); const profileDir = resolveManagedGitHubProfileDir({ agentId: record.agentId, scope: record.scope, profileId }); let nextConfig = current; let initialRecord; const completeAuthorization = async (config) => { queueDeviceCleanup(record.requestId); if (record.expectedIdentity?.kind === "oauth") queueOAuthCleanup(record.expectedIdentity.profileId); return { status: "success", githubStatus: await resolveGitHubToolIdentityStatus({ config, agentId: record.agentId, selectedScope: record.scope }) }; }; 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 } : {} }; const nextRecord = createGitHubOAuthRecord({ profileId, scope: record.scope, agentId: record.agentId, account, tokens, now: Date.now(), pendingInitial }); writeGitHubOAuthRecord(nextRecord); initialRecord = nextRecord; const identity = { profileId, kind: "oauth", gitAuthor: record.expectedIdentity?.gitAuthor ? structuredClone(record.expectedIdentity.gitAuthor) : { name: account.login, email: `${account.accountId}+${account.login}@users.noreply.github.com` } }; 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."); const { pendingInitial: _pendingInitial, ...settledRecord } = inspected.record; writeGitHubOAuthRecord(settledRecord); initialRecord = settledRecord; } }); } catch { if (initialRecord) try { const runtimeConfig = params.getConfig(); const runtimeConfigValue = structuredClone(runtimeConfig); const persistedConfig = await params.getPersistedConfig?.(); if (!persistedConfig) throw new Error("Authoritative persisted config is unavailable."); assertInitialRecordCurrent(initialRecord, runtimeConfig, runtimeConfigValue); const persistedIdentity = resolveConfiguredGitHubToolIdentity({ config: persistedConfig, scope: record.scope, agentId: record.agentId }); if (persistedIdentity?.profileId === profileId && persistedIdentity.kind === "oauth") { if (initialRecord.pendingInitial) writeGitHubOAuthRecord({ ...initialRecord, pendingInitial: void 0 }); return await completeAuthorization(persistedConfig); } } catch { queueDeviceCleanup(record.requestId); return { status: "failed", reason: "setup_failed" }; } if (initialRecord) queueOAuthCleanup(profileId); await removeManagedGitHubProfile(profileDir).catch(() => void 0); queueDeviceCleanup(record.requestId); return { status: "failed", reason: "setup_failed" }; } finally { committingRequests.delete(record.requestId); } return await completeAuthorization(nextConfig); }; 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" }; } if (result.kind === "authorized") return await installDeviceTokens(currentRecord, result.tokens); if (result.kind === "waiting") writeGitHubDeviceAuthorizationRecord({ ...currentRecord, 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 = resolveConfiguredGitHubToolIdentity({ config: 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 runtimeConfig = params.getConfig(); const runtimeConfigValue = structuredClone(runtimeConfig); const persisted = await params.getPersistedConfig?.(); if (!persisted) continue; assertInitialRecordCurrent(record, runtimeConfig, runtimeConfigValue); if (committingRequests.has(record.pendingInitial.requestId)) continue; persistedConfig = persisted; } catch { continue; } const persistedIdentity = resolveConfiguredGitHubToolIdentity({ config: 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 = resolveConfiguredGitHubToolIdentity({ config: 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(); maintenance ??= runMaintenance().catch(warnMaintenanceError).finally(() => { maintenance = void 0; }); return maintenance; }; 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" ? await captureAgentLifecycleBinding(params.getConfig, input.agentId) : void 0; if (input.scope === "agent" && !agentLifecycleBinding) throw new Error("GitHub authorization requires an active agent."); if (!identityStillSelected(params.getConfig(), input, expectedIdentity)) throw new Error("GitHub identity changed while authorization was starting."); deviceController.signal.throwIfAborted(); 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")}`; writeGitHubDeviceAuthorizationRecord({ version: 1, requestId, ...authorization, agentId: input.agentId, scope: input.scope, expectedIdentity, ...agentLifecycleBinding ? { agentLifecycleBinding } : {} }); return { requestId, userCode: authorization.userCode, verificationUri: authorization.verificationUri, expiresInMs: Math.max(0, authorization.expiresAtMs - Date.now()), pollAfterMs: authorization.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: queueOAuthCleanup, 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: async () => { await Promise.all([maintain(), personal.maintain()]).catch(warnMaintenanceError); }, start: () => { if (stopping || maintenanceScope) return; const scheduler = params.scheduler.scope(); maintenanceScope = scheduler; scheduler.schedule({ id: "maintenance:github-oauth", delayMs: 0, everyMs: MAINTENANCE_INTERVAL_MS, run: maintain }); scheduler.schedule({ id: "maintenance:github-personal-oauth", delayMs: 0, everyMs: MAINTENANCE_INTERVAL_MS, run: () => personal.maintain().catch(warnMaintenanceError) }); }, stop: async () => { clearGitHubCredentialVerificationCache(); stopping = true; maintenanceScope?.beginClose(); deviceController.abort(); const drain = (async () => { await Promise.allSettled([ personal.stop(), maintenanceScope?.stop(), maintenance, ...devicePolls.values(), ...refreshes.values() ]); if (pendingRefreshes.size > 0) await runMaintenance(); })(); await settlesWithin(drain, SHUTDOWN_DRAIN_TIMEOUT_MS); } }; } //#endregion export { personalGitHubStatus as a, requestCurrentPersonalGitHubRefresh as i, installActiveGitHubOAuthLifecycle as n, requestCurrentGitHubOAuthRefresh as r, createGitHubOAuthLifecycle as t };