diff --git a/src/servers/sidecar/headscale/assist.ts b/src/servers/sidecar/headscale/assist.ts new file mode 100644 index 00000000..c2d220fc --- /dev/null +++ b/src/servers/sidecar/headscale/assist.ts @@ -0,0 +1,153 @@ +import type { OfficerContext } from './routes'; +import { badRequest, methodNotAllowed, readJson } from './routes'; +import { activeClient } from './active'; +import { arrayField, toNode, toUser } from './normalize'; +import { askClaude, ProxyUnavailable } from './claude-proxy'; + +// `POST /_officer/policy/assist` — describe a change in English, get a complete revised policy back. +// +// The ACL is the one document in this app that nobody can write from memory: HuJSON, Tailscale's grammar, +// and every rule keyed to user, tag and host names that only this server knows. The gap this closes is not +// "typing is slow", it is "I do not know what the file is supposed to look like". +// +// THREE THINGS THIS DELIBERATELY DOES NOT DO. +// +// 1. **It never saves.** The proposal comes back as text and lands in the editor as a draft. Headscale is +// written to by exactly one thing, the Save button, and it is pressed by a person who has read the diff. +// A model that could write the ACL directly is a model that can partition the network the owner is +// connected through — including the SSH route back in to fix it. +// 2. **It never validates.** Same argument as policy.ts: Headscale owns the only parser that counts, and a +// proposal that looks fine here and is refused on save is a normal, visible outcome. +// 3. **It sends no credentials.** The prompt carries user names, node names and tags — the vocabulary the +// rules must reference — and nothing else. No API keys, no pre-auth keys, no node addresses. +// +// The current document is sent in full and the reply must be the full replacement, not a patch. Patches +// against a hand-formatted HuJSON file are where comments and alignment get silently destroyed, and this +// file's whole premise is that those are worth keeping. + +const MODEL = 'claude-sonnet-5'; +const MAX_TOKENS = 8_000; +/** A prompt long enough to be an essay is a prompt that should be a conversation. Cheap guard, not a limit. */ +const MAX_PROMPT_CHARS = 2_000; +/** Enough context to write rules against without pasting an entire large tailnet into the request. */ +const MAX_NODES = 60; + +const SYSTEM = `You are helping the owner of a self-hosted Headscale server edit their tailnet's ACL policy. + +The policy is a HuJSON document (JSON with // comments and trailing commas) in Tailscale's ACL format: +groups, tagOwners, hosts, acls, ssh, autoApprovers. Headscale implements a subset — it has no Tailscale SaaS +features such as nodeAttrs postures, and grants are supported only in recent versions, so prefer classic +"acls" entries unless the existing document already uses grants. + +Rules for your reply, in this order: + +1. First, one short paragraph of plain English: what you changed and, where it matters, what it now allows or + denies. No preamble, no restating the request. +2. Then the COMPLETE new policy document inside a single fenced code block tagged hujson. Not a patch, not an + excerpt — the whole file, ready to replace what is there. + +Preserve the existing document's comments, key order and indentation wherever your change does not touch +them; they are hand-maintained and the owner reads this file. Only reference users, tags and hosts that exist +in the context given to you, or that you also define in the same document. If the request is ambiguous enough +that you would have to guess at something consequential, say so in the paragraph and make the narrower, +safer choice rather than asking a question — the owner reviews a diff before anything is saved. + +If the request cannot be expressed in this policy at all, say why in the paragraph and return the document +unchanged in the code block.`; + +type TailnetContext = { users: string[]; tags: string[]; nodes: string[] }; + +/** + * The vocabulary a usable rule has to be written in: who exists, what tags are in use, what the machines are + * called. Best-effort — a server that will not answer these still gets an assistant, just a less informed + * one, which beats failing the request over context that is an optimisation. + */ +async function readContext(userId: number): Promise { + const client = await activeClient(userId); + if (client instanceof Response) return { users: [], tags: [], nodes: [] }; + + try { + const [userBody, nodeBody] = await Promise.all([client.call('/api/v1/user'), client.call('/api/v1/node')]); + + const users = arrayField(userBody, 'users') + .map((raw) => toUser(raw)?.name) + .filter((name): name is string => !!name); + + const nodes = arrayField(nodeBody, 'nodes').map(toNode); + const tags = [...new Set(nodes.flatMap((node) => node.tags))].sort(); + const named = nodes.slice(0, MAX_NODES).map((node) => { + const owner = node.user?.name ?? 'unknown'; + const tagged = node.tags.length ? ` [${node.tags.join(' ')}]` : ''; + return `${node.name} (user: ${owner})${tagged}`; + }); + + return { users, tags, nodes: named }; + } catch { + return { users: [], tags: [], nodes: [] }; + } +} + +function buildPrompt(policy: string, request: string, context: TailnetContext): string { + const lines = [ + 'Current policy document:', + '```hujson', + policy.trim() || '// (this server has no policy yet)', + '```', + '', + 'This tailnet:', + `- users: ${context.users.length ? context.users.join(', ') : '(none)'}`, + `- tags in use: ${context.tags.length ? context.tags.join(', ') : '(none)'}`, + `- machines: ${context.nodes.length ? context.nodes.join('; ') : '(none)'}`, + ]; + if (context.nodes.length === MAX_NODES) lines.push(` (first ${MAX_NODES} shown)`); + lines.push('', 'Requested change:', request.trim()); + return lines.join('\n'); +} + +/** + * Split the reply into the explanation and the document. + * + * The fence is the contract, so a reply without one is a failure to report rather than something to salvage: + * feeding half an answer into the editor as if it were a policy is worse than saying the model didn't comply. + */ +function splitReply(text: string): { explanation: string; policy: string } | null { + const match = text.match(/```(?:hujson|json|jsonc)?\s*\n([\s\S]*?)```/); + if (!match || !match[1]?.trim()) return null; + return { explanation: text.slice(0, match.index).trim(), policy: match[1].replace(/\s+$/, '') }; +} + +/** `POST /_officer/policy/assist {prompt, policy}` → `{explanation, policy}`. Nothing is written upstream. */ +export async function handlePolicyAssistRoute(ctx: OfficerContext, rest: string[]): Promise { + if (rest.length > 0) return badRequest('unexpected path'); + if (ctx.req.method !== 'POST') return methodNotAllowed(); + + const body = await readJson(ctx.req); + const request = typeof body?.prompt === 'string' ? body.prompt.trim() : ''; + if (!request) return badRequest('prompt is required'); + if (request.length > MAX_PROMPT_CHARS) return badRequest(`prompt must be under ${MAX_PROMPT_CHARS} characters`); + // The draft on screen, not the saved document: the owner may have edited it, and a proposal built against + // a version they cannot see would come back as a diff full of changes they never asked for. + const policy = typeof body?.policy === 'string' ? body.policy : ''; + + const context = await readContext(ctx.userId); + + try { + const reply = await askClaude({ + model: MODEL, + maxTokens: MAX_TOKENS, + system: SYSTEM, + prompt: buildPrompt(policy, request, context), + }); + + const split = splitReply(reply); + if (!split) { + return Response.json({ error: 'the model did not return a policy document — try rephrasing' }, { status: 502 }); + } + return Response.json({ explanation: split.explanation, policy: split.policy }); + } catch (err) { + if (err instanceof ProxyUnavailable) { + return Response.json({ error: err.message, code: 'assistant_unavailable' }, { status: 503 }); + } + throw err; + } +} diff --git a/src/servers/sidecar/headscale/claude-proxy.ts b/src/servers/sidecar/headscale/claude-proxy.ts new file mode 100644 index 00000000..5219dd55 --- /dev/null +++ b/src/servers/sidecar/headscale/claude-proxy.ts @@ -0,0 +1,97 @@ +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 { + 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; +} diff --git a/src/servers/sidecar/headscale/policy.ts b/src/servers/sidecar/headscale/policy.ts index 38dde6da..8a5a3bd2 100644 --- a/src/servers/sidecar/headscale/policy.ts +++ b/src/servers/sidecar/headscale/policy.ts @@ -2,6 +2,7 @@ import type { OfficerContext } from './routes'; import { badRequest, methodNotAllowed, readJson } from './routes'; import { HeadscaleError } from './client'; import { activeClient } from './active'; +import { handlePolicyAssistRoute } from './assist'; // The ACL policy — /_officer/policy. One HuJSON document that decides which node may reach which, so it is // the highest-consequence thing this app can write and the only place a typo silently partitions a network. @@ -103,8 +104,10 @@ async function putPolicy(ctx: OfficerContext): Promise { } } -/** Dispatch `/_officer/policy`. No sub-paths: there is exactly one policy per server. */ +/** Dispatch `/_officer/policy`. One policy per server, plus the drafting assistant beside it. */ export async function handlePolicyRoute(ctx: OfficerContext, rest: string[]): Promise { + // `/policy/assist` proposes a document; it never writes one. See assist.ts. + if (rest[0] === 'assist') return handlePolicyAssistRoute(ctx, rest.slice(1)); if (rest.length > 0) return badRequest('unexpected path'); if (ctx.req.method === 'GET') return getPolicy(ctx); if (ctx.req.method === 'PUT') return putPolicy(ctx); diff --git a/src/workspaces/officerdev/src/apps/Headscale/PolicyAssistant.tsx b/src/workspaces/officerdev/src/apps/Headscale/PolicyAssistant.tsx new file mode 100644 index 00000000..4bfed3f9 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Headscale/PolicyAssistant.tsx @@ -0,0 +1,177 @@ +import { useMemo, useState } from 'react'; +import { Check, Loader2, Sparkles, Wand2, X } from 'lucide-react'; +import { useHeadscalePolicyAssist, assistFailure } from './useHeadscalePolicy'; +import { collapseUnchanged, diffCounts, diffLines } from './diff'; +import { Button, Card, ErrorNote } from './Cards'; + +// Ask for a policy change in English; read the diff; decide. +// +// The whole point of this panel is the middle step. The model is good at the grammar — HuJSON, tagOwners, +// the src/dst shapes — and has no idea which of the owner's machines matter, so its proposal is a draft to +// be read, not an answer to be trusted. Nothing here writes to Headscale: Apply puts the text in the editor +// above and the existing Save button is still the only thing that leaves the browser. +// +// The diff is against what is CURRENTLY in the editor, which is also what was sent up, so it always shows +// exactly what accepting would change on screen — including edits the owner made and hasn't saved. + +const EXAMPLES = [ + 'let everyone reach the machines tagged tag:server on port 22', + 'stop the phones from reaching anything except the DNS server', + 'add a group for family with just my own user in it', +]; + +const DiffBody = ({ before, after }: { before: string; after: string }) => { + const lines = useMemo(() => diffLines(before, after), [before, after]); + const rows = useMemo(() => collapseUnchanged(lines), [lines]); + const { added, removed } = useMemo(() => diffCounts(lines), [lines]); + + if (!added && !removed) { + return

