offscale is a plugin

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>
This commit is contained in:
2026-08-15 00:15:38 +00:00
co-authored by Claude Opus 5
parent 0e24aa3d52
commit e13128846b
111 changed files with 351 additions and 302 deletions
+11 -2
View File
@@ -55,7 +55,13 @@ async function listTaskFiles(): Promise<TaskFile[]> {
const p = join(tasksDir, f);
const st = await stat(p).catch(() => null);
if (!st) continue;
out.push({ taskId: f.slice(0, -'.output'.length), path: p, cwdLabel: cwd.name, sizeBytes: st.size, mtimeMs: st.mtimeMs });
out.push({
taskId: f.slice(0, -'.output'.length),
path: p,
cwdLabel: cwd.name,
sizeBytes: st.size,
mtimeMs: st.mtimeMs,
});
}
}
}
@@ -109,7 +115,10 @@ activityRouter.post('/announce', async (ctx) => {
if (!safe) return ctx.text('path is not under an allowed root', 403);
await mkdir(dirname(ANNOUNCED_PATH), { recursive: true });
const list = await readAnnounced();
const next = [{ name: body.name, path: safe, ts: Date.now() }, ...list.filter((a) => a.name !== body.name)].slice(0, 100);
const next = [{ name: body.name, path: safe, ts: Date.now() }, ...list.filter((a) => a.name !== body.name)].slice(
0,
100,
);
await writeFile(ANNOUNCED_PATH, JSON.stringify(next));
return ctx.json({ ok: true, name: body.name, path: safe });
});
+1 -3
View File
@@ -11,9 +11,7 @@ const RELAY_TOKEN_CONTEXT = 'officer-browser-relay-v1';
// tokens from the platform's signing key is the coupling the per-purpose split exists to remove.
export function deriveRelayToken(userId: number, port: number, salt: string): string {
return createHmac('sha256', getKey('jwt'))
.update(`${RELAY_TOKEN_CONTEXT}:${port}:${userId}:${salt}`)
.digest('hex');
return createHmac('sha256', getKey('jwt')).update(`${RELAY_TOKEN_CONTEXT}:${port}:${userId}:${salt}`).digest('hex');
}
const tokenToUser = new Map<string, number>();
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* @deprecated Legacy chat types - Use types from ./chat/types.ts instead
*
*
* This file is kept for backward compatibility with existing code.
* New code should import from ./chat/types.ts
*/
+2 -2
View File
@@ -26,11 +26,11 @@ function formatTimestamp(): string {
function formatContext(context?: LogContext): string {
if (!context || Object.keys(context).length === 0) return '';
const lines = Object.entries(context)
.map(([key, value]) => ` ${key}=${value}`)
.join('\n');
return '\n' + lines;
}
-18
View File
@@ -1,18 +0,0 @@
import { createSidecarProxy } from '../../sidecar/create-proxy';
// /api/headscale/* — auth, then forward to officer-headscale. No routes of its own and no headscale knowledge:
// this file must never grow app logic.
//
// The sidecar exposes only Officer-owned routes under `/_officer/` — Headscale's REST shape differs
// across releases, and version handling belongs in the sidecar. It holds the admin API key; the platform
// does not know Headscale's URL.
const proxy = createSidecarProxy({
name: 'headscale',
prefix: '/api/headscale',
});
export const headscaleRouter = proxy.router;
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
export const getHeadscaleServerUrl = proxy.getHttpUrl;
+7 -4
View File
@@ -38,7 +38,9 @@ function buildTransportUrl(config: SmtpConfig): string {
if (config.provider === 'mailhog') {
return `smtp://${config.host ?? 'localhost'}:${config.port ?? 1025}`;
}
const auth = config.username ? `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password ?? '')}@` : '';
const auth = config.username
? `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password ?? '')}@`
: '';
const protocol = config.secure ? 'smtps' : 'smtp';
return `${protocol}://${auth}${config.host}:${config.port ?? 587}`;
}
@@ -73,7 +75,7 @@ smtpRouter.post('/test-connection', async (ctx) => {
if (result.type === 'resend') {
const res = await fetch('https://api.resend.com/domains', {
headers: { 'Authorization': `Bearer ${result.apiKey}` },
headers: { Authorization: `Bearer ${result.apiKey}` },
});
if (!res.ok) {
const err = await res.json();
@@ -103,14 +105,15 @@ smtpRouter.post('/test', async (ctx) => {
}
const from = `${body.fromName} <${body.fromEmail}>`;
const testHtml = '<h2>Officer Test Email</h2><p>If you received this, your email configuration is working correctly.</p>';
const testHtml =
'<h2>Officer Test Email</h2><p>If you received this, your email configuration is working correctly.</p>';
try {
if (body.provider === 'resend') {
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${body.apiKey}`,
Authorization: `Bearer ${body.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ from, to: body.to, subject: 'officer.dev Test Email', html: testHtml }),
@@ -57,7 +57,16 @@ async function readDisks() {
const { stdout } = await exec('df', [
'-B1',
'--output=target,fstype,size,used,pcent',
'-x', 'tmpfs', '-x', 'devtmpfs', '-x', 'squashfs', '-x', 'overlay', '-x', 'efivarfs',
'-x',
'tmpfs',
'-x',
'devtmpfs',
'-x',
'squashfs',
'-x',
'overlay',
'-x',
'efivarfs',
]);
return stdout
.trim()
@@ -131,7 +140,9 @@ async function readTemps() {
}
const CPU_DRIVERS = ['k10temp', 'zenpower', 'coretemp', 'k8temp', 'cpu_thermal'];
const cpu =
sensors.find((s) => CPU_DRIVERS.includes(s.name.toLowerCase()) && /tctl|tdie|package|composite|core 0/i.test(s.label)) ??
sensors.find(
(s) => CPU_DRIVERS.includes(s.name.toLowerCase()) && /tctl|tdie|package|composite|core 0/i.test(s.label),
) ??
sensors.find((s) => CPU_DRIVERS.includes(s.name.toLowerCase())) ??
null;
return { cpuC: cpu?.celsius ?? null, cpuLabel: cpu ? `${cpu.name} · ${cpu.label}` : null, sensors };
@@ -151,15 +162,18 @@ async function readGpu() {
const busyRaw = await readFile(`${dev}/gpu_busy_percent`, 'utf8').catch(() => null);
if (busyRaw == null) continue;
const busyPct = Number.parseInt(busyRaw.trim(), 10);
const vramUsed = Number.parseInt((await readFile(`${dev}/mem_info_vram_used`, 'utf8').catch(() => '0')).trim(), 10) || 0;
const vramTotal = Number.parseInt((await readFile(`${dev}/mem_info_vram_total`, 'utf8').catch(() => '0')).trim(), 10) || 0;
const vramUsed =
Number.parseInt((await readFile(`${dev}/mem_info_vram_used`, 'utf8').catch(() => '0')).trim(), 10) || 0;
const vramTotal =
Number.parseInt((await readFile(`${dev}/mem_info_vram_total`, 'utf8').catch(() => '0')).trim(), 10) || 0;
return { busyPct: Number.isFinite(busyPct) ? busyPct : 0, vramUsedBytes: vramUsed, vramTotalBytes: vramTotal };
}
return null;
}
// Network throughput — bytes/sec computed from the delta since the previous /stats call (~2s window).
let lastNet: { total: { rx: number; tx: number }; per: Record<string, { rx: number; tx: number }>; ts: number } | null = null;
let lastNet: { total: { rx: number; tx: number }; per: Record<string, { rx: number; tx: number }>; ts: number } | null =
null;
async function readNet() {
const now = Date.now();
let data: string;
@@ -185,11 +199,20 @@ async function readNet() {
}
const prev = lastNet;
lastNet = { total: { rx: totRx, tx: totTx }, per, ts: now };
if (!prev || now <= prev.ts) return { rxBytesPerSec: 0, txBytesPerSec: 0, interfaces: [] as { name: string; rxBytesPerSec: number; txBytesPerSec: number }[] };
if (!prev || now <= prev.ts)
return {
rxBytesPerSec: 0,
txBytesPerSec: 0,
interfaces: [] as { name: string; rxBytesPerSec: number; txBytesPerSec: number }[],
};
const dt = (now - prev.ts) / 1000;
const rate = (cur: number, old: number) => Math.max(0, Math.round((cur - old) / dt));
const interfaces = Object.entries(per)
.map(([name, v]) => ({ name, rxBytesPerSec: rate(v.rx, prev.per[name]?.rx ?? v.rx), txBytesPerSec: rate(v.tx, prev.per[name]?.tx ?? v.tx) }))
.map(([name, v]) => ({
name,
rxBytesPerSec: rate(v.rx, prev.per[name]?.rx ?? v.rx),
txBytesPerSec: rate(v.tx, prev.per[name]?.tx ?? v.tx),
}))
.filter((i) => i.rxBytesPerSec > 0 || i.txBytesPerSec > 0)
.sort((a, b) => b.rxBytesPerSec + b.txBytesPerSec - (a.rxBytesPerSec + a.txBytesPerSec));
return { rxBytesPerSec: rate(totRx, prev.total.rx), txBytesPerSec: rate(totTx, prev.total.tx), interfaces };
@@ -215,7 +238,9 @@ async function readPower() {
for (const d of await readdir('/sys/class/hwmon')) {
const base = `/sys/class/hwmon/${d}`;
if ((await readFile(`${base}/name`, 'utf8').catch(() => '')).trim() !== 'amdgpu') continue;
const p = (await readFile(`${base}/power1_average`, 'utf8').catch(() => null)) ?? (await readFile(`${base}/power1_input`, 'utf8').catch(() => null));
const p =
(await readFile(`${base}/power1_average`, 'utf8').catch(() => null)) ??
(await readFile(`${base}/power1_input`, 'utf8').catch(() => null));
if (p != null) {
const uw = Number.parseInt(p.trim(), 10);
if (Number.isFinite(uw)) gpuWatts = Math.round((uw / 1e6) * 10) / 10;
@@ -233,8 +258,7 @@ systemMonitorRouter.get('/stats', async (ctx) => {
await new Promise((r) => setTimeout(r, 120));
const second = await readCpuSample().catch(() => null);
const cpuUsage =
first && second ? pct(second.busy - first.busy, second.total - first.total) : 0;
const cpuUsage = first && second ? pct(second.busy - first.busy, second.total - first.total) : 0;
const perCore =
first && second
? second.perCore.map((c, i) => {
@@ -311,7 +335,9 @@ systemMonitorRouter.get('/pm2', async (ctx) => {
// GET /docker — running docker containers (the "dockers" scope).
systemMonitorRouter.get('/docker', async (ctx) => {
try {
const { stdout } = await exec('docker', ['ps', '--no-trunc', '--format', '{{json .}}'], { maxBuffer: 8 * 1024 * 1024 });
const { stdout } = await exec('docker', ['ps', '--no-trunc', '--format', '{{json .}}'], {
maxBuffer: 8 * 1024 * 1024,
});
return ctx.json({
containers: stdout
.trim()
+10 -2
View File
@@ -43,11 +43,19 @@ export function descendantPids(root: number): number[] {
export function killTree(root: number) {
const pids = [root, ...descendantPids(root)];
for (const pid of pids) {
try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ }
try {
process.kill(pid, 'SIGTERM');
} catch {
/* already gone */
}
}
setTimeout(() => {
for (const pid of pids) {
try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ }
try {
process.kill(pid, 'SIGKILL');
} catch {
/* gone */
}
}
}, 2000);
}
+1 -6
View File
@@ -36,12 +36,7 @@ import { validatePassword } from '../auth/validate-password';
* The specials are a subset of the class `validatePassword` accepts, chosen to survive being copied,
* pasted, quoted in a shell and read aloud: no quotes, no backslash, no backtick.
*/
const CLASSES = [
'abcdefghijkmnopqrstuvwxyz',
'ABCDEFGHJKLMNPQRSTUVWXYZ',
'23456789',
'!@#$%^&*()-_=+',
] as const;
const CLASSES = ['abcdefghijkmnopqrstuvwxyz', 'ABCDEFGHJKLMNPQRSTUVWXYZ', '23456789', '!@#$%^&*()-_=+'] as const;
const PASSWORD_LENGTH = 20;