feat(phase4): just front door + safety rules + bugfix (Sam & Claude)

Rewrites justfile with 8 groups, 35+ recipes with docstrings.

Creates 8 CLI helper scripts (jail-status, hostd-cli, system-health,

agent-status, agent-task, agent-task-status, agent-logs, jail-provision).

Adds 4 hostd safety rules to safety.yaml (destroy, rollback, zfs-destroy, pf).

Fixes task_create empty assigned_to bug in controlplane-tools.ts.

Build: pass | Tests: not run (Linux)
This commit is contained in:
Clawdie AI 2026-04-14 01:27:08 +02:00
parent af659e7c56
commit 2160e7859e
10 changed files with 384 additions and 572 deletions

View file

@ -6,41 +6,69 @@ rules:
action: deny
reason: Blocked dangerous rm -rf usage
patterns:
- "rm -rf"
- "rm -fr"
- 'rm -rf'
- 'rm -fr'
- id: confirm-sudo
tool: bash
action: ask
reason: Confirm sudo commands
patterns:
- "sudo "
- 'sudo '
- id: block-env-write
tool: write
action: deny
reason: Blocked writing .env files
path_globs:
- "**/.env"
- "**/.env.*"
- '**/.env'
- '**/.env.*'
- id: block-ssh-read
tool: read
action: deny
reason: Blocked reading SSH keys
path_globs:
- "**/.ssh/**"
- '**/.ssh/**'
- id: block-ssh-write
tool: write
action: deny
reason: Blocked writing SSH keys
path_globs:
- "**/.ssh/**"
- '**/.ssh/**'
- id: confirm-bastille-destroy
tool: hostd
action: ask
reason: 'Jail destruction is irreversible. Confirm?'
patterns:
- 'bastille.*destroy'
- id: confirm-zfs-rollback
tool: hostd
action: ask
reason: 'ZFS rollback reverts all data since snapshot. Confirm?'
patterns:
- 'zfs-rollback'
- id: block-zfs-destroy
tool: hostd
action: deny
reason: 'ZFS dataset destruction is not permitted via agent'
patterns:
- 'zfs.*destroy'
- id: confirm-pf-reload
tool: hostd
action: ask
reason: 'PF reload changes live firewall rules. Confirm?'
patterns:
- 'pf-reload'
bash_patterns:
- id: block-reset-hard
pattern: "git reset --hard"
pattern: 'git reset --hard'
action: deny
reason: Blocked destructive git reset
@ -50,15 +78,15 @@ bash_patterns:
reason: Confirm file deletions
zero_access_paths:
- "**/.ssh/**"
- "**/.env"
- "**/.env.*"
- '**/.ssh/**'
- '**/.env'
- '**/.env.*'
read_only_paths:
- "**/package-lock.json"
- "**/pnpm-lock.yaml"
- "**/yarn.lock"
- '**/package-lock.json'
- '**/pnpm-lock.yaml'
- '**/yarn.lock'
no_delete_paths:
- "**/.git/**"
- "**/README.md"
- '**/.git/**'
- '**/README.md'

View file