No change — the proposal matches what you have.

; + } + + return ( + <> +
+ +{added} + −{removed} + unchanged lines collapsed +
+
+ {rows.map((row, index) => + row === null ? ( +
+ ⋯ +
+ ) : ( +
+ {row.kind === 'add' ? '+' : row.kind === 'remove' ? '−' : ' '} {row.text} +
+ ), + )} +
+ + ); +}; + +type PolicyAssistantProps = { + /** The text on screen right now. Sent up as the base, and diffed against. */ + policy: string; + /** Accepting a proposal — puts it in the editor's draft. Never saves. */ + onApply: (policy: string) => void; + disabled?: boolean; +}; + +export const PolicyAssistant = ({ policy, onApply, disabled }: PolicyAssistantProps) => { + const assist = useHeadscalePolicyAssist(); + const [prompt, setPrompt] = useState(''); + // The proposal lives here, not in the mutation's `data`, so that discarding it is a real state change and + // a second ask doesn't briefly show the previous answer against the new base. + const [proposal, setProposal] = useState<{ explanation: string; policy: string } | null>(null); + + const ask = async () => { + const request = prompt.trim(); + if (!request || assist.isPending) return; + setProposal(null); + try { + setProposal(await assist.mutateAsync({ prompt: request, policy })); + } catch { + // Rendered from `assist.error` below — mutateAsync rejecting is the same failure twice. + } + }; + + const apply = () => { + if (!proposal) return; + onApply(proposal.policy); + setProposal(null); + setPrompt(''); + assist.reset(); + }; + + return ( + +
+ + Describe the change + Proposes a document — never saves it +
+ +
+