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.
This commit is contained in:
@@ -4,7 +4,7 @@ Dual-agent AI assistant (Claude Code + Codex) over Discord. Based on [qwibitai/n
|
|||||||
|
|
||||||
## Quick Context
|
## 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
|
## 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/index.ts` | Orchestrator: state, message loop, agent invocation |
|
||||||
| `src/agent-runner.ts` | Spawns agent processes, manages env/sessions/skills |
|
| `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/channels/discord.ts` | Discord channel (8s typing refresh, Groq/OpenAI Whisper transcription) |
|
||||||
| `src/ipc.ts` | IPC watcher and task processing |
|
| `src/ipc.ts` | IPC watcher and task processing |
|
||||||
| `src/router.ts` | Message formatting and outbound routing |
|
| `src/router.ts` | Message formatting and outbound routing |
|
||||||
|
|||||||
@@ -63,13 +63,6 @@ function prepareGroupEnvironment(
|
|||||||
);
|
);
|
||||||
fs.mkdirSync(groupSessionsDir, { recursive: true });
|
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');
|
const settingsFile = path.join(groupSessionsDir, 'settings.json');
|
||||||
if (!fs.existsSync(settingsFile)) {
|
if (!fs.existsSync(settingsFile)) {
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
|
|||||||
@@ -57,10 +57,6 @@ 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, ChannelMeta, NewMessage, RegisteredGroup } from './types.js';
|
import { Channel, ChannelMeta, NewMessage, RegisteredGroup } from './types.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
@@ -1017,7 +1013,6 @@ 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);
|
||||||
@@ -1077,9 +1072,6 @@ 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,
|
||||||
|
|||||||
@@ -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<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 {
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user