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>
154 lines
7.7 KiB
TypeScript
154 lines
7.7 KiB
TypeScript
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<TailnetContext> {
|
|
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<Response> {
|
|
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;
|
|
}
|
|
}
|