Files
offscale/sidecar/claude-proxy.ts
T
pastilhasandClaude Opus 5 8a446bb4b5 offscale, extracted from the platform into its own repository
The tailnet plugin — machines, users, pre-auth keys, access policy and device
invites. Moved out of officerdev/platform, where it had lived in plugins/ since
the plugin system was built.

Until now this code existed in exactly one place: the platform repository. That
made "gitignore the plugins directory" impossible to do safely, because
untracking it would have left 49 files on a single disk with no remote. This
repository is what makes that move safe.

Same extraction as plugins/music before it: source only, no history. The
platform's history still holds every commit that shaped this, and the SHAs cited
across the codebase keep resolving — replaying it here would have created a
second, divergent account of the same work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 18:12:57 +00:00

98 lines
4.4 KiB
TypeScript

import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { DATA_PATH } from '@@/data-path';
import { ANTHROPIC_PROXY_URL } from '@@/officer-url.mjs';
// One-shot model calls, for sidecar features that need a sentence of reasoning rather than an agent.
//
// The target is `officer-anthropic-proxy` on loopback — the same process Claude Code itself talks to. It
// holds the owner's OAuth credential and refreshes it; callers hold nothing. Its `x-api-key` is a locally
// generated secret it writes to its own state file, so authenticating is a file read, not a credential this
// sidecar is given. That file is written by the proxy and read by everyone else; see claude/state.ts
// (`readProxySecretFromDisk`), which does the same thing for the agent.
//
// Not imported from claude/state.ts on purpose: that module initialises paths and a lock for a sidecar this
// one is not. Twenty lines of file read is a better dependency than another sidecar's lifecycle.
//
// This is a REQUEST-SCOPED call with a timeout, not a session. Anything conversational belongs in the chat
// surface, which already exists and already persists.
const DEFAULT_TIMEOUT_MS = 120_000;
/** The proxy is not running, has no token, or refused us. Distinct from the model declining to answer. */
export class ProxyUnavailable extends Error {}
/**
* The proxy's own generated secret. Empty means "not on disk yet" — it persists on a debounce, so a
* freshly installed machine has a window where the file exists without it.
*/
function readProxySecret(): string {
try {
const file = join(DATA_PATH, 'sidecar', 'claude-state.json');
if (!existsSync(file)) return '';
const parsed = JSON.parse(readFileSync(file, 'utf-8')) as { proxySecret?: unknown };
return typeof parsed.proxySecret === 'string' ? parsed.proxySecret : '';
} catch {
return '';
}
}
type AskParams = { model: string; system: string; prompt: string; maxTokens: number; timeoutMs?: number };
type MessagesResponse = { content?: { type?: string; text?: string }[]; error?: { message?: string } };
/**
* One user turn, one reply, as plain text.
*
* The system prompt is sent as two blocks with Claude Code's own identity first. The proxy authenticates
* with a Claude Pro/Max OAuth token, and that credential is issued to the CLI — asking it to be something
* else is a request the upstream is entitled to refuse. (Measured 2026-08-06: a plain assistant prompt is
* currently accepted too. Keeping the block costs ~14 tokens and removes the question.)
*/
export async function askClaude({ model, system, prompt, maxTokens, timeoutMs }: AskParams): Promise<string> {
const secret = readProxySecret();
if (!secret) throw new ProxyUnavailable('the Claude proxy has not started yet — try again in a moment');
let res: Response;
try {
res = await fetch(`${ANTHROPIC_PROXY_URL}/v1/messages`, {
method: 'POST',
headers: { 'x-api-key': secret, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
body: JSON.stringify({
model,
max_tokens: maxTokens,
system: [
{ type: 'text', text: "You are Claude Code, Anthropic's official CLI for Claude." },
{ type: 'text', text: system },
],
messages: [{ role: 'user', content: prompt }],
}),
signal: AbortSignal.timeout(timeoutMs ?? DEFAULT_TIMEOUT_MS),
});
} catch (err) {
if (err instanceof Error && err.name === 'TimeoutError') throw new ProxyUnavailable('the model took too long');
throw new ProxyUnavailable('the Claude proxy is not reachable');
}
const body = (await res.json().catch(() => null)) as MessagesResponse | null;
if (!res.ok) {
const detail = body?.error?.message;
// 401/429 are the proxy's own credential problems and read as "unavailable"; anything else is upstream
// saying something specific about this request, which is worth passing through.
if (res.status === 401 || res.status === 429) {
throw new ProxyUnavailable(detail ?? `the Claude proxy returned ${res.status}`);
}
throw new Error(detail ?? `the model returned ${res.status}`);
}
const text = (body?.content ?? [])
.filter((block) => block.type === 'text' && typeof block.text === 'string')
.map((block) => block.text)
.join('')
.trim();
if (!text) throw new Error('the model returned an empty reply');
return text;
}