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:
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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,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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user