pipeline executor improvements, proxy refresh, task runner step list
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@ import { buildSandboxPrefix, buildRunuserSuffix, SANDBOX_HOME } from '../sandbox
|
||||
import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
|
||||
import { parseStream } from './stream-parser';
|
||||
|
||||
const SEND_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const SEND_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
|
||||
// Use /usr/local/bin/claude so it's visible inside bwrap sandbox (which ro-binds /usr).
|
||||
// The actual binary lives at ~/.local/bin/claude, symlinked from /usr/local/bin/claude.
|
||||
|
||||
@@ -5,24 +5,119 @@ import { getState, updateState } from './state';
|
||||
const PROXY_PORT = Number(process.env.ANTHROPIC_PROXY_PORT ?? '5051');
|
||||
const ANTHROPIC_API_BASE = 'https://api.anthropic.com';
|
||||
const CREDENTIALS_PATH = join(homedir(), '.claude', '.credentials.json');
|
||||
const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
|
||||
const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
||||
|
||||
type CredentialsFile = {
|
||||
claudeAiOauth?: {
|
||||
accessToken?: string;
|
||||
};
|
||||
// Buffer: refresh 5 minutes before expiry
|
||||
const EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
||||
|
||||
type OAuthCredentials = {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
};
|
||||
|
||||
async function readOAuthToken(): Promise<string | null> {
|
||||
type CredentialsFile = {
|
||||
claudeAiOauth?: OAuthCredentials;
|
||||
};
|
||||
|
||||
async function readCredentials(): Promise<CredentialsFile | null> {
|
||||
try {
|
||||
const file = Bun.file(CREDENTIALS_PATH);
|
||||
if (!(await file.exists())) return null;
|
||||
const data = (await file.json()) as CredentialsFile;
|
||||
return data.claudeAiOauth?.accessToken?.trim() || null;
|
||||
return (await file.json()) as CredentialsFile;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCredentials(creds: CredentialsFile): Promise<void> {
|
||||
try {
|
||||
await Bun.write(CREDENTIALS_PATH, JSON.stringify(creds, null, 2));
|
||||
} catch (err) {
|
||||
console.error('[claude:proxy] failed to write credentials:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshOAuthToken(refreshToken: string): Promise<OAuthCredentials | null> {
|
||||
try {
|
||||
console.log('[claude:proxy] refreshing OAuth token...');
|
||||
const res = await fetch(TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: CLIENT_ID,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
console.error(`[claude:proxy] token refresh failed (${res.status}): ${text}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { access_token?: string; refresh_token?: string; expires_in?: number };
|
||||
if (!data.access_token) {
|
||||
console.error('[claude:proxy] token refresh response missing access_token');
|
||||
return null;
|
||||
}
|
||||
|
||||
const expiresAt = data.expires_in ? Date.now() + data.expires_in * 1000 : undefined;
|
||||
console.log(`[claude:proxy] token refreshed, expires in ${data.expires_in ?? '?'}s`);
|
||||
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token ?? refreshToken,
|
||||
expiresAt,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[claude:proxy] token refresh error:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getValidToken(): Promise<string | null> {
|
||||
const creds = await readCredentials();
|
||||
if (!creds?.claudeAiOauth) return null;
|
||||
|
||||
const oauth = creds.claudeAiOauth;
|
||||
const now = Date.now();
|
||||
|
||||
// Check if token is still valid (with buffer)
|
||||
if (oauth.accessToken && oauth.expiresAt && oauth.expiresAt - now > EXPIRY_BUFFER_MS) {
|
||||
return oauth.accessToken.trim();
|
||||
}
|
||||
|
||||
// Token expired or about to expire — try refresh
|
||||
if (oauth.refreshToken) {
|
||||
const refreshed = await refreshOAuthToken(oauth.refreshToken);
|
||||
if (refreshed?.accessToken) {
|
||||
creds.claudeAiOauth = { ...oauth, ...refreshed };
|
||||
await writeCredentials(creds);
|
||||
return refreshed.accessToken.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return current token even if potentially expired (let upstream decide)
|
||||
return oauth.accessToken?.trim() || null;
|
||||
}
|
||||
|
||||
async function forceRefresh(): Promise<string | null> {
|
||||
const creds = await readCredentials();
|
||||
const refreshToken = creds?.claudeAiOauth?.refreshToken;
|
||||
if (!refreshToken) return null;
|
||||
|
||||
const refreshed = await refreshOAuthToken(refreshToken);
|
||||
if (refreshed?.accessToken && creds?.claudeAiOauth) {
|
||||
creds.claudeAiOauth = { ...creds.claudeAiOauth, ...refreshed };
|
||||
await writeCredentials(creds);
|
||||
return refreshed.accessToken;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getProxySecret(): string {
|
||||
return getState().proxySecret;
|
||||
}
|
||||
@@ -48,6 +143,18 @@ export function startAnthropicProxy() {
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
// Internal refresh endpoint (no auth needed — localhost only)
|
||||
if (url.pathname === '/refresh') {
|
||||
const token = await forceRefresh();
|
||||
if (token) {
|
||||
return new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
return new Response(JSON.stringify({ error: 'Refresh failed' }), {
|
||||
status: 502,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Validate proxy secret
|
||||
const incomingKey = req.headers.get('x-api-key');
|
||||
if (incomingKey !== secret) {
|
||||
@@ -57,8 +164,8 @@ export function startAnthropicProxy() {
|
||||
});
|
||||
}
|
||||
|
||||
// Read fresh OAuth token
|
||||
const token = await readOAuthToken();
|
||||
// Get valid token (refreshes if needed)
|
||||
const token = await getValidToken();
|
||||
if (!token) {
|
||||
return new Response(JSON.stringify({ error: 'No OAuth token available' }), {
|
||||
status: 502,
|
||||
@@ -84,6 +191,42 @@ export function startAnthropicProxy() {
|
||||
body,
|
||||
});
|
||||
|
||||
// If upstream returns 401, try one refresh and retry
|
||||
if (upstreamRes.status === 401) {
|
||||
const creds = await readCredentials();
|
||||
const refreshToken = creds?.claudeAiOauth?.refreshToken;
|
||||
if (refreshToken) {
|
||||
const refreshed = await refreshOAuthToken(refreshToken);
|
||||
if (refreshed?.accessToken) {
|
||||
if (creds?.claudeAiOauth) {
|
||||
creds.claudeAiOauth = { ...creds.claudeAiOauth, ...refreshed };
|
||||
await writeCredentials(creds);
|
||||
}
|
||||
|
||||
headers.set('x-api-key', refreshed.accessToken.trim());
|
||||
const retryBody = body ? new Uint8Array(body) : null;
|
||||
const retryRes = await fetch(upstream, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: retryBody,
|
||||
});
|
||||
|
||||
const retryHeaders = new Headers();
|
||||
for (const [key, value] of retryRes.headers) {
|
||||
const lower = key.toLowerCase();
|
||||
if (lower === 'content-encoding' || lower === 'content-length' || lower === 'transfer-encoding') continue;
|
||||
retryHeaders.set(key, value);
|
||||
}
|
||||
|
||||
return new Response(retryRes.body, {
|
||||
status: retryRes.status,
|
||||
statusText: retryRes.statusText,
|
||||
headers: retryHeaders,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build clean response headers
|
||||
const resHeaders = new Headers();
|
||||
for (const [key, value] of upstreamRes.headers) {
|
||||
|
||||
Reference in New Issue
Block a user