/** * dsh-fleet-control — host half. * * Persists the set of session ids that "Stop All" gracefully cancelled so that * "Resume All" can re-prompt exactly those sessions later — including after an * app restart. State lives in ~/.dsh/fleet-control/stopped.json. * * Exposes one loopback-only route, GET/POST /fleet/stopped: * GET -> { ok: true, value: { ids: string[], updatedAt: string } } * POST { ids: string[] } -> replaces the set, returns the same envelope. * * @module dsh-fleet-control */ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { dirname, join } from 'node:path' const STORE_FILE = join(process.env.DSH_HOME?.trim() || join(homedir(), '.dsh'), 'fleet-control', 'stopped.json') export const name = 'dsh-fleet-control' export const inject = ['webServer'] const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer', } function writeJson(response, status, body) { response.writeHead(status, JSON_HEADERS) response.end(JSON.stringify(body)) } // ---- loopback guards (mirrors dsh-open-in-smerge) ------------------------- function isIPv4Loopback(address) { const parts = address.split('.') return parts.length === 4 && parts[0] === '127' && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) } function isLoopbackAddress(address) { if (address === undefined) return false const normalized = address.toLowerCase() if (normalized === '::1') return true if (normalized.startsWith('::ffff:')) return isIPv4Loopback(normalized.slice('::ffff:'.length)) return isIPv4Loopback(normalized) } function isLoopbackHostname(hostname) { return hostname === 'localhost' || hostname === '[::1]' || isIPv4Loopback(hostname) } export function isLoopbackRequest(request) { if (!isLoopbackAddress(request.socket?.remoteAddress)) return false const host = request.headers.host if (typeof host !== 'string') return false let hostUrl try { hostUrl = new URL(`http://${host}`) } catch { return false } if (!isLoopbackHostname(hostUrl.hostname)) return false if (request.headers['sec-fetch-site'] === 'cross-site') return false const origin = request.headers.origin if (origin === undefined) return true try { return new URL(origin).host === hostUrl.host } catch { return false } } // -------------------------------------------------------------------------- async function readJsonBody(request) { const chunks = [] let size = 0 for await (const chunk of request) { size += chunk.length if (size > 64 * 1024) { request.destroy() return null } chunks.push(chunk) } if (chunks.length === 0) return null try { const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8')) return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : null } catch { return null } } function sanitizeIds(value) { if (!Array.isArray(value)) return [] const out = [] for (const entry of value) { if (typeof entry === 'string' && entry !== '' && out.indexOf(entry) === -1) out.push(entry) } return out } export class StoppedSessionStore { constructor(filePath = STORE_FILE) { this.filePath = filePath this.ids = [] this.updatedAt = new Date(0).toISOString() this.ready = this.load() this.pendingWrite = Promise.resolve() } async load() { try { const parsed = JSON.parse(await readFile(this.filePath, 'utf8')) if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return this.ids = sanitizeIds(parsed.ids) if (typeof parsed.updatedAt === 'string') this.updatedAt = parsed.updatedAt } catch { this.ids = [] } } async get() { await this.ready return { ids: this.ids.slice(), updatedAt: this.updatedAt } } async set(ids) { await this.ready this.ids = sanitizeIds(ids) this.updatedAt = new Date().toISOString() this.pendingWrite = this.pendingWrite.then(() => this.persist()) await this.pendingWrite return { ids: this.ids.slice(), updatedAt: this.updatedAt } } async persist() { await mkdir(dirname(this.filePath), { recursive: true }) const temporaryPath = `${this.filePath}.tmp-${process.pid}` const body = JSON.stringify({ ids: this.ids, updatedAt: this.updatedAt }, null, 2) await writeFile(temporaryPath, `${body}\n`, 'utf8') await rename(temporaryPath, this.filePath) } } export function apply(context) { const store = new StoppedSessionStore() const handler = async (request, response) => { const method = (request.method ?? 'GET').toUpperCase() if (method !== 'GET' && method !== 'POST') { writeJson(response, 405, { ok: false, error: { code: 'method-not-allowed', message: 'method not allowed' } }) return } if (!isLoopbackRequest(request)) { writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'loopback only' } }) return } if (method === 'GET') { writeJson(response, 200, { ok: true, value: await store.get() }) return } const body = await readJsonBody(request) const value = await store.set(body && body.ids) writeJson(response, 200, { ok: true, value }) } context.effect(() => { const disposers = [ context.webServer.register({ kind: 'exact', path: '/fleet/stopped', handler }), ] return () => { for (const dispose of disposers.reverse()) dispose() } }, 'fleet-control: stopped-session store route') }