headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.
api/router.ts the thin auth-gated proxy, now at /api/offscale
sidecar/ 18 files, the whole headscale contract and its admin keys
db/ schema + queries, offscale_servers
web/ 26 files as panels and a layout — no screen, per the rule
removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.
the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.
AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.
install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.
verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.
757 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
427 lines
16 KiB
TypeScript
427 lines
16 KiB
TypeScript
import { join } from 'node:path';
|
|
import { homedir, userInfo } from 'node:os';
|
|
import { getState, updateState } from './state';
|
|
import { ANTHROPIC_PROXY_PORT as PROXY_PORT } from '../../officer-url.mjs';
|
|
|
|
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';
|
|
|
|
// Buffer: refresh 5 minutes before expiry
|
|
const EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
|
|
|
/*
|
|
macOS keeps Claude Code's OAuth credentials in the login Keychain, NOT in ~/.claude/.credentials.json.
|
|
|
|
This proxy used to read the file only, which on a Mac is a file Claude Code never writes. The
|
|
workaround was to copy the Keychain blob into it by hand — and that is a snapshot, so it dies the
|
|
first time the real client rotates the token. Worse than dying: a refresh ROTATES the refresh token
|
|
and revokes the previous one, so two stores holding what looks like the same credential are actually
|
|
racing, and whichever refreshes second gets `401 OAuth access token has been revoked`. That is the
|
|
failure this file now exists to prevent, and it took out every mobile chat turn for six hours on
|
|
2026-08-08 while the terminal CLI beside it worked fine.
|
|
|
|
So on darwin the Keychain is the authority and the file is a mirror. Elsewhere — every Linux server —
|
|
the file is the authority and nothing here changes.
|
|
*/
|
|
const IS_MACOS = process.platform === 'darwin';
|
|
const KEYCHAIN_SERVICE = 'Claude Code-credentials';
|
|
|
|
/** Which store a credential came from, so a refresh is written back to the one that owns it. */
|
|
type CredentialSource = 'keychain' | 'file';
|
|
type LoadedCredentials = { creds: CredentialsFile; source: CredentialSource };
|
|
|
|
/*
|
|
How far ahead the watchdog works.
|
|
|
|
It CHECKS every 30 minutes and refreshes only when the token has under an hour left, rather than
|
|
refreshing on a blind schedule. The distinction matters: every refresh rotates the token, so a
|
|
pointless refresh is not free — it is another chance for the two stores to disagree. This keeps a
|
|
valid token permanently ahead of the next request without spending one to do it.
|
|
*/
|
|
const WATCHDOG_INTERVAL_MS = 30 * 60 * 1000;
|
|
const WATCHDOG_REFRESH_WITHIN_MS = 60 * 60 * 1000;
|
|
|
|
type OAuthCredentials = {
|
|
accessToken?: string;
|
|
refreshToken?: string;
|
|
expiresAt?: number;
|
|
};
|
|
|
|
type CredentialsFile = {
|
|
claudeAiOauth?: OAuthCredentials;
|
|
};
|
|
|
|
async function readCredentials(): Promise<CredentialsFile | null> {
|
|
try {
|
|
const file = Bun.file(CREDENTIALS_PATH);
|
|
if (!(await file.exists())) return 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);
|
|
}
|
|
}
|
|
|
|
/** Run `security`, returning stdout on success and null on any failure. Never throws. */
|
|
async function runSecurity(args: string[]): Promise<string | null> {
|
|
try {
|
|
const proc = Bun.spawn(['security', ...args], { stdout: 'pipe', stderr: 'pipe' });
|
|
const [out, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
|
|
return code === 0 ? out : null;
|
|
} catch {
|
|
// No `security` binary, or spawning is not permitted. Both mean "no Keychain here".
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/*
|
|
The Keychain account the item is filed under — the macOS short username in practice.
|
|
|
|
Read from the existing item rather than assumed, because `add-generic-password -U` matches on the
|
|
service AND account pair: guess the account wrong and it silently creates a SECOND item instead of
|
|
updating the one Claude Code reads, which would look like a write that worked and changed nothing.
|
|
*/
|
|
let keychainAccount: string | null = null;
|
|
async function getKeychainAccount(): Promise<string> {
|
|
if (keychainAccount) return keychainAccount;
|
|
const out = await runSecurity(['find-generic-password', '-s', KEYCHAIN_SERVICE]);
|
|
keychainAccount = out?.match(/"acct"<blob>="([^"]*)"/)?.[1] || userInfo().username;
|
|
return keychainAccount;
|
|
}
|
|
|
|
async function readKeychainCredentials(): Promise<CredentialsFile | null> {
|
|
if (!IS_MACOS) return null;
|
|
const raw = await runSecurity(['find-generic-password', '-s', KEYCHAIN_SERVICE, '-w']);
|
|
if (!raw?.trim()) return null;
|
|
try {
|
|
const parsed = JSON.parse(raw.trim()) as CredentialsFile;
|
|
// An item with no access token is not a credential. Fall through to the file rather than
|
|
// adopting it — an empty Keychain entry must not shadow a working one on disk.
|
|
return parsed.claudeAiOauth?.accessToken ? parsed : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function writeKeychainCredentials(creds: CredentialsFile): Promise<boolean> {
|
|
if (!IS_MACOS) return false;
|
|
const account = await getKeychainAccount();
|
|
// The blob goes through argv, which is the only interface `security` offers for a non-interactive
|
|
// write — it has no stdin form. Acceptable here: this is a single-user laptop whose Keychain the
|
|
// caller already holds, and the alternative is leaving Claude Code's own store stale.
|
|
const out = await runSecurity([
|
|
'add-generic-password',
|
|
'-U',
|
|
'-a',
|
|
account,
|
|
'-s',
|
|
KEYCHAIN_SERVICE,
|
|
'-w',
|
|
JSON.stringify(creds),
|
|
]);
|
|
return out !== null;
|
|
}
|
|
|
|
/**
|
|
* The credentials to work from, and where they came from.
|
|
*
|
|
* Keychain first on macOS: it is what the real Claude Code reads and writes, so it is the copy that
|
|
* is current even when nothing here has run for hours.
|
|
*/
|
|
async function loadCredentials(): Promise<LoadedCredentials | null> {
|
|
const fromKeychain = await readKeychainCredentials();
|
|
if (fromKeychain) return { creds: fromKeychain, source: 'keychain' };
|
|
const fromFile = await readCredentials();
|
|
return fromFile ? { creds: fromFile, source: 'file' } : null;
|
|
}
|
|
|
|
/**
|
|
* Persist a refreshed credential to the store that owns it, and mirror it to the file.
|
|
*
|
|
* The mirror is deliberate. Both copies then hold the SAME token rather than two rotations of it,
|
|
* which is the difference between a redundant copy and the race this file was written to end.
|
|
*/
|
|
async function saveCredentials(creds: CredentialsFile, source: CredentialSource): Promise<void> {
|
|
if (source === 'keychain' && !(await writeKeychainCredentials(creds))) {
|
|
console.error('[claude:proxy] could not write the Keychain — the file mirror is now the only copy');
|
|
}
|
|
await writeCredentials(creds);
|
|
}
|
|
|
|
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 loaded = await loadCredentials();
|
|
const oauth = loaded?.creds.claudeAiOauth;
|
|
if (!loaded || !oauth) return null;
|
|
|
|
const { creds, source } = loaded;
|
|
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 saveCredentials(creds, source);
|
|
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 loaded = await loadCredentials();
|
|
const oauth = loaded?.creds.claudeAiOauth;
|
|
if (!loaded || !oauth?.refreshToken) return null;
|
|
|
|
const refreshed = await refreshOAuthToken(oauth.refreshToken);
|
|
if (!refreshed?.accessToken) return null;
|
|
|
|
loaded.creds.claudeAiOauth = { ...oauth, ...refreshed };
|
|
await saveCredentials(loaded.creds, loaded.source);
|
|
return refreshed.accessToken.trim();
|
|
}
|
|
|
|
/**
|
|
* Recover from an upstream 401, in the order that costs least.
|
|
*
|
|
* Re-reading comes FIRST, and on macOS that is the whole fix: when a token has genuinely been revoked,
|
|
* the machine usually already holds a good one — Claude Code refreshed it into the Keychain minutes
|
|
* ago and never touched our copy. Spending our own refresh token in that situation is not just
|
|
* wasteful, it is how the two stores got out of step to begin with.
|
|
*
|
|
* Only when nobody else has moved do we refresh ourselves.
|
|
*/
|
|
async function recoverFromUnauthorized(usedToken: string): Promise<string | null> {
|
|
const loaded = await loadCredentials();
|
|
const current = loaded?.creds.claudeAiOauth?.accessToken?.trim();
|
|
if (current && current !== usedToken) {
|
|
console.log('[claude:proxy] 401 — a newer token was already on disk, retrying with it');
|
|
return current;
|
|
}
|
|
console.log('[claude:proxy] 401 — refreshing');
|
|
return forceRefresh();
|
|
}
|
|
|
|
/**
|
|
* Keep a valid token ahead of the next request, so nobody discovers an expired one by being told to
|
|
* wait three minutes for a chat that then fails to authenticate.
|
|
*
|
|
* Runs once at start-up and every {@link WATCHDOG_INTERVAL_MS} after. Every failure is logged and
|
|
* swallowed: a refresh that cannot happen must not take the proxy down with it, because the token on
|
|
* hand may still have hours left.
|
|
*/
|
|
export function startCredentialWatchdog(): void {
|
|
const tick = async () => {
|
|
const loaded = await loadCredentials();
|
|
const oauth = loaded?.creds.claudeAiOauth;
|
|
if (!loaded || !oauth?.refreshToken) return;
|
|
|
|
const remaining = (oauth.expiresAt ?? 0) - Date.now();
|
|
if (remaining > WATCHDOG_REFRESH_WITHIN_MS) return;
|
|
|
|
const refreshed = await refreshOAuthToken(oauth.refreshToken);
|
|
if (!refreshed?.accessToken) {
|
|
console.error('[claude:proxy] watchdog refresh failed — the next request will try again');
|
|
return;
|
|
}
|
|
loaded.creds.claudeAiOauth = { ...oauth, ...refreshed };
|
|
await saveCredentials(loaded.creds, loaded.source);
|
|
console.log(`[claude:proxy] watchdog refreshed the token from the ${loaded.source}`);
|
|
};
|
|
|
|
const run = () => {
|
|
tick().catch((err) => console.error('[claude:proxy] watchdog error:', err));
|
|
};
|
|
run();
|
|
setInterval(run, WATCHDOG_INTERVAL_MS).unref?.();
|
|
}
|
|
|
|
export function getProxySecret(): string {
|
|
return getState().proxySecret;
|
|
}
|
|
|
|
export function ensureProxySecret(): string {
|
|
const state = getState();
|
|
if (state.proxySecret) return state.proxySecret;
|
|
|
|
// Generate once, persist forever
|
|
const secret = `sk-ant-api03-${crypto.randomUUID()}`;
|
|
updateState({ proxySecret: secret });
|
|
return secret;
|
|
}
|
|
|
|
export function startAnthropicProxy() {
|
|
const secret = ensureProxySecret();
|
|
|
|
Bun.serve({
|
|
port: PROXY_PORT,
|
|
hostname: '127.0.0.1',
|
|
idleTimeout: 0,
|
|
|
|
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) {
|
|
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
status: 401,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
// Get valid token (refreshes if needed)
|
|
const token = await getValidToken();
|
|
if (!token) {
|
|
return new Response(JSON.stringify({ error: 'No OAuth token available' }), {
|
|
status: 502,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
// Build upstream URL
|
|
const upstream = `${ANTHROPIC_API_BASE}${url.pathname}${url.search}`;
|
|
|
|
// Clone headers, swap proxy secret for the OAuth bearer token. Anthropic
|
|
// only accepts Claude Pro/Max OAuth tokens via Authorization + the oauth beta header.
|
|
// Preserve any anthropic-beta values Claude Code sent (context-management, etc.)
|
|
// and append oauth-2025-04-20 alongside them.
|
|
const headers = new Headers(req.headers);
|
|
headers.delete('x-api-key');
|
|
headers.set('Authorization', `Bearer ${token}`);
|
|
const existingBeta = headers.get('anthropic-beta');
|
|
const betas = existingBeta
|
|
? existingBeta
|
|
.split(',')
|
|
.map((s) => s.trim())
|
|
.filter(Boolean)
|
|
: [];
|
|
if (!betas.includes('oauth-2025-04-20')) betas.push('oauth-2025-04-20');
|
|
headers.set('anthropic-beta', betas.join(','));
|
|
headers.delete('host');
|
|
|
|
// Read request body fully before forwarding
|
|
const body = req.body ? await req.arrayBuffer() : null;
|
|
|
|
// Forward request
|
|
const upstreamRes = await fetch(upstream, {
|
|
method: req.method,
|
|
headers,
|
|
body,
|
|
});
|
|
|
|
// If upstream returns 401, recover the credential and retry once
|
|
if (upstreamRes.status === 401) {
|
|
const recovered = await recoverFromUnauthorized(token);
|
|
if (recovered) {
|
|
headers.set('Authorization', `Bearer ${recovered}`);
|
|
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) {
|
|
const lower = key.toLowerCase();
|
|
if (lower === 'content-encoding' || lower === 'content-length' || lower === 'transfer-encoding') continue;
|
|
resHeaders.set(key, value);
|
|
}
|
|
|
|
return new Response(upstreamRes.body, {
|
|
status: upstreamRes.status,
|
|
statusText: upstreamRes.statusText,
|
|
headers: resHeaders,
|
|
});
|
|
},
|
|
});
|
|
|
|
console.log(`[claude:proxy] listening on 127.0.0.1:${PROXY_PORT}`);
|
|
if (IS_MACOS) console.log(`[claude:proxy] macOS — reading credentials from the "${KEYCHAIN_SERVICE}" Keychain item`);
|
|
startCredentialWatchdog();
|
|
}
|