Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const envConfig = readEnvFile([
'ASSISTANT_NAME',
'ASSISTANT_HAS_OWN_NUMBER',
'ONECLI_URL',
'ONECLI_OAUTH_SECRET_ID',
'TZ',
]);

Expand Down Expand Up @@ -52,6 +53,15 @@ export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(
10,
); // 10MB default
export const ONECLI_URL = process.env.ONECLI_URL || envConfig.ONECLI_URL;
export const ONECLI_OAUTH_SECRET_ID =
process.env.ONECLI_OAUTH_SECRET_ID || envConfig.ONECLI_OAUTH_SECRET_ID;
// OneCLI CLI binary — defaults to ~/.local/bin/onecli
export const ONECLI_BIN = path.join(
process.env.HOME || os.homedir(),
'.local',
'bin',
'onecli',
);
export const MAX_MESSAGES_PER_PROMPT = Math.max(
1,
parseInt(process.env.MAX_MESSAGES_PER_PROMPT || '10', 10) || 10,
Expand Down
12 changes: 12 additions & 0 deletions src/container-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ import {
DATA_DIR,
GROUPS_DIR,
IDLE_TIMEOUT,
ONECLI_BIN,
ONECLI_OAUTH_SECRET_ID,
ONECLI_URL,
TIMEZONE,
} from './config.js';
import { ensureFreshOAuthToken } from './oauth-token.js';
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
import { logger } from './logger.js';
import {
Expand Down Expand Up @@ -252,6 +255,15 @@ async function buildContainerArgs(
// Pass host timezone so container's local time matches the user's
args.push('-e', `TZ=${TIMEZONE}`);

// Ensure the OAuth token is fresh before asking OneCLI to apply credentials.
// This refreshes ~/.claude/.credentials.json and syncs the OneCLI secret
// on-demand, replacing the old cron-based approach that just copied a
// potentially-expired token.
await ensureFreshOAuthToken({
secretId: ONECLI_OAUTH_SECRET_ID,
onecliPath: ONECLI_BIN,
});

// OneCLI gateway handles credential injection — containers never see real secrets.
// The gateway intercepts HTTPS traffic and injects API keys or OAuth tokens.
const onecliApplied = await onecli.applyContainerConfig(args, {
Expand Down
14 changes: 14 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ import {
GROUPS_DIR,
IDLE_TIMEOUT,
MAX_MESSAGES_PER_PROMPT,
ONECLI_BIN,
ONECLI_OAUTH_SECRET_ID,
ONECLI_URL,
POLL_INTERVAL,
TIMEZONE,
} from './config.js';
import { ensureFreshOAuthToken } from './oauth-token.js';
import './channels/index.js';
import {
getChannelFactory,
Expand Down Expand Up @@ -748,6 +751,17 @@ async function main(): Promise<void> {
},
});
startSessionCleanup();

// Proactively refresh the OAuth token every 30 minutes so it doesn't expire
// during periods of inactivity (container-runner only refreshes on demand).
const refreshOAuth = () =>
ensureFreshOAuthToken({
secretId: ONECLI_OAUTH_SECRET_ID,
onecliPath: ONECLI_BIN,
}).catch((err) => logger.warn({ err }, 'Background OAuth refresh failed'));
refreshOAuth();
setInterval(refreshOAuth, 30 * 60 * 1000);

queue.setProcessMessagesFn(processGroupMessages);
recoverPendingMessages();
startMessageLoop().catch((err) => {
Expand Down
174 changes: 174 additions & 0 deletions src/oauth-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/**
* OAuth token management for Claude Code credentials.
*
* Reads OAuth tokens from ~/.claude/.credentials.json, refreshes them
* when expired, and keeps the OneCLI secret in sync so newly launched
* containers always get a valid token.
*/
import { execFile } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { request as httpsRequest } from 'https';
import { promisify } from 'util';

import { logger } from './logger.js';

const execFileAsync = promisify(execFile);

const CREDENTIALS_FILE = path.join(
os.homedir(),
'.claude',
'.credentials.json',
);
const BUFFER_MS = 5 * 60 * 1000; // refresh 5 minutes before expiry

interface ClaudeCredentials {
claudeAiOauth?: {
accessToken: string;
refreshToken: string;
expiresAt: number;
scopes: string[];
subscriptionType: string | null;
};
}

const CLAUDE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
const CLAUDE_SCOPES =
'user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload';

interface RefreshResponse {
access_token: string;
refresh_token?: string;
expires_in: number;
expires_at?: number;
}

async function callRefreshEndpoint(
refreshToken: string,
): Promise<{ accessToken: string; refreshToken: string; expiresAt: number }> {
return new Promise((resolve, reject) => {
const postData = JSON.stringify({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: CLAUDE_CLIENT_ID,
scope: CLAUDE_SCOPES,
});

const req = httpsRequest(
{
hostname: 'platform.claude.com',
port: 443,
path: '/v1/oauth/token',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData),
},
},
(res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
if (res.statusCode !== 200) {
reject(
new Error(`OAuth refresh failed: ${res.statusCode} ${data}`),
);
return;
}
try {
const response: RefreshResponse = JSON.parse(data);
resolve({
accessToken: response.access_token,
refreshToken: response.refresh_token ?? refreshToken,
expiresAt:
response.expires_at ?? Date.now() + response.expires_in * 1000,
});
} catch (err) {
reject(new Error(`Failed to parse OAuth response: ${err}`));
}
});
},
);
req.on('error', reject);
req.write(postData);
req.end();
});
}

async function syncOneCLISecret(
secretId: string,
token: string,
onecliPath: string,
): Promise<void> {
try {
await execFileAsync(onecliPath, [
'secrets',
'update',
'--id',
secretId,
'--value',
token,
]);
logger.info({ secretId }, 'OneCLI secret updated with fresh OAuth token');
} catch (err) {
logger.warn(
{ err, secretId },
'Failed to update OneCLI secret — container may use stale token',
);
}
}

/**
* Ensures the OAuth token in ~/.claude/.credentials.json is fresh.
* If the token is within 5 minutes of expiry (or already expired),
* refreshes it via the OAuth endpoint and writes the new token back.
* Also syncs the OneCLI secret if secretId + onecliPath are provided.
*
* No-op when ANTHROPIC_API_KEY is set (API key mode needs no OAuth refresh).
*/
export async function ensureFreshOAuthToken(opts: {
secretId?: string;
onecliPath?: string;
}): Promise<void> {
if (process.env.ANTHROPIC_API_KEY) return;

let credentials: ClaudeCredentials;
try {
credentials = JSON.parse(fs.readFileSync(CREDENTIALS_FILE, 'utf-8'));
} catch {
logger.debug('Credentials file not readable, skipping OAuth refresh');
return;
}

const oauth = credentials.claudeAiOauth;
if (!oauth?.refreshToken) {
logger.debug('No OAuth credentials in credentials file');
return;
}

if (oauth.expiresAt > Date.now() + BUFFER_MS) {
logger.debug('OAuth token still valid, syncing to OneCLI');
if (opts.secretId && opts.onecliPath) {
await syncOneCLISecret(opts.secretId, oauth.accessToken, opts.onecliPath);
}
return;
}

logger.info('OAuth token expired or near expiry, refreshing...');
try {
const { accessToken, refreshToken: newRefreshToken, expiresAt } = await callRefreshEndpoint(
oauth.refreshToken,
);

credentials.claudeAiOauth = { ...oauth, accessToken, refreshToken: newRefreshToken, expiresAt };
fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2));
logger.info('OAuth token refreshed and credentials.json updated');

if (opts.secretId && opts.onecliPath) {
await syncOneCLISecret(opts.secretId, accessToken, opts.onecliPath);
}
} catch (err) {
logger.error({ err }, 'OAuth token refresh failed — container may get 401');
}
}