import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import type { OrchestratorConfig } from "../config"; import { errMessage } from "agent-relay-sdk"; import { shellEscape } from "agent-relay-sdk/shell-utils"; import { tmuxHasSession } from "agent-relay-sdk/tmux-utils"; import { sanitizeFsName } from "agent-relay-sdk/fs-name"; import { GUEST_STATE_FILE, GUEST_TTL_MS, guestStateHydrated, markGuestStateHydrated, terminalGuests } from "./constants"; import { findSessionRecord, isSessionRecordAlive, readRunnerInfo } from "./runtime"; import { isWithinBaseDir } from "./command"; import type { TerminalAttachSpec, TerminalGuestSession } from "./types"; export async function createTerminalGuest( input: { agentId?: string; policyName?: string; spawnRequestId?: string; tmuxSession?: string }, config: OrchestratorConfig, ): Promise { cleanupExpiredTerminalGuests(); const record = findSessionRecord(input); if (!record || !isSessionRecordAlive(record)) throw new Error("managed runner session not found"); const runner = readRunnerInfo(record); if (!runner?.controlUrl) throw new Error("runner control URL is unavailable; restart the agent to enable terminal attach"); const spec = await fetchTerminalAttachSpec(runner.controlUrl); validateAttachSpec(spec, config); const session = guestSessionName(config, spec.provider, record.agentId); killTmuxSession(session); const expiresAt = Date.now() + Math.min(Math.max(spec.ttlMs ?? GUEST_TTL_MS, 60_000), 4 * GUEST_TTL_MS); const shellCmd = spec.command.map(shellEscape).join(" "); const tmuxArgs = ["new-session", "-d", "-s", session, "-x", "200", "-y", "50"]; for (const [key, value] of Object.entries(spec.env ?? {}).sort(([a], [b]) => a.localeCompare(b))) { if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) tmuxArgs.push("-e", `${key}=${value}`); } tmuxArgs.push("-c", spec.cwd, shellCmd); const result = Bun.spawnSync(["tmux", ...tmuxArgs], { stdin: "ignore", stdout: "pipe", stderr: "pipe", }); if (result.exitCode !== 0) { const stderr = result.stderr.toString().trim(); throw new Error(stderr || `tmux guest creation failed with exit code ${result.exitCode}`); } terminalGuests.set(session, { expiresAt }); saveGuestState(); return { session, mode: "guest", provider: spec.provider, running: true, interactive: true, expiresAt }; } export function stopTerminalGuest(session: string, config: OrchestratorConfig): { session: string; stopped: boolean } { if (!isGuestSessionName(session, config)) throw new Error("terminal session is not a guest session"); const running = tmuxHasSession(session); if (running) killTmuxSession(session); terminalGuests.delete(session); saveGuestState(); return { session, stopped: running }; } async function fetchTerminalAttachSpec(controlUrl: string): Promise { const res = await fetch(`${controlUrl}/terminal/attach-spec`, { signal: AbortSignal.timeout(5_000) }); const body = await res.json().catch(() => null) as unknown; if (!res.ok) { const message = body && typeof body === "object" && !Array.isArray(body) && typeof (body as { error?: unknown }).error === "string" ? (body as { error: string }).error : `runner attach-spec failed with ${res.status}`; throw new Error(message); } if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("runner attach-spec response must be an object"); return body as TerminalAttachSpec; } function validateAttachSpec(spec: TerminalAttachSpec, config: OrchestratorConfig): void { if (spec.mode !== "guest") throw new Error("runner attach-spec mode must be guest"); if (typeof spec.provider !== "string" || !spec.provider.trim()) throw new Error("runner attach-spec provider required"); if (typeof spec.cwd !== "string" || !isWithinBaseDir(spec.cwd, config.baseDir)) throw new Error("runner attach-spec cwd must be within base directory"); if (!Array.isArray(spec.command) || spec.command.length === 0 || spec.command.some((item) => typeof item !== "string" || !item)) { throw new Error("runner attach-spec command must be a non-empty string array"); } if (spec.env !== undefined && (!spec.env || typeof spec.env !== "object" || Array.isArray(spec.env) || Object.values(spec.env).some((value) => typeof value !== "string"))) { throw new Error("runner attach-spec env must be a string record"); } } function guestSessionName(config: OrchestratorConfig, provider: string, agentId: string): string { const cleanProvider = sanitizeFsName(provider, { replacement: "-", lowercase: true, fallback: "provider" }); const cleanAgent = sanitizeFsName(agentId, { replacement: "-", lowercase: true, maxLen: 48, fallback: "agent" }); return `${config.tmuxPrefix}-guest-${cleanProvider}-${cleanAgent}-${crypto.randomUUID().slice(0, 8)}`; } function isGuestSessionName(session: string, config: OrchestratorConfig): boolean { return session.startsWith(`${config.tmuxPrefix}-guest-`); } interface GuestRecord { session: string; expiresAt: number; } interface LiveGuestSession { session: string; createdAtMs: number; } /** Flatten the in-memory guest registry to a persistable, deterministic list. */ export function serializeGuests(guests: Map): GuestRecord[] { return [...guests.entries()] .map(([session, { expiresAt }]) => ({ session, expiresAt })) .sort((a, b) => a.session.localeCompare(b.session)); } /** Tolerant inverse of serializeGuests — drops malformed entries instead of throwing. */ export function deserializeGuests(raw: unknown): Map { const map = new Map(); if (!Array.isArray(raw)) return map; for (const entry of raw) { if (!entry || typeof entry !== "object") continue; const { session, expiresAt } = entry as Record; if (typeof session === "string" && session && typeof expiresAt === "number" && Number.isFinite(expiresAt)) { map.set(session, { expiresAt }); } } return map; } function saveGuestState(): void { try { mkdirSync(join(homedir(), ".agent-relay"), { recursive: true }); const tmp = `${GUEST_STATE_FILE}.tmp`; writeFileSync(tmp, JSON.stringify(serializeGuests(terminalGuests), null, 2) + "\n"); renameSync(tmp, GUEST_STATE_FILE); } catch { // Persistence is best-effort: a write failure must never break guest creation. // The periodic reaper's tmux age-based fallback still bounds orphan lifetime. } } /** * Rehydrate the in-memory guest registry from disk so guest TTLs survive an * orchestrator restart. Call once at boot before the first reap. */ export function hydrateTerminalGuests(): void { if (guestStateHydrated) return; markGuestStateHydrated(); try { const persisted = deserializeGuests(JSON.parse(readFileSync(GUEST_STATE_FILE, "utf8"))); for (const [session, value] of persisted) { if (!terminalGuests.has(session)) terminalGuests.set(session, value); } } catch { // No persisted state (first boot or unreadable) — the age-based fallback in // reapTerminalGuests still cleans any orphaned guest tmux sessions. } } /** Live `-guest-*` tmux sessions with their creation time (ms). */ function listGuestTmuxSessions(config: OrchestratorConfig): LiveGuestSession[] { const result = Bun.spawnSync(["tmux", "list-sessions", "-F", "#{session_name}\t#{session_created}"], { stdin: "ignore", stdout: "pipe", stderr: "ignore", }); if (result.exitCode !== 0) return []; // no tmux server / no sessions const sessions: LiveGuestSession[] = []; for (const line of result.stdout.toString().split("\n")) { const tab = line.indexOf("\t"); if (tab < 0) continue; const session = line.slice(0, tab); if (!isGuestSessionName(session, config)) continue; const createdSec = Number(line.slice(tab + 1).trim()); sessions.push({ session, createdAtMs: Number.isFinite(createdSec) ? createdSec * 1000 : 0 }); } return sessions; } /** * Decide which live guest sessions to reap. Pure so the TTL policy is testable * without tmux or fs: * - tracked + past its recorded expiry → reap * - untracked (metadata lost across a restart) + older than the fallback TTL → reap */ export function selectExpiredGuests( tracked: Map, liveGuests: LiveGuestSession[], now: number, fallbackTtlMs = GUEST_TTL_MS, ): string[] { const toReap = new Set(); for (const { session, createdAtMs } of liveGuests) { const record = tracked.get(session); if (record) { if (record.expiresAt <= now) toReap.add(session); } else if (now - createdAtMs >= fallbackTtlMs) { toReap.add(session); } } return [...toReap]; } /** * Kill guest tmux sessions whose TTL has elapsed, independent of any new guest * creation, and prune tracked entries whose tmux session is already gone. Runs * at boot and on a periodic timer (see orchestrator index). */ export function reapTerminalGuests(config: OrchestratorConfig, now = Date.now()): string[] { const live = listGuestTmuxSessions(config); const liveNames = new Set(live.map((g) => g.session)); const reaped = selectExpiredGuests(terminalGuests, live, now); for (const session of reaped) { killTmuxSession(session); terminalGuests.delete(session); } // Drop tracked guests with no live tmux session (manually killed, or reaped // above) so the registry can't grow without bound. let pruned = false; for (const session of [...terminalGuests.keys()]) { if (!liveNames.has(session)) { terminalGuests.delete(session); pruned = true; } } if (reaped.length || pruned) saveGuestState(); return reaped; } function cleanupExpiredTerminalGuests(): void { const now = Date.now(); let changed = false; for (const [session, guest] of terminalGuests.entries()) { if (guest.expiresAt > now) continue; killTmuxSession(session); terminalGuests.delete(session); changed = true; } if (changed) saveGuestState(); } function killTmuxSession(session: string): void { Bun.spawnSync(["tmux", "kill-session", "-t", session], { stdin: "ignore", stdout: "ignore", stderr: "ignore", }); }