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
This commit is contained in:
@@ -235,10 +235,16 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Query loop: run codex exec → wait for IPC message → repeat
|
// Query loop: run codex exec → wait for IPC message → repeat
|
||||||
// First exec is a fresh session; subsequent execs resume the last session.
|
// If we have a previous sessionId, resume instead of starting fresh.
|
||||||
let isFirstExec = true;
|
// CODEX_HOME is per-group persistent, so `resume --last` picks up the right session.
|
||||||
|
const hasPreviousSession = !!containerInput.sessionId;
|
||||||
|
let isFirstExec = !hasPreviousSession;
|
||||||
let turnCount = 0;
|
let turnCount = 0;
|
||||||
|
|
||||||
|
if (hasPreviousSession) {
|
||||||
|
log(`Resuming previous session (sessionId: ${containerInput.sessionId})`);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
turnCount++;
|
turnCount++;
|
||||||
@@ -252,17 +258,23 @@ async function main(): Promise<void> {
|
|||||||
const { result, error } = await runCodexExec(prompt, !isFirstExec);
|
const { result, error } = await runCodexExec(prompt, !isFirstExec);
|
||||||
isFirstExec = false;
|
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) {
|
if (error) {
|
||||||
log(`Codex error: ${error}`);
|
log(`Codex error: ${error}`);
|
||||||
writeOutput({
|
writeOutput({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
result: result || null,
|
result: result || null,
|
||||||
|
newSessionId: sessionId,
|
||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
writeOutput({
|
writeOutput({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
result: result || null,
|
result: result || null,
|
||||||
|
newSessionId: sessionId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
env.CODEX_HOME = sessionCodexDir;
|
||||||
} else {
|
} else {
|
||||||
// Claude Code — pass real credentials directly
|
// Claude Code — pass real credentials directly
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
isSessionCommandAllowed,
|
isSessionCommandAllowed,
|
||||||
} from './session-commands.js';
|
} from './session-commands.js';
|
||||||
import { startSchedulerLoop } from './task-scheduler.js';
|
import { startSchedulerLoop } from './task-scheduler.js';
|
||||||
|
import { startTokenRefreshLoop, stopTokenRefreshLoop } from './token-refresh.js';
|
||||||
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
@@ -554,6 +555,7 @@ async function main(): Promise<void> {
|
|||||||
// Graceful shutdown handlers
|
// Graceful shutdown handlers
|
||||||
const shutdown = async (signal: string) => {
|
const shutdown = async (signal: string) => {
|
||||||
logger.info({ signal }, 'Shutdown signal received');
|
logger.info({ signal }, 'Shutdown signal received');
|
||||||
|
stopTokenRefreshLoop();
|
||||||
await queue.shutdown(10000);
|
await queue.shutdown(10000);
|
||||||
for (const ch of channels) await ch.disconnect();
|
for (const ch of channels) await ch.disconnect();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -613,6 +615,9 @@ async function main(): Promise<void> {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start OAuth token auto-refresh (before subsystems that spawn agents)
|
||||||
|
startTokenRefreshLoop();
|
||||||
|
|
||||||
// Start subsystems (independently of connection handler)
|
// Start subsystems (independently of connection handler)
|
||||||
startSchedulerLoop({
|
startSchedulerLoop({
|
||||||
registeredGroups: () => registeredGroups,
|
registeredGroups: () => registeredGroups,
|
||||||
|
|||||||
212
src/token-refresh.ts
Normal file
212
src/token-refresh.ts
Normal file
@@ -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<TokenResponse> {
|
||||||
|
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<void> {
|
||||||
|
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<typeof setInterval> | 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user