// Read-only connector discovery. Never load binding policy or token resources. import { lstatSync, readdirSync, openSync, closeSync, fstatSync, readFileSync, constants } from 'node:fs'; import { join, resolve, parse } from 'node:path'; import { readPid, ownerState } from '../../discord/src/journal.mjs'; const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/; // Check every existing ancestor, not just the final component. export function noLinks(path) { const absolute = resolve(path); let current = parse(absolute).root; for (const part of absolute.slice(current.length).split('/').filter(Boolean)) { current = join(current, part); try { if (lstatSync(current).isSymbolicLink()) return false; } catch (e) { if (e.code === 'ENOENT') return true; return false; } } return true; } function stat(path) { try { return lstatSync(path); } catch { return null; } } export function discoverDiscordAgents(dataRoot) { const specs = [], errors = []; const dir = join(dataRoot, 'discord'); if (!noLinks(dir)) return { specs, errors: ['discord discovery: unsafe directory'] }; const ds = stat(dir); if (!ds) return { specs, errors }; if (!ds.isDirectory()) return { specs, errors: ['discord discovery: invalid directory'] }; let names; try { names = readdirSync(dir).sort(); } catch { return { specs, errors: ['discord discovery: unreadable directory'] }; } for (const file of names.filter(n => n.endsWith('.json'))) { let fd; try { const name = file.slice(0, -5); if (!NAME.test(name)) throw new Error(); fd = openSync(join(dir, file), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); const s = fstatSync(fd); if (!s.isFile() || (s.mode & 0o7777) !== 0o600 || s.size > 1024 * 1024) throw new Error(); const raw = JSON.parse(readFileSync(fd, 'utf8')); if (!raw || raw.name !== name || typeof raw.seat !== 'string' || !NAME.test(raw.seat)) throw new Error(); // Do not retain the binding object, even in internal scan specs. specs.push({ agent: `${raw.seat} (discord: ${name})`, project: 'fleet', sessionsDir: join(dataRoot, 'sessions', `discord-${name}`), tmux: null, connector: { binding: name, journalDir: join(dir, name) } }); } catch { errors.push('discord discovery: skipped invalid binding'); } finally { if (fd !== undefined) closeSync(fd); } } return { specs, errors }; } export function inspectDiscord(connector, { identity } = {}) { const dir = connector.journalDir, owner = join(dir, 'run.lock', 'owner.json'); let braked = null; if (noLinks(dir)) { try { lstatSync(join(dir, 'STOP')); braked = true; } catch (e) { if (e.code === 'ENOENT') braked = false; } } let ownerStatus = 'invalid'; if (noLinks(owner)) { const s = stat(owner); if (!s || s.isFile()) { try { ownerStatus = ownerState(readPid(dir), identity ? { identity } : {}); } catch { ownerStatus = 'invalid'; } } } return { binding: connector.binding, braked, ownerState: ownerStatus, alive: ownerStatus === 'live' }; } // Connector session trees must not follow links, including ancestor links. export function newestDiscordSession(dir) { if (!noLinks(dir)) return null; let best = null; const walk = path => { const s = stat(path); if (!s || s.isSymbolicLink()) return; if (s.isDirectory()) { for (const name of readdirSync(path)) walk(join(path, name)); } else if (s.isFile() && path.endsWith('.jsonl') && (!best || s.mtimeMs > best.time)) best = { path, time: s.mtimeMs }; }; walk(dir); return best?.path ?? null; }