feat(booterm): structured pty_exited WS notifications. Plan-validated, impl-validated, code-reviewed green (contracts build clean, contracts test 29/29, booterm + web typecheck clean). wip: in-progress inference/provider refactor (agents.ts, provider.ts, new llama-providers.ts, removed llama-args-validator), plus arena, dispatcher, compaction, schema changes. openspec: pty-exit-notifications complete; x-agent-flags planned (not yet implemented).
362 lines
13 KiB
TypeScript
362 lines
13 KiB
TypeScript
/**
|
|
* P9.1: SSH config editor for llama-swap hosts.
|
|
*
|
|
* Pipeline (design §5, stackctl flow with the tests stackctl never had):
|
|
* SFTP/SSH read -> schema-validated edit (config-schema.json from the fork)
|
|
* -> diff preview -> timestamped backup -> write -> restart -> health-wait.
|
|
*
|
|
* SSH I/O is shelled out via `ssh` (matching the booterm precedent — no ssh2
|
|
* dependency, key from `secrets/`), injected as `SshExec` so every failure path
|
|
* is unit-testable without a live host. The pure helpers (validate, diff,
|
|
* backup filename) carry the logic and are tested directly.
|
|
*/
|
|
|
|
import { spawn } from 'node:child_process';
|
|
import { createRequire } from 'node:module';
|
|
import { load as loadYaml } from 'js-yaml';
|
|
import type { ValidateFunction } from 'ajv';
|
|
|
|
// ajv + ajv-formats are CJS. Under NodeNext ESM the default-import interop binds
|
|
// the namespace, not the constructable class, so load them via createRequire to
|
|
// get the real module.exports (class / plugin fn) at both type and runtime.
|
|
const require = createRequire(import.meta.url);
|
|
const Ajv = require('ajv') as typeof import('ajv').default;
|
|
const addFormats = require('ajv-formats') as typeof import('ajv-formats').default;
|
|
|
|
// ─── host SSH target ─────────────────────────────────────────────────────────
|
|
|
|
export interface SshTarget {
|
|
host: string;
|
|
user: string;
|
|
keyPath: string;
|
|
}
|
|
|
|
export interface ExecResult {
|
|
code: number;
|
|
stdout: string;
|
|
stderr: string;
|
|
}
|
|
|
|
/** Injectable SSH executor. `stdin`, when present, is piped to the remote command. */
|
|
export type SshExec = (target: SshTarget, command: string, stdin?: string) => Promise<ExecResult>;
|
|
|
|
// ─── pure: schema validation ─────────────────────────────────────────────────
|
|
|
|
export interface ValidationResult {
|
|
valid: boolean;
|
|
errors: string[];
|
|
/** Parsed config object when YAML is syntactically valid. */
|
|
parsed?: unknown;
|
|
}
|
|
|
|
let cachedValidator: ValidateFunction | null = null;
|
|
let cachedSchemaRef: object | null = null;
|
|
|
|
function getValidator(schema: object): ValidateFunction {
|
|
if (cachedValidator && cachedSchemaRef === schema) return cachedValidator;
|
|
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
addFormats(ajv);
|
|
const validate = ajv.compile(schema);
|
|
cachedValidator = validate;
|
|
cachedSchemaRef = schema;
|
|
return validate;
|
|
}
|
|
|
|
/**
|
|
* Validate a llama-swap config YAML string against the fork's
|
|
* config-schema.json. Catches YAML syntax errors first, then schema errors.
|
|
* Pure — no I/O; the schema object is passed in.
|
|
*/
|
|
export function validateLlamaConfig(yamlText: string, schema: object): ValidationResult {
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = loadYaml(yamlText);
|
|
} catch (err) {
|
|
return { valid: false, errors: [`YAML parse error: ${(err as Error).message}`] };
|
|
}
|
|
if (parsed === null || typeof parsed !== 'object') {
|
|
return { valid: false, errors: ['config must be a YAML mapping'], parsed };
|
|
}
|
|
|
|
const validate = getValidator(schema);
|
|
const ok = validate(parsed);
|
|
if (ok) return { valid: true, errors: [], parsed };
|
|
|
|
const errors = (validate.errors ?? []).map((e) => {
|
|
const path = e.instancePath || '(root)';
|
|
return `${path} ${e.message ?? 'invalid'}`;
|
|
});
|
|
return { valid: false, errors: errors.length ? errors : ['schema validation failed'], parsed };
|
|
}
|
|
|
|
// ─── pure: unified-ish diff ──────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Produce a compact line diff between two texts. Trims a common prefix/suffix
|
|
* and marks the changed middle with -/+ lines. Sufficient for a preview; not a
|
|
* minimal-edit Myers diff.
|
|
*/
|
|
export function computeDiff(oldText: string, newText: string): string {
|
|
const oldLines = oldText.split('\n');
|
|
const newLines = newText.split('\n');
|
|
|
|
let start = 0;
|
|
while (start < oldLines.length && start < newLines.length && oldLines[start] === newLines[start]) {
|
|
start++;
|
|
}
|
|
let endOld = oldLines.length - 1;
|
|
let endNew = newLines.length - 1;
|
|
while (endOld >= start && endNew >= start && oldLines[endOld] === newLines[endNew]) {
|
|
endOld--;
|
|
endNew--;
|
|
}
|
|
|
|
if (endOld < start && endNew < start) return ''; // identical
|
|
|
|
const out: string[] = [];
|
|
out.push(`@@ lines ${start + 1}..${endOld + 1} -> ${start + 1}..${endNew + 1} @@`);
|
|
for (let i = start; i <= endOld; i++) out.push(`- ${oldLines[i]}`);
|
|
for (let i = start; i <= endNew; i++) out.push(`+ ${newLines[i]}`);
|
|
return out.join('\n');
|
|
}
|
|
|
|
// ─── pure: backup filename ───────────────────────────────────────────────────
|
|
|
|
/** Timestamped backup path: `<configPath>.bak-YYYYMMDDTHHMMSSZ`. */
|
|
export function backupFilename(configPath: string, now: Date): string {
|
|
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
|
return `${configPath}.bak-${stamp}`;
|
|
}
|
|
|
|
// ─── RemoteOps seam (shell vs wrapper) ───────────────────────────────────────
|
|
//
|
|
// 'shell' mode issues raw shell commands (P9.1 behavior). 'wrapper' mode issues
|
|
// fixed verbs so the key can be bound to an authorized_keys forced command that
|
|
// hardcodes the paths. Both drive the same apply pipeline.
|
|
|
|
export type SshMode = 'shell' | 'wrapper';
|
|
|
|
export interface RemoteOps {
|
|
read(): Promise<string>;
|
|
backup(now: Date): Promise<string>; // returns the backup path
|
|
write(content: string): Promise<void>;
|
|
restart(restartCmd: string): Promise<void>;
|
|
}
|
|
|
|
function fail(label: string, res: ExecResult): never {
|
|
throw new Error(`${label} failed (exit ${res.code}): ${res.stderr.slice(0, 300)}`);
|
|
}
|
|
|
|
/** Raw-command ops (no wrapper on the host). */
|
|
export function shellOps(target: SshTarget, configPath: string, exec: SshExec): RemoteOps {
|
|
return {
|
|
async read() {
|
|
const r = await exec(target, `cat ${shellQuote(configPath)}`);
|
|
if (r.code !== 0) fail('read', r);
|
|
return r.stdout;
|
|
},
|
|
async backup(now) {
|
|
const backupPath = backupFilename(configPath, now);
|
|
const r = await exec(target, `cp ${shellQuote(configPath)} ${shellQuote(backupPath)}`);
|
|
if (r.code !== 0) fail('backup', r);
|
|
return backupPath;
|
|
},
|
|
async write(content) {
|
|
const r = await exec(target, `cat > ${shellQuote(configPath)}`, content);
|
|
if (r.code !== 0) fail('write', r);
|
|
},
|
|
async restart(restartCmd) {
|
|
const r = await exec(target, restartCmd);
|
|
if (r.code !== 0) fail('restart', r);
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Verb ops for a forced-command-locked key. The wrapper hardcodes the paths;
|
|
* the backup verb stamps and returns the backup path on stdout. */
|
|
export function wrapperOps(target: SshTarget, exec: SshExec): RemoteOps {
|
|
return {
|
|
async read() {
|
|
const r = await exec(target, 'read');
|
|
if (r.code !== 0) fail('read', r);
|
|
return r.stdout;
|
|
},
|
|
async backup() {
|
|
const r = await exec(target, 'backup');
|
|
if (r.code !== 0) fail('backup', r);
|
|
return r.stdout.trim();
|
|
},
|
|
async write(content) {
|
|
const r = await exec(target, 'write', content);
|
|
if (r.code !== 0) fail('write', r);
|
|
},
|
|
async restart() {
|
|
const r = await exec(target, 'restart');
|
|
if (r.code !== 0) fail('restart', r);
|
|
},
|
|
};
|
|
}
|
|
|
|
export function makeRemoteOps(mode: SshMode, target: SshTarget, configPath: string, exec: SshExec): RemoteOps {
|
|
return mode === 'wrapper' ? wrapperOps(target, exec) : shellOps(target, configPath, exec);
|
|
}
|
|
|
|
// ─── orchestration (injectable exec) ─────────────────────────────────────────
|
|
|
|
/** Read the remote config file (mode-aware; defaults to shell for compat). */
|
|
export async function readRemoteConfig(
|
|
target: SshTarget,
|
|
configPath: string,
|
|
exec: SshExec,
|
|
mode: SshMode = 'shell',
|
|
): Promise<string> {
|
|
return makeRemoteOps(mode, target, configPath, exec).read();
|
|
}
|
|
|
|
export interface ApplyResult {
|
|
ok: boolean;
|
|
step: 'validate' | 'backup' | 'write' | 'restart' | 'health' | 'done';
|
|
backupPath?: string;
|
|
diff?: string;
|
|
error?: string;
|
|
}
|
|
|
|
export interface ApplyOptions {
|
|
target: SshTarget;
|
|
configPath: string;
|
|
restartCmd: string;
|
|
newConfig: string;
|
|
schema: object;
|
|
baseUrl: string;
|
|
exec: SshExec;
|
|
/** 'shell' (default) or 'wrapper'. */
|
|
mode?: SshMode;
|
|
fetcher?: typeof fetch;
|
|
now?: Date;
|
|
healthAttempts?: number;
|
|
healthDelayMs?: number;
|
|
}
|
|
|
|
/**
|
|
* The full apply pipeline. Aborts at the first failing step and reports which
|
|
* one. Backup ALWAYS precedes write, so a failed write leaves the timestamped
|
|
* backup intact for manual recovery. Mode selects the wire commands (raw shell
|
|
* vs forced-command verbs); the pipeline is identical.
|
|
*/
|
|
export async function applyRemoteConfig(opts: ApplyOptions): Promise<ApplyResult> {
|
|
const {
|
|
target, configPath, restartCmd, newConfig, schema, baseUrl, exec,
|
|
mode = 'shell', fetcher = fetch, now = new Date(),
|
|
healthAttempts = 10, healthDelayMs = 2000,
|
|
} = opts;
|
|
|
|
const ops = makeRemoteOps(mode, target, configPath, exec);
|
|
|
|
// 1. Validate before touching the host.
|
|
const validation = validateLlamaConfig(newConfig, schema);
|
|
if (!validation.valid) {
|
|
return { ok: false, step: 'validate', error: validation.errors.join('; ') };
|
|
}
|
|
|
|
// Read current for diff + so an unreadable host fails before any write.
|
|
let current = '';
|
|
try {
|
|
current = await ops.read();
|
|
} catch (err) {
|
|
return { ok: false, step: 'validate', error: `read current failed: ${(err as Error).message}` };
|
|
}
|
|
const diff = computeDiff(current, newConfig);
|
|
|
|
// 2. Timestamped backup BEFORE write.
|
|
let backupPath: string;
|
|
try {
|
|
backupPath = await ops.backup(now);
|
|
} catch (err) {
|
|
return { ok: false, step: 'backup', diff, error: (err as Error).message };
|
|
}
|
|
|
|
// 3. Write new config.
|
|
try {
|
|
await ops.write(newConfig);
|
|
} catch (err) {
|
|
return { ok: false, step: 'write', backupPath, diff, error: (err as Error).message };
|
|
}
|
|
|
|
// 4. Restart the service.
|
|
try {
|
|
await ops.restart(restartCmd);
|
|
} catch (err) {
|
|
return { ok: false, step: 'restart', backupPath, diff, error: (err as Error).message };
|
|
}
|
|
|
|
// 5. Health-wait: poll the provider until it serves /v1/models.
|
|
const healthy = await healthWait(baseUrl, fetcher, healthAttempts, healthDelayMs);
|
|
if (!healthy) {
|
|
return { ok: false, step: 'health', backupPath, diff, error: 'health check did not pass after restart; backup retained' };
|
|
}
|
|
|
|
return { ok: true, step: 'done', backupPath, diff };
|
|
}
|
|
|
|
/** Poll the provider's /v1/models until it responds OK or attempts run out. */
|
|
export async function healthWait(
|
|
baseUrl: string,
|
|
fetcher: typeof fetch,
|
|
attempts: number,
|
|
delayMs: number,
|
|
): Promise<boolean> {
|
|
for (let i = 0; i < attempts; i++) {
|
|
try {
|
|
const res = await fetcher(`${baseUrl.replace(/\/+$/, '')}/v1/models`, {
|
|
signal: AbortSignal.timeout(5_000),
|
|
});
|
|
if (res.ok) return true;
|
|
} catch {
|
|
// not up yet
|
|
}
|
|
if (i < attempts - 1) await sleep(delayMs);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((r) => setTimeout(r, ms));
|
|
}
|
|
|
|
// Minimal POSIX single-quote shell escape for the remote command string.
|
|
function shellQuote(s: string): string {
|
|
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
}
|
|
|
|
// ─── real SSH executor (spawn) ───────────────────────────────────────────────
|
|
|
|
/**
|
|
* Default SSH executor. Uses the system `ssh` with an explicit identity file and
|
|
* IdentitiesOnly so the agent's default key is never offered (the boocode Gitea
|
|
* lesson). BatchMode avoids interactive prompts hanging the service.
|
|
*/
|
|
export const sshExec: SshExec = (target, command, stdin) => {
|
|
return new Promise<ExecResult>((resolve) => {
|
|
const args = [
|
|
'-i', target.keyPath,
|
|
'-o', 'IdentitiesOnly=yes',
|
|
'-o', 'BatchMode=yes',
|
|
'-o', 'StrictHostKeyChecking=accept-new',
|
|
'-o', 'ConnectTimeout=10',
|
|
`${target.user}@${target.host}`,
|
|
command,
|
|
];
|
|
const child = spawn('ssh', args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
let stdout = '';
|
|
let stderr = '';
|
|
child.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
child.on('error', (err) => resolve({ code: 127, stdout, stderr: `${stderr}${(err as Error).message}` }));
|
|
child.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
if (stdin !== undefined) {
|
|
child.stdin.write(stdin);
|
|
}
|
|
child.stdin.end();
|
|
});
|
|
};
|