From 0515edc93da467dada4a13d801363205b29da8f8 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Thu, 12 Mar 2026 02:47:24 +0900 Subject: [PATCH] feat: OAuth auto-refresh, Codex session resume, MCP injection, and secret sanitization - Add token-refresh module that auto-renews Claude OAuth tokens 30min before expiry - Fix Codex session loss on service restart by resuming previous sessions - Inject nanoclaw MCP server into Codex config.toml for send_message/schedule_task - Sanitize ANTHROPIC_API_KEY and CLAUDE_CODE_OAUTH_TOKEN from Codex subprocess env --- container/codex-runner/src/index.ts | 16 ++- src/container-runner.ts | 37 +++++ src/index.ts | 5 + src/token-refresh.ts | 212 ++++++++++++++++++++++++++++ 4 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 src/token-refresh.ts diff --git a/container/codex-runner/src/index.ts b/container/codex-runner/src/index.ts index 5331643..47503a3 100644 --- a/container/codex-runner/src/index.ts +++ b/container/codex-runner/src/index.ts @@ -235,10 +235,16 @@ async function main(): Promise { } // Query loop: run codex exec → wait for IPC message → repeat - // First exec is a fresh session; subsequent execs resume the last session. - let isFirstExec = true; + // If we have a previous sessionId, resume instead of starting fresh. + // CODEX_HOME is per-group persistent, so `resume --last` picks up the right session. + const hasPreviousSession = !!containerInput.sessionId; + let isFirstExec = !hasPreviousSession; let turnCount = 0; + if (hasPreviousSession) { + log(`Resuming previous session (sessionId: ${containerInput.sessionId})`); + } + try { while (true) { turnCount++; @@ -252,17 +258,23 @@ async function main(): Promise { const { result, error } = await runCodexExec(prompt, !isFirstExec); isFirstExec = false; + // Generate a session marker so nanoclaw tracks this codex session. + // On next spawn, containerInput.sessionId will be set → resume --last. + const sessionId = containerInput.sessionId || `codex-${containerInput.groupFolder}-${Date.now()}`; + if (error) { log(`Codex error: ${error}`); writeOutput({ status: 'error', result: result || null, + newSessionId: sessionId, error, }); } else { writeOutput({ status: 'success', result: result || null, + newSessionId: sessionId, }); } diff --git a/src/container-runner.ts b/src/container-runner.ts index 2c84e2e..7a13df2 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -261,6 +261,43 @@ function prepareGroupEnvironment( } } + // Inject nanoclaw MCP server into Codex config.toml + const mcpServerPath = path.join( + projectRoot, + 'container', + 'agent-runner', + 'dist', + 'ipc-mcp-stdio.js', + ); + const configTomlPath = path.join(sessionCodexDir, 'config.toml'); + if (fs.existsSync(mcpServerPath)) { + let toml = fs.existsSync(configTomlPath) + ? fs.readFileSync(configTomlPath, 'utf-8') + : ''; + // Remove existing nanoclaw MCP section if present (to refresh env vars) + toml = toml.replace( + /\n?\[mcp_servers\.nanoclaw\][\s\S]*?(?=\n\[|$)/, + '', + ); + const mcpSection = ` +[mcp_servers.nanoclaw] +command = "node" +args = [${JSON.stringify(mcpServerPath)}] + +[mcp_servers.nanoclaw.env] +NANOCLAW_IPC_DIR = ${JSON.stringify(env.NANOCLAW_IPC_DIR)} +NANOCLAW_CHAT_JID = ${JSON.stringify(group.folder)} +NANOCLAW_GROUP_FOLDER = ${JSON.stringify(group.folder)} +NANOCLAW_IS_MAIN = ${JSON.stringify(isMain ? '1' : '0')} +`; + toml = toml.trimEnd() + '\n' + mcpSection; + fs.writeFileSync(configTomlPath, toml); + } + + // Sanitize secrets: prevent API keys from leaking to codex subprocesses + delete env.ANTHROPIC_API_KEY; + delete env.CLAUDE_CODE_OAUTH_TOKEN; + env.CODEX_HOME = sessionCodexDir; } else { // Claude Code — pass real credentials directly diff --git a/src/index.ts b/src/index.ts index 9c6aeee..1a3ef70 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,6 +52,7 @@ import { isSessionCommandAllowed, } from './session-commands.js'; import { startSchedulerLoop } from './task-scheduler.js'; +import { startTokenRefreshLoop, stopTokenRefreshLoop } from './token-refresh.js'; import { Channel, NewMessage, RegisteredGroup } from './types.js'; import { logger } from './logger.js'; @@ -554,6 +555,7 @@ 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); @@ -613,6 +615,9 @@ 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 new file mode 100644 index 0000000..60a5646 --- /dev/null +++ b/src/token-refresh.ts @@ -0,0 +1,212 @@ +/** + * 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'; + +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); +} + +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 { + // 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; + } +}