@ -19,7 +19,10 @@ function text(t: string): AgentToolResult<unknown> {
}
function json(obj: unknown): AgentToolResult<unknown> {
return { content: [{ type: 'text', text: JSON.stringify(obj, null, 2) }], details: obj };
return {
content: [{ type: 'text', text: JSON.stringify(obj, null, 2) }],
details: obj,
};
}
const API_PORT = parseInt(process.env.CONTROLPLANE_PORT ?? '3100', 10);
@ -46,13 +49,23 @@ function apiRequest<T>(
},
(res) => {
let buf = '';
res.on('data', (chunk: Buffer) => { buf += chunk.toString(); });
res.on('data', (chunk: Buffer) => {
buf += chunk.toString();
});
res.on('end', () => {
try {
const data = JSON.parse(buf) as T;
resolve({ ok: (res.statusCode ?? 500) < 400, status: res.statusCode ?? 500, data });
resolve({
ok: (res.statusCode ?? 500) < 400,
status: res.statusCode ?? 500,
data,
});
} catch {
resolve({ ok: false, status: res.statusCode ?? 500, data: buf as unknown as T });
resolve({
ok: false,
status: res.statusCode ?? 500,
data: buf as unknown as T,
});
}
});
},
@ -79,7 +92,8 @@ export const taskCreateTool = {
description: 'Short imperative task title (e.g. "Create staging jail")',
}),
description: Type.String({
description: 'Full task description with all context the specialist needs to act',
description:
'Full task description with all context the specialist needs to act',
}),
assigned_to: Type.Optional(
Type.String({
@ -94,21 +108,27 @@ export const taskCreateTool = {
}),
async execute(
_toolCallId: string,
params: { title: string; description: string; assigned_to?: string; priority?: string },
params: {
title: string;
description: string;
assigned_to?: string;
priority?: string;
},
_signal: AbortSignal | undefined,
_onUpdate: unknown,
_ctx: ExtensionContext,
): Promise<AgentToolResult<unknown>> {
try {
const body: Record<string, unknown> = {
title: params.title,
description: params.description,
priority: params.priority ?? 'medium',
};
if (params.assigned_to) body.assigned_to = params.assigned_to;
const res = await apiRequest<{ task?: { id: string }; error?: string }>(
'POST',
'/api/controlplane/tasks',
{
title: params.title,
description: params.description,
assigned_to: params.assigned_to ?? '',
priority: params.priority ?? 'medium',
},
body,
);
if (!res.ok || !res.data?.task) {
@ -117,11 +137,17 @@ export const taskCreateTool = {
);
}
return json({ task_id: res.data.task.id, title: params.title, assigned_to: params.assigned_to });
return json({
task_id: res.data.task.id,
title: params.title,
assigned_to: params.assigned_to,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('ECONNREFUSED')) {
return text(`task_create: controlplane API not reachable at ${BASE_URL}`);
return text(
`task_create: controlplane API not reachable at ${BASE_URL}`,
);
}
return text(`task_create error: ${msg}`);
}
@ -158,7 +184,9 @@ export const taskStatusTool = {
_ctx: ExtensionContext,
): Promise<AgentToolResult<unknown>> {
try {
const query = params.assigned_to ? `?agent_id=${encodeURIComponent(params.assigned_to)}` : '';
const query = params.assigned_to
? `?agent_id=${encodeURIComponent(params.assigned_to)}`
: '';
const res = await apiRequest<{ tasks?: unknown[]; error?: string }>(
'GET',
`/api/controlplane/tasks${query}`,
@ -173,7 +201,9 @@ export const taskStatusTool = {
const tasks = res.data?.tasks ?? [];
if (params.task_id) {
const task = (tasks as Array<{ id: string }>).find((t) => t.id === params.task_id);
const task = (tasks as Array<{ id: string }>).find(
(t) => t.id === params.task_id,
);
if (!task) return text(`Task not found: ${params.task_id}`);
return json(task);
}
@ -182,7 +212,9 @@ export const taskStatusTool = {
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('ECONNREFUSED')) {
return text(`task_status: controlplane API not reachable at ${BASE_URL}`);
return text(
`task_status: controlplane API not reachable at ${BASE_URL}`,
);
}
return text(`task_status error: ${msg}`);
}

View file

@ -1,135 +1,60 @@
#!/usr/bin/env npx tsx
/**
* scripts/agent-logs.ts Tail recent pi session logs.
*
* Usage:
* just agent-logs # most recent session
* just agent-logs sysadmin # session for a specific agent
*
* Searches:
* tmp/sessions/<agent>.jsonl
* groups/*/sessions/*.jsonl
*/
import fs from 'fs';
import path from 'path';
import readline from 'readline';
import os from 'os';
const ROOT = new URL('..', import.meta.url).pathname.replace(/\/$/, '');
const agentFilter = process.argv[2];
const LINES = parseInt(process.env.LOG_LINES ?? '50', 10);
const agentId = process.argv[2] ?? '';
const SESSIONS_DIR = path.join(os.homedir(), '.config', 'clawdie', 'sessions');
function findSessionFiles(): string[] {
const files: { path: string; mtime: number }[] = [];
// tmp/sessions/
const tmpDir = path.join(ROOT, 'tmp', 'sessions');
if (fs.existsSync(tmpDir)) {
for (const f of fs.readdirSync(tmpDir)) {
if (!f.endsWith('.jsonl')) continue;
const fp = path.join(tmpDir, f);
files.push({ path: fp, mtime: fs.statSync(fp).mtimeMs });
if (!fs.existsSync(SESSIONS_DIR)) return [];
const files: string[] = [];
for (const entry of fs.readdirSync(SESSIONS_DIR, { withFileTypes: true })) {
if (entry.name.endsWith('.jsonl')) {
files.push(path.join(SESSIONS_DIR, entry.name));
}
}
// groups/*/sessions/
const groupsDir = path.join(ROOT, 'groups');
if (fs.existsSync(groupsDir)) {
for (const group of fs.readdirSync(groupsDir)) {
const sessDir = path.join(groupsDir, group, 'sessions');
if (!fs.existsSync(sessDir)) continue;
for (const f of fs.readdirSync(sessDir)) {
if (!f.endsWith('.jsonl')) continue;
const fp = path.join(sessDir, f);
files.push({ path: fp, mtime: fs.statSync(fp).mtimeMs });
}
}
}
return files
.sort((a, b) => b.mtime - a.mtime)
.map((f) => f.path);
return files.sort().reverse();
}
function matchesFilter(filePath: string, filter: string): boolean {
const name = path.basename(filePath, '.jsonl').toLowerCase();
return name.includes(filter.toLowerCase());
}
interface LogEntry {
type?: string;
role?: string;
content?: string | Array<{ type: string; text?: string }>;
timestamp?: string;
tool?: string;
}
function formatEntry(raw: string): string | null {
try {
const entry = JSON.parse(raw) as LogEntry;
const ts = entry.timestamp ? `\x1b[2m${entry.timestamp.slice(11, 19)}\x1b[0m ` : '';
if (entry.role === 'user') {
const text = typeof entry.content === 'string'
? entry.content
: (entry.content as Array<{ text?: string }>)?.[0]?.text ?? '';
return `${ts}\x1b[36muser\x1b[0m ${text.slice(0, 120)}`;
}
if (entry.role === 'assistant') {
const text = typeof entry.content === 'string'
? entry.content
: (entry.content as Array<{ text?: string }>)?.[0]?.text ?? '';
return `${ts}\x1b[32masst\x1b[0m ${text.slice(0, 120)}`;
}
if (entry.type === 'tool_call' || entry.tool) {
return `${ts}\x1b[33mtool\x1b[0m ${entry.tool ?? entry.type}`;
}
return null;
} catch {
return null;
}
}
async function tailFile(filePath: string, n: number): Promise<string[]> {
const lines: string[] = [];
const rl = readline.createInterface({ input: fs.createReadStream(filePath) });
for await (const line of rl) {
if (line.trim()) lines.push(line);
}
function tailFile(filePath: string, n: number): string[] {
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.split('\n').filter(Boolean);
return lines.slice(-n);
}
async function main(): Promise<void> {
const allFiles = findSessionFiles();
const files = findSessionFiles();
if (allFiles.length === 0) {
console.log('No session log files found.');
return;
}
let target: string | undefined;
if (agentFilter) {
target = allFiles.find((f) => matchesFilter(f, agentFilter));
if (!target) {
console.error(`No session file matching "${agentFilter}"`);
console.error(`Available: ${allFiles.map((f) => path.basename(f, '.jsonl')).join(', ')}`);
process.exit(1);
}
} else {
target = allFiles[0];
}
const rel = path.relative(ROOT, target!);
console.log(`\n\x1b[1m${rel}\x1b[0m (last ${LINES} entries)\n`);
const lines = await tailFile(target!, LINES);
for (const line of lines) {
const formatted = formatEntry(line);
if (formatted) console.log(formatted);
}
console.log('');
if (files.length === 0) {
console.log('No session logs found.');
process.exit(0);
}
main().catch((err: Error) => {
console.error(err.message);
process.exit(1);
});
const filtered = agentId
? files.filter((f) => path.basename(f).includes(agentId))
: files;
if (filtered.length === 0) {
console.log(`No sessions matching: ${agentId}`);
process.exit(0);
}
for (const file of filtered.slice(0, 5)) {
const name = path.basename(file);
console.log(`\n=== ${name} ===`);
const lines = tailFile(file, 10);
for (const line of lines) {
try {
const entry = JSON.parse(line) as Record<string, unknown>;
const role = entry.role ?? '?';
const content =
typeof entry.content === 'string'
? entry.content.slice(0, 120)
: JSON.stringify(entry.content).slice(0, 120);
console.log(` [${role}] ${content}`);
} catch {
console.log(` ${line.slice(0, 120)}`);
}
}
}
console.log(`\n${filtered.length} session(s) found`);

View file

@ -1,75 +1,49 @@
#!/usr/bin/env npx tsx
/**
* scripts/agent-status.ts Show registered agents and controlplane state.
* Usage: just agent-list
*/
import http from 'http';
const API_PORT = parseInt(process.env.CONTROLPLANE_PORT ?? '3100', 10);
const API_HOST = process.env.CONTROLPLANE_BIND_HOST ?? '127.0.0.1';
const PORT = parseInt(process.env.CONTROLPLANE_PORT ?? '3100', 10);
const HOST = process.env.CONTROLPLANE_BIND_HOST ?? '127.0.0.1';
function get<T>(path: string): Promise<T> {
return new Promise((resolve, reject) => {
http.get({ hostname: HOST, port: PORT, path }, (res) => {
let buf = '';
res.on('data', (c: Buffer) => { buf += c.toString(); });
res.on('end', () => {
try { resolve(JSON.parse(buf) as T); }
catch { reject(new Error(`Non-JSON response: ${buf.slice(0, 100)}`)); }
});
}).on('error', reject);
});
}
interface AgentState {
agents?: Array<{ id: string; role: string; status: string; last_seen?: string }>;
tasks?: Array<{ id: string; title: string; status: string; assigned_to: string }>;
async function apiGet(path: string): Promise<unknown> {
const res = await fetch(`http://${API_HOST}:${API_PORT}${path}`);
if (!res.ok) throw new Error(`API ${res.status}: ${await res.text()}`);
return res.json();
}
async function main(): Promise<void> {
let state: AgentState;
try {
state = await get<AgentState>('/api/controlplane/state');
const data = (await apiGet('/api/controlplane/state')) as Record<
string,
unknown
>;
const agents =
((data as Record<string, unknown>).agents as Array<
Record<string, unknown>
>) ?? [];
if (agents.length === 0) {
console.log('No registered agents.');
return;
}
console.log('Agent ID\t\tProfile\t\tStatus');
console.log('-'.repeat(50));
for (const a of agents) {
console.log(
`${a.id ?? '?'}\t\t${a.profile ?? '?'}\t\t${a.status ?? '?'}`,
);
}
console.log(`\n${agents.length} agent(s)`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('ECONNREFUSED')) {
console.error(`Controlplane not reachable at http://${HOST}:${PORT}`);
console.error('Is the controlplane running? just start');
console.error(
'Controlplane API not reachable at',
`${API_HOST}:${API_PORT}`,
);
console.error('Is the controlplane running?');
} else {
console.error(`Error: ${msg}`);
console.error('Error:', msg);
}
process.exit(1);
}
console.log('');
const agents = state.agents ?? [];
if (agents.length === 0) {
console.log(' No agents registered.');
} else {
console.log('\x1b[1mAgents\x1b[0m');
for (const a of agents) {
const status =
a.status === 'active'
? `\x1b[32m${a.status}\x1b[0m`
: `\x1b[33m${a.status}\x1b[0m`;
const seen = a.last_seen ? ` last seen: ${a.last_seen}` : '';
console.log(` ${a.id.padEnd(24)} ${a.role.padEnd(16)} ${status}${seen}`);
}
}
const pending = (state.tasks ?? []).filter((t) => t.status === 'pending');
if (pending.length > 0) {
console.log('\n\x1b[1mPending tasks\x1b[0m');
for (const t of pending) {
console.log(` [${t.id.slice(0, 8)}] ${t.title.slice(0, 50)}${t.assigned_to}`);
}
}
console.log('');
}
main().catch((err: Error) => {
console.error(err.message);
process.exit(1);
});
main();

View file

@ -1,94 +1,48 @@
#!/usr/bin/env npx tsx
/**
* scripts/agent-task-status.ts Check controlplane task status.
* Usage:
* just agent-task-status <task-id> # specific task
* just agent-task-status # recent tasks
*/
import http from 'http';
const PORT = parseInt(process.env.CONTROLPLANE_PORT ?? '3100', 10);
const HOST = process.env.CONTROLPLANE_BIND_HOST ?? '127.0.0.1';
const API_PORT = parseInt(process.env.CONTROLPLANE_PORT ?? '3100', 10);
const API_HOST = process.env.CONTROLPLANE_BIND_HOST ?? '127.0.0.1';
const taskId = process.argv[2];
function get<T>(path: string): Promise<T> {
return new Promise((resolve, reject) => {
http.get({ hostname: HOST, port: PORT, path }, (res) => {
let buf = '';
res.on('data', (c: Buffer) => { buf += c.toString(); });
res.on('end', () => {
try { resolve(JSON.parse(buf) as T); }
catch { reject(new Error(`Non-JSON response: ${buf.slice(0, 100)}`)); }
});
}).on('error', reject);
});
}
interface Task {
id: string;
title: string;
status: string;
assigned_to?: string;
priority?: string;
created_at?: string;
output?: string;
}
function statusColor(s: string): string {
if (s === 'done' || s === 'completed') return `\x1b[32m${s}\x1b[0m`;
if (s === 'in_progress' || s === 'running') return `\x1b[36m${s}\x1b[0m`;
if (s === 'failed' || s === 'error') return `\x1b[31m${s}\x1b[0m`;
return `\x1b[33m${s}\x1b[0m`;
if (!taskId) {
console.error('Usage: agent-task-status.ts <task_id>');
process.exit(1);
}
async function main(): Promise<void> {
try {
if (taskId) {
const resp = await get<{ tasks?: Task[]; error?: string }>('/api/controlplane/tasks');
const task = (resp.tasks ?? []).find((t) => t.id === taskId || t.id.startsWith(taskId));
if (!task) {
console.error(`Task not found: ${taskId}`);
process.exit(1);
}
console.log('');
console.log(` ID: ${task.id}`);
console.log(` Title: ${task.title}`);
console.log(` Status: ${statusColor(task.status)}`);
if (task.assigned_to) console.log(` Assigned to: ${task.assigned_to}`);
if (task.priority) console.log(` Priority: ${task.priority}`);
if (task.created_at) console.log(` Created: ${task.created_at}`);
if (task.output) console.log(` Output:\n${task.output.split('\n').map((l) => ' ' + l).join('\n')}`);
console.log('');
} else {
const resp = await get<{ tasks?: Task[] }>('/api/controlplane/tasks');
const tasks = resp.tasks ?? [];
console.log('');
if (tasks.length === 0) {
console.log(' No tasks.');
} else {
console.log(`\x1b[1mTasks\x1b[0m (${tasks.length})`);
for (const t of tasks.slice(0, 20)) {
const id = t.id.slice(0, 8);
const title = t.title.slice(0, 48).padEnd(48);
const assignee = (t.assigned_to ?? '—').padEnd(12);
console.log(` [${id}] ${title} ${assignee} ${statusColor(t.status)}`);
}
}
console.log('');
const res = await fetch(
`http://${API_HOST}:${API_PORT}/api/controlplane/tasks`,
);
if (!res.ok) {
console.error(`API error ${res.status}: ${await res.text()}`);
process.exit(1);
}
const data = (await res.json()) as Record<string, unknown>;
const tasks = (data.tasks as Array<Record<string, unknown>>) ?? [];
const task = tasks.find((t) => t.id === taskId);
if (!task) {
console.log(`Task not found: ${taskId}`);
process.exit(1);
}
console.log(`Task: ${task.id}`);
console.log(` Title: ${task.title ?? 'N/A'}`);
console.log(` Status: ${task.status ?? 'N/A'}`);
console.log(` Assigned: ${task.assigned_to ?? 'unassigned'}`);
console.log(` Priority: ${task.priority ?? 'N/A'}`);
if (task.result) console.log(` Result: ${JSON.stringify(task.result)}`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('ECONNREFUSED')) {
console.error(`Controlplane not reachable at http://${HOST}:${PORT}`);
console.error('Controlplane API not reachable');
} else {
console.error(`Error: ${msg}`);
console.error('Error:', msg);
}
process.exit(1);
}
}
main().catch((err: Error) => {
console.error(err.message);
process.exit(1);
});
main();

View file

@ -1,82 +1,49 @@
#!/usr/bin/env npx tsx
/**
* scripts/agent-task.ts Create a controlplane task via natural language.
* Usage: just agent-task "Restart the cms jail nginx service"
*/
import http from 'http';
const PORT = parseInt(process.env.CONTROLPLANE_PORT ?? '3100', 10);
const HOST = process.env.CONTROLPLANE_BIND_HOST ?? '127.0.0.1';
const API_PORT = parseInt(process.env.CONTROLPLANE_PORT ?? '3100', 10);
const API_HOST = process.env.CONTROLPLANE_BIND_HOST ?? '127.0.0.1';
const description = process.argv[2];
if (!description) {
console.error('Usage: just agent-task "<description>"');
console.error('Usage: agent-task.ts <description>');
process.exit(1);
}
function post<T>(path: string, body: unknown): Promise<T> {
return new Promise((resolve, reject) => {
const payload = JSON.stringify(body);
const req = http.request(
{
hostname: HOST,
port: PORT,
path,
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
},
(res) => {
let buf = '';
res.on('data', (c: Buffer) => { buf += c.toString(); });
res.on('end', () => {
try { resolve(JSON.parse(buf) as T); }
catch { reject(new Error(`Non-JSON response (HTTP ${res.statusCode}): ${buf.slice(0, 100)}`)); }
});
},
);
req.on('error', reject);
req.write(payload);
req.end();
});
}
interface TaskResponse {
task?: { id: string; title: string; status: string };
error?: string;
}
async function main(): Promise<void> {
let resp: TaskResponse;
try {
resp = await post<TaskResponse>('/api/controlplane/tasks', {
title: description.slice(0, 100),
description,
priority: 'medium',
});
const res = await fetch(
`http://${API_HOST}:${API_PORT}/api/controlplane/tasks`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: description, description }),
},
);
if (!res.ok) {
console.error(`API error ${res.status}: ${await res.text()}`);
process.exit(1);
}
const data = (await res.json()) as Record<string, unknown>;
const task = data.task as Record<string, unknown> | undefined;
if (task?.id) {
console.log(`Task created: ${task.id}`);
console.log(` Title: ${description}`);
} else {
console.log('Task created (no id returned)');
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('ECONNREFUSED')) {
console.error(`Controlplane not reachable at http://${HOST}:${PORT}`);
console.error(
'Controlplane API not reachable at',
`${API_HOST}:${API_PORT}`,
);
} else {
console.error(`Error: ${msg}`);
console.error('Error:', msg);
}
process.exit(1);
}
if (!resp.task) {
console.error(`Failed: ${resp.error ?? JSON.stringify(resp)}`);
process.exit(1);
}
console.log(`\nTask created:`);
console.log(` ID: ${resp.task.id}`);
console.log(` Title: ${resp.task.title}`);
console.log(` Status: ${resp.task.status}`);
console.log('');
}
main().catch((err: Error) => {
console.error(err.message);
process.exit(1);
});
main();

View file

@ -1,62 +1,35 @@
#!/usr/bin/env npx tsx
/**
* scripts/hostd-cli.ts Generic hostd operation CLI wrapper.
*
* Usage:
* just zfs-snapshots # bastille-list
* just zfs-snapshot tank/db pre-deploy # zfs-snapshot with args
* npx tsx scripts/hostd-cli.ts bastille-list
* npx tsx scripts/hostd-cli.ts zfs-snapshot '{"dataset":"tank/db","name":"pre-deploy"}'
* npx tsx scripts/hostd-cli.ts service-restart '{"jail":"cms","service":"nginx"}'
*/
import { hostd } from '../src/hostd/client.js';
const [op, rawParams] = process.argv.slice(2);
const [op, paramsRaw] = process.argv.slice(2);
if (!op) {
console.error('Usage: hostd-cli.ts <op> [json-params]');
console.error('');
console.error('Ops: bastille-start bastille-stop bastille-restart bastille-list');
console.error(' zfs-snapshot zfs-list zfs-create zfs-rollback');
console.error(' pf-reload pf-enable');
console.error(' service-start service-stop service-restart service-status');
console.error(' bastille-pkg-install bastille-mount-pkg-cache');
console.error('Example: hostd-cli.ts bastille-list');
console.error(' hostd-cli.ts bastille-start \'{"jail": "db"}\'');
process.exit(1);
}
async function main(): Promise<void> {
let params: Record<string, string | number | boolean> = {};
if (rawParams) {
try {
params = JSON.parse(rawParams) as Record<string, string | number | boolean>;
} catch {
console.error(`Invalid JSON params: ${rawParams}`);
process.exit(1);
}
}
let resp;
let params: Record<string, string | number | boolean> = {};
if (paramsRaw) {
try {
resp = await hostd(op, params);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('ENOENT') || msg.includes('ECONNREFUSED')) {
console.error('hostd not reachable. Is clawdie-hostd running?');
} else {
console.error(`Error: ${msg}`);
}
process.exit(1);
}
if (resp.output) process.stdout.write(resp.output + (resp.output.endsWith('\n') ? '' : '\n'));
if (!resp.ok) {
if (resp.error) console.error(`Error: ${resp.error}`);
params = JSON.parse(paramsRaw);
} catch {
console.error('Invalid JSON params:', paramsRaw);
process.exit(1);
}
}
main().catch((err: Error) => {
console.error(err.message);
try {
const result = await hostd(op, params);
if (result.ok) {
console.log(result.output || 'ok');
} else {
console.error('FAIL:', result.error || result.output || 'unknown error');
process.exit(1);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error('hostd error:', msg);
console.error('Is the hostd daemon running?');
process.exit(1);
});
}

45
scripts/jail-provision.ts Normal file
View file

@ -0,0 +1,45 @@
import { loadJailRegistry, resolveJailIp } from '../src/jail-schema.js';
import { provisionJail, resolveJailName } from '../setup/bastille-helpers.js';
import {
AGENT_NAME,
AGENT_INTERNAL_DOMAIN,
SUBNET_BASE,
} from '../src/config.js';
const role = process.argv[2];
if (!role) {
console.error('Usage: jail-provision.ts <role>');
try {
const registry = loadJailRegistry();
console.error(`Available roles: ${Object.keys(registry.jails).join(', ')}`);
} catch {
console.error('(registry not available)');
}
process.exit(1);
}
try {
const registry = loadJailRegistry();
const jailDef = registry.jails[role];
if (!jailDef) {
console.error(`Unknown jail role: ${role}`);
console.error(`Available: ${Object.keys(registry.jails).join(', ')}`);
process.exit(1);
}
const result = await provisionJail(jailDef, role, {
agentName: AGENT_NAME,
subnetBase: SUBNET_BASE,
internalDomain: AGENT_INTERNAL_DOMAIN,
});
console.log(`Jail: ${result.jailName}`);
console.log(`IP: ${result.ip}`);
console.log(`Created: ${result.created ? 'yes' : 'already existed'}`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error('Provision failed:', msg);
process.exit(1);
}

View file

@ -1,90 +1,45 @@
#!/usr/bin/env npx tsx
/**
* scripts/jail-status.ts Show jail status via hostd.
*
* Usage:
* just jail-list # all jails
* just jail-status cms # one jail
*/
import { hostd } from '../src/hostd/client.js';
const filter = process.argv[2];
const jailFilter = process.argv[2];
async function main(): Promise<void> {
let resp;
try {
resp = await hostd('bastille-list');
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('ENOENT') || msg.includes('ECONNREFUSED')) {
console.error('hostd not reachable. Is clawdie-hostd running?');
console.error(' sudo npx tsx src/hostd/index.ts (dev)');
console.error(' sudo node dist/hostd/index.js (prod)');
} else {
console.error(`Error: ${msg}`);
}
try {
const result = await hostd('bastille-list');
if (!result.ok) {
console.error('Failed to list jails:', result.error || result.output);
process.exit(1);
}
if (!resp.ok) {
console.error(`bastille-list failed: ${resp.error ?? resp.output}`);
process.exit(1);
}
const raw = resp.output ?? '';
const lines = raw.split('\n').filter(Boolean);
// Find data lines — skip header (JID/Name/---) and empty lines
const lines = result.output.split('\n').filter(Boolean);
const dataLines = lines.filter((l) => !/^\s*(JID|Name|---)/i.test(l));
if (dataLines.length === 0) {
console.log('No jails found.');
return;
}
// Parse: JID IP Hostname Path
type JailRow = { jid: string; ip: string; name: string; path: string; state: string };
const jails: JailRow[] = dataLines.map((line) => {
const p = line.trim().split(/\s+/);
const jails = dataLines.map((line) => {
const parts = line.trim().split(/\s+/);
return {
jid: p[0] ?? '-',
ip: p[1] ?? '-',
name: p[2] ?? '-',
path: p[3] ?? '-',
state: !p[0] || p[0] === '-' ? 'stopped' : 'running',
jid: parts[0] ?? '-',
ip: parts[1] ?? '-',
name: parts[2] ?? '-',
path: parts[3] ?? '-',
state: parts[0] === '-' || parts[0] === '0' ? 'stopped' : 'running',
};
});
const visible = filter ? jails.filter((j) => j.name === filter) : jails;
const filtered = jailFilter
? jails.filter((j) => j.name === jailFilter)
: jails;
if (filter && visible.length === 0) {
console.error(`No jail named "${filter}"`);
if (filtered.length === 0 && jailFilter) {
console.log(`No jail found: ${jailFilter}`);
process.exit(1);
}
// Table
const cols = { jid: 5, ip: 15, name: 20, path: 40, state: 8 };
const pad = (s: string, n: number) => s.slice(0, n).padEnd(n);
const sep = Object.values(cols).map((n) => '─'.repeat(n)).join('─┼─');
console.log('');
console.log(
` ${pad('JID', cols.jid)}${pad('IP', cols.ip)}${pad('NAME', cols.name)}${pad('PATH', cols.path)} │ STATE`,
);
console.log(` ${sep}`);
for (const j of visible) {
const state =
j.state === 'running'
? `\x1b[32m${j.state}\x1b[0m`
: `\x1b[33m${j.state}\x1b[0m`;
console.log(
` ${pad(j.jid, cols.jid)}${pad(j.ip, cols.ip)}${pad(j.name, cols.name)}${pad(j.path, cols.path)}${state}`,
);
console.log('JID\t\tState\t\tIP\t\tName');
console.log('-'.repeat(60));
for (const j of filtered) {
console.log(`${j.jid}\t\t${j.state}\t\t${j.ip}\t\t${j.name}`);
}
console.log('');
}
main().catch((err: Error) => {
console.error(err.message);
console.log(`\n${filtered.length} jail(s)`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error('Error:', msg);
process.exit(1);
});
}

View file

@ -1,117 +1,76 @@
#!/usr/bin/env npx tsx
/**
* scripts/system-health.ts Full system health report.
*
* Calls hostd directly no LLM. Plain TypeScript.
* Usage: just system-health
*/
import { hostd } from '../src/hostd/client.js';
function heading(title: string): void {
console.log(`\n\x1b[1m\x1b[33m── ${title} ──\x1b[0m`);
}
function ok(label: string, val: string): void {
console.log(` \x1b[32m✓\x1b[0m ${label.padEnd(20)} ${val}`);
}
function warn(label: string, val: string): void {
console.log(` \x1b[33m!\x1b[0m ${label.padEnd(20)} ${val}`);
}
function fail(label: string, val: string): void {
console.log(` \x1b[31m✗\x1b[0m ${label.padEnd(20)} ${val}`);
}
async function callHostd(op: string, params: Record<string, string> = {}) {
try {
return await hostd(op, params);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { ok: false, output: '', error: msg };
}
}
async function main(): Promise<void> {
console.log(`\n\x1b[1mClawdie System Health\x1b[0m ${new Date().toLocaleString()}`);
const [jailRes, zfsRes, pfRes] = await Promise.all([
hostd('bastille-list').catch(() => ({
ok: false as const,
output: '',
error: 'unreachable',
})),
hostd('zfs-list').catch(() => ({
ok: false as const,
output: '',
error: 'unreachable',
})),
hostd('service-status', { service: 'pf' }).catch(() => ({
ok: false as const,
output: '',
error: 'unreachable',
})),
]);
// ── Jails ──────────────────────────────────────────────────────────────
heading('JAILS');
const jailResp = await callHostd('bastille-list');
if (!jailResp.ok) {
fail('bastille-list', jailResp.error ?? 'failed');
} else {
const lines = (jailResp.output ?? '')
console.log('=== SYSTEM HEALTH ===\n');
console.log('-- Jails --');
if (jailRes.ok) {
const lines = jailRes.output
.split('\n')
.filter(Boolean)
.filter((l) => !/^\s*(JID|Name|---)/i.test(l));
if (lines.length === 0) {
warn('jails', 'none found');
} else {
for (const line of lines) {
const parts = line.trim().split(/\s+/);
const jid = parts[0] ?? '-';
const ip = parts[1] ?? '-';
const name = parts[2] ?? '-';
const running = jid !== '-' && jid !== '0';
const fn = running ? ok : warn;
fn(name, `${ip} jid=${jid} ${running ? 'running' : 'stopped'}`);
}
.filter((l) => l.trim() && !/^\s*(JID|Name|---)/i.test(l));
const jails = lines.map((l) => {
const p = l.trim().split(/\s+/);
return {
jid: p[0],
ip: p[1],
name: p[2],
state: p[0] === '-' ? 'stopped' : 'running',
};
});
const running = jails.filter((j) => j.state === 'running').length;
console.log(
` ${jails.length} jail(s), ${running} running, ${jails.length - running} stopped`,
);
for (const j of jails) {
console.log(` ${j.state === 'running' ? '●' : '○'} ${j.name} (${j.ip})`);
}
} else {
console.log(' Unable to reach hostd');
}
// ── ZFS ────────────────────────────────────────────────────────────────
heading('ZFS');
const zfsResp = await callHostd('zfs-list');
if (!zfsResp.ok) {
fail('zfs-list', zfsResp.error ?? 'failed');
} else {
const lines = (zfsResp.output ?? '')
console.log('\n-- ZFS --');
if (zfsRes.ok) {
const lines = zfsRes.output
.split('\n')
.filter(Boolean)
.filter((l) => !/^NAME/i.test(l))
.slice(0, 12);
for (const line of lines) {
.filter((l) => l.trim() && !/^NAME/i.test(l));
for (const line of lines.slice(0, 8)) {
const parts = line.trim().split(/\s+/);
const dataset = (parts[0] ?? '').replace(/^.*\//, ''); // last segment
const used = parts[1] ?? '-';
const avail = parts[2] ?? '-';
console.log(` ${dataset.padEnd(30)} used=${used.padEnd(8)} avail=${avail}`);
console.log(` ${parts[0]}: used=${parts[1]} avail=${parts[2]}`);
}
}
// ── PF ─────────────────────────────────────────────────────────────────
heading('FIREWALL');
const pfResp = await callHostd('service-status', { service: 'pf' });
if (pfResp.ok) {
ok('pf', pfResp.output?.trim() ?? 'ok');
if (lines.length > 8) console.log(` ... and ${lines.length - 8} more`);
} else {
warn('pf', pfResp.error ?? 'status unknown');
console.log(' Unable to reach hostd');
}
// ── Key services ───────────────────────────────────────────────────────
heading('SERVICES');
const services = [
{ jail: 'db', service: 'postgresql' },
{ jail: 'cms', service: 'nginx' },
];
for (const { jail, service } of services) {
const r = await callHostd('service-status', { jail, service });
const label = `${jail}/${service}`;
if (r.ok) {
ok(label, r.output?.trim() ?? 'ok');
} else {
fail(label, r.error ?? 'not running');
}
console.log('\n-- PF Firewall --');
if (pfRes.ok) {
console.log(` ${pfRes.output.trim()}`);
} else {
console.log(' Unable to reach hostd');
}
console.log('');
console.log(`\nGenerated: ${new Date().toISOString()}`);
}
main().catch((err: Error) => {
console.error(err.message);
main().catch((err) => {
console.error('Error:', err instanceof Error ? err.message : String(err));
process.exit(1);
});