From 35356dd8a7a876d2c1727f666763d977d98c69e5 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Sat, 14 Mar 2026 19:09:40 +0900 Subject: [PATCH] refactor: remove token-refresh, use env-based auth only Delete token-refresh.ts and credentials.json sync. Auth is now via CLAUDE_CODE_OAUTH_TOKEN in .env (1-year setup-token). No more auto-refresh loop or credentials.json dependency. --- CLAUDE.md | 3 +- src/agent-runner.ts | 7 -- src/index.ts | 8 -- src/token-refresh.ts | 258 ------------------------------------------- 4 files changed, 1 insertion(+), 275 deletions(-) delete mode 100644 src/token-refresh.ts diff --git a/CLAUDE.md b/CLAUDE.md index 24daf11..44c7ecc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Dual-agent AI assistant (Claude Code + Codex) over Discord. Based on [qwibitai/n ## Quick Context -Two systemd services (`nanoclaw`, `nanoclaw-codex`) share the same codebase but run with separate stores, data, and groups. Agents run as direct host processes (no containers). Claude Code uses the Agent SDK; Codex uses `codex app-server` via JSON-RPC. OAuth tokens auto-refresh and sync to per-group session dirs. +Two systemd services (`nanoclaw`, `nanoclaw-codex`) share the same codebase but run with separate stores, data, and groups. Agents run as direct host processes (no containers). Claude Code uses the Agent SDK; Codex uses `codex app-server` via JSON-RPC. Auth via `CLAUDE_CODE_OAUTH_TOKEN` in `.env` (1-year token from `claude setup-token`). ## Key Files @@ -12,7 +12,6 @@ Two systemd services (`nanoclaw`, `nanoclaw-codex`) share the same codebase but |------|---------| | `src/index.ts` | Orchestrator: state, message loop, agent invocation | | `src/agent-runner.ts` | Spawns agent processes, manages env/sessions/skills | -| `src/token-refresh.ts` | OAuth auto-refresh + session directory sync | | `src/channels/discord.ts` | Discord channel (8s typing refresh, Groq/OpenAI Whisper transcription) | | `src/ipc.ts` | IPC watcher and task processing | | `src/router.ts` | Message formatting and outbound routing | diff --git a/src/agent-runner.ts b/src/agent-runner.ts index d8a0b0e..da252e9 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -63,13 +63,6 @@ function prepareGroupEnvironment( ); fs.mkdirSync(groupSessionsDir, { recursive: true }); - // Sync credentials from user's home ~/.claude/ so per-group sessions stay fresh - const homeClaudeDir = path.join(os.homedir(), '.claude'); - const credsSrc = path.join(homeClaudeDir, '.credentials.json'); - if (fs.existsSync(credsSrc)) { - fs.copyFileSync(credsSrc, path.join(groupSessionsDir, '.credentials.json')); - } - const settingsFile = path.join(groupSessionsDir, 'settings.json'); if (!fs.existsSync(settingsFile)) { fs.writeFileSync( diff --git a/src/index.ts b/src/index.ts index de62e13..4066ddc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -57,10 +57,6 @@ import { isSessionCommandAllowed, } from './session-commands.js'; import { startSchedulerLoop } from './task-scheduler.js'; -import { - startTokenRefreshLoop, - stopTokenRefreshLoop, -} from './token-refresh.js'; import { Channel, ChannelMeta, NewMessage, RegisteredGroup } from './types.js'; import { logger } from './logger.js'; @@ -1017,7 +1013,6 @@ async function main(): Promise { // Graceful shutdown handlers const shutdown = async (signal: string) => { logger.info({ signal }, 'Shutdown signal received'); - stopTokenRefreshLoop(); await queue.shutdown(10000); for (const ch of channels) await ch.disconnect(); process.exit(0); @@ -1077,9 +1072,6 @@ async function main(): Promise { process.exit(1); } - // Start OAuth token auto-refresh (before subsystems that spawn agents) - startTokenRefreshLoop(); - // Start subsystems (independently of connection handler) startSchedulerLoop({ registeredGroups: () => registeredGroups, diff --git a/src/token-refresh.ts b/src/token-refresh.ts deleted file mode 100644 index f92046b..0000000 --- a/src/token-refresh.ts +++ /dev/null @@ -1,258 +0,0 @@ -/** - * OAuth Token Auto-Refresh for Claude Code - * - * Periodically checks ~/.claude/.credentials.json and refreshes - * the access token before it expires. This solves the known issue - * where Claude Code CLI fails to auto-refresh in headless environments. - * - * Endpoint and client_id extracted from Claude Code CLI binary. - */ -import fs from 'fs'; -import os from 'os'; -import path from 'path'; - -import { logger } from './logger.js'; -import { DATA_DIR } from './config.js'; -import { readEnvFile } from './env.js'; - -const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token'; -const FALLBACK_TOKEN_URL = 'https://api.anthropic.com/v1/oauth/token'; -const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'; -const DEFAULT_SCOPES = [ - 'user:profile', - 'user:inference', - 'user:sessions:claude_code', - 'user:mcp_servers', -]; - -// Check every 5 minutes, refresh if within 30 minutes of expiry -const CHECK_INTERVAL_MS = 5 * 60 * 1000; -const REFRESH_BEFORE_EXPIRY_MS = 30 * 60 * 1000; -const REQUEST_TIMEOUT_MS = 15_000; - -interface OAuthCredentials { - accessToken: string; - refreshToken: string; - expiresAt: number; - scopes: string[]; - subscriptionType?: string; - rateLimitTier?: string; -} - -interface CredentialsFile { - claudeAiOauth: OAuthCredentials; -} - -interface TokenResponse { - access_token: string; - refresh_token?: string; - expires_in: number; - scope?: string; -} - -function getCredentialsPath(): string { - return path.join(os.homedir(), '.claude', '.credentials.json'); -} - -function readCredentials(): CredentialsFile | null { - const credsPath = getCredentialsPath(); - try { - if (!fs.existsSync(credsPath)) return null; - return JSON.parse(fs.readFileSync(credsPath, 'utf-8')); - } catch (err) { - logger.warn({ err }, 'Failed to read Claude credentials'); - return null; - } -} - -function writeCredentials(creds: CredentialsFile): void { - const credsPath = getCredentialsPath(); - const tempPath = `${credsPath}.tmp`; - fs.writeFileSync(tempPath, JSON.stringify(creds, null, 2), { mode: 0o600 }); - fs.renameSync(tempPath, credsPath); - - // Sync to all per-group session directories so running agents pick up the new token - syncToSessionDirs(credsPath); -} - -function syncToSessionDirs(credsPath: string): void { - const sessionsDir = path.join(DATA_DIR, 'sessions'); - try { - if (!fs.existsSync(sessionsDir)) return; - const groups = fs.readdirSync(sessionsDir); - let synced = 0; - for (const group of groups) { - const dest = path.join( - sessionsDir, - group, - '.claude', - '.credentials.json', - ); - if (fs.existsSync(path.dirname(dest))) { - fs.copyFileSync(credsPath, dest); - synced++; - } - } - if (synced > 0) { - logger.info( - { count: synced }, - 'Synced credentials to session directories', - ); - } - } catch (err) { - logger.warn( - { err: err instanceof Error ? err.message : String(err) }, - 'Failed to sync credentials to sessions', - ); - } -} - -async function refreshToken( - refreshTokenStr: string, - scopes: string[], -): Promise { - const body = JSON.stringify({ - grant_type: 'refresh_token', - refresh_token: refreshTokenStr, - client_id: CLIENT_ID, - scope: (scopes.length > 0 ? scopes : DEFAULT_SCOPES).join(' '), - }); - - const headers = { 'Content-Type': 'application/json' }; - - // Try primary endpoint first, then fallback - for (const url of [TOKEN_URL, FALLBACK_TOKEN_URL]) { - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); - - const res = await fetch(url, { - method: 'POST', - headers, - body, - signal: controller.signal, - }); - - clearTimeout(timeout); - - if (res.status === 200) { - return (await res.json()) as TokenResponse; - } - - const errText = await res.text().catch(() => ''); - logger.warn( - { url, status: res.status, body: errText.slice(0, 200) }, - 'Token refresh failed at endpoint', - ); - } catch (err) { - logger.warn( - { url, err: err instanceof Error ? err.message : String(err) }, - 'Token refresh request error', - ); - } - } - - throw new Error('Token refresh failed on all endpoints'); -} - -async function checkAndRefresh(): Promise { - const creds = readCredentials(); - if (!creds?.claudeAiOauth) return; - - const { expiresAt, refreshToken: rt } = creds.claudeAiOauth; - if (!rt) { - logger.debug('No refresh token in credentials, skipping'); - return; - } - - const now = Date.now(); - const remaining = expiresAt - now; - - if (remaining > REFRESH_BEFORE_EXPIRY_MS) { - logger.debug( - { remainingMin: Math.round(remaining / 60000) }, - 'Token still valid, no refresh needed', - ); - return; - } - - const isExpired = remaining <= 0; - logger.info( - { remainingMin: Math.round(remaining / 60000), isExpired }, - 'Refreshing Claude OAuth token', - ); - - try { - const response = await refreshToken( - rt, - creds.claudeAiOauth.scopes || DEFAULT_SCOPES, - ); - - creds.claudeAiOauth.accessToken = response.access_token; - creds.claudeAiOauth.refreshToken = response.refresh_token || rt; - creds.claudeAiOauth.expiresAt = now + response.expires_in * 1000; - - if (response.scope) { - creds.claudeAiOauth.scopes = response.scope.split(' '); - } - - writeCredentials(creds); - - const newExpiryMin = Math.round(response.expires_in / 60); - logger.info( - { expiresInMin: newExpiryMin }, - 'Claude OAuth token refreshed successfully', - ); - } catch (err) { - logger.error( - { err: err instanceof Error ? err.message : String(err) }, - 'Failed to refresh Claude OAuth token — manual re-login may be required', - ); - } -} - -let refreshInterval: ReturnType | null = null; - -export function startTokenRefreshLoop(): void { - // Skip if using env-based auth (API key or long-lived setup-token) - const envVars = readEnvFile(['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN']); - if ( - envVars.ANTHROPIC_API_KEY || - process.env.ANTHROPIC_API_KEY || - envVars.CLAUDE_CODE_OAUTH_TOKEN || - process.env.CLAUDE_CODE_OAUTH_TOKEN - ) { - logger.info('Using env-based auth, token auto-refresh disabled'); - return; - } - - // Only run if credentials file exists (skip for API key-only setups) - const creds = readCredentials(); - if (!creds?.claudeAiOauth) { - logger.info('No OAuth credentials found, token refresh disabled'); - return; - } - - logger.info( - { checkIntervalMin: CHECK_INTERVAL_MS / 60000 }, - 'Token auto-refresh started', - ); - - // Check immediately on startup - checkAndRefresh().catch((err) => - logger.error({ err }, 'Initial token refresh check failed'), - ); - - refreshInterval = setInterval(() => { - checkAndRefresh().catch((err) => - logger.error({ err }, 'Token refresh check failed'), - ); - }, CHECK_INTERVAL_MS); -} - -export function stopTokenRefreshLoop(): void { - if (refreshInterval) { - clearInterval(refreshInterval); - refreshInterval = null; - } -}