The policy editor now opens read-only behind an Edit button, and edit mode brings up an assistant: describe the change, get a complete revised document back, read a line diff of it, and only then press Save. Nobody writes an ACL from memory — it is HuJSON in Tailscale's grammar keyed to names only the server knows — and that, not typing speed, is what made this screen unusable. The model never touches Headscale. It proposes text, the text lands in the editor's draft, and the existing Save button is still the only thing that leaves the browser. A model that could write the ACL directly is one that can partition the network the owner is connected through. The sidecar calls officer-anthropic-proxy on loopback for one request with a timeout — no session, no agent, nothing persisted. It sends the draft on screen plus the tailnet's vocabulary (user, node and tag names) and no credentials of any kind. It does not validate the reply either: Headscale owns the only parser that counts, same argument as policy.ts.
98 lines
4.4 KiB
TypeScript
98 lines
4.4 KiB
TypeScript
import { existsSync, readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
// 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 PROXY_PORT = process.env.ANTHROPIC_PROXY_PORT ?? '5051';
|
|
const DATA_PATH = process.env.DATA_PATH ?? '';
|
|
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(`http://127.0.0.1:${PROXY_PORT}/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;
|
|
}
|