From a69227790ba17b03ed1ab899515f7841d84a6c17 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Sun, 14 Jun 2026 19:15:25 -0600 Subject: [PATCH 01/62] add pino logger writing to stderr to keep stdout clean for mcp transport --- typescript/src/utils/logging.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 typescript/src/utils/logging.ts diff --git a/typescript/src/utils/logging.ts b/typescript/src/utils/logging.ts new file mode 100644 index 0000000..3266ea3 --- /dev/null +++ b/typescript/src/utils/logging.ts @@ -0,0 +1,32 @@ +import pino from "pino"; +import { getSettings } from "../config/settings.js"; + +/** + * Pino-based logging for the MCP server. + * + * IMPORTANT: all log output goes to stderr (file descriptor 2). stdout is + * reserved exclusively for the MCP stdio transport - writing logs there would + * corrupt the JSON-RPC protocol stream. + * + * The root level is initialised from settings (env-driven) at module load so + * child loggers created eagerly (e.g. the module-level OpenROADManager + * singleton) honour the configured level without depending on setupLogging() + * being called first. setupLogging() mutates the root level for loggers created + * afterwards; note that pino child loggers capture their level at creation time + * and do not dynamically follow the parent. + */ +function createRoot(level: string): pino.Logger { + return pino({ name: "openroad_mcp", level: level.toLowerCase() }, pino.destination(2)); +} + +let rootLogger: pino.Logger = createRoot(getSettings().LOG_LEVEL); + +/** Configure the root log level. Call once at startup before heavy logging. */ +export function setupLogging(level: string): void { + rootLogger.level = level.toLowerCase(); +} + +/** Return a child logger namespaced under `openroad_mcp.`. */ +export function getLogger(name: string): pino.Logger { + return rootLogger.child({ module: name }); +} From 5b0f22a25d57a4760aedf57ec3e09bae4a727278 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Sun, 14 Jun 2026 19:15:25 -0600 Subject: [PATCH 02/62] port command whitelist with minimatch replacing fnmatch for verb matching --- typescript/src/config/command_whitelist.ts | 229 +++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 typescript/src/config/command_whitelist.ts diff --git a/typescript/src/config/command_whitelist.ts b/typescript/src/config/command_whitelist.ts new file mode 100644 index 0000000..b589772 --- /dev/null +++ b/typescript/src/config/command_whitelist.ts @@ -0,0 +1,229 @@ +/** + * Command filter for OpenROAD PTY session security. + * + * Prevents execution of dangerous OS-level Tcl commands by AI agents. + * + * Three-tier design: + * + * BLOCKED_COMMANDS - denied in both tools (OS-level Tcl built-ins that can + * escape the EDA sandbox) + * + * EXEC_ONLY_PATTERNS - explicitly known state-modifying commands; denied in + * the query tool, allowed in the exec tool + * + * READONLY_PATTERNS - explicitly known safe read-only commands; allowed in + * both tools + * + * Unknown commands - treated as exec-only: denied in the query tool, + * allowed in the exec tool (they will fail at the Tcl + * level if invalid) + * + * This is distinct from PtyHandler.validateCommand(), which guards the shell + * binary/args. This module guards the Tcl statements sent to the REPL. + */ + +import { minimatch } from "minimatch"; +import { getLogger } from "../utils/logging.js"; + +const logger = getLogger("command_whitelist"); + +// Python uses fnmatch.fnmatch, which (unlike default glob) does not special-case +// a leading dot. `dot: true` makes minimatch's `*` match leading dots too, so +// single-token verb matching stays faithful to the Python implementation. +const MINIMATCH_OPTS = { dot: true } as const; + +function matchVerb(verb: string, pattern: string): boolean { + return minimatch(verb, pattern, MINIMATCH_OPTS); +} + +// Blocked commands - denied in both query and exec tools. +export const BLOCKED_COMMANDS: ReadonlySet = new Set([ + "quit", // Terminate the OpenROAD process (ORFS uses exit instead) + "socket", // Network connections + "load", // Load compiled C extensions into the interpreter + "glob", // Filesystem enumeration + "fconfigure", // I/O channel configuration + "chan", // Channel operations + "vwait", // Block the event loop + "rename", // Renames/removes commands, can bypass top-level checks + "after", // Schedules arbitrary code execution + "subst", // Performs substitutions that can invoke arbitrary commands +]); + +// Exec-only commands - denied in the query, allowed in the exec tool. +// Unknown commands are implicitly exec-only and do not need to appear here. +export const EXEC_ONLY_PATTERNS: readonly string[] = [ + // ORFS file and process operations + "exec", // Run external tools (Yosys, KLayout, Python helpers) + "source", // Load Tcl scripts (primary ORFS script-loading mechanism) + "exit", // Process exit (used in ORFS error handlers) + "open", // Open file handles (reports, SDC files, metrics) + "close", // Close file handles + "file", // Filesystem ops: mkdir, delete, link, copy + "cd", // Change working directory (used in platform setup scripts) + "uplevel", // Evaluate in parent stack frame (used by ORFS log_cmd) + // OpenROAD constraints / design setup + "set_*", + "create_*", + // File I/O through OpenROAD wrappers + "read_*", + "write_*", + // OpenROAD flow commands + "initialize_floorplan", + "place_pins", + "global_placement", + "detailed_placement", + "clock_tree_synthesis", + "global_route", + "detailed_route", + "repair_design", + "repair_timing", + "repair_clock_nets", + // OpenROAD utility + "log_begin", + "log_end", +]; + +// Safe Tcl built-ins - usable in both tools. +export const _TCL_BUILTINS: readonly string[] = [ + "puts", + "set", + "expr", + "if", + "else", + "elseif", + "for", + "foreach", + "while", + "proc", + "return", + "break", + "continue", + "list", + "llength", + "lindex", + "lappend", + "lrange", + "lsort", + "lsearch", + "lreplace", + "string", + "regexp", + "regsub", + "format", + "scan", + "array", + "dict", + "catch", + "error", + "namespace", + "upvar", + "global", + "variable", + "concat", + "join", + "split", + "incr", + "append", + "info", + "unset", +]; + +// Read-only OpenROAD command patterns - allowed in the query tool. +export const READONLY_PATTERNS: readonly string[] = [ + // OpenROAD reporting + "report_*", + // OpenROAD design queries + "get_*", + // OpenROAD validation + "check_*", + // OpenROAD analysis + "estimate_parasitics", + "sta", + // OpenROAD utility + "help", + "version", + ..._TCL_BUILTINS, +]; + +/** + * Return the command verb (first token) of a single Tcl statement. + * + * Returns null only for blank lines and comment lines. Lines that start with a + * substitution or grouping character (`$`, `[`, `]`, `{`, `}`) are returned + * as-is so the caller can reject them via the allowlist. + */ +export function extractVerb(statement: string): string | null { + const stripped = statement.trim(); + if (stripped === "" || stripped.startsWith("#")) { + return null; + } + const firstToken = stripped.split(/\s+/)[0]!; + return firstToken.replace(/;+$/, ""); +} + +/** Iterate the verbs of a command, mirroring Python's naive `;`->newline split. */ +function* iterVerbs(command: string): Generator { + // Preserve the naive splitting behavior exactly: replace ';' with newline, + // then split into lines. Semicolons inside Tcl braces or quoted strings are + // not handled - this matches the Python implementation's known limitation. + for (const rawLine of command.replace(/;/g, "\n").split("\n")) { + const verb = extractVerb(rawLine); + if (verb !== null) { + yield verb; + } + } +} + +/** + * Check whether `command` is safe for the read-only query tool. + * + * A verb is allowed only when it matches READONLY_PATTERNS and is not in + * BLOCKED_COMMANDS. Commands in EXEC_ONLY_PATTERNS and unknown commands are + * both treated as exec-only and are rejected here. + */ +export function isQueryCommand(command: string): [boolean, string | null] { + for (const verb of iterVerbs(command)) { + if (BLOCKED_COMMANDS.has(verb)) { + logger.warn(`Blocked command '${verb}' (explicit blocklist)`); + return [false, verb]; + } + + if (!READONLY_PATTERNS.some((pattern) => matchVerb(verb, pattern))) { + if (EXEC_ONLY_PATTERNS.some((pattern) => matchVerb(verb, pattern))) { + logger.warn(`Blocked command '${verb}' (exec-only, use the exec tool)`); + } else { + logger.warn(`Blocked command '${verb}' (unknown, treated as exec-only)`); + } + return [false, verb]; + } + } + + return [true, null]; +} + +/** + * Check whether `command` is safe for the state-modifying exec tool. + * + * Blocks only BLOCKED_COMMANDS (OS-level danger). All other commands - + * including EXEC_ONLY_PATTERNS, READONLY_PATTERNS, and unknown ones - are + * allowed; they will fail at the Tcl level if invalid. + */ +export function isExecCommand(command: string): [boolean, string | null] { + for (const verb of iterVerbs(command)) { + if (BLOCKED_COMMANDS.has(verb)) { + logger.warn(`Blocked command '${verb}' (explicit blocklist)`); + return [false, verb]; + } + } + + return [true, null]; +} + +/** + * Check `command` against BLOCKED_COMMANDS only (allow-by-default). + * Equivalent to isExecCommand. Kept for backward compatibility. + */ +export function isCommandAllowed(command: string): [boolean, string | null] { + return isExecCommand(command); +} From d9140e719c4e6dfcd015995e2a38b2573893de20 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Sun, 14 Jun 2026 19:15:25 -0600 Subject: [PATCH 03/62] add zod result schemas using nullable default null to match pydantic none serialization --- typescript/src/core/models.ts | 174 ++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) diff --git a/typescript/src/core/models.ts b/typescript/src/core/models.ts index 8d4d00e..9241e03 100644 --- a/typescript/src/core/models.ts +++ b/typescript/src/core/models.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + export enum SessionState { CREATING = "creating", ACTIVE = "active", @@ -5,6 +7,17 @@ export enum SessionState { ERROR = "error", } +export enum ProcessState { + STOPPED = "stopped", + STARTING = "starting", + RUNNING = "running", + ERROR = "error", +} + +// Domain interfaces (camelCase) +// These remain plain interfaces and are converted to the snake_case MCP wire +// format at the tool serialization boundary (BaseTool.formatResult, Part 2). + export interface InteractiveSessionInfo { sessionId: string; createdAt: string; @@ -25,3 +38,164 @@ export interface InteractiveExecResult { bufferSize: number; error?: string | null; } + +// Opaque snake_case payloads +// These are passed straight through to the wire (no camel->snake conversion), +// matching Python's dict output byte-for-byte. + +/** One entry in a session's command history. */ +export interface CommandHistoryEntry { + command: string; + timestamp: string; + command_number: number; + execution_start: number; + execution_time?: number; + output_length?: number; +} + +/** Detailed per-session metrics returned by InteractiveSession.getDetailedMetrics. */ +export interface SessionDetailedMetrics { + session_id: string; + state: string; + is_alive: boolean; + created_at: string; + last_activity: string; + uptime_seconds: number; + idle_seconds: number; + commands: { + total_executed: number; + current_count: number; + history_length: number; + }; + performance: { + total_cpu_time: number; + peak_memory_mb: number; + current_memory_mb: number; + }; + buffer: { + current_size: number; + max_size: number; + utilization_percent: number; + }; + timeout: { + configured_seconds: number | null; + is_timed_out: boolean; + }; +} + +/** Aggregate metrics across all sessions returned by OpenROADManager.sessionMetrics. */ +export interface ManagerMetrics { + manager: { + total_sessions: number; + active_sessions: number; + terminated_sessions: number; + max_sessions: number; + utilization_percent: number; + }; + aggregate: { + total_commands: number; + total_cpu_time: number; + total_memory_mb: number; + avg_memory_per_session: number; + }; + sessions: SessionDetailedMetrics[]; +} + +// Zod result schemas +// BaseResult pattern: every result carries `error: string | null`, defaulting to +// null. Python Pydantic always emits the `error` key (`= None` -> `null`), so we +// use `.nullable().default(null)`, never `.optional()`, to preserve key presence. + +const errorField = z.string().nullable().default(null); + +export const CommandRecord = z.object({ + command: z.string(), + timestamp: z.string(), + id: z.number(), +}); +export type CommandRecord = z.infer; + +export const InteractiveSessionListResult = z.object({ + sessions: z.array(z.custom()).default([]), + totalCount: z.number().default(0), + activeCount: z.number().default(0), + error: errorField, +}); +export type InteractiveSessionListResult = z.infer; + +export const SessionTerminationResult = z.object({ + sessionId: z.string(), + terminated: z.boolean(), + wasAlive: z.boolean().default(false), + force: z.boolean().default(false), + error: errorField, +}); +export type SessionTerminationResult = z.infer; + +export const SessionInspectionResult = z.object({ + sessionId: z.string(), + metrics: z.custom().nullable().default(null), + error: errorField, +}); +export type SessionInspectionResult = z.infer; + +export const SessionHistoryResult = z.object({ + sessionId: z.string(), + history: z.array(z.custom()).default([]), + totalCommands: z.number().default(0), + limit: z.number().nullable().default(null), + search: z.string().nullable().default(null), + error: errorField, +}); +export type SessionHistoryResult = z.infer; + +export const SessionMetricsResult = z.object({ + metrics: z.custom().nullable().default(null), + error: errorField, +}); +export type SessionMetricsResult = z.infer; + +// Image models + +export const ImageInfo = z.object({ + filename: z.string(), + path: z.string(), + sizeBytes: z.number(), + modifiedTime: z.string(), + type: z.string(), +}); +export type ImageInfo = z.infer; + +export const ImageMetadata = z.object({ + filename: z.string(), + format: z.string(), + sizeBytes: z.number(), + width: z.number().nullable().default(null), + height: z.number().nullable().default(null), + modifiedTime: z.string(), + stage: z.string(), + type: z.string(), + compressionApplied: z.boolean().default(false), + originalSizeBytes: z.number().nullable().default(null), + originalWidth: z.number().nullable().default(null), + originalHeight: z.number().nullable().default(null), + compressionRatio: z.number().nullable().default(null), +}); +export type ImageMetadata = z.infer; + +export const ListImagesResult = z.object({ + runPath: z.string().nullable().default(null), + totalImages: z.number().nullable().default(null), + imagesByStage: z.record(z.string(), z.array(ImageInfo)).nullable().default(null), + message: z.string().nullable().default(null), + error: errorField, +}); +export type ListImagesResult = z.infer; + +export const ReadImageResult = z.object({ + imageData: z.string().nullable().default(null), + metadata: ImageMetadata.nullable().default(null), + message: z.string().nullable().default(null), + error: errorField, +}); +export type ReadImageResult = z.infer; From dbaea3c71206fe33520cc624c66c608480812423 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Sun, 14 Jun 2026 19:15:25 -0600 Subject: [PATCH 04/62] expose pty process pid getter for session performance sampling --- typescript/src/interactive/pty_handler.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/typescript/src/interactive/pty_handler.ts b/typescript/src/interactive/pty_handler.ts index fafa0cf..0061a1e 100644 --- a/typescript/src/interactive/pty_handler.ts +++ b/typescript/src/interactive/pty_handler.ts @@ -15,6 +15,11 @@ export class PtyHandler { constructor(private readonly _settings: Settings = getSettings()) {} + /** PID of the underlying PTY process, or null if no process is active. */ + get pid(): number | null { + return this._ptyProcess?.pid ?? null; + } + validateCommand(command: string[]): void { if (!this._settings.ENABLE_COMMAND_VALIDATION) return; From 56a08fa333b18d9a382f0705725a52d07be7f4b8 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Sun, 14 Jun 2026 19:15:35 -0600 Subject: [PATCH 05/62] add session metrics, command history, and idle timeout tracking --- typescript/src/interactive/session.ts | 176 +++++++++++++++++++++++++- 1 file changed, 173 insertions(+), 3 deletions(-) diff --git a/typescript/src/interactive/session.ts b/typescript/src/interactive/session.ts index 95c9644..821db4e 100644 --- a/typescript/src/interactive/session.ts +++ b/typescript/src/interactive/session.ts @@ -1,9 +1,15 @@ +import pidusage from "pidusage"; import { ANSIDecoder } from "../utils/ansi_decoder.js"; import { getSettings } from "../config/settings.js"; import type { Settings } from "../config/settings.js"; import { SessionState } from "../core/models.js"; -import type { InteractiveExecResult, InteractiveSessionInfo } from "../core/models.js"; -import { MAX_COMMAND_COMPLETION_WINDOW } from "../constants.js"; +import type { + CommandHistoryEntry, + InteractiveExecResult, + InteractiveSessionInfo, + SessionDetailedMetrics, +} from "../core/models.js"; +import { BYTES_TO_MB, MAX_COMMAND_COMPLETION_WINDOW, UTILIZATION_PERCENTAGE_BASE } from "../constants.js"; import { CircularBuffer } from "./buffer.js"; import { SessionError, SessionTerminatedError } from "./models.js"; import { PtyHandler } from "./pty_handler.js"; @@ -33,6 +39,14 @@ export class InteractiveSession { readonly createdAt: Date; commandCount = 0; + // Activity / history / performance tracking (consumed by the manager). + lastActivity: Date = new Date(); + readonly commandHistory: CommandHistoryEntry[] = []; + totalCpuTime = 0; + peakMemoryMb = 0; + totalCommandsExecuted = 0; + sessionTimeoutSeconds: number | null = null; + private _state: SessionState; pty: PtyHandler; readonly outputBuffer: CircularBuffer; @@ -141,9 +155,20 @@ export class InteractiveSession { ); } + // Record the command in history before bumping the counters so the entry's + // command_number matches Python (command_count + 1). + this.commandHistory.push({ + command: command.trim(), + timestamp: new Date().toISOString(), + command_number: this.commandCount + 1, + execution_start: Date.now() / 1000, + }); + const data = command.endsWith("\n") ? command : command + "\n"; this._inputQueue.push(data); this.commandCount++; + this.totalCommandsExecuted++; + this.lastActivity = new Date(); const waiters = this._inputWaiters.splice(0); for (const w of waiters) w(); @@ -168,11 +193,13 @@ export class InteractiveSession { } const rawOutput = chunks.join(""); const output = ANSIDecoder.cleanOpenroadOutput(rawOutput); + const executionTime = (Date.now() - startTime) / 1000; + this._recordReadResult(output.length, executionTime); return { output, sessionId: this.sessionId, timestamp: new Date().toISOString(), - executionTime: (Date.now() - startTime) / 1000, + executionTime, commandCount: this.commandCount, bufferSize: this.outputBuffer.size, error: this._detectErrors(output) ?? null, @@ -206,6 +233,9 @@ export class InteractiveSession { const executionTime = (Date.now() - startTime) / 1000; const output = ANSIDecoder.cleanOpenroadOutput(rawOutput); + await this._updatePerformanceMetrics(); + this._recordReadResult(output.length, executionTime); + return { output, sessionId: this.sessionId, @@ -324,4 +354,144 @@ export class InteractiveSession { return undefined; } + + /** Update lastActivity and backfill the last history entry after a read. */ + private _recordReadResult(outputLength: number, executionTime: number): void { + const last = this.commandHistory[this.commandHistory.length - 1]; + if (last && last.execution_time === undefined) { + last.execution_time = executionTime; + last.output_length = outputLength; + } + this.lastActivity = new Date(); + } + + /** Sample CPU/memory from the live process. Best-effort; silently ignores a + * dead or inaccessible PID. CPU time is cumulative (assigned, not summed). */ + private async _updatePerformanceMetrics(): Promise { + const pid = this.pty.pid; + if (pid == null) return; + try { + const usage = await pidusage(pid); + this.totalCpuTime = usage.ctime / 1000; + const currentMemoryMb = Math.max(0, usage.memory) / BYTES_TO_MB; + this.peakMemoryMb = Math.max(this.peakMemoryMb, currentMemoryMb); + } catch { + // Process may have exited or be inaccessible. + } + } + + private async _getCurrentMemoryMb(): Promise { + const pid = this.pty.pid; + if (pid == null) return 0; + try { + const usage = await pidusage(pid); + return Math.max(0, usage.memory) / BYTES_TO_MB; + } catch { + return 0; + } + } + + /** True when a configured per-session timeout has been exceeded by uptime. + * Distinct from idle timeout - this is wall-clock lifetime, not inactivity. */ + private _checkSessionTimeout(): boolean { + if (this.sessionTimeoutSeconds === null) return false; + const uptime = (Date.now() - this.createdAt.getTime()) / 1000; + return uptime > this.sessionTimeoutSeconds; + } + + async getDetailedMetrics(): Promise { + await this._updatePerformanceMetrics(); + const now = Date.now(); + const uptimeSeconds = (now - this.createdAt.getTime()) / 1000; + const idleSeconds = (now - this.lastActivity.getTime()) / 1000; + const bufferSize = this.outputBuffer.size; + const maxSize = this.outputBuffer.maxSize; + + return { + session_id: this.sessionId, + state: this._state, + is_alive: this.isAlive(), + created_at: this.createdAt.toISOString(), + last_activity: this.lastActivity.toISOString(), + uptime_seconds: uptimeSeconds, + idle_seconds: idleSeconds, + commands: { + total_executed: this.totalCommandsExecuted, + current_count: this.commandCount, + history_length: this.commandHistory.length, + }, + performance: { + total_cpu_time: this.totalCpuTime, + peak_memory_mb: this.peakMemoryMb, + current_memory_mb: await this._getCurrentMemoryMb(), + }, + buffer: { + current_size: bufferSize, + max_size: maxSize, + utilization_percent: maxSize > 0 ? (bufferSize / maxSize) * UTILIZATION_PERCENTAGE_BASE : 0, + }, + timeout: { + configured_seconds: this.sessionTimeoutSeconds, + is_timed_out: this._checkSessionTimeout(), + }, + }; + } + + getCommandHistory(limit?: number, search?: string): CommandHistoryEntry[] { + let history = [...this.commandHistory]; + + if (search) { + const needle = search.toLowerCase(); + history = history.filter((cmd) => cmd.command.toLowerCase().includes(needle)); + } + + // Sort by timestamp, most recent first. + history.sort((a, b) => (a.timestamp < b.timestamp ? 1 : a.timestamp > b.timestamp ? -1 : 0)); + + // Match Python's truthy check: limit === 0 leaves the list unsliced. + if (limit) { + history = history.slice(0, limit); + } + + return history; + } + + async replayCommand(commandNumber: number): Promise { + for (const cmd of this.commandHistory) { + if (cmd.command_number === commandNumber) { + await this.sendCommand(cmd.command); + return cmd.command; + } + } + throw new SessionError(`Command ${commandNumber} not found in history`, this.sessionId); + } + + setSessionTimeout(timeoutSeconds: number): void { + this.sessionTimeoutSeconds = timeoutSeconds; + } + + isIdleTimeout(idleThresholdSeconds: number = this._settings.SESSION_IDLE_TIMEOUT): boolean { + const idleTime = (Date.now() - this.lastActivity.getTime()) / 1000; + return idleTime > idleThresholdSeconds; + } + + async filterOutput(pattern: string, maxLines = 1000): Promise { + const chunks = await this.outputBuffer.peekAll(); + if (chunks.length === 0) return []; + + const text = this.outputBuffer.toText(chunks); + const lines = text.split("\n"); + + let matching: string[]; + try { + const regex = new RegExp(pattern, "i"); + matching = lines.filter((line) => regex.test(line)); + } catch { + // Fallback to a case-insensitive substring search on invalid regex. + const needle = pattern.toLowerCase(); + matching = lines.filter((line) => line.toLowerCase().includes(needle)); + } + + return matching.length > 0 ? matching.slice(-maxLines) : []; + } } From 5f2addb744dd312aac489b99575771f928cbd7ca Mon Sep 17 00:00:00 2001 From: kartikloops Date: Sun, 14 Jun 2026 19:15:35 -0600 Subject: [PATCH 06/62] add openroad manager singleton for session lifecycle and metrics aggregation --- typescript/src/core/manager.ts | 330 +++++++++++++++++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 typescript/src/core/manager.ts diff --git a/typescript/src/core/manager.ts b/typescript/src/core/manager.ts new file mode 100644 index 0000000..d2276b7 --- /dev/null +++ b/typescript/src/core/manager.ts @@ -0,0 +1,330 @@ +import { Mutex } from "async-mutex"; +import { randomUUID } from "node:crypto"; +import { getSettings } from "../config/settings.js"; +import type { Settings } from "../config/settings.js"; +import { getLogger } from "../utils/logging.js"; +import { InteractiveSession } from "../interactive/session.js"; +import { SessionError, SessionNotFoundError } from "../interactive/models.js"; +import type { + CommandHistoryEntry, + InteractiveExecResult, + InteractiveSessionInfo, + ManagerMetrics, + SessionDetailedMetrics, +} from "./models.js"; + +/** Time after which a dead session is force-removed even if cleanup fails. */ +const FORCE_CLEANUP_AFTER_SECONDS = 60; + +export interface CreateSessionOptions { + sessionId?: string; + command?: string[]; + env?: Record; + cwd?: string; + bufferSize?: number; +} + +/** + * Manages OpenROAD subprocess lifecycle and interactive sessions. + * + * Node.js is single-threaded, so no reentrant lock is needed for plain state + * access (Python used asyncio.Lock + the GIL). The async-mutex `cleanupLock` + * serialises the multi-await cleanup/creation sections so concurrent callers + * cannot interleave session-map mutations across await points. + * + * The module exports a shared `manager` singleton; the class is exported too so + * tests can construct isolated instances. + */ +export class OpenROADManager { + private readonly logger = getLogger("manager"); + private readonly sessions = new Map(); + private readonly cleanupLock = new Mutex(); + private readonly settings: Settings = getSettings(); + private readonly maxSessions: number; + private readonly defaultTimeoutMs: number; + private readonly defaultBufferSize: number; + + constructor(maxSessions?: number) { + this.maxSessions = maxSessions ?? this.settings.MAX_SESSIONS; + this.defaultTimeoutMs = Math.round(this.settings.COMMAND_TIMEOUT * 1000); + this.defaultBufferSize = this.settings.DEFAULT_BUFFER_SIZE; + this.logger.info(`Initialized OpenROADManager with maxSessions=${this.maxSessions}`); + } + + async createSession(opts: CreateSessionOptions = {}): Promise { + const sessionId = opts.sessionId ?? randomUUID().slice(0, 8); + + return this.cleanupLock.runExclusive(async () => { + await this._cleanupTerminatedSessions(); + + if (this.sessions.has(sessionId)) { + throw new SessionError(`Session ${sessionId} already exists`, sessionId); + } + + const activeCount = this._countActive(); + if (activeCount >= this.maxSessions) { + throw new SessionError( + `Maximum session limit reached (${this.maxSessions}). Currently ${activeCount} active sessions.`, + sessionId, + ); + } + + // Placeholder distinguishes "creating" (null) from "not found" (absent). + this.sessions.set(sessionId, null); + + try { + // Match Python's `buffer_size or default`: 0 (and undefined) fall back + // to the default so a zero-capacity buffer can't silently drop all output. + const bufferSize = opts.bufferSize && opts.bufferSize > 0 ? opts.bufferSize : this.defaultBufferSize; + const session = new InteractiveSession(sessionId, bufferSize); + await session.start(opts.command, opts.env, opts.cwd); + + this.sessions.set(sessionId, session); + this.logger.info(`Created session ${sessionId}, total sessions: ${this.sessions.size}`); + return sessionId; + } catch (e) { + this.sessions.delete(sessionId); + this.logger.error(`Failed to create session ${sessionId}: ${String(e)}`); + throw new SessionError(`Failed to create session: ${String(e)}`, sessionId); + } + }); + } + + async executeCommand(sessionId: string, command: string, timeoutMs?: number): Promise { + const session = this._getSession(sessionId); + // Match Python's `timeout_ms or default`: 0 (and undefined) fall back to the + // configured default rather than becoming an instant timeout. + const actualTimeout = timeoutMs && timeoutMs > 0 ? timeoutMs : this.defaultTimeoutMs; + + await session.sendCommand(command); + return session.readOutput(actualTimeout); + } + + async getSessionInfo(sessionId: string): Promise { + return this._getSession(sessionId).getInfo(); + } + + async listSessions(): Promise { + await this._cleanupTerminatedSessionsWithLock(); + + const infos: InteractiveSessionInfo[] = []; + for (const [, session] of this._initializedSessions()) { + try { + infos.push(await session.getInfo()); + } catch (e) { + this.logger.warn(`Failed to get info for session ${session.sessionId}: ${String(e)}`); + } + } + return infos; + } + + async terminateSession(sessionId: string, force = false): Promise { + const session = this._getSession(sessionId); + + await session.terminate(force); + await session.cleanup(); + this.logger.info(`Terminated session ${sessionId}`); + + await this.cleanupLock.runExclusive(() => { + this.sessions.delete(sessionId); + }); + } + + async terminateAllSessions(force = false): Promise { + const sessionIds = [...this.sessions.keys()]; + if (sessionIds.length === 0) return 0; + + const results = await Promise.allSettled( + sessionIds.map((sid) => this.terminateSession(sid, force)), + ); + const terminated = results.filter((r) => r.status === "fulfilled").length; + + this.logger.info(`Terminated ${terminated}/${sessionIds.length} sessions`); + return terminated; + } + + async inspectSession(sessionId: string): Promise { + return this._getSession(sessionId).getDetailedMetrics(); + } + + async getSessionHistory(sessionId: string, limit?: number, search?: string): Promise { + return this._getSession(sessionId).getCommandHistory(limit, search); + } + + async replayCommand(sessionId: string, commandNumber: number): Promise { + return this._getSession(sessionId).replayCommand(commandNumber); + } + + async filterSessionOutput(sessionId: string, pattern: string, maxLines = 1000): Promise { + return this._getSession(sessionId).filterOutput(pattern, maxLines); + } + + async setSessionTimeout(sessionId: string, timeoutSeconds: number): Promise { + this._getSession(sessionId).setSessionTimeout(timeoutSeconds); + } + + async sessionMetrics(): Promise { + await this._cleanupTerminatedSessionsWithLock(); + + const totalSessions = this.sessions.size; + const activeSessions = this.getActiveSessionCount(); + const terminatedSessions = totalSessions - activeSessions; + + const sessionDetails: SessionDetailedMetrics[] = []; + let totalCommands = 0; + let totalCpuTime = 0; + let totalMemoryMb = 0; + + for (const [, session] of this._initializedSessions()) { + try { + const metrics = await session.getDetailedMetrics(); + sessionDetails.push(metrics); + totalCommands += metrics.commands.total_executed; + totalCpuTime += metrics.performance.total_cpu_time; + totalMemoryMb += metrics.performance.current_memory_mb; + } catch (e) { + this.logger.warn(`Failed to get metrics for session ${session.sessionId}: ${String(e)}`); + } + } + + return { + manager: { + total_sessions: totalSessions, + active_sessions: activeSessions, + terminated_sessions: terminatedSessions, + max_sessions: this.maxSessions, + utilization_percent: this.maxSessions > 0 ? (activeSessions / this.maxSessions) * 100 : 0, + }, + aggregate: { + total_commands: totalCommands, + total_cpu_time: totalCpuTime, + total_memory_mb: totalMemoryMb, + avg_memory_per_session: activeSessions > 0 ? totalMemoryMb / activeSessions : 0, + }, + sessions: sessionDetails, + }; + } + + async cleanupIdleSessions(idleThresholdSeconds = 300, force = false): Promise { + let cleaned = 0; + for (const [sessionId, session] of this._initializedSessions()) { + try { + if (session.isIdleTimeout(idleThresholdSeconds)) { + await this.terminateSession(sessionId, force); + cleaned++; + this.logger.info(`Cleaned up idle session ${sessionId}`); + } + } catch (e) { + this.logger.error(`Error checking idle status for session ${sessionId}: ${String(e)}`); + } + } + return cleaned; + } + + async cleanupAll(): Promise { + this.logger.info("Starting OpenROAD cleanup"); + + await this.terminateAllSessions(true); + + await this.cleanupLock.runExclusive(async () => { + for (const [, session] of this._initializedSessions()) { + try { + await session.cleanup(); + } catch (e) { + this.logger.warn(`Error during session cleanup: ${String(e)}`); + } + } + this.sessions.clear(); + }); + + this.logger.info("OpenROAD cleanup completed"); + } + + getSessionCount(): number { + return this.sessions.size; + } + + getActiveSessionCount(): number { + return this._countActive(); + } + + // internals + + private _countActive(): number { + let count = 0; + for (const session of this.sessions.values()) { + if (session !== null && session.isAlive()) count++; + } + return count; + } + + private _initializedSessions(): Array<[string, InteractiveSession]> { + const result: Array<[string, InteractiveSession]> = []; + for (const [sid, session] of this.sessions) { + if (session !== null) result.push([sid, session]); + } + return result; + } + + private _getSession(sessionId: string): InteractiveSession { + if (!this.sessions.has(sessionId)) { + throw new SessionNotFoundError(`Session ${sessionId} not found`, sessionId); + } + const session = this.sessions.get(sessionId); + if (session == null) { + throw new SessionError(`Session ${sessionId} is still being created`, sessionId); + } + return session; + } + + private async _cleanupTerminatedSessionsWithLock(): Promise { + return this.cleanupLock.runExclusive(() => this._cleanupTerminatedSessions()); + } + + private async _cleanupTerminatedSessions(): Promise { + const now = Date.now(); + const terminated: Array<[string, InteractiveSession, boolean]> = []; + + for (const [sessionId, session] of this._initializedSessions()) { + if (!session.isAlive()) { + const timeSinceDeath = (now - session.lastActivity.getTime()) / 1000; + terminated.push([sessionId, session, timeSinceDeath > FORCE_CLEANUP_AFTER_SECONDS]); + } + } + + let cleaned = 0; + for (const [sessionId, session, forceCleanup] of terminated) { + try { + if (forceCleanup) { + this.logger.warn(`Force cleaning up session ${sessionId} after ${FORCE_CLEANUP_AFTER_SECONDS}s`); + try { + await session.cleanup(); + } catch (cleanupError) { + this.logger.error(`Force cleanup failed for session ${sessionId}: ${String(cleanupError)}`); + } finally { + this.sessions.delete(sessionId); + cleaned++; + } + } else { + await session.cleanup(); + this.sessions.delete(sessionId); + cleaned++; + } + } catch (e) { + this.logger.error(`Error during session ${sessionId} cleanup: ${String(e)}`); + if (forceCleanup && this.sessions.has(sessionId)) { + this.sessions.delete(sessionId); + cleaned++; + } + } + } + + if (cleaned > 0) { + this.logger.info(`Cleaned up ${cleaned} terminated sessions`); + } + return cleaned; + } +} + +/** Shared process-wide manager instance used by the MCP tools. */ +export const manager = new OpenROADManager(); From 686248b0951768f868bafe8954044d9784446e96 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Sun, 14 Jun 2026 19:15:35 -0600 Subject: [PATCH 07/62] add command whitelist tests ported from python suite --- .../config/command_whitelist.test.ts | 299 ++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 typescript/__tests__/config/command_whitelist.test.ts diff --git a/typescript/__tests__/config/command_whitelist.test.ts b/typescript/__tests__/config/command_whitelist.test.ts new file mode 100644 index 0000000..459b930 --- /dev/null +++ b/typescript/__tests__/config/command_whitelist.test.ts @@ -0,0 +1,299 @@ +import { describe, it, expect } from "vitest"; +import { + BLOCKED_COMMANDS, + EXEC_ONLY_PATTERNS, + READONLY_PATTERNS, + extractVerb, + isCommandAllowed, + isExecCommand, + isQueryCommand, +} from "../../src/config/command_whitelist.js"; + +// extractVerb + +describe("extractVerb", () => { + it("returns a simple command", () => { + expect(extractVerb("report_checks")).toBe("report_checks"); + }); + + it("returns only the first token for a command with args", () => { + expect(extractVerb("report_checks -path_delay max")).toBe("report_checks"); + }); + + it("strips leading whitespace", () => { + expect(extractVerb(" get_nets *")).toBe("get_nets"); + }); + + it("returns null for an empty string", () => { + expect(extractVerb("")).toBeNull(); + }); + + it("returns null for blank whitespace", () => { + expect(extractVerb(" ")).toBeNull(); + }); + + it("returns null for a comment line", () => { + expect(extractVerb("# this is a comment")).toBeNull(); + }); + + it("returns null for a comment with leading whitespace", () => { + expect(extractVerb(" # comment")).toBeNull(); + }); + + it("returns $-prefixed tokens as-is for rejection", () => { + expect(extractVerb("$variable")).toBe("$variable"); + }); + + it("returns [-prefixed tokens as-is for rejection", () => { + expect(extractVerb("[report_wns]")).toBe("[report_wns]"); + }); + + it("strips a trailing semicolon", () => { + expect(extractVerb("puts;")).toBe("puts"); + }); +}); + +// Pattern set membership + +describe("pattern sets", () => { + it("READONLY contains report/get/check globs", () => { + expect(READONLY_PATTERNS).toContain("report_*"); + expect(READONLY_PATTERNS).toContain("get_*"); + expect(READONLY_PATTERNS).toContain("check_*"); + }); + + it("READONLY contains Tcl builtins", () => { + expect(READONLY_PATTERNS).toContain("puts"); + expect(READONLY_PATTERNS).toContain("foreach"); + expect(READONLY_PATTERNS).toContain("set"); + }); + + it("EXEC_ONLY contains set_*/read_*/write_* globs", () => { + expect(EXEC_ONLY_PATTERNS).toContain("set_*"); + expect(EXEC_ONLY_PATTERNS).toContain("read_*"); + expect(EXEC_ONLY_PATTERNS).toContain("write_*"); + }); + + it("EXEC_ONLY contains flow commands", () => { + expect(EXEC_ONLY_PATTERNS).toContain("global_placement"); + expect(EXEC_ONLY_PATTERNS).toContain("detailed_route"); + }); + + it("EXEC_ONLY does not contain report_*", () => { + expect(EXEC_ONLY_PATTERNS).not.toContain("report_*"); + }); + + it("READONLY does not contain set_* (exec-only setter)", () => { + expect(READONLY_PATTERNS).not.toContain("set_*"); + }); + + it("ORFS file ops are exec-only, not blocked", () => { + for (const cmd of ["exec", "source", "exit", "open", "close", "file", "cd", "uplevel"]) { + expect(EXEC_ONLY_PATTERNS).toContain(cmd); + expect(BLOCKED_COMMANDS.has(cmd)).toBe(false); + } + }); + + it("BLOCKED contains all 10 OS-level commands", () => { + for (const cmd of [ + "quit", + "socket", + "load", + "glob", + "fconfigure", + "chan", + "vwait", + "rename", + "after", + "subst", + ]) { + expect(BLOCKED_COMMANDS.has(cmd)).toBe(true); + } + expect(BLOCKED_COMMANDS.size).toBe(10); + }); +}); + +// isQueryCommand + +describe("isQueryCommand", () => { + it("allows report_*", () => { + expect(isQueryCommand("report_checks -path_delay max")).toEqual([true, null]); + }); + + it("allows get_*", () => { + expect(isQueryCommand("get_nets *")).toEqual([true, null]); + }); + + it("allows check_*", () => { + expect(isQueryCommand("check_placement")).toEqual([true, null]); + }); + + it("allows sta", () => { + expect(isQueryCommand("sta")).toEqual([true, null]); + }); + + it("allows help", () => { + expect(isQueryCommand("help")).toEqual([true, null]); + }); + + it("allows puts", () => { + expect(isQueryCommand("puts hello")).toEqual([true, null]); + }); + + it("allows bare set (Tcl assignment)", () => { + expect(isQueryCommand("set x 42")).toEqual([true, null]); + }); + + it("blocks set_* (exec-only)", () => { + expect(isQueryCommand("set_clock_period -name clk 2.0")).toEqual([false, "set_clock_period"]); + }); + + it("blocks read_db (exec-only)", () => { + expect(isQueryCommand("read_db /path/to/design.odb")).toEqual([false, "read_db"]); + }); + + it("blocks write_db (exec-only)", () => { + expect(isQueryCommand("write_db /out/design.odb")).toEqual([false, "write_db"]); + }); + + it("blocks flow commands (exec-only)", () => { + expect(isQueryCommand("global_placement")).toEqual([false, "global_placement"]); + }); + + it("denies blocked exec", () => { + expect(isQueryCommand("exec ls -la")).toEqual([false, "exec"]); + }); + + it("blocks unknown commands as exec-only", () => { + expect(isQueryCommand("pdngen")).toEqual([false, "pdngen"]); + }); + + it("allows a comment-only line", () => { + expect(isQueryCommand("# comment")).toEqual([true, null]); + }); + + it("allows an empty command", () => { + expect(isQueryCommand("")).toEqual([true, null]); + }); + + it("allows a multiline all-readonly command", () => { + expect(isQueryCommand("report_checks\nreport_wns\nget_nets *")).toEqual([true, null]); + }); + + it("blocks a multiline command with one exec verb", () => { + expect(isQueryCommand("report_checks\nglobal_placement")).toEqual([false, "global_placement"]); + }); + + it("rejects [exec ls] without allowlist bypass", () => { + expect(isQueryCommand("[exec ls]")).toEqual([false, "[exec"]); + }); + + it("rejects $cmd without allowlist bypass", () => { + expect(isQueryCommand("$cmd")).toEqual([false, "$cmd"]); + }); + + it("splits on semicolons and rejects the offending verb", () => { + expect(isQueryCommand("report_wns; global_placement")).toEqual([false, "global_placement"]); + }); +}); + +// isExecCommand + +describe("isExecCommand", () => { + it("allows set_clock_period", () => { + expect(isExecCommand("set_clock_period -name clk 2.0")).toEqual([true, null]); + }); + + it("allows create_clock", () => { + expect(isExecCommand("create_clock -name clk -period 2.0 [get_ports clk]")).toEqual([true, null]); + }); + + it("allows read_db / write_db", () => { + expect(isExecCommand("read_db /path/to/design.odb")).toEqual([true, null]); + expect(isExecCommand("write_db /out/design.odb")).toEqual([true, null]); + }); + + it("allows flow commands", () => { + expect(isExecCommand("global_placement")).toEqual([true, null]); + }); + + it("allows readonly commands (allow-by-default)", () => { + expect(isExecCommand("report_wns")).toEqual([true, null]); + expect(isExecCommand("get_nets *")).toEqual([true, null]); + }); + + it("allows puts and foreach", () => { + expect(isExecCommand("puts hello")).toEqual([true, null]); + expect(isExecCommand("foreach net [get_nets *] { puts $net }")).toEqual([true, null]); + }); + + it("allows unknown commands", () => { + expect(isExecCommand("pdngen")).toEqual([true, null]); + }); + + it("allows exec / source / exit (ORFS use)", () => { + expect(isExecCommand("exec yosys $::env(SCRIPTS_DIR)/synth.tcl")).toEqual([true, null]); + expect(isExecCommand("source $::env(SCRIPTS_DIR)/load.tcl")).toEqual([true, null]); + expect(isExecCommand("exit 1")).toEqual([true, null]); + }); + + it("allows open/close/file ops", () => { + expect(isExecCommand("open /tmp/report.log w")).toEqual([true, null]); + expect(isExecCommand("close $fh")).toEqual([true, null]); + expect(isExecCommand("file mkdir /results/6_final")).toEqual([true, null]); + }); + + it("blocks socket", () => { + expect(isExecCommand("socket tcp localhost 8080")).toEqual([false, "socket"]); + }); + + it("blocks quit", () => { + expect(isExecCommand("quit")).toEqual([false, "quit"]); + }); + + it("allows a multiline all-allowed command", () => { + expect(isExecCommand("read_db design.odb\nglobal_placement\nwrite_db out.odb")).toEqual([true, null]); + }); + + it("blocks a multiline command with one blocked verb", () => { + expect(isExecCommand("global_placement\nsocket tcp localhost")).toEqual([false, "socket"]); + }); +}); + +// isCommandAllowed (backward-compat alias) + +describe("isCommandAllowed", () => { + it("mirrors isExecCommand for allowed commands", () => { + expect(isCommandAllowed("report_checks -path_delay max")).toEqual([true, null]); + expect(isCommandAllowed("read_db /path/to/design.odb")).toEqual([true, null]); + expect(isCommandAllowed("set_clock_period -name clk 2.0")).toEqual([true, null]); + expect(isCommandAllowed("global_placement")).toEqual([true, null]); + expect(isCommandAllowed("pdngen")).toEqual([true, null]); + expect(isCommandAllowed("exec yosys synth.tcl")).toEqual([true, null]); + }); + + it("allows a multi-statement command with semicolons", () => { + expect(isCommandAllowed("set x 1; report_wns; puts $x")).toEqual([true, null]); + }); + + it("blocks socket and gives it priority", () => { + expect(isCommandAllowed("socket tcp localhost 8080")).toEqual([false, "socket"]); + expect(isCommandAllowed("global_placement\nsocket tcp localhost")).toEqual([false, "socket"]); + }); +}); + +// minimatch vs fnmatch parity + +describe("glob parity (minimatch vs fnmatch)", () => { + it("matches star-suffix against the empty remainder", () => { + // report_ / set_ / read_ match report_* / set_* / read_* (star matches empty) + expect(isQueryCommand("report_")).toEqual([true, null]); + expect(isExecCommand("set_")).toEqual([true, null]); + expect(isExecCommand("read_")).toEqual([true, null]); + }); + + it("is case-sensitive like POSIX fnmatch on verbs", () => { + // Report_Checks (capitalized) does not match report_* and is unknown -> blocked in query + expect(isQueryCommand("Report_Checks")).toEqual([false, "Report_Checks"]); + }); +}); From 6a02c33490f4e3ee4a486dd1831829eb574d5049 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Sun, 14 Jun 2026 19:15:35 -0600 Subject: [PATCH 08/62] add manager tests mocking the interactive session constructor --- typescript/__tests__/core/manager.test.ts | 250 ++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 typescript/__tests__/core/manager.test.ts diff --git a/typescript/__tests__/core/manager.test.ts b/typescript/__tests__/core/manager.test.ts new file mode 100644 index 0000000..07c7982 --- /dev/null +++ b/typescript/__tests__/core/manager.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { Mock } from "vitest"; +import { OpenROADManager } from "../../src/core/manager.js"; +import { SessionError, SessionNotFoundError } from "../../src/interactive/models.js"; +import { InteractiveSession } from "../../src/interactive/session.js"; +import { SessionState } from "../../src/core/models.js"; +import type { SessionDetailedMetrics } from "../../src/core/models.js"; + +// Stub the InteractiveSession constructor so the manager never spawns a PTY. +vi.mock("../../src/interactive/session.js", () => { + return { + InteractiveSession: vi.fn(), + }; +}); + +interface MockSession { + sessionId: string; + lastActivity: Date; + isAlive: Mock; + start: Mock; + sendCommand: Mock; + readOutput: Mock; + getInfo: Mock; + getDetailedMetrics: Mock; + getCommandHistory: Mock; + isIdleTimeout: Mock; + setSessionTimeout: Mock; + terminate: Mock; + cleanup: Mock; +} + +function makeMockSession(sessionId: string, alive = true): MockSession { + const metrics: SessionDetailedMetrics = { + session_id: sessionId, + state: SessionState.ACTIVE, + is_alive: alive, + created_at: new Date().toISOString(), + last_activity: new Date().toISOString(), + uptime_seconds: 1, + idle_seconds: 0, + commands: { total_executed: 3, current_count: 3, history_length: 3 }, + performance: { total_cpu_time: 0.5, peak_memory_mb: 10, current_memory_mb: 8 }, + buffer: { current_size: 0, max_size: 1024, utilization_percent: 0 }, + timeout: { configured_seconds: null, is_timed_out: false }, + }; + + return { + sessionId, + lastActivity: new Date(), + isAlive: vi.fn().mockReturnValue(alive), + start: vi.fn().mockResolvedValue(undefined), + sendCommand: vi.fn().mockResolvedValue(undefined), + readOutput: vi.fn().mockResolvedValue({ + output: "ok", + sessionId, + timestamp: new Date().toISOString(), + executionTime: 0.01, + commandCount: 1, + bufferSize: 0, + error: null, + }), + getInfo: vi.fn().mockResolvedValue({ + sessionId, + createdAt: new Date().toISOString(), + isAlive: alive, + commandCount: 0, + bufferSize: 0, + uptimeSeconds: 1, + state: SessionState.ACTIVE, + }), + getDetailedMetrics: vi.fn().mockResolvedValue(metrics), + getCommandHistory: vi.fn().mockReturnValue([]), + isIdleTimeout: vi.fn().mockReturnValue(false), + setSessionTimeout: vi.fn(), + terminate: vi.fn().mockResolvedValue(undefined), + cleanup: vi.fn().mockResolvedValue(undefined), + }; +} + +const MockedSession = vi.mocked(InteractiveSession); + +describe("OpenROADManager", () => { + let manager: OpenROADManager; + let created: MockSession[]; + + beforeEach(() => { + vi.clearAllMocks(); + created = []; + // Each `new InteractiveSession(id)` yields a fresh mock the test can inspect. + // A regular function (not an arrow) is required so it is constructable. + MockedSession.mockImplementation(function (this: unknown, sessionId: string) { + const mock = makeMockSession(sessionId); + created.push(mock); + return mock as unknown as InteractiveSession; + } as unknown as (sessionId: string) => InteractiveSession); + manager = new OpenROADManager(50); + }); + + describe("createSession", () => { + it("generates an 8-char id, starts the session, and stores it", async () => { + const id = await manager.createSession(); + expect(id).toHaveLength(8); + expect(created).toHaveLength(1); + expect(created[0]!.start).toHaveBeenCalledOnce(); + expect(manager.getSessionCount()).toBe(1); + }); + + it("honours an explicit session id and forwards start args", async () => { + const id = await manager.createSession({ + sessionId: "abc", + command: ["openroad", "-no_init"], + env: { FOO: "bar" }, + cwd: "/tmp", + }); + expect(id).toBe("abc"); + expect(created[0]!.start).toHaveBeenCalledWith(["openroad", "-no_init"], { FOO: "bar" }, "/tmp"); + }); + + it("throws SessionError on a duplicate id", async () => { + await manager.createSession({ sessionId: "dup" }); + await expect(manager.createSession({ sessionId: "dup" })).rejects.toBeInstanceOf(SessionError); + }); + + it("throws SessionError when at max capacity", async () => { + const limited = new OpenROADManager(1); + await limited.createSession({ sessionId: "s1" }); + await expect(limited.createSession({ sessionId: "s2" })).rejects.toThrow(/Maximum session limit/); + }); + + it("falls back to the default buffer size when bufferSize is 0", async () => { + await manager.createSession({ sessionId: "zero", bufferSize: 0 }); + // InteractiveSession is constructed with (sessionId, bufferSize); a 0 must + // not reach it - it would yield a zero-capacity buffer that drops output. + expect(MockedSession).toHaveBeenCalledWith("zero", expect.any(Number)); + const bufArg = MockedSession.mock.calls[0]![1] as number; + expect(bufArg).toBeGreaterThan(0); + }); + + it("removes the placeholder when start() fails", async () => { + MockedSession.mockImplementationOnce(function (this: unknown, sessionId: string) { + const mock = makeMockSession(sessionId); + mock.start.mockRejectedValueOnce(new Error("spawn failed")); + created.push(mock); + return mock as unknown as InteractiveSession; + } as unknown as (sessionId: string) => InteractiveSession); + await expect(manager.createSession({ sessionId: "bad" })).rejects.toBeInstanceOf(SessionError); + expect(manager.getSessionCount()).toBe(0); + }); + }); + + describe("executeCommand", () => { + it("delegates to sendCommand then readOutput", async () => { + await manager.createSession({ sessionId: "s1" }); + const result = await manager.executeCommand("s1", "report_wns"); + expect(created[0]!.sendCommand).toHaveBeenCalledWith("report_wns"); + expect(created[0]!.readOutput).toHaveBeenCalledOnce(); + expect(result.output).toBe("ok"); + }); + + it("throws SessionNotFoundError for an unknown session", async () => { + await expect(manager.executeCommand("nope", "report_wns")).rejects.toBeInstanceOf( + SessionNotFoundError, + ); + }); + + it("falls back to the default timeout when timeoutMs is 0", async () => { + await manager.createSession({ sessionId: "s1" }); + await manager.executeCommand("s1", "report_wns", 0); + // 0 must not be forwarded as an instant timeout; readOutput gets the default. + const timeoutArg = created[0]!.readOutput.mock.calls[0]![0] as number; + expect(timeoutArg).toBeGreaterThan(0); + }); + }); + + describe("listSessions", () => { + it("returns info for each active session", async () => { + await manager.createSession({ sessionId: "s1" }); + await manager.createSession({ sessionId: "s2" }); + const infos = await manager.listSessions(); + expect(infos).toHaveLength(2); + expect(infos.map((i) => i.sessionId).sort()).toEqual(["s1", "s2"]); + }); + }); + + describe("terminateSession", () => { + it("terminates, cleans up, and removes the session", async () => { + await manager.createSession({ sessionId: "s1" }); + await manager.terminateSession("s1", true); + expect(created[0]!.terminate).toHaveBeenCalledWith(true); + expect(created[0]!.cleanup).toHaveBeenCalledOnce(); + expect(manager.getSessionCount()).toBe(0); + }); + }); + + describe("terminateAllSessions", () => { + it("terminates every session in parallel", async () => { + await manager.createSession({ sessionId: "s1" }); + await manager.createSession({ sessionId: "s2" }); + const count = await manager.terminateAllSessions(); + expect(count).toBe(2); + expect(manager.getSessionCount()).toBe(0); + }); + }); + + describe("inspectSession & getSessionHistory", () => { + it("inspectSession delegates to getDetailedMetrics", async () => { + await manager.createSession({ sessionId: "s1" }); + const metrics = await manager.inspectSession("s1"); + expect(created[0]!.getDetailedMetrics).toHaveBeenCalledOnce(); + expect(metrics.session_id).toBe("s1"); + }); + + it("getSessionHistory forwards limit and search", async () => { + await manager.createSession({ sessionId: "s1" }); + await manager.getSessionHistory("s1", 5, "report"); + expect(created[0]!.getCommandHistory).toHaveBeenCalledWith(5, "report"); + }); + }); + + describe("sessionMetrics", () => { + it("aggregates per-session metrics", async () => { + await manager.createSession({ sessionId: "s1" }); + await manager.createSession({ sessionId: "s2" }); + const metrics = await manager.sessionMetrics(); + expect(metrics.manager.total_sessions).toBe(2); + expect(metrics.manager.active_sessions).toBe(2); + expect(metrics.aggregate.total_commands).toBe(6); // 3 per mock session + expect(metrics.sessions).toHaveLength(2); + }); + }); + + describe("cleanupIdleSessions", () => { + it("terminates idle sessions and leaves active ones", async () => { + await manager.createSession({ sessionId: "idle" }); + await manager.createSession({ sessionId: "busy" }); + created[0]!.isIdleTimeout.mockReturnValue(true); // "idle" + created[1]!.isIdleTimeout.mockReturnValue(false); // "busy" + + const cleaned = await manager.cleanupIdleSessions(300, true); + expect(cleaned).toBe(1); + expect(manager.getSessionCount()).toBe(1); + }); + }); + + describe("_getSession behaviour via public methods", () => { + it("getSessionInfo throws SessionNotFoundError for an unknown id", async () => { + await expect(manager.getSessionInfo("ghost")).rejects.toBeInstanceOf(SessionNotFoundError); + }); + }); +}); From ea19694d303b4eb0be2277709410e8f07ffcc77b Mon Sep 17 00:00:00 2001 From: kartikloops Date: Sun, 14 Jun 2026 19:15:35 -0600 Subject: [PATCH 09/62] add tests for session metrics, history, and idle timeout methods --- .../__tests__/interactive/session.test.ts | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/typescript/__tests__/interactive/session.test.ts b/typescript/__tests__/interactive/session.test.ts index 504b56b..a9a16bc 100644 --- a/typescript/__tests__/interactive/session.test.ts +++ b/typescript/__tests__/interactive/session.test.ts @@ -598,4 +598,99 @@ describe("InteractiveSession", () => { expect(result.error).toMatch(/Design not found: top/); }); }); + + describe("activity, history, and metrics", () => { + beforeEach(async () => { + (mockPty.isProcessAlive as ReturnType).mockReturnValue(true); + await session.start(); + }); + + it("updates lastActivity and grows history on sendCommand", async () => { + const before = session.lastActivity.getTime(); + await session.sendCommand("report_wns"); + + expect(session.commandHistory).toHaveLength(1); + expect(session.commandHistory[0]!.command).toBe("report_wns"); + expect(session.commandHistory[0]!.command_number).toBe(1); + expect(typeof session.commandHistory[0]!.execution_start).toBe("number"); + expect(session.totalCommandsExecuted).toBe(1); + expect(session.lastActivity.getTime()).toBeGreaterThanOrEqual(before); + }); + + it("trims the recorded command text", async () => { + await session.sendCommand(" puts hi "); + expect(session.commandHistory[0]!.command).toBe("puts hi"); + }); + + it("getCommandHistory filters by search (case-insensitive)", async () => { + await session.sendCommand("report_wns"); + await session.sendCommand("get_nets foo"); + + const filtered = session.getCommandHistory(undefined, "REPORT"); + expect(filtered).toHaveLength(1); + expect(filtered[0]!.command).toBe("report_wns"); + }); + + it("getCommandHistory applies a limit and sorts most-recent-first", async () => { + await session.sendCommand("cmd_a"); + await session.sendCommand("cmd_b"); + // Pin timestamps so the sort is deterministic regardless of wall-clock resolution. + session.commandHistory[0]!.timestamp = "2024-01-01T00:00:00.000Z"; + session.commandHistory[1]!.timestamp = "2024-01-01T00:00:01.000Z"; + + const limited = session.getCommandHistory(1); + expect(limited).toHaveLength(1); + expect(limited[0]!.command).toBe("cmd_b"); + }); + + it("getDetailedMetrics returns the full nested shape", async () => { + await session.sendCommand("report_wns"); + const m = await session.getDetailedMetrics(); + + expect(m.session_id).toBe("test-session-1"); + expect(m.is_alive).toBe(true); + expect(m.commands.total_executed).toBe(1); + expect(m.commands.history_length).toBe(1); + expect(m.buffer.max_size).toBe(1024); + expect(m.timeout.configured_seconds).toBeNull(); + expect(m.timeout.is_timed_out).toBe(false); + }); + + it("isIdleTimeout is false right after activity, true past the threshold", async () => { + await session.sendCommand("report_wns"); + expect(session.isIdleTimeout(1000)).toBe(false); + + session.lastActivity = new Date(Date.now() - 10_000); + expect(session.isIdleTimeout(1)).toBe(true); + }); + + it("setSessionTimeout drives the uptime-based is_timed_out flag", async () => { + // is_timed_out compares configured timeout against wall-clock uptime + // (distinct from idle timeout). Push createdAt into the past so uptime + // deterministically exceeds the configured 1s timeout. + session.setSessionTimeout(1); + expect(session.sessionTimeoutSeconds).toBe(1); + session.createdAt.setTime(Date.now() - 10_000); + + const m = await session.getDetailedMetrics(); + expect(m.timeout.configured_seconds).toBe(1); + expect(m.timeout.is_timed_out).toBe(true); + }); + + it("readOutput backfills execution_time and output_length on the last entry", async () => { + await session.sendCommand("report_wns"); + await session.outputBuffer.append("wns 0.1\n"); + await session.readOutput(100); + + const entry = session.commandHistory[0]!; + expect(entry.execution_time).toBeGreaterThanOrEqual(0); + expect(entry.output_length).toBeGreaterThan(0); + }); + + it("filterOutput returns matching lines (regex, case-insensitive)", async () => { + await session.outputBuffer.append("alpha\nbeta\ngamma beta\n"); + const matches = await session.filterOutput("BETA"); + expect(matches).toEqual(["beta", "gamma beta"]); + }); + }); }); From cf72041d5b27e805b8b40390fd5ce209238a4000 Mon Sep 17 00:00:00 2001 From: Kartik Mittal <100078751+kartikloops@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:22:09 -0600 Subject: [PATCH 10/62] Feat/ts pty handler migration (#126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add vscode workspace settings for typescript sdk path * add plan doc * add core session state and error models * add interactive session error models * add circular buffer for pty output * add pty handler for managing node-pty processes * add interactive session manager * add tests for circular buffer * add tests for command validation * add tests for pty handler * add tests for interactive session * update eslint config with coverage ignore and stricter rules * fix pending waiters, env cast and resolver types in pty handler * guard read chunk size against zero in session onData handler * add test for cleanup resolving pending waitForExit waiters * add real openroad repl integration check script * code cleanup * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Kartik Mittal <100078751+kartikloops@users.noreply.github.com> * added case for READ_CHUNK_SIZE is larger than limit Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Kartik Mittal <100078751+kartikloops@users.noreply.github.com> * fix isAlive signaling shutdown when process death is detected * add test for isAlive shutdown signal on process death * fix force terminate returning before alive flag clears * update tests for force terminate waiting for exit * fix terminate not disposing pty listeners and pending waiters * update terminate tests to assert pty cleanup is called * fix clear not waking pending waitForData callers * add regression test for clear waking pending waitForData callers * signal shutdown in readOutput drain path so writer task does not loop indefinitely * add regression test for readOutput signaling shutdown on dead session * fix waitForData not re-checking dataAvailable under mutex after runExclusive * add regression test for waitForData race between fast-path check and mutex * handle append rejection in onData handler to avoid silent data loss * add regression test for append rejection transitioning session to terminated * move MacOSPTYHandler to production and add create_pty_handler factory * use create_pty_handler so macOS gets the keepalive and drain subclass * remove duplicate MacOSPTYHandler from test file and import from production * remove MacOSPTYHandler and create_pty_handler — not needed for PoC * await SIGKILL exit in terminateProcess so cleanup cannot race the exit listener * block path traversal sequences in validateCommand argument check * reject absolute paths in python validateCommand to match typescript behavior * truncate single oversized chunk to maxSize to enforce capacity invariant * throw on unrecognised boolean env vars and lazily init settings to prevent silent security disable * fix ansi decoder escape coverage, annotate mode cr handling, mode validation and pre-compile sequence patterns * wait for exit after kill() throw to observe natural death rather than returning blind * bound waitForExit after SIGKILL to 5s to prevent indefinite hang on D-state processes * revert python absolute path change and align typescript to accept absolute paths via basename check * revert python pty handler and integration test to main --------- Signed-off-by: Kartik Mittal <100078751+kartikloops@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Cursor --- .../__tests__/interactive/session.test.ts | 95 ---------- typescript/src/core/models.ts | 174 ----------------- typescript/src/interactive/pty_handler.ts | 5 - typescript/src/interactive/session.ts | 176 +----------------- 4 files changed, 3 insertions(+), 447 deletions(-) diff --git a/typescript/__tests__/interactive/session.test.ts b/typescript/__tests__/interactive/session.test.ts index a9a16bc..504b56b 100644 --- a/typescript/__tests__/interactive/session.test.ts +++ b/typescript/__tests__/interactive/session.test.ts @@ -598,99 +598,4 @@ describe("InteractiveSession", () => { expect(result.error).toMatch(/Design not found: top/); }); }); - - describe("activity, history, and metrics", () => { - beforeEach(async () => { - (mockPty.isProcessAlive as ReturnType).mockReturnValue(true); - await session.start(); - }); - - it("updates lastActivity and grows history on sendCommand", async () => { - const before = session.lastActivity.getTime(); - await session.sendCommand("report_wns"); - - expect(session.commandHistory).toHaveLength(1); - expect(session.commandHistory[0]!.command).toBe("report_wns"); - expect(session.commandHistory[0]!.command_number).toBe(1); - expect(typeof session.commandHistory[0]!.execution_start).toBe("number"); - expect(session.totalCommandsExecuted).toBe(1); - expect(session.lastActivity.getTime()).toBeGreaterThanOrEqual(before); - }); - - it("trims the recorded command text", async () => { - await session.sendCommand(" puts hi "); - expect(session.commandHistory[0]!.command).toBe("puts hi"); - }); - - it("getCommandHistory filters by search (case-insensitive)", async () => { - await session.sendCommand("report_wns"); - await session.sendCommand("get_nets foo"); - - const filtered = session.getCommandHistory(undefined, "REPORT"); - expect(filtered).toHaveLength(1); - expect(filtered[0]!.command).toBe("report_wns"); - }); - - it("getCommandHistory applies a limit and sorts most-recent-first", async () => { - await session.sendCommand("cmd_a"); - await session.sendCommand("cmd_b"); - // Pin timestamps so the sort is deterministic regardless of wall-clock resolution. - session.commandHistory[0]!.timestamp = "2024-01-01T00:00:00.000Z"; - session.commandHistory[1]!.timestamp = "2024-01-01T00:00:01.000Z"; - - const limited = session.getCommandHistory(1); - expect(limited).toHaveLength(1); - expect(limited[0]!.command).toBe("cmd_b"); - }); - - it("getDetailedMetrics returns the full nested shape", async () => { - await session.sendCommand("report_wns"); - const m = await session.getDetailedMetrics(); - - expect(m.session_id).toBe("test-session-1"); - expect(m.is_alive).toBe(true); - expect(m.commands.total_executed).toBe(1); - expect(m.commands.history_length).toBe(1); - expect(m.buffer.max_size).toBe(1024); - expect(m.timeout.configured_seconds).toBeNull(); - expect(m.timeout.is_timed_out).toBe(false); - }); - - it("isIdleTimeout is false right after activity, true past the threshold", async () => { - await session.sendCommand("report_wns"); - expect(session.isIdleTimeout(1000)).toBe(false); - - session.lastActivity = new Date(Date.now() - 10_000); - expect(session.isIdleTimeout(1)).toBe(true); - }); - - it("setSessionTimeout drives the uptime-based is_timed_out flag", async () => { - // is_timed_out compares configured timeout against wall-clock uptime - // (distinct from idle timeout). Push createdAt into the past so uptime - // deterministically exceeds the configured 1s timeout. - session.setSessionTimeout(1); - expect(session.sessionTimeoutSeconds).toBe(1); - session.createdAt.setTime(Date.now() - 10_000); - - const m = await session.getDetailedMetrics(); - expect(m.timeout.configured_seconds).toBe(1); - expect(m.timeout.is_timed_out).toBe(true); - }); - - it("readOutput backfills execution_time and output_length on the last entry", async () => { - await session.sendCommand("report_wns"); - await session.outputBuffer.append("wns 0.1\n"); - await session.readOutput(100); - - const entry = session.commandHistory[0]!; - expect(entry.execution_time).toBeGreaterThanOrEqual(0); - expect(entry.output_length).toBeGreaterThan(0); - }); - - it("filterOutput returns matching lines (regex, case-insensitive)", async () => { - await session.outputBuffer.append("alpha\nbeta\ngamma beta\n"); - const matches = await session.filterOutput("BETA"); - expect(matches).toEqual(["beta", "gamma beta"]); - }); - }); }); diff --git a/typescript/src/core/models.ts b/typescript/src/core/models.ts index 9241e03..8d4d00e 100644 --- a/typescript/src/core/models.ts +++ b/typescript/src/core/models.ts @@ -1,5 +1,3 @@ -import { z } from "zod"; - export enum SessionState { CREATING = "creating", ACTIVE = "active", @@ -7,17 +5,6 @@ export enum SessionState { ERROR = "error", } -export enum ProcessState { - STOPPED = "stopped", - STARTING = "starting", - RUNNING = "running", - ERROR = "error", -} - -// Domain interfaces (camelCase) -// These remain plain interfaces and are converted to the snake_case MCP wire -// format at the tool serialization boundary (BaseTool.formatResult, Part 2). - export interface InteractiveSessionInfo { sessionId: string; createdAt: string; @@ -38,164 +25,3 @@ export interface InteractiveExecResult { bufferSize: number; error?: string | null; } - -// Opaque snake_case payloads -// These are passed straight through to the wire (no camel->snake conversion), -// matching Python's dict output byte-for-byte. - -/** One entry in a session's command history. */ -export interface CommandHistoryEntry { - command: string; - timestamp: string; - command_number: number; - execution_start: number; - execution_time?: number; - output_length?: number; -} - -/** Detailed per-session metrics returned by InteractiveSession.getDetailedMetrics. */ -export interface SessionDetailedMetrics { - session_id: string; - state: string; - is_alive: boolean; - created_at: string; - last_activity: string; - uptime_seconds: number; - idle_seconds: number; - commands: { - total_executed: number; - current_count: number; - history_length: number; - }; - performance: { - total_cpu_time: number; - peak_memory_mb: number; - current_memory_mb: number; - }; - buffer: { - current_size: number; - max_size: number; - utilization_percent: number; - }; - timeout: { - configured_seconds: number | null; - is_timed_out: boolean; - }; -} - -/** Aggregate metrics across all sessions returned by OpenROADManager.sessionMetrics. */ -export interface ManagerMetrics { - manager: { - total_sessions: number; - active_sessions: number; - terminated_sessions: number; - max_sessions: number; - utilization_percent: number; - }; - aggregate: { - total_commands: number; - total_cpu_time: number; - total_memory_mb: number; - avg_memory_per_session: number; - }; - sessions: SessionDetailedMetrics[]; -} - -// Zod result schemas -// BaseResult pattern: every result carries `error: string | null`, defaulting to -// null. Python Pydantic always emits the `error` key (`= None` -> `null`), so we -// use `.nullable().default(null)`, never `.optional()`, to preserve key presence. - -const errorField = z.string().nullable().default(null); - -export const CommandRecord = z.object({ - command: z.string(), - timestamp: z.string(), - id: z.number(), -}); -export type CommandRecord = z.infer; - -export const InteractiveSessionListResult = z.object({ - sessions: z.array(z.custom()).default([]), - totalCount: z.number().default(0), - activeCount: z.number().default(0), - error: errorField, -}); -export type InteractiveSessionListResult = z.infer; - -export const SessionTerminationResult = z.object({ - sessionId: z.string(), - terminated: z.boolean(), - wasAlive: z.boolean().default(false), - force: z.boolean().default(false), - error: errorField, -}); -export type SessionTerminationResult = z.infer; - -export const SessionInspectionResult = z.object({ - sessionId: z.string(), - metrics: z.custom().nullable().default(null), - error: errorField, -}); -export type SessionInspectionResult = z.infer; - -export const SessionHistoryResult = z.object({ - sessionId: z.string(), - history: z.array(z.custom()).default([]), - totalCommands: z.number().default(0), - limit: z.number().nullable().default(null), - search: z.string().nullable().default(null), - error: errorField, -}); -export type SessionHistoryResult = z.infer; - -export const SessionMetricsResult = z.object({ - metrics: z.custom().nullable().default(null), - error: errorField, -}); -export type SessionMetricsResult = z.infer; - -// Image models - -export const ImageInfo = z.object({ - filename: z.string(), - path: z.string(), - sizeBytes: z.number(), - modifiedTime: z.string(), - type: z.string(), -}); -export type ImageInfo = z.infer; - -export const ImageMetadata = z.object({ - filename: z.string(), - format: z.string(), - sizeBytes: z.number(), - width: z.number().nullable().default(null), - height: z.number().nullable().default(null), - modifiedTime: z.string(), - stage: z.string(), - type: z.string(), - compressionApplied: z.boolean().default(false), - originalSizeBytes: z.number().nullable().default(null), - originalWidth: z.number().nullable().default(null), - originalHeight: z.number().nullable().default(null), - compressionRatio: z.number().nullable().default(null), -}); -export type ImageMetadata = z.infer; - -export const ListImagesResult = z.object({ - runPath: z.string().nullable().default(null), - totalImages: z.number().nullable().default(null), - imagesByStage: z.record(z.string(), z.array(ImageInfo)).nullable().default(null), - message: z.string().nullable().default(null), - error: errorField, -}); -export type ListImagesResult = z.infer; - -export const ReadImageResult = z.object({ - imageData: z.string().nullable().default(null), - metadata: ImageMetadata.nullable().default(null), - message: z.string().nullable().default(null), - error: errorField, -}); -export type ReadImageResult = z.infer; diff --git a/typescript/src/interactive/pty_handler.ts b/typescript/src/interactive/pty_handler.ts index 0061a1e..fafa0cf 100644 --- a/typescript/src/interactive/pty_handler.ts +++ b/typescript/src/interactive/pty_handler.ts @@ -15,11 +15,6 @@ export class PtyHandler { constructor(private readonly _settings: Settings = getSettings()) {} - /** PID of the underlying PTY process, or null if no process is active. */ - get pid(): number | null { - return this._ptyProcess?.pid ?? null; - } - validateCommand(command: string[]): void { if (!this._settings.ENABLE_COMMAND_VALIDATION) return; diff --git a/typescript/src/interactive/session.ts b/typescript/src/interactive/session.ts index 821db4e..95c9644 100644 --- a/typescript/src/interactive/session.ts +++ b/typescript/src/interactive/session.ts @@ -1,15 +1,9 @@ -import pidusage from "pidusage"; import { ANSIDecoder } from "../utils/ansi_decoder.js"; import { getSettings } from "../config/settings.js"; import type { Settings } from "../config/settings.js"; import { SessionState } from "../core/models.js"; -import type { - CommandHistoryEntry, - InteractiveExecResult, - InteractiveSessionInfo, - SessionDetailedMetrics, -} from "../core/models.js"; -import { BYTES_TO_MB, MAX_COMMAND_COMPLETION_WINDOW, UTILIZATION_PERCENTAGE_BASE } from "../constants.js"; +import type { InteractiveExecResult, InteractiveSessionInfo } from "../core/models.js"; +import { MAX_COMMAND_COMPLETION_WINDOW } from "../constants.js"; import { CircularBuffer } from "./buffer.js"; import { SessionError, SessionTerminatedError } from "./models.js"; import { PtyHandler } from "./pty_handler.js"; @@ -39,14 +33,6 @@ export class InteractiveSession { readonly createdAt: Date; commandCount = 0; - // Activity / history / performance tracking (consumed by the manager). - lastActivity: Date = new Date(); - readonly commandHistory: CommandHistoryEntry[] = []; - totalCpuTime = 0; - peakMemoryMb = 0; - totalCommandsExecuted = 0; - sessionTimeoutSeconds: number | null = null; - private _state: SessionState; pty: PtyHandler; readonly outputBuffer: CircularBuffer; @@ -155,20 +141,9 @@ export class InteractiveSession { ); } - // Record the command in history before bumping the counters so the entry's - // command_number matches Python (command_count + 1). - this.commandHistory.push({ - command: command.trim(), - timestamp: new Date().toISOString(), - command_number: this.commandCount + 1, - execution_start: Date.now() / 1000, - }); - const data = command.endsWith("\n") ? command : command + "\n"; this._inputQueue.push(data); this.commandCount++; - this.totalCommandsExecuted++; - this.lastActivity = new Date(); const waiters = this._inputWaiters.splice(0); for (const w of waiters) w(); @@ -193,13 +168,11 @@ export class InteractiveSession { } const rawOutput = chunks.join(""); const output = ANSIDecoder.cleanOpenroadOutput(rawOutput); - const executionTime = (Date.now() - startTime) / 1000; - this._recordReadResult(output.length, executionTime); return { output, sessionId: this.sessionId, timestamp: new Date().toISOString(), - executionTime, + executionTime: (Date.now() - startTime) / 1000, commandCount: this.commandCount, bufferSize: this.outputBuffer.size, error: this._detectErrors(output) ?? null, @@ -233,9 +206,6 @@ export class InteractiveSession { const executionTime = (Date.now() - startTime) / 1000; const output = ANSIDecoder.cleanOpenroadOutput(rawOutput); - await this._updatePerformanceMetrics(); - this._recordReadResult(output.length, executionTime); - return { output, sessionId: this.sessionId, @@ -354,144 +324,4 @@ export class InteractiveSession { return undefined; } - - /** Update lastActivity and backfill the last history entry after a read. */ - private _recordReadResult(outputLength: number, executionTime: number): void { - const last = this.commandHistory[this.commandHistory.length - 1]; - if (last && last.execution_time === undefined) { - last.execution_time = executionTime; - last.output_length = outputLength; - } - this.lastActivity = new Date(); - } - - /** Sample CPU/memory from the live process. Best-effort; silently ignores a - * dead or inaccessible PID. CPU time is cumulative (assigned, not summed). */ - private async _updatePerformanceMetrics(): Promise { - const pid = this.pty.pid; - if (pid == null) return; - try { - const usage = await pidusage(pid); - this.totalCpuTime = usage.ctime / 1000; - const currentMemoryMb = Math.max(0, usage.memory) / BYTES_TO_MB; - this.peakMemoryMb = Math.max(this.peakMemoryMb, currentMemoryMb); - } catch { - // Process may have exited or be inaccessible. - } - } - - private async _getCurrentMemoryMb(): Promise { - const pid = this.pty.pid; - if (pid == null) return 0; - try { - const usage = await pidusage(pid); - return Math.max(0, usage.memory) / BYTES_TO_MB; - } catch { - return 0; - } - } - - /** True when a configured per-session timeout has been exceeded by uptime. - * Distinct from idle timeout - this is wall-clock lifetime, not inactivity. */ - private _checkSessionTimeout(): boolean { - if (this.sessionTimeoutSeconds === null) return false; - const uptime = (Date.now() - this.createdAt.getTime()) / 1000; - return uptime > this.sessionTimeoutSeconds; - } - - async getDetailedMetrics(): Promise { - await this._updatePerformanceMetrics(); - const now = Date.now(); - const uptimeSeconds = (now - this.createdAt.getTime()) / 1000; - const idleSeconds = (now - this.lastActivity.getTime()) / 1000; - const bufferSize = this.outputBuffer.size; - const maxSize = this.outputBuffer.maxSize; - - return { - session_id: this.sessionId, - state: this._state, - is_alive: this.isAlive(), - created_at: this.createdAt.toISOString(), - last_activity: this.lastActivity.toISOString(), - uptime_seconds: uptimeSeconds, - idle_seconds: idleSeconds, - commands: { - total_executed: this.totalCommandsExecuted, - current_count: this.commandCount, - history_length: this.commandHistory.length, - }, - performance: { - total_cpu_time: this.totalCpuTime, - peak_memory_mb: this.peakMemoryMb, - current_memory_mb: await this._getCurrentMemoryMb(), - }, - buffer: { - current_size: bufferSize, - max_size: maxSize, - utilization_percent: maxSize > 0 ? (bufferSize / maxSize) * UTILIZATION_PERCENTAGE_BASE : 0, - }, - timeout: { - configured_seconds: this.sessionTimeoutSeconds, - is_timed_out: this._checkSessionTimeout(), - }, - }; - } - - getCommandHistory(limit?: number, search?: string): CommandHistoryEntry[] { - let history = [...this.commandHistory]; - - if (search) { - const needle = search.toLowerCase(); - history = history.filter((cmd) => cmd.command.toLowerCase().includes(needle)); - } - - // Sort by timestamp, most recent first. - history.sort((a, b) => (a.timestamp < b.timestamp ? 1 : a.timestamp > b.timestamp ? -1 : 0)); - - // Match Python's truthy check: limit === 0 leaves the list unsliced. - if (limit) { - history = history.slice(0, limit); - } - - return history; - } - - async replayCommand(commandNumber: number): Promise { - for (const cmd of this.commandHistory) { - if (cmd.command_number === commandNumber) { - await this.sendCommand(cmd.command); - return cmd.command; - } - } - throw new SessionError(`Command ${commandNumber} not found in history`, this.sessionId); - } - - setSessionTimeout(timeoutSeconds: number): void { - this.sessionTimeoutSeconds = timeoutSeconds; - } - - isIdleTimeout(idleThresholdSeconds: number = this._settings.SESSION_IDLE_TIMEOUT): boolean { - const idleTime = (Date.now() - this.lastActivity.getTime()) / 1000; - return idleTime > idleThresholdSeconds; - } - - async filterOutput(pattern: string, maxLines = 1000): Promise { - const chunks = await this.outputBuffer.peekAll(); - if (chunks.length === 0) return []; - - const text = this.outputBuffer.toText(chunks); - const lines = text.split("\n"); - - let matching: string[]; - try { - const regex = new RegExp(pattern, "i"); - matching = lines.filter((line) => regex.test(line)); - } catch { - // Fallback to a case-insensitive substring search on invalid regex. - const needle = pattern.toLowerCase(); - matching = lines.filter((line) => line.toLowerCase().includes(needle)); - } - - return matching.length > 0 ? matching.slice(-maxLines) : []; - } } From 507ec80b6f6150a400b790370f241bb21ee49547 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Sun, 14 Jun 2026 19:15:25 -0600 Subject: [PATCH 11/62] add zod result schemas using nullable default null to match pydantic none serialization --- typescript/src/core/models.ts | 174 ++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) diff --git a/typescript/src/core/models.ts b/typescript/src/core/models.ts index 8d4d00e..9241e03 100644 --- a/typescript/src/core/models.ts +++ b/typescript/src/core/models.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + export enum SessionState { CREATING = "creating", ACTIVE = "active", @@ -5,6 +7,17 @@ export enum SessionState { ERROR = "error", } +export enum ProcessState { + STOPPED = "stopped", + STARTING = "starting", + RUNNING = "running", + ERROR = "error", +} + +// Domain interfaces (camelCase) +// These remain plain interfaces and are converted to the snake_case MCP wire +// format at the tool serialization boundary (BaseTool.formatResult, Part 2). + export interface InteractiveSessionInfo { sessionId: string; createdAt: string; @@ -25,3 +38,164 @@ export interface InteractiveExecResult { bufferSize: number; error?: string | null; } + +// Opaque snake_case payloads +// These are passed straight through to the wire (no camel->snake conversion), +// matching Python's dict output byte-for-byte. + +/** One entry in a session's command history. */ +export interface CommandHistoryEntry { + command: string; + timestamp: string; + command_number: number; + execution_start: number; + execution_time?: number; + output_length?: number; +} + +/** Detailed per-session metrics returned by InteractiveSession.getDetailedMetrics. */ +export interface SessionDetailedMetrics { + session_id: string; + state: string; + is_alive: boolean; + created_at: string; + last_activity: string; + uptime_seconds: number; + idle_seconds: number; + commands: { + total_executed: number; + current_count: number; + history_length: number; + }; + performance: { + total_cpu_time: number; + peak_memory_mb: number; + current_memory_mb: number; + }; + buffer: { + current_size: number; + max_size: number; + utilization_percent: number; + }; + timeout: { + configured_seconds: number | null; + is_timed_out: boolean; + }; +} + +/** Aggregate metrics across all sessions returned by OpenROADManager.sessionMetrics. */ +export interface ManagerMetrics { + manager: { + total_sessions: number; + active_sessions: number; + terminated_sessions: number; + max_sessions: number; + utilization_percent: number; + }; + aggregate: { + total_commands: number; + total_cpu_time: number; + total_memory_mb: number; + avg_memory_per_session: number; + }; + sessions: SessionDetailedMetrics[]; +} + +// Zod result schemas +// BaseResult pattern: every result carries `error: string | null`, defaulting to +// null. Python Pydantic always emits the `error` key (`= None` -> `null`), so we +// use `.nullable().default(null)`, never `.optional()`, to preserve key presence. + +const errorField = z.string().nullable().default(null); + +export const CommandRecord = z.object({ + command: z.string(), + timestamp: z.string(), + id: z.number(), +}); +export type CommandRecord = z.infer; + +export const InteractiveSessionListResult = z.object({ + sessions: z.array(z.custom()).default([]), + totalCount: z.number().default(0), + activeCount: z.number().default(0), + error: errorField, +}); +export type InteractiveSessionListResult = z.infer; + +export const SessionTerminationResult = z.object({ + sessionId: z.string(), + terminated: z.boolean(), + wasAlive: z.boolean().default(false), + force: z.boolean().default(false), + error: errorField, +}); +export type SessionTerminationResult = z.infer; + +export const SessionInspectionResult = z.object({ + sessionId: z.string(), + metrics: z.custom().nullable().default(null), + error: errorField, +}); +export type SessionInspectionResult = z.infer; + +export const SessionHistoryResult = z.object({ + sessionId: z.string(), + history: z.array(z.custom()).default([]), + totalCommands: z.number().default(0), + limit: z.number().nullable().default(null), + search: z.string().nullable().default(null), + error: errorField, +}); +export type SessionHistoryResult = z.infer; + +export const SessionMetricsResult = z.object({ + metrics: z.custom().nullable().default(null), + error: errorField, +}); +export type SessionMetricsResult = z.infer; + +// Image models + +export const ImageInfo = z.object({ + filename: z.string(), + path: z.string(), + sizeBytes: z.number(), + modifiedTime: z.string(), + type: z.string(), +}); +export type ImageInfo = z.infer; + +export const ImageMetadata = z.object({ + filename: z.string(), + format: z.string(), + sizeBytes: z.number(), + width: z.number().nullable().default(null), + height: z.number().nullable().default(null), + modifiedTime: z.string(), + stage: z.string(), + type: z.string(), + compressionApplied: z.boolean().default(false), + originalSizeBytes: z.number().nullable().default(null), + originalWidth: z.number().nullable().default(null), + originalHeight: z.number().nullable().default(null), + compressionRatio: z.number().nullable().default(null), +}); +export type ImageMetadata = z.infer; + +export const ListImagesResult = z.object({ + runPath: z.string().nullable().default(null), + totalImages: z.number().nullable().default(null), + imagesByStage: z.record(z.string(), z.array(ImageInfo)).nullable().default(null), + message: z.string().nullable().default(null), + error: errorField, +}); +export type ListImagesResult = z.infer; + +export const ReadImageResult = z.object({ + imageData: z.string().nullable().default(null), + metadata: ImageMetadata.nullable().default(null), + message: z.string().nullable().default(null), + error: errorField, +}); +export type ReadImageResult = z.infer; From 987a69f0545e3f08492b54965679af91dc84dca1 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Sun, 14 Jun 2026 19:15:25 -0600 Subject: [PATCH 12/62] expose pty process pid getter for session performance sampling --- typescript/src/interactive/pty_handler.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/typescript/src/interactive/pty_handler.ts b/typescript/src/interactive/pty_handler.ts index fafa0cf..0061a1e 100644 --- a/typescript/src/interactive/pty_handler.ts +++ b/typescript/src/interactive/pty_handler.ts @@ -15,6 +15,11 @@ export class PtyHandler { constructor(private readonly _settings: Settings = getSettings()) {} + /** PID of the underlying PTY process, or null if no process is active. */ + get pid(): number | null { + return this._ptyProcess?.pid ?? null; + } + validateCommand(command: string[]): void { if (!this._settings.ENABLE_COMMAND_VALIDATION) return; From 758958e5633991eec610af284be6b2c54024c36c Mon Sep 17 00:00:00 2001 From: kartikloops Date: Sun, 14 Jun 2026 19:15:35 -0600 Subject: [PATCH 13/62] add session metrics, command history, and idle timeout tracking --- typescript/src/interactive/session.ts | 176 +++++++++++++++++++++++++- 1 file changed, 173 insertions(+), 3 deletions(-) diff --git a/typescript/src/interactive/session.ts b/typescript/src/interactive/session.ts index 95c9644..821db4e 100644 --- a/typescript/src/interactive/session.ts +++ b/typescript/src/interactive/session.ts @@ -1,9 +1,15 @@ +import pidusage from "pidusage"; import { ANSIDecoder } from "../utils/ansi_decoder.js"; import { getSettings } from "../config/settings.js"; import type { Settings } from "../config/settings.js"; import { SessionState } from "../core/models.js"; -import type { InteractiveExecResult, InteractiveSessionInfo } from "../core/models.js"; -import { MAX_COMMAND_COMPLETION_WINDOW } from "../constants.js"; +import type { + CommandHistoryEntry, + InteractiveExecResult, + InteractiveSessionInfo, + SessionDetailedMetrics, +} from "../core/models.js"; +import { BYTES_TO_MB, MAX_COMMAND_COMPLETION_WINDOW, UTILIZATION_PERCENTAGE_BASE } from "../constants.js"; import { CircularBuffer } from "./buffer.js"; import { SessionError, SessionTerminatedError } from "./models.js"; import { PtyHandler } from "./pty_handler.js"; @@ -33,6 +39,14 @@ export class InteractiveSession { readonly createdAt: Date; commandCount = 0; + // Activity / history / performance tracking (consumed by the manager). + lastActivity: Date = new Date(); + readonly commandHistory: CommandHistoryEntry[] = []; + totalCpuTime = 0; + peakMemoryMb = 0; + totalCommandsExecuted = 0; + sessionTimeoutSeconds: number | null = null; + private _state: SessionState; pty: PtyHandler; readonly outputBuffer: CircularBuffer; @@ -141,9 +155,20 @@ export class InteractiveSession { ); } + // Record the command in history before bumping the counters so the entry's + // command_number matches Python (command_count + 1). + this.commandHistory.push({ + command: command.trim(), + timestamp: new Date().toISOString(), + command_number: this.commandCount + 1, + execution_start: Date.now() / 1000, + }); + const data = command.endsWith("\n") ? command : command + "\n"; this._inputQueue.push(data); this.commandCount++; + this.totalCommandsExecuted++; + this.lastActivity = new Date(); const waiters = this._inputWaiters.splice(0); for (const w of waiters) w(); @@ -168,11 +193,13 @@ export class InteractiveSession { } const rawOutput = chunks.join(""); const output = ANSIDecoder.cleanOpenroadOutput(rawOutput); + const executionTime = (Date.now() - startTime) / 1000; + this._recordReadResult(output.length, executionTime); return { output, sessionId: this.sessionId, timestamp: new Date().toISOString(), - executionTime: (Date.now() - startTime) / 1000, + executionTime, commandCount: this.commandCount, bufferSize: this.outputBuffer.size, error: this._detectErrors(output) ?? null, @@ -206,6 +233,9 @@ export class InteractiveSession { const executionTime = (Date.now() - startTime) / 1000; const output = ANSIDecoder.cleanOpenroadOutput(rawOutput); + await this._updatePerformanceMetrics(); + this._recordReadResult(output.length, executionTime); + return { output, sessionId: this.sessionId, @@ -324,4 +354,144 @@ export class InteractiveSession { return undefined; } + + /** Update lastActivity and backfill the last history entry after a read. */ + private _recordReadResult(outputLength: number, executionTime: number): void { + const last = this.commandHistory[this.commandHistory.length - 1]; + if (last && last.execution_time === undefined) { + last.execution_time = executionTime; + last.output_length = outputLength; + } + this.lastActivity = new Date(); + } + + /** Sample CPU/memory from the live process. Best-effort; silently ignores a + * dead or inaccessible PID. CPU time is cumulative (assigned, not summed). */ + private async _updatePerformanceMetrics(): Promise { + const pid = this.pty.pid; + if (pid == null) return; + try { + const usage = await pidusage(pid); + this.totalCpuTime = usage.ctime / 1000; + const currentMemoryMb = Math.max(0, usage.memory) / BYTES_TO_MB; + this.peakMemoryMb = Math.max(this.peakMemoryMb, currentMemoryMb); + } catch { + // Process may have exited or be inaccessible. + } + } + + private async _getCurrentMemoryMb(): Promise { + const pid = this.pty.pid; + if (pid == null) return 0; + try { + const usage = await pidusage(pid); + return Math.max(0, usage.memory) / BYTES_TO_MB; + } catch { + return 0; + } + } + + /** True when a configured per-session timeout has been exceeded by uptime. + * Distinct from idle timeout - this is wall-clock lifetime, not inactivity. */ + private _checkSessionTimeout(): boolean { + if (this.sessionTimeoutSeconds === null) return false; + const uptime = (Date.now() - this.createdAt.getTime()) / 1000; + return uptime > this.sessionTimeoutSeconds; + } + + async getDetailedMetrics(): Promise { + await this._updatePerformanceMetrics(); + const now = Date.now(); + const uptimeSeconds = (now - this.createdAt.getTime()) / 1000; + const idleSeconds = (now - this.lastActivity.getTime()) / 1000; + const bufferSize = this.outputBuffer.size; + const maxSize = this.outputBuffer.maxSize; + + return { + session_id: this.sessionId, + state: this._state, + is_alive: this.isAlive(), + created_at: this.createdAt.toISOString(), + last_activity: this.lastActivity.toISOString(), + uptime_seconds: uptimeSeconds, + idle_seconds: idleSeconds, + commands: { + total_executed: this.totalCommandsExecuted, + current_count: this.commandCount, + history_length: this.commandHistory.length, + }, + performance: { + total_cpu_time: this.totalCpuTime, + peak_memory_mb: this.peakMemoryMb, + current_memory_mb: await this._getCurrentMemoryMb(), + }, + buffer: { + current_size: bufferSize, + max_size: maxSize, + utilization_percent: maxSize > 0 ? (bufferSize / maxSize) * UTILIZATION_PERCENTAGE_BASE : 0, + }, + timeout: { + configured_seconds: this.sessionTimeoutSeconds, + is_timed_out: this._checkSessionTimeout(), + }, + }; + } + + getCommandHistory(limit?: number, search?: string): CommandHistoryEntry[] { + let history = [...this.commandHistory]; + + if (search) { + const needle = search.toLowerCase(); + history = history.filter((cmd) => cmd.command.toLowerCase().includes(needle)); + } + + // Sort by timestamp, most recent first. + history.sort((a, b) => (a.timestamp < b.timestamp ? 1 : a.timestamp > b.timestamp ? -1 : 0)); + + // Match Python's truthy check: limit === 0 leaves the list unsliced. + if (limit) { + history = history.slice(0, limit); + } + + return history; + } + + async replayCommand(commandNumber: number): Promise { + for (const cmd of this.commandHistory) { + if (cmd.command_number === commandNumber) { + await this.sendCommand(cmd.command); + return cmd.command; + } + } + throw new SessionError(`Command ${commandNumber} not found in history`, this.sessionId); + } + + setSessionTimeout(timeoutSeconds: number): void { + this.sessionTimeoutSeconds = timeoutSeconds; + } + + isIdleTimeout(idleThresholdSeconds: number = this._settings.SESSION_IDLE_TIMEOUT): boolean { + const idleTime = (Date.now() - this.lastActivity.getTime()) / 1000; + return idleTime > idleThresholdSeconds; + } + + async filterOutput(pattern: string, maxLines = 1000): Promise { + const chunks = await this.outputBuffer.peekAll(); + if (chunks.length === 0) return []; + + const text = this.outputBuffer.toText(chunks); + const lines = text.split("\n"); + + let matching: string[]; + try { + const regex = new RegExp(pattern, "i"); + matching = lines.filter((line) => regex.test(line)); + } catch { + // Fallback to a case-insensitive substring search on invalid regex. + const needle = pattern.toLowerCase(); + matching = lines.filter((line) => line.toLowerCase().includes(needle)); + } + + return matching.length > 0 ? matching.slice(-maxLines) : []; + } } From fdc9e1c569ef903b0719a9bbd323e02404512176 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Sun, 14 Jun 2026 19:15:35 -0600 Subject: [PATCH 14/62] add tests for session metrics, history, and idle timeout methods --- .../__tests__/interactive/session.test.ts | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/typescript/__tests__/interactive/session.test.ts b/typescript/__tests__/interactive/session.test.ts index 504b56b..a9a16bc 100644 --- a/typescript/__tests__/interactive/session.test.ts +++ b/typescript/__tests__/interactive/session.test.ts @@ -598,4 +598,99 @@ describe("InteractiveSession", () => { expect(result.error).toMatch(/Design not found: top/); }); }); + + describe("activity, history, and metrics", () => { + beforeEach(async () => { + (mockPty.isProcessAlive as ReturnType).mockReturnValue(true); + await session.start(); + }); + + it("updates lastActivity and grows history on sendCommand", async () => { + const before = session.lastActivity.getTime(); + await session.sendCommand("report_wns"); + + expect(session.commandHistory).toHaveLength(1); + expect(session.commandHistory[0]!.command).toBe("report_wns"); + expect(session.commandHistory[0]!.command_number).toBe(1); + expect(typeof session.commandHistory[0]!.execution_start).toBe("number"); + expect(session.totalCommandsExecuted).toBe(1); + expect(session.lastActivity.getTime()).toBeGreaterThanOrEqual(before); + }); + + it("trims the recorded command text", async () => { + await session.sendCommand(" puts hi "); + expect(session.commandHistory[0]!.command).toBe("puts hi"); + }); + + it("getCommandHistory filters by search (case-insensitive)", async () => { + await session.sendCommand("report_wns"); + await session.sendCommand("get_nets foo"); + + const filtered = session.getCommandHistory(undefined, "REPORT"); + expect(filtered).toHaveLength(1); + expect(filtered[0]!.command).toBe("report_wns"); + }); + + it("getCommandHistory applies a limit and sorts most-recent-first", async () => { + await session.sendCommand("cmd_a"); + await session.sendCommand("cmd_b"); + // Pin timestamps so the sort is deterministic regardless of wall-clock resolution. + session.commandHistory[0]!.timestamp = "2024-01-01T00:00:00.000Z"; + session.commandHistory[1]!.timestamp = "2024-01-01T00:00:01.000Z"; + + const limited = session.getCommandHistory(1); + expect(limited).toHaveLength(1); + expect(limited[0]!.command).toBe("cmd_b"); + }); + + it("getDetailedMetrics returns the full nested shape", async () => { + await session.sendCommand("report_wns"); + const m = await session.getDetailedMetrics(); + + expect(m.session_id).toBe("test-session-1"); + expect(m.is_alive).toBe(true); + expect(m.commands.total_executed).toBe(1); + expect(m.commands.history_length).toBe(1); + expect(m.buffer.max_size).toBe(1024); + expect(m.timeout.configured_seconds).toBeNull(); + expect(m.timeout.is_timed_out).toBe(false); + }); + + it("isIdleTimeout is false right after activity, true past the threshold", async () => { + await session.sendCommand("report_wns"); + expect(session.isIdleTimeout(1000)).toBe(false); + + session.lastActivity = new Date(Date.now() - 10_000); + expect(session.isIdleTimeout(1)).toBe(true); + }); + + it("setSessionTimeout drives the uptime-based is_timed_out flag", async () => { + // is_timed_out compares configured timeout against wall-clock uptime + // (distinct from idle timeout). Push createdAt into the past so uptime + // deterministically exceeds the configured 1s timeout. + session.setSessionTimeout(1); + expect(session.sessionTimeoutSeconds).toBe(1); + session.createdAt.setTime(Date.now() - 10_000); + + const m = await session.getDetailedMetrics(); + expect(m.timeout.configured_seconds).toBe(1); + expect(m.timeout.is_timed_out).toBe(true); + }); + + it("readOutput backfills execution_time and output_length on the last entry", async () => { + await session.sendCommand("report_wns"); + await session.outputBuffer.append("wns 0.1\n"); + await session.readOutput(100); + + const entry = session.commandHistory[0]!; + expect(entry.execution_time).toBeGreaterThanOrEqual(0); + expect(entry.output_length).toBeGreaterThan(0); + }); + + it("filterOutput returns matching lines (regex, case-insensitive)", async () => { + await session.outputBuffer.append("alpha\nbeta\ngamma beta\n"); + const matches = await session.filterOutput("BETA"); + expect(matches).toEqual(["beta", "gamma beta"]); + }); + }); }); From a03c65ab0f8af81b8f1ba0a530e84a7097209241 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Tue, 16 Jun 2026 21:22:32 -0600 Subject: [PATCH 15/62] add base tool class with camelcase to snake case serialization boundary --- typescript/src/tools/base.ts | 39 ++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 typescript/src/tools/base.ts diff --git a/typescript/src/tools/base.ts b/typescript/src/tools/base.ts new file mode 100644 index 0000000..7b34706 --- /dev/null +++ b/typescript/src/tools/base.ts @@ -0,0 +1,39 @@ +import type { OpenROADManager } from "../core/manager.js"; + +function camelToSnakeKey(key: string): string { + return key.replace(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`); +} + +/** + * Recursively converts camelCase object keys to snake_case. + * Idempotent on already-snake_case strings (no uppercase → no change), + * so opaque snake_case payloads (SessionDetailedMetrics, ManagerMetrics, + * CommandHistoryEntry) pass through unchanged. + */ +export function toSnakeCase(value: unknown): unknown { + if (Array.isArray(value)) return value.map(toSnakeCase); + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record).map(([k, v]) => [ + camelToSnakeKey(k), + toSnakeCase(v), + ]), + ); + } + return value; +} + +/** + * Base class for all MCP tool implementations. + * + * Provides the manager dependency and a serialization helper that converts the + * camelCase domain model to the snake_case MCP wire format in one place. + * Each subclass declares its own typed execute() signature. + */ +export abstract class BaseTool { + protected constructor(protected readonly manager: OpenROADManager) {} + + protected formatResult(result: Record): string { + return JSON.stringify(toSnakeCase(result)); + } +} From c00d445095b3997f2c3e471c396f24e1815b277c Mon Sep 17 00:00:00 2001 From: kartikloops Date: Tue, 16 Jun 2026 21:22:37 -0600 Subject: [PATCH 16/62] add interactive tool classes porting session management and command execution from python --- typescript/src/tools/interactive.ts | 442 ++++++++++++++++++++++++++++ 1 file changed, 442 insertions(+) create mode 100644 typescript/src/tools/interactive.ts diff --git a/typescript/src/tools/interactive.ts b/typescript/src/tools/interactive.ts new file mode 100644 index 0000000..8433ced --- /dev/null +++ b/typescript/src/tools/interactive.ts @@ -0,0 +1,442 @@ +import { getSettings } from "../config/settings.js"; +import { + isExecCommand, + isQueryCommand, +} from "../config/command_whitelist.js"; +import type { OpenROADManager } from "../core/manager.js"; +import { + InteractiveSessionListResult, + SessionHistoryResult, + SessionInspectionResult, + SessionMetricsResult, + SessionTerminationResult, +} from "../core/models.js"; +import type { + InteractiveExecResult, + InteractiveSessionInfo, +} from "../core/models.js"; +import { + SessionError, + SessionNotFoundError, + SessionTerminatedError, +} from "../interactive/models.js"; +import { getLogger } from "../utils/logging.js"; +import { BaseTool, toSnakeCase } from "./base.js"; + +const logger = getLogger("tools.interactive"); + +// --------------------------------------------------------------------------- +// Module-level helpers +// --------------------------------------------------------------------------- + +/** Emulate Python's repr() for simple strings: single-quoted with escaping. */ +function pyRepr(s: string): string { + const escaped = s.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); + return `'${escaped}'`; +} + +/** Build a blank InteractiveExecResult skeleton for error paths. */ +function blankExecResult( + sessionId: string | null, + error: string, +): InteractiveExecResult { + return { + output: "", + sessionId, + timestamp: new Date().toISOString(), + executionTime: 0.0, + commandCount: 0, + bufferSize: 0, + error, + }; +} + +/** + * Returns an InteractiveExecResult representing a session-not-found condition. + * Error message matches the Python server byte-for-byte. + */ +function sessionNotFoundExecResult( + sessionId: string | null, + error: unknown, +): InteractiveExecResult { + return { + output: `Error: Session '${sessionId}' not found.`, + sessionId, + timestamp: new Date().toISOString(), + executionTime: 0.0, + commandCount: 0, + bufferSize: 0, + error: String(error), + }; +} + +/** + * Build and serialize a blocked-command result. + * Matches Python's _blocked_error() output exactly, including repr() quoting. + */ +function blockedError( + command: string, + blockedVerb: string, + sessionId: string | null, +): string { + const base: InteractiveExecResult = { + output: "", + sessionId, + timestamp: new Date().toISOString(), + executionTime: 0.0, + commandCount: 0, + bufferSize: 0, + error: `CommandBlocked: '${blockedVerb}'`, + }; + const message = `Command blocked: '${blockedVerb}' is not on the OpenROAD allowlist.\nFull command: ${pyRepr(command)}`; + return JSON.stringify(toSnakeCase({ ...base, message })); +} + +/** + * Gate a command through the Tcl whitelist when WHITELIST_ENABLED is set. + * Returns a serialised blocked-error JSON string when the command is rejected, + * or null when it is allowed (or when the whitelist is disabled). + */ +function applyWhitelist( + command: string, + validator: (cmd: string) => [boolean, string | null], + sessionId: string | null, +): string | null { + const settings = getSettings(); + if (!settings.WHITELIST_ENABLED) return null; + const [allowed, blockedVerb] = validator(command); + if (!allowed && blockedVerb !== null) { + logger.warn( + `Command blocked: '${blockedVerb}' for session ${sessionId ?? "new"}`, + ); + return blockedError(command, blockedVerb, sessionId); + } + return null; +} + +// --------------------------------------------------------------------------- +// Tool classes +// --------------------------------------------------------------------------- + +/** Read-only query tool: report_*, get_*, check_*, sta, help, version, etc. */ +export class QueryShellTool extends BaseTool { + constructor(manager: OpenROADManager) { + super(manager); + } + + async execute( + command: string, + sessionId?: string | null, + timeoutMs?: number | null, + ): Promise { + const sid = sessionId ?? null; + + const blocked = applyWhitelist(command, isQueryCommand, sid); + if (blocked !== null) return blocked; + + let resolvedId = sid; + try { + if (resolvedId === null || resolvedId === undefined) { + resolvedId = await this.manager.createSession({}); + } + const result = await this.manager.executeCommand( + resolvedId, + command, + timeoutMs ?? undefined, + ); + return this.formatResult(result as unknown as Record); + } catch (e) { + if (e instanceof SessionNotFoundError) { + return this.formatResult( + sessionNotFoundExecResult( + resolvedId, + e, + ) as unknown as Record, + ); + } + if (e instanceof SessionTerminatedError || e instanceof SessionError) { + return this.formatResult( + blankExecResult( + resolvedId, + (e as Error).message, + ) as unknown as Record, + ); + } + return this.formatResult( + blankExecResult( + resolvedId, + `Unexpected error: ${(e as Error).message ?? String(e)}`, + ) as unknown as Record, + ); + } + } +} + +/** State-modifying exec tool: set_*, create_*, read_*, write_*, flow/repair, etc. */ +export class ExecShellTool extends BaseTool { + constructor(manager: OpenROADManager) { + super(manager); + } + + async execute( + command: string, + sessionId?: string | null, + timeoutMs?: number | null, + ): Promise { + const sid = sessionId ?? null; + + const blocked = applyWhitelist(command, isExecCommand, sid); + if (blocked !== null) return blocked; + + let resolvedId = sid; + try { + if (resolvedId === null || resolvedId === undefined) { + resolvedId = await this.manager.createSession({}); + } + const result = await this.manager.executeCommand( + resolvedId, + command, + timeoutMs ?? undefined, + ); + return this.formatResult(result as unknown as Record); + } catch (e) { + if (e instanceof SessionNotFoundError) { + return this.formatResult( + sessionNotFoundExecResult( + resolvedId, + e, + ) as unknown as Record, + ); + } + if (e instanceof SessionTerminatedError || e instanceof SessionError) { + return this.formatResult( + blankExecResult( + resolvedId, + (e as Error).message, + ) as unknown as Record, + ); + } + return this.formatResult( + blankExecResult( + resolvedId, + `Unexpected error: ${(e as Error).message ?? String(e)}`, + ) as unknown as Record, + ); + } + } +} + +/** Lists all active and terminated sessions tracked by the manager. */ +export class ListSessionsTool extends BaseTool { + constructor(manager: OpenROADManager) { + super(manager); + } + + async execute(): Promise { + try { + const sessions = await this.manager.listSessions(); + const activeCount = sessions.filter((s) => s.isAlive).length; + return this.formatResult( + InteractiveSessionListResult.parse({ + sessions, + totalCount: sessions.length, + activeCount, + }) as unknown as Record, + ); + } catch (e) { + return this.formatResult( + InteractiveSessionListResult.parse({ + error: String(e), + }) as unknown as Record, + ); + } + } +} + +/** Creates a new OpenROAD interactive session. */ +export class CreateSessionTool extends BaseTool { + constructor(manager: OpenROADManager) { + super(manager); + } + + async execute( + sessionId?: string, + command?: string[], + env?: Record, + cwd?: string, + ): Promise { + try { + const opts = { + ...(sessionId !== undefined && { sessionId }), + ...(command !== undefined && { command }), + ...(env !== undefined && { env }), + ...(cwd !== undefined && { cwd }), + }; + const id = await this.manager.createSession(opts); + const info = await this.manager.getSessionInfo(id); + return this.formatResult(info as unknown as Record); + } catch (e) { + const errInfo: InteractiveSessionInfo = { + sessionId: sessionId ?? "unknown", + createdAt: new Date().toISOString(), + isAlive: false, + commandCount: 0, + bufferSize: 0, + uptimeSeconds: null, + state: null, + error: String(e), + }; + return this.formatResult(errInfo as unknown as Record); + } + } +} + +/** Terminates an existing session by ID. */ +export class TerminateSessionTool extends BaseTool { + constructor(manager: OpenROADManager) { + super(manager); + } + + async execute(sessionId: string, force = false): Promise { + let wasAlive = true; + try { + await this.manager.getSessionInfo(sessionId); + } catch (e) { + if (e instanceof SessionNotFoundError) { + wasAlive = false; + } + } + + try { + await this.manager.terminateSession(sessionId, force); + return this.formatResult( + SessionTerminationResult.parse({ + sessionId, + terminated: true, + wasAlive, + force, + }) as unknown as Record, + ); + } catch (e) { + if (e instanceof SessionNotFoundError) { + return this.formatResult( + SessionTerminationResult.parse({ + sessionId, + terminated: false, + error: String(e), + }) as unknown as Record, + ); + } + return this.formatResult( + SessionTerminationResult.parse({ + sessionId, + terminated: false, + error: `Termination failed: ${(e as Error).message ?? String(e)}`, + }) as unknown as Record, + ); + } + } +} + +/** Returns detailed metrics for a single session. */ +export class InspectSessionTool extends BaseTool { + constructor(manager: OpenROADManager) { + super(manager); + } + + async execute(sessionId: string): Promise { + try { + const metrics = await this.manager.inspectSession(sessionId); + return this.formatResult( + SessionInspectionResult.parse({ + sessionId, + metrics, + }) as unknown as Record, + ); + } catch (e) { + if (e instanceof SessionNotFoundError) { + return this.formatResult( + SessionInspectionResult.parse({ + sessionId, + error: String(e), + }) as unknown as Record, + ); + } + return this.formatResult( + SessionInspectionResult.parse({ + sessionId, + error: `Inspection failed: ${(e as Error).message ?? String(e)}`, + }) as unknown as Record, + ); + } + } +} + +/** Returns the command history for a session, with optional limit and search. */ +export class SessionHistoryTool extends BaseTool { + constructor(manager: OpenROADManager) { + super(manager); + } + + async execute( + sessionId: string, + limit?: number, + search?: string, + ): Promise { + try { + const history = await this.manager.getSessionHistory(sessionId, limit, search); + return this.formatResult( + SessionHistoryResult.parse({ + sessionId, + history, + totalCommands: history.length, + limit: limit ?? null, + search: search ?? null, + }) as unknown as Record, + ); + } catch (e) { + if (e instanceof SessionNotFoundError) { + return this.formatResult( + SessionHistoryResult.parse({ + sessionId, + error: String(e), + }) as unknown as Record, + ); + } + return this.formatResult( + SessionHistoryResult.parse({ + sessionId, + error: `History retrieval failed: ${(e as Error).message ?? String(e)}`, + }) as unknown as Record, + ); + } + } +} + +/** Returns aggregate metrics across all sessions managed by the manager. */ +export class SessionMetricsTool extends BaseTool { + constructor(manager: OpenROADManager) { + super(manager); + } + + async execute(): Promise { + try { + const metrics = await this.manager.sessionMetrics(); + return this.formatResult( + SessionMetricsResult.parse({ metrics }) as unknown as Record< + string, + unknown + >, + ); + } catch (e) { + return this.formatResult( + SessionMetricsResult.parse({ + error: `Metrics retrieval failed: ${(e as Error).message ?? String(e)}`, + }) as unknown as Record, + ); + } + } +} + +// Backwards-compat alias matching Python's InteractiveShellTool = QueryShellTool +export const InteractiveShellTool = QueryShellTool; From a3fb991408a553c6f6c55f8322e16ec5b0010d9f Mon Sep 17 00:00:00 2001 From: kartikloops Date: Tue, 16 Jun 2026 21:23:06 -0600 Subject: [PATCH 17/62] add report image tools using sharp for webp compression and path traversal validation --- typescript/src/tools/report_images.ts | 501 ++++++++++++++++++++++++++ 1 file changed, 501 insertions(+) create mode 100644 typescript/src/tools/report_images.ts diff --git a/typescript/src/tools/report_images.ts b/typescript/src/tools/report_images.ts new file mode 100644 index 0000000..8daf037 --- /dev/null +++ b/typescript/src/tools/report_images.ts @@ -0,0 +1,501 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import sharp from "sharp"; +import type { OpenROADManager } from "../core/manager.js"; +import { + ImageInfo, + ImageMetadata, + ListImagesResult, + ReadImageResult, +} from "../core/models.js"; +import { ValidationError } from "../exceptions.js"; +import { + validatePathSegment, + validateSafePathContainment, +} from "../utils/path_security.js"; +import { getSettings } from "../config/settings.js"; +import { getLogger } from "../utils/logging.js"; +import { BaseTool } from "./base.js"; + +const logger = getLogger("tools.report_images"); + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const MAX_BASE64_SIZE_KB = 15; +const MAX_IMAGE_SIZE_MB = 50; + +// --------------------------------------------------------------------------- +// Image type mapping (exact copy of Python dict) +// --------------------------------------------------------------------------- + +const IMAGE_TYPE_MAPPING: Record = { + cts_clk: "clock_visualization", + cts_clk_layout: "clock_layout", + cts_core_clock: "core_clock_visualization", + cts_core_clock_layout: "core_clock_layout", + final_all: "complete_design", + final_clocks: "clock_routing", + final_congestion: "congestion_heatmap", + final_ir_drop: "ir_drop_analysis", + final_placement: "cell_placement", + final_resizer: "resizer_results", + final_routing: "routing_visualization", +}; + +// --------------------------------------------------------------------------- +// Helper functions +// --------------------------------------------------------------------------- + +/** + * Derive the image stage and semantic type from a filename. + * Returns ["unknown", "unknown"] for files with no underscore or unrecognised keys. + */ +export function classifyImageType(filename: string): [string, string] { + const basename = path.basename(filename, path.extname(filename)); + const underscoreIdx = basename.indexOf("_"); + let stage: string; + let key: string; + if (underscoreIdx === -1) { + stage = "unknown"; + key = basename; + } else { + stage = basename.slice(0, underscoreIdx); + key = basename; + } + const type = IMAGE_TYPE_MAPPING[key] ?? "unknown"; + return [stage, type]; +} + +/** + * Verify that `platform` and `design` are known in the current ORFS configuration. + * Throws ValidationError when either is not found. + */ +export function validatePlatformDesign(platform: string, design: string): void { + const settings = getSettings(); + const platforms = settings.platforms; + if (!platforms.includes(platform)) { + throw new ValidationError( + `Platform '${platform}' not found. Available platforms: ${platforms.join(", ") || "none"}`, + ); + } + const designs = settings.designs(platform); + if (!designs.includes(design)) { + throw new ValidationError( + `Design '${design}' not found for platform '${platform}'. Available designs: ${designs.join(", ") || "none"}`, + ); + } +} + +/** + * Resolve and validate the reports base path and per-run sub-directory. + * Returns [reportsBase, runPath] as absolute path strings. + */ +function resolveRunPath( + platform: string, + design: string, + runSlug: string, +): [string, string] { + validatePlatformDesign(platform, design); + validatePathSegment(runSlug, "run_slug"); + const settings = getSettings(); + const reportsBase = path.join(settings.flowPath, "reports", platform, design); + const runPath = path.join(reportsBase, runSlug); + validateSafePathContainment(runPath, reportsBase, "run directory"); + return [reportsBase, runPath]; +} + +/** List available run slugs in reportsBase for error messages. */ +function availableRuns(reportsBase: string): string[] { + try { + return fs + .readdirSync(reportsBase, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name); + } catch { + return []; + } +} + +/** + * Recursively find all .webp files under `dir`, returning absolute paths. + * Requires Node.js ≥ 20 for the `recursive` option on readdirSync. + */ +function findWebpFiles(dir: string): string[] { + const entries = fs.readdirSync(dir, { recursive: true, withFileTypes: true }); + return entries + .filter((e) => e.isFile() && e.name.endsWith(".webp")) + .map((e) => { + // `parentPath` is available in Node 20.12+; `path` is the older alias. + const parent = (e as unknown as { parentPath?: string; path?: string }) + .parentPath ?? (e as unknown as { path: string }).path; + return path.join(parent, e.name); + }); +} + +interface CompressResult { + imageBytes: Buffer; + compressionApplied: boolean; + originalSize: number; + compressedSize: number; + originalWidth: number | null; + originalHeight: number | null; + width: number | null; + height: number | null; +} + +/** + * Load an image and compress it to fit within `maxSizeKb` of base64 output. + * Uses sharp for resizing (lanczos3) and WebP encoding (quality=85). + * Falls back to returning raw bytes when sharp fails, with null dimensions. + */ +async function loadAndCompressImage( + imagePath: string, + maxSizeKb: number = MAX_BASE64_SIZE_KB, +): Promise { + const originalSize = fs.statSync(imagePath).size; + const estimatedBase64 = Math.floor((originalSize * 4) / 3); + + if (estimatedBase64 / 1024 <= maxSizeKb) { + try { + const rawBytes = fs.readFileSync(imagePath); + const meta = await sharp(imagePath).metadata(); + return { + imageBytes: rawBytes, + compressionApplied: false, + originalSize, + compressedSize: originalSize, + originalWidth: meta.width ?? null, + originalHeight: meta.height ?? null, + width: meta.width ?? null, + height: meta.height ?? null, + }; + } catch (e) { + logger.warn({ err: e, imagePath }, "sharp.metadata() failed on small image; returning raw bytes with null dims"); + return { + imageBytes: fs.readFileSync(imagePath), + compressionApplied: false, + originalSize, + compressedSize: originalSize, + originalWidth: null, + originalHeight: null, + width: null, + height: null, + }; + } + } + + try { + const targetBytes = Math.floor((maxSizeKb * 1024 * 3) / 4); + const scale = Math.sqrt(targetBytes / originalSize); + const meta = await sharp(imagePath).metadata(); + const origW = meta.width ?? 0; + const origH = meta.height ?? 0; + const newW = Math.max(Math.round(origW * scale), 256); + const newH = Math.max(Math.round(origH * scale), 256); + const compressed = await sharp(imagePath) + .resize(newW, newH, { kernel: "lanczos3" }) + .webp({ quality: 85 }) + .toBuffer(); + return { + imageBytes: compressed, + compressionApplied: true, + originalSize, + compressedSize: compressed.length, + originalWidth: meta.width ?? null, + originalHeight: meta.height ?? null, + width: newW, + height: newH, + }; + } catch (e) { + logger.warn({ err: e, imagePath }, "Image compression failed; returning raw bytes with null dims"); + return { + imageBytes: fs.readFileSync(imagePath), + compressionApplied: false, + originalSize, + compressedSize: originalSize, + originalWidth: null, + originalHeight: null, + width: null, + height: null, + }; + } +} + +// --------------------------------------------------------------------------- +// Tool classes +// --------------------------------------------------------------------------- + +/** Lists .webp report images for a specific platform/design/run. */ +export class ListReportImagesTool extends BaseTool { + constructor(manager: OpenROADManager) { + super(manager); + } + + async execute( + platform: string, + design: string, + runSlug: string, + stage = "all", + ): Promise { + let reportsBase: string; + let runPath: string; + + try { + [reportsBase, runPath] = resolveRunPath(platform, design, runSlug); + } catch (e) { + if (e instanceof ValidationError) { + return this.formatResult( + ListImagesResult.parse({ + error: e.constructor.name, + message: e.message, + }) as unknown as Record, + ); + } + return this.formatResult( + ListImagesResult.parse({ + error: "UnexpectedError", + message: (e as Error).message ?? String(e), + }) as unknown as Record, + ); + } + + if (!fs.existsSync(runPath)) { + const runs = availableRuns(reportsBase); + return this.formatResult( + ListImagesResult.parse({ + error: "RunNotFound", + message: `Run directory '${runSlug}' not found. Available runs: ${runs.join(", ") || "none"}`, + }) as unknown as Record, + ); + } + + try { + let files: string[]; + try { + files = findWebpFiles(runPath); + } catch { + files = []; + } + + if (files.length === 0) { + return this.formatResult( + ListImagesResult.parse({ + runPath, + totalImages: 0, + imagesByStage: {}, + }) as unknown as Record, + ); + } + + const imagesByStage: Record = {}; + let total = 0; + + for (const filePath of files) { + const filename = path.basename(filePath); + const [fileStage, type] = classifyImageType(filename); + if (stage !== "all" && stage !== fileStage) continue; + + const stat = fs.statSync(filePath); + const imageInfo = ImageInfo.parse({ + filename, + path: filePath, + sizeBytes: stat.size, + modifiedTime: stat.mtime.toISOString(), + type, + }); + + const bucket = imagesByStage[fileStage] ?? []; + bucket.push(imageInfo); + imagesByStage[fileStage] = bucket; + total++; + } + + // Sort each stage bucket by filename + for (const key of Object.keys(imagesByStage)) { + imagesByStage[key] = (imagesByStage[key] as Array<{ filename: string }>).sort((a, b) => + a.filename.localeCompare(b.filename), + ); + } + + return this.formatResult( + ListImagesResult.parse({ + runPath, + totalImages: total, + imagesByStage, + }) as unknown as Record, + ); + } catch (e) { + return this.formatResult( + ListImagesResult.parse({ + error: "UnexpectedError", + message: (e as Error).message ?? String(e), + }) as unknown as Record, + ); + } + } +} + +/** Reads, optionally compresses, and base64-encodes a single report image. */ +export class ReadReportImageTool extends BaseTool { + constructor(manager: OpenROADManager) { + super(manager); + } + + async execute( + platform: string, + design: string, + runSlug: string, + imageName: string, + ): Promise { + let reportsBase: string; + let runPath: string; + + try { + [reportsBase, runPath] = resolveRunPath(platform, design, runSlug); + } catch (e) { + if (e instanceof ValidationError) { + return this.formatResult( + ReadImageResult.parse({ + error: e.constructor.name, + message: e.message, + }) as unknown as Record, + ); + } + return this.formatResult( + ReadImageResult.parse({ + error: "UnexpectedError", + message: (e as Error).message ?? String(e), + }) as unknown as Record, + ); + } + + try { + validatePathSegment(imageName, "image_name"); + } catch (e) { + return this.formatResult( + ReadImageResult.parse({ + error: (e as ValidationError).constructor.name, + message: (e as Error).message, + }) as unknown as Record, + ); + } + + if (!imageName.endsWith(".webp")) { + return this.formatResult( + ReadImageResult.parse({ + error: "InvalidImageName", + message: `Image '${imageName}' must have a .webp extension`, + }) as unknown as Record, + ); + } + + if (!fs.existsSync(runPath)) { + const runs = availableRuns(reportsBase); + return this.formatResult( + ReadImageResult.parse({ + error: "RunNotFound", + message: `Run directory '${runSlug}' not found. Available runs: ${runs.join(", ") || "none"}`, + }) as unknown as Record, + ); + } + + const imagePath = path.join(runPath, imageName); + + try { + validateSafePathContainment(imagePath, runPath, "image file"); + } catch (e) { + return this.formatResult( + ReadImageResult.parse({ + error: (e as ValidationError).constructor.name, + message: (e as Error).message, + }) as unknown as Record, + ); + } + + if (!fs.existsSync(imagePath)) { + let available: string[] = []; + try { + available = findWebpFiles(runPath).map((f) => path.basename(f)); + } catch { + available = []; + } + return this.formatResult( + ReadImageResult.parse({ + error: "ImageNotFound", + message: `Image '${imageName}' not found. Available images: ${available.join(", ") || "none"}`, + }) as unknown as Record, + ); + } + + const stat = fs.statSync(imagePath); + if (!stat.isFile()) { + return this.formatResult( + ReadImageResult.parse({ + error: "NotAFile", + message: `'${imageName}' is not a regular file`, + }) as unknown as Record, + ); + } + + if (stat.size > MAX_IMAGE_SIZE_MB * 1024 * 1024) { + return this.formatResult( + ReadImageResult.parse({ + error: "FileTooLarge", + message: `Image '${imageName}' exceeds the ${MAX_IMAGE_SIZE_MB} MB size limit`, + }) as unknown as Record, + ); + } + + try { + const r = await loadAndCompressImage(imagePath); + const imageData = r.imageBytes.toString("base64"); + const [stage, type] = classifyImageType(imageName); + const compressionRatio = + r.compressionApplied && r.compressedSize > 0 + ? r.originalSize / r.compressedSize + : null; + + const metadata = ImageMetadata.parse({ + filename: imageName, + format: "webp", + sizeBytes: r.compressedSize, + width: r.width, + height: r.height, + modifiedTime: stat.mtime.toISOString(), + stage, + type, + compressionApplied: r.compressionApplied, + originalSizeBytes: r.compressionApplied ? r.originalSize : null, + originalWidth: r.originalWidth, + originalHeight: r.originalHeight, + compressionRatio, + }); + + return this.formatResult( + ReadImageResult.parse({ + imageData, + metadata, + }) as unknown as Record, + ); + } catch (e) { + if (e instanceof ValidationError) { + return this.formatResult( + ReadImageResult.parse({ + error: e.constructor.name, + message: e.message, + }) as unknown as Record, + ); + } + return this.formatResult( + ReadImageResult.parse({ + error: "UnexpectedError", + message: (e as Error).message ?? String(e), + }) as unknown as Record, + ); + } + } +} + From 5fc6a341d1c143cee236822e009111a92985bcd9 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Tue, 16 Jun 2026 21:23:11 -0600 Subject: [PATCH 18/62] add tools barrel export --- typescript/src/tools/index.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 typescript/src/tools/index.ts diff --git a/typescript/src/tools/index.ts b/typescript/src/tools/index.ts new file mode 100644 index 0000000..bdcee09 --- /dev/null +++ b/typescript/src/tools/index.ts @@ -0,0 +1,13 @@ +export { BaseTool, toSnakeCase } from "./base.js"; +export { + CreateSessionTool, + ExecShellTool, + InspectSessionTool, + InteractiveShellTool, + ListSessionsTool, + QueryShellTool, + SessionHistoryTool, + SessionMetricsTool, + TerminateSessionTool, +} from "./interactive.js"; +export { ListReportImagesTool, ReadReportImageTool, classifyImageType, validatePlatformDesign } from "./report_images.js"; From 762b1484fa70e7d09968c33a636a3af092cbe908 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Tue, 16 Jun 2026 21:24:44 -0600 Subject: [PATCH 19/62] add tests for interactive shell and session management tools --- .../__tests__/tools/interactive.test.ts | 496 ++++++++++++++++++ 1 file changed, 496 insertions(+) create mode 100644 typescript/__tests__/tools/interactive.test.ts diff --git a/typescript/__tests__/tools/interactive.test.ts b/typescript/__tests__/tools/interactive.test.ts new file mode 100644 index 0000000..bf1ef74 --- /dev/null +++ b/typescript/__tests__/tools/interactive.test.ts @@ -0,0 +1,496 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { Mock } from "vitest"; +import { QueryShellTool, ExecShellTool, ListSessionsTool, CreateSessionTool, TerminateSessionTool, InspectSessionTool, SessionHistoryTool, SessionMetricsTool, InteractiveShellTool } from "../../src/tools/interactive.js"; +import type { OpenROADManager } from "../../src/core/manager.js"; +import { SessionNotFoundError, SessionTerminatedError, SessionError } from "../../src/interactive/models.js"; +import { SessionState } from "../../src/core/models.js"; +import type { InteractiveExecResult, InteractiveSessionInfo, SessionDetailedMetrics, ManagerMetrics } from "../../src/core/models.js"; + +// --------------------------------------------------------------------------- +// Mock helpers +// --------------------------------------------------------------------------- + +const NOW = "2024-01-01T00:00:00.000Z"; + +function makeExecResult(overrides: Partial = {}): InteractiveExecResult { + return { + output: "test output", + sessionId: "session-1", + timestamp: NOW, + executionTime: 0.1, + commandCount: 1, + bufferSize: 0, + error: null, + ...overrides, + }; +} + +function makeSessionInfo(overrides: Partial = {}): InteractiveSessionInfo { + return { + sessionId: "session-1", + createdAt: NOW, + isAlive: true, + commandCount: 5, + bufferSize: 1024, + uptimeSeconds: 100.0, + state: SessionState.ACTIVE, + error: null, + ...overrides, + }; +} + +function makeMetrics(sessionId = "session-1"): SessionDetailedMetrics { + return { + session_id: sessionId, + state: SessionState.ACTIVE, + is_alive: true, + created_at: NOW, + last_activity: NOW, + uptime_seconds: 1, + idle_seconds: 0, + commands: { total_executed: 1, current_count: 1, history_length: 1 }, + performance: { total_cpu_time: 0.1, peak_memory_mb: 10, current_memory_mb: 8 }, + buffer: { current_size: 0, max_size: 1024, utilization_percent: 0 }, + timeout: { configured_seconds: null, is_timed_out: false }, + }; +} + +function makeManagerMetrics(): ManagerMetrics { + return { + manager: { total_sessions: 1, active_sessions: 1, terminated_sessions: 0, max_sessions: 50, utilization_percent: 2 }, + aggregate: { total_commands: 5, total_cpu_time: 0.5, total_memory_mb: 8, avg_memory_per_session: 8 }, + sessions: [makeMetrics()], + }; +} + +interface MockManager extends Record { + createSession: Mock; + executeCommand: Mock; + listSessions: Mock; + getSessionInfo: Mock; + terminateSession: Mock; + inspectSession: Mock; + getSessionHistory: Mock; + sessionMetrics: Mock; +} + +function makeMockManager(): MockManager { + return { + createSession: vi.fn().mockResolvedValue("session-1"), + executeCommand: vi.fn().mockResolvedValue(makeExecResult()), + listSessions: vi.fn().mockResolvedValue([]), + getSessionInfo: vi.fn().mockResolvedValue(makeSessionInfo()), + terminateSession: vi.fn().mockResolvedValue(undefined), + inspectSession: vi.fn().mockResolvedValue(makeMetrics()), + getSessionHistory: vi.fn().mockResolvedValue([]), + sessionMetrics: vi.fn().mockResolvedValue(makeManagerMetrics()), + }; +} + +// --------------------------------------------------------------------------- +// QueryShellTool +// --------------------------------------------------------------------------- + +describe("QueryShellTool", () => { + let mgr: MockManager; + let tool: QueryShellTool; + + beforeEach(() => { + mgr = makeMockManager(); + tool = new QueryShellTool(mgr as unknown as OpenROADManager); + }); + + it("auto-creates a session when sessionId is null", async () => { + const raw = await tool.execute("help", null); + const result = JSON.parse(raw); + expect(mgr.createSession).toHaveBeenCalledOnce(); + expect(result.output).toBe("test output"); + expect(result.session_id).toBe("session-1"); + }); + + it("uses an existing session without creating a new one", async () => { + const raw = await tool.execute("help", "session-1"); + JSON.parse(raw); + expect(mgr.createSession).not.toHaveBeenCalled(); + expect(mgr.executeCommand).toHaveBeenCalledWith("session-1", "help", undefined); + }); + + it("returns snake_case keys in JSON output", async () => { + const raw = await tool.execute("help", "session-1"); + const result = JSON.parse(raw); + expect(Object.keys(result)).toContain("session_id"); + expect(Object.keys(result)).toContain("execution_time"); + expect(Object.keys(result)).toContain("command_count"); + expect(Object.keys(result)).toContain("buffer_size"); + }); + + it("handles SessionNotFoundError", async () => { + mgr.executeCommand.mockRejectedValue(new SessionNotFoundError("not found", "session-1")); + const raw = await tool.execute("help", "session-1"); + const result = JSON.parse(raw); + expect(result.output).toBe("Error: Session 'session-1' not found."); + expect(result.error).toContain("not found"); + }); + + it("handles SessionTerminatedError", async () => { + mgr.executeCommand.mockRejectedValue(new SessionTerminatedError("terminated", "session-1")); + const raw = await tool.execute("help", "session-1"); + const result = JSON.parse(raw); + expect(result.output).toBe(""); + expect(result.error).toContain("terminated"); + }); + + it("handles unexpected errors", async () => { + mgr.executeCommand.mockRejectedValue(new Error("boom")); + const raw = await tool.execute("help", "session-1"); + const result = JSON.parse(raw); + expect(result.error).toContain("Unexpected error"); + expect(result.error).toContain("boom"); + }); + + it("blocks dangerous commands when whitelist is enabled", async () => { + // `quit` is in BLOCKED_COMMANDS + const raw = await tool.execute("quit"); + const result = JSON.parse(raw); + expect(result.error).toMatch(/CommandBlocked/); + expect(mgr.executeCommand).not.toHaveBeenCalled(); + }); + + it("InteractiveShellTool is an alias for QueryShellTool", () => { + expect(InteractiveShellTool).toBe(QueryShellTool); + }); +}); + +// --------------------------------------------------------------------------- +// ExecShellTool +// --------------------------------------------------------------------------- + +describe("ExecShellTool", () => { + let mgr: MockManager; + let tool: ExecShellTool; + + beforeEach(() => { + mgr = makeMockManager(); + tool = new ExecShellTool(mgr as unknown as OpenROADManager); + }); + + it("executes a state-modifying command", async () => { + const raw = await tool.execute("set_wire_rc -signal -layer metal3", "session-1"); + const result = JSON.parse(raw); + expect(result.output).toBe("test output"); + expect(mgr.createSession).not.toHaveBeenCalled(); + }); + + it("blocks quit via BLOCKED_COMMANDS", async () => { + const raw = await tool.execute("quit"); + const result = JSON.parse(raw); + expect(result.error).toMatch(/CommandBlocked/); + }); + + it("handles SessionNotFoundError", async () => { + mgr.executeCommand.mockRejectedValue(new SessionNotFoundError("missing", "session-1")); + const raw = await tool.execute("read_lef foo.lef", "session-1"); + const result = JSON.parse(raw); + expect(result.output).toContain("not found"); + }); +}); + +// --------------------------------------------------------------------------- +// ListSessionsTool +// --------------------------------------------------------------------------- + +describe("ListSessionsTool", () => { + let mgr: MockManager; + let tool: ListSessionsTool; + + beforeEach(() => { + mgr = makeMockManager(); + tool = new ListSessionsTool(mgr as unknown as OpenROADManager); + }); + + it("returns empty list when no sessions exist", async () => { + const raw = await tool.execute(); + const result = JSON.parse(raw); + expect(result.sessions).toEqual([]); + expect(result.total_count).toBe(0); + expect(result.active_count).toBe(0); + expect(result.error).toBeNull(); + }); + + it("counts only alive sessions in active_count", async () => { + mgr.listSessions.mockResolvedValue([ + makeSessionInfo({ sessionId: "s1", isAlive: true }), + makeSessionInfo({ sessionId: "s2", isAlive: false }), + makeSessionInfo({ sessionId: "s3", isAlive: true }), + ]); + const raw = await tool.execute(); + const result = JSON.parse(raw); + expect(result.total_count).toBe(3); + expect(result.active_count).toBe(2); + }); + + it("returns error field on exception", async () => { + mgr.listSessions.mockRejectedValue(new Error("db error")); + const raw = await tool.execute(); + const result = JSON.parse(raw); + expect(result.error).toBeTruthy(); + expect(result.sessions).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// CreateSessionTool +// --------------------------------------------------------------------------- + +describe("CreateSessionTool", () => { + let mgr: MockManager; + let tool: CreateSessionTool; + + beforeEach(() => { + mgr = makeMockManager(); + tool = new CreateSessionTool(mgr as unknown as OpenROADManager); + }); + + it("creates a session with default parameters", async () => { + const raw = await tool.execute(); + const result = JSON.parse(raw); + expect(mgr.createSession).toHaveBeenCalledOnce(); + expect(result.session_id).toBe("session-1"); + expect(result.is_alive).toBe(true); + }); + + it("passes custom parameters to createSession", async () => { + await tool.execute("my-id", ["openroad"], { KEY: "VAL" }, "/tmp"); + expect(mgr.createSession).toHaveBeenCalledWith({ + sessionId: "my-id", + command: ["openroad"], + env: { KEY: "VAL" }, + cwd: "/tmp", + }); + }); + + it("returns error info when creation fails", async () => { + mgr.createSession.mockRejectedValue(new SessionError("limit reached")); + const raw = await tool.execute("my-id"); + const result = JSON.parse(raw); + expect(result.is_alive).toBe(false); + expect(result.error).toContain("limit reached"); + }); +}); + +// --------------------------------------------------------------------------- +// TerminateSessionTool +// --------------------------------------------------------------------------- + +describe("TerminateSessionTool", () => { + let mgr: MockManager; + let tool: TerminateSessionTool; + + beforeEach(() => { + mgr = makeMockManager(); + tool = new TerminateSessionTool(mgr as unknown as OpenROADManager); + }); + + it("terminates a session normally", async () => { + const raw = await tool.execute("session-1"); + const result = JSON.parse(raw); + expect(result.terminated).toBe(true); + expect(result.was_alive).toBe(true); + expect(result.force).toBe(false); + expect(result.error).toBeNull(); + }); + + it("force-terminates a session", async () => { + const raw = await tool.execute("session-1", true); + const result = JSON.parse(raw); + expect(result.force).toBe(true); + expect(result.terminated).toBe(true); + }); + + it("handles terminating a non-existent session", async () => { + mgr.getSessionInfo.mockRejectedValue(new SessionNotFoundError("not found", "session-1")); + mgr.terminateSession.mockRejectedValue(new SessionNotFoundError("not found", "session-1")); + const raw = await tool.execute("session-1"); + const result = JSON.parse(raw); + expect(result.terminated).toBe(false); + expect(result.error).toBeTruthy(); + }); + + it("handles unexpected termination errors", async () => { + mgr.terminateSession.mockRejectedValue(new Error("PTY crash")); + const raw = await tool.execute("session-1"); + const result = JSON.parse(raw); + expect(result.terminated).toBe(false); + expect(result.error).toContain("Termination failed"); + }); +}); + +// --------------------------------------------------------------------------- +// InspectSessionTool +// --------------------------------------------------------------------------- + +describe("InspectSessionTool", () => { + let mgr: MockManager; + let tool: InspectSessionTool; + + beforeEach(() => { + mgr = makeMockManager(); + tool = new InspectSessionTool(mgr as unknown as OpenROADManager); + }); + + it("returns session metrics", async () => { + const raw = await tool.execute("session-1"); + const result = JSON.parse(raw); + expect(result.session_id).toBe("session-1"); + expect(result.metrics).toBeTruthy(); + expect(result.metrics.session_id).toBe("session-1"); + expect(result.error).toBeNull(); + }); + + it("returns error when session not found", async () => { + mgr.inspectSession.mockRejectedValue(new SessionNotFoundError("missing", "session-1")); + const raw = await tool.execute("session-1"); + const result = JSON.parse(raw); + expect(result.metrics).toBeNull(); + expect(result.error).toBeTruthy(); + }); + + it("returns error on unexpected failure", async () => { + mgr.inspectSession.mockRejectedValue(new Error("cpu panic")); + const raw = await tool.execute("session-1"); + const result = JSON.parse(raw); + expect(result.error).toContain("Inspection failed"); + }); +}); + +// --------------------------------------------------------------------------- +// SessionHistoryTool +// --------------------------------------------------------------------------- + +describe("SessionHistoryTool", () => { + let mgr: MockManager; + let tool: SessionHistoryTool; + + beforeEach(() => { + mgr = makeMockManager(); + tool = new SessionHistoryTool(mgr as unknown as OpenROADManager); + }); + + it("returns session history", async () => { + mgr.getSessionHistory.mockResolvedValue([ + { command: "help", timestamp: NOW, command_number: 1, execution_start: 0 }, + ]); + const raw = await tool.execute("session-1"); + const result = JSON.parse(raw); + expect(result.session_id).toBe("session-1"); + expect(result.total_commands).toBe(1); + expect(result.history).toHaveLength(1); + expect(result.error).toBeNull(); + }); + + it("passes limit and search parameters", async () => { + await tool.execute("session-1", 10, "report"); + expect(mgr.getSessionHistory).toHaveBeenCalledWith("session-1", 10, "report"); + }); + + it("returns error when session not found", async () => { + mgr.getSessionHistory.mockRejectedValue(new SessionNotFoundError("gone", "session-1")); + const raw = await tool.execute("session-1"); + const result = JSON.parse(raw); + expect(result.history).toEqual([]); + expect(result.total_commands).toBe(0); + expect(result.error).toBeTruthy(); + }); + + it("returns error on unexpected failure", async () => { + mgr.getSessionHistory.mockRejectedValue(new Error("disk full")); + const raw = await tool.execute("session-1"); + const result = JSON.parse(raw); + expect(result.error).toContain("History retrieval failed"); + }); +}); + +// --------------------------------------------------------------------------- +// SessionMetricsTool +// --------------------------------------------------------------------------- + +describe("SessionMetricsTool", () => { + let mgr: MockManager; + let tool: SessionMetricsTool; + + beforeEach(() => { + mgr = makeMockManager(); + tool = new SessionMetricsTool(mgr as unknown as OpenROADManager); + }); + + it("returns manager-wide metrics", async () => { + const raw = await tool.execute(); + const result = JSON.parse(raw); + expect(result.metrics).toBeTruthy(); + expect(result.metrics.manager.total_sessions).toBe(1); + expect(result.error).toBeNull(); + }); + + it("returns error on unexpected failure", async () => { + mgr.sessionMetrics.mockRejectedValue(new Error("overload")); + const raw = await tool.execute(); + const result = JSON.parse(raw); + expect(result.metrics).toBeNull(); + expect(result.error).toContain("Metrics retrieval failed"); + }); +}); + +// --------------------------------------------------------------------------- +// Integration: full workflow +// --------------------------------------------------------------------------- + +describe("Integration: session workflow", () => { + it("create → execute → list → terminate", async () => { + const mgr = makeMockManager(); + mgr.listSessions.mockResolvedValue([makeSessionInfo()]); + + const created = JSON.parse(await new CreateSessionTool(mgr as unknown as OpenROADManager).execute("test-id")); + expect(created.session_id).toBe("session-1"); + + const exec = JSON.parse(await new QueryShellTool(mgr as unknown as OpenROADManager).execute("help", "session-1")); + expect(exec.output).toBe("test output"); + + const list = JSON.parse(await new ListSessionsTool(mgr as unknown as OpenROADManager).execute()); + expect(list.total_count).toBe(1); + + const term = JSON.parse(await new TerminateSessionTool(mgr as unknown as OpenROADManager).execute("session-1")); + expect(term.terminated).toBe(true); + }); + + it("concurrent operations complete without interference", async () => { + const mgr = makeMockManager(); + const queryTool = new QueryShellTool(mgr as unknown as OpenROADManager); + const [r1, r2, r3] = await Promise.all([ + queryTool.execute("help", "session-1"), + queryTool.execute("version", "session-1"), + queryTool.execute("report_checks", "session-1"), + ]); + expect(JSON.parse(r1).output).toBe("test output"); + expect(JSON.parse(r2).output).toBe("test output"); + expect(JSON.parse(r3).output).toBe("test output"); + }); +}); + +// --------------------------------------------------------------------------- +// Snapshot: one representative output per tool +// --------------------------------------------------------------------------- + +describe("Snapshots: wire format stability", () => { + it("QueryShellTool success output", async () => { + const mgr = makeMockManager(); + const raw = await new QueryShellTool(mgr as unknown as OpenROADManager).execute("help", "session-1"); + expect(raw).toMatchSnapshot(); + }); + + it("ListSessionsTool with sessions", async () => { + const mgr = makeMockManager(); + mgr.listSessions.mockResolvedValue([makeSessionInfo()]); + const raw = await new ListSessionsTool(mgr as unknown as OpenROADManager).execute(); + expect(raw).toMatchSnapshot(); + }); +}); From 5a9c3169d0105e11c09b3f9ec2917f5cc0b13f6f Mon Sep 17 00:00:00 2001 From: kartikloops Date: Tue, 16 Jun 2026 21:24:51 -0600 Subject: [PATCH 20/62] add tests for report image tools covering path traversal and platform validation --- .../__tests__/tools/report_images.test.ts | 443 ++++++++++++++++++ 1 file changed, 443 insertions(+) create mode 100644 typescript/__tests__/tools/report_images.test.ts diff --git a/typescript/__tests__/tools/report_images.test.ts b/typescript/__tests__/tools/report_images.test.ts new file mode 100644 index 0000000..1485588 --- /dev/null +++ b/typescript/__tests__/tools/report_images.test.ts @@ -0,0 +1,443 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + classifyImageType, + ListReportImagesTool, + ReadReportImageTool, + validatePlatformDesign, +} from "../../src/tools/report_images.js"; +import type { OpenROADManager } from "../../src/core/manager.js"; + +// --------------------------------------------------------------------------- +// Mock getSettings so tests don't depend on the filesystem ORFS installation +// --------------------------------------------------------------------------- + +vi.mock("../../src/config/settings.js", () => { + let mockFlowPath = "/mock/flow"; + let mockPlatforms: string[] = []; + let mockDesigns: Record = {}; + return { + getSettings: vi.fn(() => ({ + get flowPath() { return mockFlowPath; }, + get platforms() { return mockPlatforms; }, + designs(platform: string) { return mockDesigns[platform] ?? []; }, + WHITELIST_ENABLED: false, + LOG_LEVEL: "INFO", + COMMAND_TIMEOUT: 30, + COMMAND_COMPLETION_DELAY: 0.1, + DEFAULT_BUFFER_SIZE: 131072, + MAX_SESSIONS: 50, + SESSION_QUEUE_SIZE: 128, + SESSION_IDLE_TIMEOUT: 300, + READ_CHUNK_SIZE: 8192, + LOG_FORMAT: "", + ALLOWED_COMMANDS: ["openroad"], + ENABLE_COMMAND_VALIDATION: true, + ORFS_FLOW_PATH: "/mock/flow", + })), + __setMock(fp: string, plats: string[], des: Record) { + mockFlowPath = fp; + mockPlatforms = plats; + mockDesigns = des; + }, + }; +}); + +import { getSettings } from "../../src/config/settings.js"; + +// --------------------------------------------------------------------------- +// Fixture helpers +// --------------------------------------------------------------------------- + +let tmpDir: string; + +function createFixture( + platform = "nangate45", + design = "gcd", + runSlug = "run-123", + imageFiles: string[] = ["cts_clk.webp", "final_all.webp"], +) { + const flowPath = tmpDir; + // Settings directories (used by platforms / designs accessors) + fs.mkdirSync(path.join(flowPath, "platforms", platform), { recursive: true }); + fs.mkdirSync(path.join(flowPath, "designs", platform, design), { recursive: true }); + // Reports directory + const runPath = path.join(flowPath, "reports", platform, design, runSlug); + fs.mkdirSync(runPath, { recursive: true }); + for (const img of imageFiles) { + fs.writeFileSync(path.join(runPath, img), Buffer.from("RIFF\x00\x00\x00\x00WEBP")); + } + return { flowPath, runPath }; +} + +// Stub manager (tools don't call manager methods directly, but constructor requires it) +const stubManager = {} as unknown as OpenROADManager; + +// --------------------------------------------------------------------------- + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openroad-test-")); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + vi.clearAllMocks(); +}); + +// --------------------------------------------------------------------------- +// classifyImageType +// --------------------------------------------------------------------------- + +describe("classifyImageType", () => { + it("classifies CTS images correctly", () => { + expect(classifyImageType("cts_clk.webp")).toEqual(["cts", "clock_visualization"]); + expect(classifyImageType("cts_clk_layout.webp")).toEqual(["cts", "clock_layout"]); + expect(classifyImageType("cts_core_clock.webp")).toEqual(["cts", "core_clock_visualization"]); + }); + + it("classifies final stage images correctly", () => { + expect(classifyImageType("final_all.webp")).toEqual(["final", "complete_design"]); + expect(classifyImageType("final_congestion.webp")).toEqual(["final", "congestion_heatmap"]); + expect(classifyImageType("final_ir_drop.webp")).toEqual(["final", "ir_drop_analysis"]); + }); + + it("returns unknown for unrecognised filenames", () => { + expect(classifyImageType("unknown_image.webp")).toEqual(["unknown", "unknown"]); + expect(classifyImageType("foo.webp")).toEqual(["unknown", "unknown"]); + }); + + it("returns unknown stage when filename has no underscore", () => { + const [stage, _type] = classifyImageType("nounderscore.webp"); + expect(stage).toBe("unknown"); + }); +}); + +// --------------------------------------------------------------------------- +// validatePlatformDesign +// --------------------------------------------------------------------------- + +describe("validatePlatformDesign", () => { + it("throws on unknown platform", () => { + (getSettings as ReturnType).mockReturnValueOnce({ + platforms: ["nangate45"], + designs: () => ["gcd"], + flowPath: tmpDir, + }); + expect(() => validatePlatformDesign("bad_platform", "gcd")).toThrow(); + }); + + it("throws on unknown design", () => { + (getSettings as ReturnType).mockReturnValueOnce({ + platforms: ["nangate45"], + designs: (p: string) => (p === "nangate45" ? ["gcd"] : []), + flowPath: tmpDir, + }); + expect(() => validatePlatformDesign("nangate45", "bad_design")).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// ListReportImagesTool +// --------------------------------------------------------------------------- + +describe("ListReportImagesTool", () => { + let tool: ListReportImagesTool; + + beforeEach(() => { + tool = new ListReportImagesTool(stubManager); + }); + + it("returns error when platform is invalid", async () => { + (getSettings as ReturnType).mockReturnValueOnce({ + platforms: [], + designs: () => [], + flowPath: tmpDir, + }); + const raw = await tool.execute("bad_platform", "gcd", "run-123"); + const result = JSON.parse(raw); + expect(result.error).toBeTruthy(); + }); + + it("returns RunNotFound error when run directory does not exist", async () => { + const { flowPath } = createFixture(); + (getSettings as ReturnType).mockReturnValue({ + platforms: ["nangate45"], + designs: (p: string) => (p === "nangate45" ? ["gcd"] : []), + flowPath, + WHITELIST_ENABLED: false, + }); + const raw = await tool.execute("nangate45", "gcd", "nonexistent"); + const result = JSON.parse(raw); + expect(result.error).toBe("RunNotFound"); + }); + + it("returns totalImages 0 when run directory has no .webp files", async () => { + const { flowPath, runPath } = createFixture("nangate45", "gcd", "run-empty", []); + (getSettings as ReturnType).mockReturnValue({ + platforms: ["nangate45"], + designs: (p: string) => (p === "nangate45" ? ["gcd"] : []), + flowPath, + WHITELIST_ENABLED: false, + }); + const raw = await tool.execute("nangate45", "gcd", "run-empty"); + const result = JSON.parse(raw); + expect(result.total_images).toBe(0); + expect(result.images_by_stage).toEqual({}); + }); + + it("lists all .webp files grouped by stage", async () => { + const { flowPath } = createFixture(); + (getSettings as ReturnType).mockReturnValue({ + platforms: ["nangate45"], + designs: (p: string) => (p === "nangate45" ? ["gcd"] : []), + flowPath, + WHITELIST_ENABLED: false, + }); + const raw = await tool.execute("nangate45", "gcd", "run-123"); + const result = JSON.parse(raw); + expect(result.total_images).toBe(2); + expect(result.images_by_stage).toBeTruthy(); + expect(result.images_by_stage).toHaveProperty("cts"); + expect(result.images_by_stage).toHaveProperty("final"); + }); + + it("filters images by stage", async () => { + const { flowPath } = createFixture("nangate45", "gcd", "run-123", [ + "cts_clk.webp", + "final_all.webp", + ]); + (getSettings as ReturnType).mockReturnValue({ + platforms: ["nangate45"], + designs: (p: string) => (p === "nangate45" ? ["gcd"] : []), + flowPath, + WHITELIST_ENABLED: false, + }); + const raw = await tool.execute("nangate45", "gcd", "run-123", "cts"); + const result = JSON.parse(raw); + expect(result.total_images).toBe(1); + expect(result.images_by_stage).toHaveProperty("cts"); + expect(result.images_by_stage).not.toHaveProperty("final"); + }); +}); + +// --------------------------------------------------------------------------- +// ReadReportImageTool +// --------------------------------------------------------------------------- + +describe("ReadReportImageTool", () => { + let tool: ReadReportImageTool; + + beforeEach(() => { + tool = new ReadReportImageTool(stubManager); + }); + + it("returns error when platform is invalid", async () => { + (getSettings as ReturnType).mockReturnValueOnce({ + platforms: [], + designs: () => [], + flowPath: tmpDir, + }); + const raw = await tool.execute("bad_platform", "gcd", "run-123", "cts_clk.webp"); + const result = JSON.parse(raw); + expect(result.error).toBeTruthy(); + expect(result.image_data).toBeNull(); + }); + + it("returns RunNotFound when run directory does not exist", async () => { + const { flowPath } = createFixture(); + (getSettings as ReturnType).mockReturnValue({ + platforms: ["nangate45"], + designs: (p: string) => (p === "nangate45" ? ["gcd"] : []), + flowPath, + WHITELIST_ENABLED: false, + }); + const raw = await tool.execute("nangate45", "gcd", "missing-run", "cts_clk.webp"); + const result = JSON.parse(raw); + expect(result.error).toBe("RunNotFound"); + }); + + it("returns ImageNotFound when image does not exist", async () => { + const { flowPath } = createFixture(); + (getSettings as ReturnType).mockReturnValue({ + platforms: ["nangate45"], + designs: (p: string) => (p === "nangate45" ? ["gcd"] : []), + flowPath, + WHITELIST_ENABLED: false, + }); + const raw = await tool.execute("nangate45", "gcd", "run-123", "missing.webp"); + const result = JSON.parse(raw); + expect(result.error).toBe("ImageNotFound"); + }); + + it("reads and base64-encodes a .webp image successfully", async () => { + const { flowPath } = createFixture("nangate45", "gcd", "run-123", ["cts_clk.webp"]); + (getSettings as ReturnType).mockReturnValue({ + platforms: ["nangate45"], + designs: (p: string) => (p === "nangate45" ? ["gcd"] : []), + flowPath, + WHITELIST_ENABLED: false, + }); + const raw = await tool.execute("nangate45", "gcd", "run-123", "cts_clk.webp"); + const result = JSON.parse(raw); + // Should have image_data as a base64 string + expect(typeof result.image_data).toBe("string"); + expect(result.image_data.length).toBeGreaterThan(0); + // Round-trip check + const decoded = Buffer.from(result.image_data, "base64"); + expect(decoded.length).toBeGreaterThan(0); + // Metadata presence + expect(result.metadata).toBeTruthy(); + expect(result.metadata.filename).toBe("cts_clk.webp"); + expect(result.metadata.stage).toBe("cts"); + expect(result.metadata.type).toBe("clock_visualization"); + }); + + it("returns FileTooLarge error when image exceeds 50 MB", async () => { + const { flowPath, runPath } = createFixture("nangate45", "gcd", "run-123", []); + // Write a "file" that appears to be 51 MB by mocking statSync + const bigPath = path.join(runPath, "huge.webp"); + fs.writeFileSync(bigPath, Buffer.from("tiny content")); + (getSettings as ReturnType).mockReturnValue({ + platforms: ["nangate45"], + designs: (p: string) => (p === "nangate45" ? ["gcd"] : []), + flowPath, + WHITELIST_ENABLED: false, + }); + const originalStatSync = fs.statSync.bind(fs); + const statSpy = vi.spyOn(fs, "statSync").mockImplementation((p) => { + if (p === bigPath) return { size: 51 * 1024 * 1024, isFile: () => true, mtime: new Date() } as unknown as fs.Stats; + return originalStatSync(p) as fs.Stats; + }); + const raw = await tool.execute("nangate45", "gcd", "run-123", "huge.webp"); + const result = JSON.parse(raw); + expect(result.error).toBe("FileTooLarge"); + statSpy.mockRestore(); + }); + + it("rejects non-.webp extension", async () => { + const { flowPath } = createFixture(); + (getSettings as ReturnType).mockReturnValue({ + platforms: ["nangate45"], + designs: (p: string) => (p === "nangate45" ? ["gcd"] : []), + flowPath, + WHITELIST_ENABLED: false, + }); + const raw = await tool.execute("nangate45", "gcd", "run-123", "cts_clk.png"); + const result = JSON.parse(raw); + expect(result.error).toBe("InvalidImageName"); + }); +}); + +// --------------------------------------------------------------------------- +// TestPathTraversalSecurity +// --------------------------------------------------------------------------- + +describe("TestPathTraversalSecurity", () => { + let tool: ListReportImagesTool; + let readTool: ReadReportImageTool; + let flowPath: string; + + beforeEach(() => { + const fixture = createFixture(); + flowPath = fixture.flowPath; + (getSettings as ReturnType).mockReturnValue({ + platforms: ["nangate45"], + designs: (p: string) => (p === "nangate45" ? ["gcd"] : []), + flowPath, + WHITELIST_ENABLED: false, + }); + tool = new ListReportImagesTool(stubManager); + readTool = new ReadReportImageTool(stubManager); + }); + + it("rejects path traversal in run_slug (../../etc/passwd)", async () => { + const raw = await tool.execute("nangate45", "gcd", "../../../etc/passwd"); + const result = JSON.parse(raw); + expect(result.error).toBeTruthy(); + expect(result.error).not.toBe("RunNotFound"); // must be a validation error + }); + + it("rejects bare '..' as run_slug", async () => { + const raw = await tool.execute("nangate45", "gcd", ".."); + const result = JSON.parse(raw); + expect(result.error).toBeTruthy(); + }); + + it("rejects glob characters in run_slug", async () => { + const raw = await tool.execute("nangate45", "gcd", "*"); + const result = JSON.parse(raw); + expect(result.error).toBeTruthy(); + }); + + it("rejects path traversal in image_name", async () => { + const raw = await readTool.execute("nangate45", "gcd", "run-123", "../../../etc/passwd"); + const result = JSON.parse(raw); + expect(result.error).toBeTruthy(); + }); + + it("rejects non-.webp extension in image_name", async () => { + const raw = await readTool.execute("nangate45", "gcd", "run-123", "file.sh"); + const result = JSON.parse(raw); + expect(result.error).toBe("InvalidImageName"); + }); + + it("rejects null byte in image_name", async () => { + const raw = await readTool.execute("nangate45", "gcd", "run-123", "evil\x00.webp"); + const result = JSON.parse(raw); + expect(result.error).toBeTruthy(); + }); + + it("blocks symlink escape from run directory", async () => { + // Create a symlink inside run-123 that points outside + const runPath = path.join(flowPath, "reports", "nangate45", "gcd", "run-123"); + const linkPath = path.join(runPath, "escape.webp"); + try { + fs.symlinkSync("/etc/passwd", linkPath); + } catch { + // symlink creation may fail in some environments — skip gracefully + return; + } + const raw = await readTool.execute("nangate45", "gcd", "run-123", "escape.webp"); + const result = JSON.parse(raw); + // Should either not find the image, reject path containment, or return an error + // — but must NOT return valid image_data that resolves to /etc/passwd content + expect(result.image_data === null || result.error !== null).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// TestPlatformDesignValidationInTools +// --------------------------------------------------------------------------- + +describe("TestPlatformDesignValidationInTools", () => { + beforeEach(() => { + (getSettings as ReturnType).mockReturnValue({ + platforms: ["nangate45"], + designs: (p: string) => (p === "nangate45" ? ["gcd"] : []), + flowPath: tmpDir, + WHITELIST_ENABLED: false, + }); + }); + + it("list tool returns error for invalid platform", async () => { + const raw = await new ListReportImagesTool(stubManager).execute("invalid_plat", "gcd", "run-123"); + expect(JSON.parse(raw).error).toBeTruthy(); + }); + + it("list tool returns error for invalid design", async () => { + const raw = await new ListReportImagesTool(stubManager).execute("nangate45", "bad_design", "run-123"); + expect(JSON.parse(raw).error).toBeTruthy(); + }); + + it("read tool returns error for invalid platform", async () => { + const raw = await new ReadReportImageTool(stubManager).execute("invalid_plat", "gcd", "run-123", "img.webp"); + expect(JSON.parse(raw).error).toBeTruthy(); + }); + + it("read tool returns error for invalid design", async () => { + const raw = await new ReadReportImageTool(stubManager).execute("nangate45", "bad_design", "run-123", "img.webp"); + expect(JSON.parse(raw).error).toBeTruthy(); + }); +}); From 4278870492b291fc018da03ef7e7de77fad44454 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Tue, 16 Jun 2026 21:40:01 -0600 Subject: [PATCH 21/62] verify process liveness with kill(0) so a missed exit event cannot leave the pty marked alive --- typescript/src/interactive/pty_handler.ts | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/typescript/src/interactive/pty_handler.ts b/typescript/src/interactive/pty_handler.ts index 0061a1e..9d2a356 100644 --- a/typescript/src/interactive/pty_handler.ts +++ b/typescript/src/interactive/pty_handler.ts @@ -89,17 +89,21 @@ export class PtyHandler { this._alive = true; this._exitCode = null; - if (onData) { - this._dataDisposable = this._ptyProcess.onData(onData); - } - + // Register the exit handler before onData so a fast-exiting process can + // never slip its exit event through before we are listening. The guard + // keeps the handler idempotent against a double-delivered exit. this._exitDisposable = this._ptyProcess.onExit(({ exitCode }) => { + if (!this._alive && this._exitCode !== null) return; this._alive = false; this._exitCode = exitCode; const resolvers = this._exitResolvers.splice(0); for (const resolve of resolvers) resolve(exitCode); onExit?.(exitCode); }); + + if (onData) { + this._dataDisposable = this._ptyProcess.onData(onData); + } } catch (e) { if (e instanceof PTYError) throw e; throw new PTYError(`Failed to create PTY session: ${e}`); @@ -118,7 +122,16 @@ export class PtyHandler { } isProcessAlive(): boolean { - return this._alive; + if (!this._alive || !this._ptyProcess) return false; + // Defensive liveness probe: if the exit event was somehow missed, signal 0 + // detects a dead/reaped pid (ESRCH) so `_alive` cannot stay true forever. + try { + process.kill(this._ptyProcess.pid, 0); + return true; + } catch { + this._alive = false; + return false; + } } async waitForExit(timeoutMs?: number): Promise { From d6f4b30efe7cab4bd8537792954856f8a9afcfbb Mon Sep 17 00:00:00 2001 From: kartikloops Date: Tue, 16 Jun 2026 21:41:54 -0600 Subject: [PATCH 22/62] serialize session terminate and cleanup with a lifecycle lock to prevent double kill and stale exit codes --- typescript/src/interactive/session.ts | 46 ++++++++++++++++----------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/typescript/src/interactive/session.ts b/typescript/src/interactive/session.ts index 821db4e..d6f06d8 100644 --- a/typescript/src/interactive/session.ts +++ b/typescript/src/interactive/session.ts @@ -1,4 +1,5 @@ import pidusage from "pidusage"; +import { Mutex } from "async-mutex"; import { ANSIDecoder } from "../utils/ansi_decoder.js"; import { getSettings } from "../config/settings.js"; import type { Settings } from "../config/settings.js"; @@ -55,6 +56,9 @@ export class InteractiveSession { private _inputWaiters: Array<() => void> = []; private _isShutdown = false; private _writerTask: Promise | null = null; + // Serialises terminate()/cleanup() so concurrent callers cannot double-kill + // the process or deliver a stale exit code to waiters. + private readonly _lifecycleLock = new Mutex(); constructor(sessionId: string, bufferSize?: number, private readonly _settings: Settings = getSettings()) { this.sessionId = sessionId; @@ -261,33 +265,37 @@ export class InteractiveSession { } async terminate(force = false): Promise { - if (this._state === SessionState.TERMINATED) return; + await this._lifecycleLock.runExclusive(async () => { + if (this._state === SessionState.TERMINATED) return; - this._state = SessionState.TERMINATED; - this._signalShutdown(); + this._state = SessionState.TERMINATED; + this._signalShutdown(); - await this.pty.terminateProcess(force); - await this.pty.cleanup(); + await this.pty.terminateProcess(force); + await this.pty.cleanup(); - if (this._writerTask !== null) { - await this._writerTask; - this._writerTask = null; - } + if (this._writerTask !== null) { + await this._writerTask; + this._writerTask = null; + } + }); } async cleanup(): Promise { - if (this._state !== SessionState.TERMINATED && this._state !== SessionState.ERROR) { - this._state = SessionState.TERMINATED; - } - this._signalShutdown(); + await this._lifecycleLock.runExclusive(async () => { + if (this._state !== SessionState.TERMINATED && this._state !== SessionState.ERROR) { + this._state = SessionState.TERMINATED; + } + this._signalShutdown(); - if (this._writerTask !== null) { - await this._writerTask; - this._writerTask = null; - } + if (this._writerTask !== null) { + await this._writerTask; + this._writerTask = null; + } - await this.pty.cleanup(); - await this.outputBuffer.clear(); + await this.pty.cleanup(); + await this.outputBuffer.clear(); + }); } private _signalShutdown(): void { From 23c395026825bc2f9bb06dbaa5cd9168ab26cb6e Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 17 Jun 2026 06:39:47 -0600 Subject: [PATCH 23/62] drop redundant cleanup call in terminateSession so final output buffer is not discarded --- typescript/__tests__/core/manager.test.ts | 4 +++- typescript/src/core/manager.ts | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/typescript/__tests__/core/manager.test.ts b/typescript/__tests__/core/manager.test.ts index 07c7982..0e28a20 100644 --- a/typescript/__tests__/core/manager.test.ts +++ b/typescript/__tests__/core/manager.test.ts @@ -187,7 +187,9 @@ describe("OpenROADManager", () => { await manager.createSession({ sessionId: "s1" }); await manager.terminateSession("s1", true); expect(created[0]!.terminate).toHaveBeenCalledWith(true); - expect(created[0]!.cleanup).toHaveBeenCalledOnce(); + // terminate() handles teardown; cleanup() must not be called again here + // (it would clear the buffer and double-tear-down the PTY). + expect(created[0]!.cleanup).not.toHaveBeenCalled(); expect(manager.getSessionCount()).toBe(0); }); }); diff --git a/typescript/src/core/manager.ts b/typescript/src/core/manager.ts index d2276b7..fbe96a2 100644 --- a/typescript/src/core/manager.ts +++ b/typescript/src/core/manager.ts @@ -121,8 +121,11 @@ export class OpenROADManager { async terminateSession(sessionId: string, force = false): Promise { const session = this._getSession(sessionId); + // terminate() already tears down the PTY and stops the writer task. We do + // not also call cleanup() here: cleanup() clears the output buffer, which + // would discard final output a concurrent reader may still need. The + // session is dropped from the map below, so its buffer is GC'd anyway. await session.terminate(force); - await session.cleanup(); this.logger.info(`Terminated session ${sessionId}`); await this.cleanupLock.runExclusive(() => { From 7327517b039bc711590ab28b73b03316af28c1a7 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 17 Jun 2026 06:43:45 -0600 Subject: [PATCH 24/62] use function replacement in annotate and preserve modes so $& in escape sequences is not reinterpreted --- typescript/__tests__/utils/ansi_decoder.test.ts | 16 ++++++++++++++++ typescript/src/utils/ansi_decoder.ts | 9 +++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/typescript/__tests__/utils/ansi_decoder.test.ts b/typescript/__tests__/utils/ansi_decoder.test.ts index ba7fd2a..2731213 100644 --- a/typescript/__tests__/utils/ansi_decoder.test.ts +++ b/typescript/__tests__/utils/ansi_decoder.test.ts @@ -67,6 +67,22 @@ describe("ANSIDecoder", () => { }); }); + describe("translateOutput - $ replacement safety", () => { + it("does not reinterpret $& inside an OSC sequence body when annotating", () => { + // OSC title set with a literal "$&" in the body. With a string replacement + // the "$&" would expand to the matched sequence and corrupt the output. + const osc = "\x1b]0;$&title\x07"; + const result = ANSIDecoder.translateOutput(`before${osc}after`, "annotate"); + expect(result).toBe("before[Unknown escape sequence (\x1b]0;$&title\x07)]after"); + }); + + it("inserts the original sequence literally in preserve mode", () => { + const osc = "\x1b]0;$$x\x07"; + const result = ANSIDecoder.translateOutput(osc, "preserve"); + expect(result).toContain(osc); + }); + }); + describe("translateOutput - decode mode", () => { it("includes breakdown header", () => { const result = ANSIDecoder.translateOutput("\x1b[1mtext", "decode"); diff --git a/typescript/src/utils/ansi_decoder.ts b/typescript/src/utils/ansi_decoder.ts index 9e16ce4..91a2271 100644 --- a/typescript/src/utils/ansi_decoder.ts +++ b/typescript/src/utils/ansi_decoder.ts @@ -115,7 +115,11 @@ export class ANSIDecoder { if (mode === "annotate") { let result = text; for (const seq of new Set(sequences)) { - result = result.replaceAll(seq, `[${ANSIDecoder.decodeEscapeSequence(seq)}]`); + // Function replacement: the value is inserted literally, so `$&`/`$1` + // inside an OSC sequence body cannot be reinterpreted as a replacement + // pattern and corrupt the output. + const annotation = `[${ANSIDecoder.decodeEscapeSequence(seq)}]`; + result = result.replaceAll(seq, () => annotation); } return result.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); } @@ -123,7 +127,8 @@ export class ANSIDecoder { if (mode === "preserve") { let result = text; for (const seq of new Set(sequences)) { - result = result.replaceAll(seq, `${seq}[${ANSIDecoder.decodeEscapeSequence(seq)}]`); + const annotated = `${seq}[${ANSIDecoder.decodeEscapeSequence(seq)}]`; + result = result.replaceAll(seq, () => annotated); } return result; } From 1a68b91a0ee0b28e7ef6e936492a106a86e79a7d Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 17 Jun 2026 06:45:12 -0600 Subject: [PATCH 25/62] transition session to terminated from any non-terminal state so a startup failure cannot leave a creating zombie --- typescript/src/interactive/session.ts | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/typescript/src/interactive/session.ts b/typescript/src/interactive/session.ts index d6f06d8..c42f0fb 100644 --- a/typescript/src/interactive/session.ts +++ b/typescript/src/interactive/session.ts @@ -115,9 +115,7 @@ export class InteractiveSession { // circular buffer's eviction logic bounds memory correctly. const appendChunk = (chunk: string): void => { this.outputBuffer.append(chunk).catch(() => { - if (this._state === SessionState.ACTIVE) { - this._state = SessionState.TERMINATED; - } + this._markDead(); this._signalShutdown(); }); }; @@ -138,7 +136,12 @@ export class InteractiveSession { }, ); - this._state = SessionState.ACTIVE; + // Only promote to ACTIVE if the session is still creating. A fast process + // death during startup may already have flipped us to TERMINATED via the + // onData/onExit handlers; do not resurrect it into an undead ACTIVE state. + if (this._state === SessionState.CREATING) { + this._state = SessionState.ACTIVE; + } this._writerTask = this._writeInput(); } catch (e) { this._state = SessionState.ERROR; @@ -304,6 +307,17 @@ export class InteractiveSession { for (const w of waiters) w(); } + /** + * Transition to TERMINATED from any non-terminal state (idempotent). Covers + * CREATING as well as ACTIVE so a session that dies mid-startup is never left + * stranded as an uncollectable CREATING zombie. + */ + private _markDead(): void { + if (this._state !== SessionState.TERMINATED && this._state !== SessionState.ERROR) { + this._state = SessionState.TERMINATED; + } + } + private async _writeInput(): Promise { while (!this._isShutdown) { const data = await this._dequeueInput(1000); @@ -312,9 +326,7 @@ export class InteractiveSession { try { this.pty.writeInput(data); } catch { - if (this._state === SessionState.ACTIVE) { - this._state = SessionState.TERMINATED; - } + this._markDead(); this._signalShutdown(); break; } From 672b669747627ca8a08e121cd8984e13cc6bdeec Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 17 Jun 2026 06:57:09 -0600 Subject: [PATCH 26/62] ignore non-positive history limit so a negative value cannot drop recent commands --- typescript/__tests__/interactive/session.test.ts | 7 +++++++ typescript/src/interactive/session.ts | 5 +++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/typescript/__tests__/interactive/session.test.ts b/typescript/__tests__/interactive/session.test.ts index a9a16bc..d1b4dcb 100644 --- a/typescript/__tests__/interactive/session.test.ts +++ b/typescript/__tests__/interactive/session.test.ts @@ -643,6 +643,13 @@ describe("InteractiveSession", () => { expect(limited[0]!.command).toBe("cmd_b"); }); + it("getCommandHistory ignores a negative limit instead of dropping entries", async () => { + await session.sendCommand("cmd_a"); + await session.sendCommand("cmd_b"); + const all = session.getCommandHistory(-1); + expect(all).toHaveLength(2); + }); + it("getDetailedMetrics returns the full nested shape", async () => { await session.sendCommand("report_wns"); const m = await session.getDetailedMetrics(); diff --git a/typescript/src/interactive/session.ts b/typescript/src/interactive/session.ts index c42f0fb..b2305e0 100644 --- a/typescript/src/interactive/session.ts +++ b/typescript/src/interactive/session.ts @@ -468,8 +468,9 @@ export class InteractiveSession { // Sort by timestamp, most recent first. history.sort((a, b) => (a.timestamp < b.timestamp ? 1 : a.timestamp > b.timestamp ? -1 : 0)); - // Match Python's truthy check: limit === 0 leaves the list unsliced. - if (limit) { + // Only a positive limit slices. A zero or negative limit leaves the list + // intact rather than letting `slice(0, -n)` silently drop recent entries. + if (limit !== undefined && limit > 0) { history = history.slice(0, limit); } From 73bb01827fef07ee6a761fd8e2e4988f07af5f61 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 17 Jun 2026 07:09:07 -0600 Subject: [PATCH 27/62] track session death time and use it for force-cleanup timing instead of last activity --- typescript/src/core/manager.ts | 7 ++++++- typescript/src/interactive/session.ts | 22 +++++++++++++++++----- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/typescript/src/core/manager.ts b/typescript/src/core/manager.ts index fbe96a2..9098243 100644 --- a/typescript/src/core/manager.ts +++ b/typescript/src/core/manager.ts @@ -290,7 +290,12 @@ export class OpenROADManager { for (const [sessionId, session] of this._initializedSessions()) { if (!session.isAlive()) { - const timeSinceDeath = (now - session.lastActivity.getTime()) / 1000; + // Measure from the actual death time. lastActivity is the last command, + // which for a long-idle session is far earlier and would trip the + // force-cleanup timer immediately. Fall back to lastActivity only if the + // death timestamp is somehow unset. + const deathTime = (session.terminatedAt ?? session.lastActivity).getTime(); + const timeSinceDeath = (now - deathTime) / 1000; terminated.push([sessionId, session, timeSinceDeath > FORCE_CLEANUP_AFTER_SECONDS]); } } diff --git a/typescript/src/interactive/session.ts b/typescript/src/interactive/session.ts index b2305e0..9f7c665 100644 --- a/typescript/src/interactive/session.ts +++ b/typescript/src/interactive/session.ts @@ -49,6 +49,10 @@ export class InteractiveSession { sessionTimeoutSeconds: number | null = null; private _state: SessionState; + // Wall-clock time the process actually died, set on the first TERMINATED + // transition. Used by the manager's force-cleanup timer; lastActivity would + // be wrong because a long-idle session dies far after its last command. + private _terminatedAt: Date | null = null; pty: PtyHandler; readonly outputBuffer: CircularBuffer; @@ -73,15 +77,23 @@ export class InteractiveSession { } set state(value: SessionState) { + if (value === SessionState.TERMINATED && this._terminatedAt === null) { + this._terminatedAt = new Date(); + } this._state = value; } + /** Wall-clock time the session first became TERMINATED, or null if still alive. */ + get terminatedAt(): Date | null { + return this._terminatedAt; + } + isAlive(): boolean { if (this._state === SessionState.TERMINATED) return false; const processAlive = this.pty.isProcessAlive(); if (!processAlive && this._state === SessionState.ACTIVE) { - this._state = SessionState.TERMINATED; + this.state = SessionState.TERMINATED; this._signalShutdown(); return false; } @@ -130,7 +142,7 @@ export class InteractiveSession { }, (_exitCode: number) => { if (this._state !== SessionState.TERMINATED) { - this._state = SessionState.TERMINATED; + this.state = SessionState.TERMINATED; this._signalShutdown(); } }, @@ -271,7 +283,7 @@ export class InteractiveSession { await this._lifecycleLock.runExclusive(async () => { if (this._state === SessionState.TERMINATED) return; - this._state = SessionState.TERMINATED; + this.state = SessionState.TERMINATED; this._signalShutdown(); await this.pty.terminateProcess(force); @@ -287,7 +299,7 @@ export class InteractiveSession { async cleanup(): Promise { await this._lifecycleLock.runExclusive(async () => { if (this._state !== SessionState.TERMINATED && this._state !== SessionState.ERROR) { - this._state = SessionState.TERMINATED; + this.state = SessionState.TERMINATED; } this._signalShutdown(); @@ -314,7 +326,7 @@ export class InteractiveSession { */ private _markDead(): void { if (this._state !== SessionState.TERMINATED && this._state !== SessionState.ERROR) { - this._state = SessionState.TERMINATED; + this.state = SessionState.TERMINATED; } } From 2d5f4839ed787fed675b7d7206c1e1f312fbd331 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 17 Jun 2026 07:11:15 -0600 Subject: [PATCH 28/62] skip null session placeholders in terminateAllSessions so in-progress creates are not lost --- typescript/__tests__/core/manager.test.ts | 15 +++++++++++++++ typescript/src/core/manager.ts | 5 ++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/typescript/__tests__/core/manager.test.ts b/typescript/__tests__/core/manager.test.ts index 0e28a20..e8e6338 100644 --- a/typescript/__tests__/core/manager.test.ts +++ b/typescript/__tests__/core/manager.test.ts @@ -202,6 +202,21 @@ describe("OpenROADManager", () => { expect(count).toBe(2); expect(manager.getSessionCount()).toBe(0); }); + + it("skips in-progress placeholders instead of throwing", async () => { + // A session whose start() never resolves leaves a null placeholder in the + // map (createSession holds the lock). terminateAllSessions must not try to + // terminate it (which would throw "still being created"). + MockedSession.mockImplementationOnce(function (this: unknown, sessionId: string) { + const mock = makeMockSession(sessionId); + mock.start.mockReturnValue(new Promise(() => {})); // never resolves + created.push(mock); + return mock as unknown as InteractiveSession; + } as unknown as (sessionId: string) => InteractiveSession); + void manager.createSession({ sessionId: "pending" }); + + await expect(manager.terminateAllSessions()).resolves.toBe(0); + }); }); describe("inspectSession & getSessionHistory", () => { diff --git a/typescript/src/core/manager.ts b/typescript/src/core/manager.ts index 9098243..f9325d7 100644 --- a/typescript/src/core/manager.ts +++ b/typescript/src/core/manager.ts @@ -134,7 +134,10 @@ export class OpenROADManager { } async terminateAllSessions(force = false): Promise { - const sessionIds = [...this.sessions.keys()]; + // Only initialized sessions are terminable. Null placeholders belong to an + // in-flight createSession (which resolves or removes them itself), so + // terminating them would throw "still being created" and be lost. + const sessionIds = this._initializedSessions().map(([sid]) => sid); if (sessionIds.length === 0) return 0; const results = await Promise.allSettled( From 52312b5933fa2ccfad4481a659d54ced6fe56231 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 17 Jun 2026 07:12:19 -0600 Subject: [PATCH 29/62] reject infinite and negative float settings so a timeout cannot be silently disabled --- typescript/__tests__/config/settings.test.ts | 10 ++++++++++ typescript/src/config/settings.ts | 6 +++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/typescript/__tests__/config/settings.test.ts b/typescript/__tests__/config/settings.test.ts index 1594a74..a901663 100644 --- a/typescript/__tests__/config/settings.test.ts +++ b/typescript/__tests__/config/settings.test.ts @@ -115,6 +115,16 @@ describe("Settings", () => { expect(() => Settings.fromEnv()).toThrow("OPENROAD_COMMAND_TIMEOUT"); }); + it("rejects Infinity for float fields", () => { + process.env["OPENROAD_COMMAND_TIMEOUT"] = "Infinity"; + expect(() => Settings.fromEnv()).toThrow("OPENROAD_COMMAND_TIMEOUT"); + }); + + it("rejects negative floats", () => { + process.env["OPENROAD_COMMAND_TIMEOUT"] = "-1"; + expect(() => Settings.fromEnv()).toThrow("OPENROAD_COMMAND_TIMEOUT"); + }); + it("throws on invalid int", () => { process.env["OPENROAD_MAX_SESSIONS"] = "3.7"; expect(() => Settings.fromEnv()).toThrow("OPENROAD_MAX_SESSIONS"); diff --git a/typescript/src/config/settings.ts b/typescript/src/config/settings.ts index 97fbbc6..638ba2f 100644 --- a/typescript/src/config/settings.ts +++ b/typescript/src/config/settings.ts @@ -18,7 +18,11 @@ function parseBool(envKey: string, val: string): boolean { function parseFloat_(envKey: string, val: string): number { if (val.trim() === "") throw new Error(`Invalid value for ${envKey}: (empty string). Expected float.`); const n = Number(val); - if (isNaN(n)) throw new Error(`Invalid value for ${envKey}: ${val}. Expected float.`); + // Reject NaN and Infinity: an infinite timeout/delay would disable the very + // limit it configures, and a negative duration is never meaningful here. + if (!Number.isFinite(n) || n < 0) { + throw new Error(`Invalid value for ${envKey}: ${val}. Expected a non-negative finite float.`); + } return n; } From b9763196a0ca2bca74dc4d5c0250ae16ab552d4c Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 17 Jun 2026 07:14:31 -0600 Subject: [PATCH 30/62] reject negative integer settings so a negative session limit cannot block all creation --- typescript/__tests__/config/settings.test.ts | 5 +++++ typescript/src/config/settings.ts | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/typescript/__tests__/config/settings.test.ts b/typescript/__tests__/config/settings.test.ts index a901663..799cf97 100644 --- a/typescript/__tests__/config/settings.test.ts +++ b/typescript/__tests__/config/settings.test.ts @@ -130,6 +130,11 @@ describe("Settings", () => { expect(() => Settings.fromEnv()).toThrow("OPENROAD_MAX_SESSIONS"); }); + it("rejects negative integers for int fields", () => { + process.env["OPENROAD_MAX_SESSIONS"] = "-1"; + expect(() => Settings.fromEnv()).toThrow("OPENROAD_MAX_SESSIONS"); + }); + it("rejects decimal strings for int fields", () => { process.env["OPENROAD_MAX_SESSIONS"] = "50.0"; expect(() => Settings.fromEnv()).toThrow("OPENROAD_MAX_SESSIONS"); diff --git a/typescript/src/config/settings.ts b/typescript/src/config/settings.ts index 638ba2f..c48b383 100644 --- a/typescript/src/config/settings.ts +++ b/typescript/src/config/settings.ts @@ -29,7 +29,11 @@ function parseFloat_(envKey: string, val: string): number { function parseInt_(envKey: string, val: string): number { if (val.trim() === "") throw new Error(`Invalid value for ${envKey}: (empty string). Expected int.`); if (!/^-?\d+$/.test(val.trim())) throw new Error(`Invalid value for ${envKey}: ${val}. Expected int.`); - return Number(val); + const n = Number(val); + // Every integer setting (session/buffer/queue limits) must be non-negative; a + // negative value such as MAX_SESSIONS=-1 would block all session creation. + if (n < 0) throw new Error(`Invalid value for ${envKey}: ${val}. Expected a non-negative integer.`); + return n; } export class Settings { From f8600befafc35ddfa10f816b7c13659523b7168b Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 17 Jun 2026 07:15:14 -0600 Subject: [PATCH 31/62] backfill execution_time for all commands batched into a single readOutput --- typescript/__tests__/interactive/session.test.ts | 9 +++++++++ typescript/src/interactive/session.ts | 16 +++++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/typescript/__tests__/interactive/session.test.ts b/typescript/__tests__/interactive/session.test.ts index d1b4dcb..ba0c275 100644 --- a/typescript/__tests__/interactive/session.test.ts +++ b/typescript/__tests__/interactive/session.test.ts @@ -622,6 +622,15 @@ describe("InteractiveSession", () => { expect(session.commandHistory[0]!.command).toBe("puts hi"); }); + it("records execution_time for every command batched into one readOutput", async () => { + await session.sendCommand("cmd_a"); + await session.sendCommand("cmd_b"); + await session.readOutput(50); + + expect(session.commandHistory[0]!.execution_time).toBeDefined(); + expect(session.commandHistory[1]!.execution_time).toBeDefined(); + }); + it("getCommandHistory filters by search (case-insensitive)", async () => { await session.sendCommand("report_wns"); await session.sendCommand("get_nets foo"); diff --git a/typescript/src/interactive/session.ts b/typescript/src/interactive/session.ts index 9f7c665..a5584db 100644 --- a/typescript/src/interactive/session.ts +++ b/typescript/src/interactive/session.ts @@ -387,12 +387,18 @@ export class InteractiveSession { return undefined; } - /** Update lastActivity and backfill the last history entry after a read. */ + /** + * Update lastActivity and backfill history entries after a read. Walks back + * over every trailing entry still missing execution_time, stopping at the + * first already-recorded one, so commands batched into a single readOutput + * all get timing instead of only the most recent. + */ private _recordReadResult(outputLength: number, executionTime: number): void { - const last = this.commandHistory[this.commandHistory.length - 1]; - if (last && last.execution_time === undefined) { - last.execution_time = executionTime; - last.output_length = outputLength; + for (let i = this.commandHistory.length - 1; i >= 0; i--) { + const entry = this.commandHistory[i]; + if (!entry || entry.execution_time !== undefined) break; + entry.execution_time = executionTime; + entry.output_length = outputLength; } this.lastActivity = new Date(); } From ce957eee57ae942c51779b7c0122a7fc5fc83b31 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 17 Jun 2026 07:16:32 -0600 Subject: [PATCH 32/62] preserve exit code across cleanup so late waitForExit callers get the real code --- typescript/src/interactive/pty_handler.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/typescript/src/interactive/pty_handler.ts b/typescript/src/interactive/pty_handler.ts index 9d2a356..e66f907 100644 --- a/typescript/src/interactive/pty_handler.ts +++ b/typescript/src/interactive/pty_handler.ts @@ -135,8 +135,10 @@ export class PtyHandler { } async waitForExit(timeoutMs?: number): Promise { - if (!this._ptyProcess) return null; + // Check the recorded exit code first so callers that arrive after cleanup() + // still get the real code rather than null. if (this._exitCode !== null) return this._exitCode; + if (!this._ptyProcess) return null; return new Promise((resolve) => { let settled = false; @@ -203,6 +205,7 @@ export class PtyHandler { this._alive = false; this._dataDisposable = null; this._exitDisposable = null; - this._exitCode = null; + // Keep _exitCode so a late waitForExit() caller still sees the real exit + // code. createSession() resets it to null when the handler is reused. } } From 800a2a34b01d4678e3dafbb41cc817d334629fdb Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 17 Jun 2026 07:20:18 -0600 Subject: [PATCH 33/62] use a live pid in the pty mock so the kill(0) liveness probe sees an active process --- typescript/__tests__/interactive/pty_handler.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/typescript/__tests__/interactive/pty_handler.test.ts b/typescript/__tests__/interactive/pty_handler.test.ts index b01aabc..1dc4688 100644 --- a/typescript/__tests__/interactive/pty_handler.test.ts +++ b/typescript/__tests__/interactive/pty_handler.test.ts @@ -26,7 +26,9 @@ function makeMockPty(): MockPty { let capturedOnExit: ((e: { exitCode: number; signal?: number }) => void) | undefined; return { - pid: 12345, + // Use the test runner's own pid so isProcessAlive()'s `process.kill(pid, 0)` + // liveness probe sees a real, live process for an unterminated mock. + pid: process.pid, write: vi.fn(), kill: vi.fn(), resize: vi.fn(), From 85a645a936d7998d0c485af0049d85e8ee342705 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 17 Jun 2026 07:21:06 -0600 Subject: [PATCH 34/62] reject whitespace-only path segments in validatePathSegment --- typescript/__tests__/utils/path_security.test.ts | 6 ++++++ typescript/src/utils/path_security.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/typescript/__tests__/utils/path_security.test.ts b/typescript/__tests__/utils/path_security.test.ts index a013faa..673cabf 100644 --- a/typescript/__tests__/utils/path_security.test.ts +++ b/typescript/__tests__/utils/path_security.test.ts @@ -18,6 +18,12 @@ describe("validatePathSegment", () => { ); }); + it("rejects whitespace-only segment", () => { + expect(() => validatePathSegment(" ", "test_segment")).toThrow( + new ValidationError("test_segment cannot be empty"), + ); + }); + it("rejects '.' segment", () => { expect(() => validatePathSegment(".", "test_segment")).toThrow( new ValidationError("test_segment cannot be '.' or '..'"), diff --git a/typescript/src/utils/path_security.ts b/typescript/src/utils/path_security.ts index 0c57409..2b60008 100644 --- a/typescript/src/utils/path_security.ts +++ b/typescript/src/utils/path_security.ts @@ -3,7 +3,7 @@ import fs from "node:fs"; import { ValidationError } from "../exceptions.js"; export function validatePathSegment(segment: string, segmentName: string): void { - if (!segment) throw new ValidationError(`${segmentName} cannot be empty`); + if (!segment || segment.trim() === "") throw new ValidationError(`${segmentName} cannot be empty`); if (segment === "." || segment === "..") throw new ValidationError(`${segmentName} cannot be '.' or '..'`); if (segment.includes("/") || segment.includes("\\")) throw new ValidationError(`${segmentName} cannot contain path separators`); if (segment.includes("\x00")) throw new ValidationError(`${segmentName} cannot contain null bytes`); From 945c7ef1ba38da62b77a11c41f42841b1f613276 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 17 Jun 2026 07:23:23 -0600 Subject: [PATCH 35/62] bound per-session command history so long-lived sessions do not leak memory --- typescript/__tests__/interactive/session.test.ts | 16 ++++++++++++++++ typescript/src/constants.ts | 4 ++++ typescript/src/interactive/session.ts | 12 +++++++++++- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/typescript/__tests__/interactive/session.test.ts b/typescript/__tests__/interactive/session.test.ts index ba0c275..81567a0 100644 --- a/typescript/__tests__/interactive/session.test.ts +++ b/typescript/__tests__/interactive/session.test.ts @@ -3,6 +3,7 @@ import { InteractiveSession } from "../../src/interactive/session.js"; import { SessionState } from "../../src/core/models.js"; import { SessionError, SessionTerminatedError } from "../../src/interactive/models.js"; import { Settings } from "../../src/config/settings.js"; +import { MAX_COMMAND_HISTORY } from "../../src/constants.js"; import type { PtyHandler } from "../../src/interactive/pty_handler.js"; vi.mock("node-pty", () => ({ spawn: vi.fn() })); @@ -631,6 +632,21 @@ describe("InteractiveSession", () => { expect(session.commandHistory[1]!.execution_time).toBeDefined(); }); + it("bounds commandHistory at MAX_COMMAND_HISTORY, dropping the oldest", async () => { + // Large queue so rapid sends never hit the input-queue-full guard. + const s = new InteractiveSession("hist-cap", 1024, new Settings({ SESSION_QUEUE_SIZE: 1_000_000 })); + s.pty = makeMockPty(); + await s.start(); + + const total = MAX_COMMAND_HISTORY + 5; + for (let i = 1; i <= total; i++) await s.sendCommand(`c${i}`); + + expect(s.commandHistory).toHaveLength(MAX_COMMAND_HISTORY); + // Oldest entries dropped: first retained command_number is total - MAX + 1. + expect(s.commandHistory[0]!.command_number).toBe(total - MAX_COMMAND_HISTORY + 1); + await s.cleanup(); + }); + it("getCommandHistory filters by search (case-insensitive)", async () => { await session.sendCommand("report_wns"); await session.sendCommand("get_nets foo"); diff --git a/typescript/src/constants.ts b/typescript/src/constants.ts index 2f5e58e..ff27c14 100644 --- a/typescript/src/constants.ts +++ b/typescript/src/constants.ts @@ -24,3 +24,7 @@ export const CHUNK_JOIN_THRESHOLD = 100; export const LARGE_IO_THRESHOLD = 10_000; export const SLOW_OPERATION_THRESHOLD = 1.0; +// Cap on retained per-session command history entries. Bounds memory on +// long-lived sessions; the oldest entries are dropped once the cap is exceeded. +export const MAX_COMMAND_HISTORY = 1000; + diff --git a/typescript/src/interactive/session.ts b/typescript/src/interactive/session.ts index a5584db..dce8c2c 100644 --- a/typescript/src/interactive/session.ts +++ b/typescript/src/interactive/session.ts @@ -10,7 +10,12 @@ import type { InteractiveSessionInfo, SessionDetailedMetrics, } from "../core/models.js"; -import { BYTES_TO_MB, MAX_COMMAND_COMPLETION_WINDOW, UTILIZATION_PERCENTAGE_BASE } from "../constants.js"; +import { + BYTES_TO_MB, + MAX_COMMAND_COMPLETION_WINDOW, + MAX_COMMAND_HISTORY, + UTILIZATION_PERCENTAGE_BASE, +} from "../constants.js"; import { CircularBuffer } from "./buffer.js"; import { SessionError, SessionTerminatedError } from "./models.js"; import { PtyHandler } from "./pty_handler.js"; @@ -182,6 +187,11 @@ export class InteractiveSession { command_number: this.commandCount + 1, execution_start: Date.now() / 1000, }); + // Bound history so a long-lived session cannot grow it without limit. + // command_number keeps increasing, so dropping the oldest entry is safe. + if (this.commandHistory.length > MAX_COMMAND_HISTORY) { + this.commandHistory.shift(); + } const data = command.endsWith("\n") ? command : command + "\n"; this._inputQueue.push(data); From 463111a9e0a0256b805deb2936a5d61b8e42986a Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 17 Jun 2026 17:48:05 -0600 Subject: [PATCH 36/62] block body-eval builtins and bracket substitution so the query tool cannot be bypassed via catch/if/foreach or set x [exec ls] --- .../config/command_whitelist.test.ts | 46 +++++++++++- typescript/src/config/command_whitelist.ts | 70 +++++++++++++++---- 2 files changed, 99 insertions(+), 17 deletions(-) diff --git a/typescript/__tests__/config/command_whitelist.test.ts b/typescript/__tests__/config/command_whitelist.test.ts index 459b930..178b18d 100644 --- a/typescript/__tests__/config/command_whitelist.test.ts +++ b/typescript/__tests__/config/command_whitelist.test.ts @@ -62,10 +62,16 @@ describe("pattern sets", () => { expect(READONLY_PATTERNS).toContain("check_*"); }); - it("READONLY contains Tcl builtins", () => { + it("READONLY contains safe Tcl builtins", () => { expect(READONLY_PATTERNS).toContain("puts"); - expect(READONLY_PATTERNS).toContain("foreach"); expect(READONLY_PATTERNS).toContain("set"); + expect(READONLY_PATTERNS).toContain("expr"); + }); + + it("READONLY excludes body-eval builtins (if/for/foreach/while/proc/catch/namespace)", () => { + for (const verb of ["if", "for", "foreach", "while", "proc", "catch", "namespace", "uplevel"]) { + expect(READONLY_PATTERNS).not.toContain(verb); + } }); it("EXEC_ONLY contains set_*/read_*/write_* globs", () => { @@ -195,6 +201,42 @@ describe("isQueryCommand", () => { it("splits on semicolons and rejects the offending verb", () => { expect(isQueryCommand("report_wns; global_placement")).toEqual([false, "global_placement"]); }); + + it("body-eval: blocks catch wrapping exec (finding 1)", () => { + expect(isQueryCommand("catch { exec ls }")).toEqual([false, "catch"]); + }); + + it("body-eval: blocks if wrapping exec (finding 1)", () => { + expect(isQueryCommand("if 1 { exec ls }")).toEqual([false, "if"]); + }); + + it("body-eval: blocks foreach wrapping exec (finding 1)", () => { + expect(isQueryCommand("foreach x {a} { exec ls }")).toEqual([false, "foreach"]); + }); + + it("bracket: blocks set x [exec ls] via bracket scan (finding 2)", () => { + expect(isQueryCommand("set x [exec ls]")).toEqual([false, "exec"]); + }); + + it("bracket: blocks expr {[exec ls]} via bracket scan (finding 2)", () => { + expect(isQueryCommand("expr {[exec ls]}")).toEqual([false, "exec"]); + }); + + it("bracket: allows puts [report_wns] when bracket verb is read-only (finding 2)", () => { + expect(isQueryCommand("puts [report_wns]")).toEqual([true, null]); + }); + + it("bracket: blocks puts [global_placement] (exec-only in bracket)", () => { + expect(isQueryCommand("puts [global_placement]")).toEqual([false, "global_placement"]); + }); + + it("semicolon in quoted string is not a statement separator (finding 3)", () => { + expect(isQueryCommand('puts "hello; world"')).toEqual([true, null]); + }); + + it("semicolon inside braces is not a statement separator (finding 3)", () => { + expect(isQueryCommand("report_checks {a; b}")).toEqual([true, null]); + }); }); // isExecCommand diff --git a/typescript/src/config/command_whitelist.ts b/typescript/src/config/command_whitelist.ts index b589772..bb5f278 100644 --- a/typescript/src/config/command_whitelist.ts +++ b/typescript/src/config/command_whitelist.ts @@ -85,17 +85,14 @@ export const EXEC_ONLY_PATTERNS: readonly string[] = [ ]; // Safe Tcl built-ins - usable in both tools. +// Intentionally excludes body-eval builtins (if, for, foreach, while, proc, +// catch, namespace, uplevel) because they accept a script body argument and +// can therefore wrap any dangerous command: `catch { exec ls }` would pass the +// verb check on `catch` alone. Those builtins belong in the exec tool. export const _TCL_BUILTINS: readonly string[] = [ "puts", "set", "expr", - "if", - "else", - "elseif", - "for", - "foreach", - "while", - "proc", "return", "break", "continue", @@ -114,9 +111,7 @@ export const _TCL_BUILTINS: readonly string[] = [ "scan", "array", "dict", - "catch", "error", - "namespace", "upvar", "global", "variable", @@ -162,17 +157,62 @@ export function extractVerb(statement: string): string | null { return firstToken.replace(/;+$/, ""); } -/** Iterate the verbs of a command, mirroring Python's naive `;`->newline split. */ +/** + * Split a Tcl command string into individual statements, respecting quoted + * strings and brace groups so that semicolons inside them are not treated as + * statement separators (e.g. `puts "hello; world"` is one statement). + */ +function splitTclStatements(command: string): string[] { + const stmts: string[] = []; + let depth = 0; + let inQuote = false; + let current = ""; + + for (let i = 0; i < command.length; i++) { + const ch = command[i]!; + if (ch === "\\" && i + 1 < command.length) { + current += ch + command[i + 1]!; + i++; + } else if (ch === '"' && depth === 0) { + inQuote = !inQuote; + current += ch; + } else if (!inQuote && ch === "{") { + depth++; + current += ch; + } else if (!inQuote && ch === "}") { + depth--; + current += ch; + } else if (!inQuote && depth === 0 && (ch === ";" || ch === "\n")) { + stmts.push(current); + current = ""; + } else { + current += ch; + } + } + if (current) stmts.push(current); + return stmts; +} + +/** + * Iterate the verbs of a Tcl command string. + * + * Two passes: + * 1. Statement verbs — the leading token of each `;`/newline-separated + * statement (Tcl-aware split that skips separators inside quotes/braces). + * 2. Bracket verbs — the word immediately following each `[` (bracket + * substitution). This catches `set x [exec ls]` where the outer verb `set` + * is safe but the substituted command `exec` is not. + */ function* iterVerbs(command: string): Generator { - // Preserve the naive splitting behavior exactly: replace ';' with newline, - // then split into lines. Semicolons inside Tcl braces or quoted strings are - // not handled - this matches the Python implementation's known limitation. - for (const rawLine of command.replace(/;/g, "\n").split("\n")) { - const verb = extractVerb(rawLine); + for (const stmt of splitTclStatements(command)) { + const verb = extractVerb(stmt); if (verb !== null) { yield verb; } } + for (const match of command.matchAll(/\[(\w+)/g)) { + yield match[1]!; + } } /** From 00b10093f878d9620b74f1ea179ea955ea172a0a Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 17 Jun 2026 17:53:36 -0600 Subject: [PATCH 37/62] terminate auto-created session when executeCommand throws so the session is not leaked --- typescript/src/tools/interactive.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/typescript/src/tools/interactive.ts b/typescript/src/tools/interactive.ts index 8433ced..7badb18 100644 --- a/typescript/src/tools/interactive.ts +++ b/typescript/src/tools/interactive.ts @@ -146,6 +146,11 @@ export class QueryShellTool extends BaseTool { ); return this.formatResult(result as unknown as Record); } catch (e) { + // Auto-created sessions must be cleaned up when executeCommand throws to + // avoid leaking the session. + if (sid === null && resolvedId !== null) { + this.manager.terminateSession(resolvedId, true).catch(() => { /* best effort */ }); + } if (e instanceof SessionNotFoundError) { return this.formatResult( sessionNotFoundExecResult( @@ -200,6 +205,9 @@ export class ExecShellTool extends BaseTool { ); return this.formatResult(result as unknown as Record); } catch (e) { + if (sid === null && resolvedId !== null) { + this.manager.terminateSession(resolvedId, true).catch(() => { /* best effort */ }); + } if (e instanceof SessionNotFoundError) { return this.formatResult( sessionNotFoundExecResult( From 6764ef9a4a765b6cb8a40607ef05f27e9e60b8bd Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 17 Jun 2026 17:54:25 -0600 Subject: [PATCH 38/62] derive wasAlive from info.isAlive and propagate it through error branches in TerminateSessionTool --- typescript/src/tools/interactive.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/typescript/src/tools/interactive.ts b/typescript/src/tools/interactive.ts index 7badb18..ba0cc99 100644 --- a/typescript/src/tools/interactive.ts +++ b/typescript/src/tools/interactive.ts @@ -306,13 +306,12 @@ export class TerminateSessionTool extends BaseTool { } async execute(sessionId: string, force = false): Promise { - let wasAlive = true; + let wasAlive = false; try { - await this.manager.getSessionInfo(sessionId); + const info = await this.manager.getSessionInfo(sessionId); + wasAlive = info.isAlive; } catch (e) { - if (e instanceof SessionNotFoundError) { - wasAlive = false; - } + if (!(e instanceof SessionNotFoundError)) throw e; } try { @@ -331,6 +330,7 @@ export class TerminateSessionTool extends BaseTool { SessionTerminationResult.parse({ sessionId, terminated: false, + wasAlive, error: String(e), }) as unknown as Record, ); @@ -339,6 +339,7 @@ export class TerminateSessionTool extends BaseTool { SessionTerminationResult.parse({ sessionId, terminated: false, + wasAlive, error: `Termination failed: ${(e as Error).message ?? String(e)}`, }) as unknown as Record, ); From 34e89619fdec7e8070f55457a0f17f05047f2c07 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 17 Jun 2026 17:58:53 -0600 Subject: [PATCH 39/62] discard queued commands and warn on terminate so pending inputs are not silently dropped --- typescript/src/interactive/session.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/typescript/src/interactive/session.ts b/typescript/src/interactive/session.ts index dce8c2c..dce4f75 100644 --- a/typescript/src/interactive/session.ts +++ b/typescript/src/interactive/session.ts @@ -1,6 +1,7 @@ import pidusage from "pidusage"; import { Mutex } from "async-mutex"; import { ANSIDecoder } from "../utils/ansi_decoder.js"; +import { getLogger } from "../utils/logging.js"; import { getSettings } from "../config/settings.js"; import type { Settings } from "../config/settings.js"; import { SessionState } from "../core/models.js"; @@ -293,6 +294,12 @@ export class InteractiveSession { await this._lifecycleLock.runExclusive(async () => { if (this._state === SessionState.TERMINATED) return; + if (this._inputQueue.length > 0) { + getLogger("session").warn( + `Session ${this.sessionId}: discarding ${this._inputQueue.length} pending command(s) on terminate`, + ); + this._inputQueue.length = 0; + } this.state = SessionState.TERMINATED; this._signalShutdown(); From 515fe1773603d0db5d3a7f8cc3407cd571c24f2a Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 17 Jun 2026 18:00:00 -0600 Subject: [PATCH 40/62] rename isAlive to checkAlive on InteractiveSession to surface its state-transition side effect --- typescript/__tests__/core/manager.test.ts | 4 ++-- typescript/__tests__/interactive/session.test.ts | 16 ++++++++-------- typescript/src/core/manager.ts | 4 ++-- typescript/src/interactive/session.ts | 16 +++++++++++----- 4 files changed, 23 insertions(+), 17 deletions(-) diff --git a/typescript/__tests__/core/manager.test.ts b/typescript/__tests__/core/manager.test.ts index e8e6338..976356f 100644 --- a/typescript/__tests__/core/manager.test.ts +++ b/typescript/__tests__/core/manager.test.ts @@ -16,7 +16,7 @@ vi.mock("../../src/interactive/session.js", () => { interface MockSession { sessionId: string; lastActivity: Date; - isAlive: Mock; + checkAlive: Mock; start: Mock; sendCommand: Mock; readOutput: Mock; @@ -47,7 +47,7 @@ function makeMockSession(sessionId: string, alive = true): MockSession { return { sessionId, lastActivity: new Date(), - isAlive: vi.fn().mockReturnValue(alive), + checkAlive: vi.fn().mockReturnValue(alive), start: vi.fn().mockResolvedValue(undefined), sendCommand: vi.fn().mockResolvedValue(undefined), readOutput: vi.fn().mockResolvedValue({ diff --git a/typescript/__tests__/interactive/session.test.ts b/typescript/__tests__/interactive/session.test.ts index 81567a0..e8acb83 100644 --- a/typescript/__tests__/interactive/session.test.ts +++ b/typescript/__tests__/interactive/session.test.ts @@ -40,7 +40,7 @@ describe("InteractiveSession", () => { expect(session.sessionId).toBe("test-session-1"); expect(session.state).toBe(SessionState.CREATING); expect(session.commandCount).toBe(0); - expect(session.isAlive()).toBe(false); + expect(session.checkAlive()).toBe(false); expect(session.pty).not.toBeNull(); expect(session.outputBuffer).not.toBeNull(); }); @@ -256,17 +256,17 @@ describe("InteractiveSession", () => { }); }); - describe("isAlive", () => { + describe("checkAlive", () => { it("returns false in CREATING state", () => { expect(session.state).toBe(SessionState.CREATING); - expect(session.isAlive()).toBe(false); + expect(session.checkAlive()).toBe(false); }); it("returns false in ACTIVE state when process is dead", () => { session.state = SessionState.ACTIVE; (mockPty.isProcessAlive as ReturnType).mockReturnValue(false); - expect(session.isAlive()).toBe(false); + expect(session.checkAlive()).toBe(false); expect(session.state).toBe(SessionState.TERMINATED); }); @@ -287,13 +287,13 @@ describe("InteractiveSession", () => { session.state = SessionState.ACTIVE; (mockPty.isProcessAlive as ReturnType).mockReturnValue(true); - expect(session.isAlive()).toBe(true); + expect(session.checkAlive()).toBe(true); expect(session.state).toBe(SessionState.ACTIVE); }); it("returns false in TERMINATED state", () => { session.state = SessionState.TERMINATED; - expect(session.isAlive()).toBe(false); + expect(session.checkAlive()).toBe(false); }); }); @@ -437,7 +437,7 @@ describe("InteractiveSession", () => { await new Promise((r) => setTimeout(r, 5)); expect(session.state).toBe(SessionState.TERMINATED); - expect(session.isAlive()).toBe(false); + expect(session.checkAlive()).toBe(false); }); it("onData data exactly at READ_CHUNK_SIZE is a single append, not sliced", async () => { @@ -542,7 +542,7 @@ describe("InteractiveSession", () => { expect(mockPty.writeInput).toHaveBeenCalled(); expect(session.state).toBe(SessionState.TERMINATED); - expect(session.isAlive()).toBe(false); + expect(session.checkAlive()).toBe(false); }); it("subsequent sendCommand throws SessionTerminatedError after writer failure", async () => { diff --git a/typescript/src/core/manager.ts b/typescript/src/core/manager.ts index f9325d7..472d9e6 100644 --- a/typescript/src/core/manager.ts +++ b/typescript/src/core/manager.ts @@ -259,7 +259,7 @@ export class OpenROADManager { private _countActive(): number { let count = 0; for (const session of this.sessions.values()) { - if (session !== null && session.isAlive()) count++; + if (session !== null && session.checkAlive()) count++; } return count; } @@ -292,7 +292,7 @@ export class OpenROADManager { const terminated: Array<[string, InteractiveSession, boolean]> = []; for (const [sessionId, session] of this._initializedSessions()) { - if (!session.isAlive()) { + if (!session.checkAlive()) { // Measure from the actual death time. lastActivity is the last command, // which for a long-idle session is far earlier and would trip the // force-cleanup timer immediately. Fall back to lastActivity only if the diff --git a/typescript/src/interactive/session.ts b/typescript/src/interactive/session.ts index dce4f75..d2c6e2c 100644 --- a/typescript/src/interactive/session.ts +++ b/typescript/src/interactive/session.ts @@ -94,7 +94,13 @@ export class InteractiveSession { return this._terminatedAt; } - isAlive(): boolean { + /** + * Check whether the session is alive, syncing state as a side effect. + * If the underlying PTY process has died since the last check, this + * transitions _state to TERMINATED and signals the writer to stop. + * Named checkAlive (not isAlive) to signal that it is not a pure predicate. + */ + checkAlive(): boolean { if (this._state === SessionState.TERMINATED) return false; const processAlive = this.pty.isProcessAlive(); @@ -169,7 +175,7 @@ export class InteractiveSession { } async sendCommand(command: string): Promise { - if (!this.isAlive()) { + if (!this.checkAlive()) { throw new SessionTerminatedError(`Session ${this.sessionId} is not active`, this.sessionId); } @@ -207,7 +213,7 @@ export class InteractiveSession { async readOutput(timeoutMs = 1000): Promise { const startTime = Date.now(); - if (!this.isAlive()) { + if (!this.checkAlive()) { // Drain-before-reject: a fast-exiting command (e.g. "exit") can flip // _state to TERMINATED between sendCommand and readOutput because // sendCommand is synchronous and the event loop runs onExit at the @@ -282,7 +288,7 @@ export class InteractiveSession { return { sessionId: this.sessionId, createdAt: this.createdAt.toISOString(), - isAlive: this.isAlive(), + isAlive: this.checkAlive(), commandCount: this.commandCount, bufferSize: this.outputBuffer.size, uptimeSeconds: uptime, @@ -465,7 +471,7 @@ export class InteractiveSession { return { session_id: this.sessionId, state: this._state, - is_alive: this.isAlive(), + is_alive: this.checkAlive(), created_at: this.createdAt.toISOString(), last_activity: this.lastActivity.toISOString(), uptime_seconds: uptimeSeconds, From b2e95cde7ad382bb66f7bb423f13d33e1ad9b50a Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 17 Jun 2026 18:00:35 -0600 Subject: [PATCH 41/62] return current memory from _updatePerformanceMetrics so getDetailedMetrics avoids a second pidusage call --- typescript/src/interactive/session.ts | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/typescript/src/interactive/session.ts b/typescript/src/interactive/session.ts index d2c6e2c..2c736c8 100644 --- a/typescript/src/interactive/session.ts +++ b/typescript/src/interactive/session.ts @@ -427,26 +427,17 @@ export class InteractiveSession { } /** Sample CPU/memory from the live process. Best-effort; silently ignores a - * dead or inaccessible PID. CPU time is cumulative (assigned, not summed). */ - private async _updatePerformanceMetrics(): Promise { + * dead or inaccessible PID. CPU time is cumulative (assigned, not summed). + * Returns the sampled current memory so callers avoid a second pidusage call. */ + private async _updatePerformanceMetrics(): Promise { const pid = this.pty.pid; - if (pid == null) return; + if (pid == null) return 0; try { const usage = await pidusage(pid); this.totalCpuTime = usage.ctime / 1000; const currentMemoryMb = Math.max(0, usage.memory) / BYTES_TO_MB; this.peakMemoryMb = Math.max(this.peakMemoryMb, currentMemoryMb); - } catch { - // Process may have exited or be inaccessible. - } - } - - private async _getCurrentMemoryMb(): Promise { - const pid = this.pty.pid; - if (pid == null) return 0; - try { - const usage = await pidusage(pid); - return Math.max(0, usage.memory) / BYTES_TO_MB; + return currentMemoryMb; } catch { return 0; } @@ -461,7 +452,7 @@ export class InteractiveSession { } async getDetailedMetrics(): Promise { - await this._updatePerformanceMetrics(); + const currentMemoryMb = await this._updatePerformanceMetrics(); const now = Date.now(); const uptimeSeconds = (now - this.createdAt.getTime()) / 1000; const idleSeconds = (now - this.lastActivity.getTime()) / 1000; @@ -484,7 +475,7 @@ export class InteractiveSession { performance: { total_cpu_time: this.totalCpuTime, peak_memory_mb: this.peakMemoryMb, - current_memory_mb: await this._getCurrentMemoryMb(), + current_memory_mb: currentMemoryMb, }, buffer: { current_size: bufferSize, From f7baf77378409d27f4bd5d8f16eca56934ff62af Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 17 Jun 2026 18:01:09 -0600 Subject: [PATCH 42/62] wrap non-force cleanup in finally so a throwing cleanup still removes the dead session from the map --- typescript/src/core/manager.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/typescript/src/core/manager.ts b/typescript/src/core/manager.ts index 472d9e6..36a67ad 100644 --- a/typescript/src/core/manager.ts +++ b/typescript/src/core/manager.ts @@ -317,9 +317,12 @@ export class OpenROADManager { cleaned++; } } else { - await session.cleanup(); - this.sessions.delete(sessionId); - cleaned++; + try { + await session.cleanup(); + } finally { + this.sessions.delete(sessionId); + cleaned++; + } } } catch (e) { this.logger.error(`Error during session ${sessionId} cleanup: ${String(e)}`); From f5ee7418ed57dd7ce068ed9bd3cd5797da312ac2 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 17 Jun 2026 18:01:16 -0600 Subject: [PATCH 43/62] snapshot session counts after the async metrics loop so the totals reflect post-loop state --- typescript/src/core/manager.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/typescript/src/core/manager.ts b/typescript/src/core/manager.ts index 36a67ad..f899fe1 100644 --- a/typescript/src/core/manager.ts +++ b/typescript/src/core/manager.ts @@ -172,10 +172,6 @@ export class OpenROADManager { async sessionMetrics(): Promise { await this._cleanupTerminatedSessionsWithLock(); - const totalSessions = this.sessions.size; - const activeSessions = this.getActiveSessionCount(); - const terminatedSessions = totalSessions - activeSessions; - const sessionDetails: SessionDetailedMetrics[] = []; let totalCommands = 0; let totalCpuTime = 0; @@ -193,6 +189,12 @@ export class OpenROADManager { } } + // Capture counts after the async loop so the snapshot reflects the final + // state rather than a stale pre-loop value. + const totalSessions = this.sessions.size; + const activeSessions = this.getActiveSessionCount(); + const terminatedSessions = totalSessions - activeSessions; + return { manager: { total_sessions: totalSessions, From b075545168d928bfde57ba0a9eb73272158a1f26 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 17 Jun 2026 18:01:23 -0600 Subject: [PATCH 44/62] remove dead session loop in cleanupAll since terminateAllSessions already empties the map --- typescript/src/core/manager.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/typescript/src/core/manager.ts b/typescript/src/core/manager.ts index f899fe1..a84c539 100644 --- a/typescript/src/core/manager.ts +++ b/typescript/src/core/manager.ts @@ -231,20 +231,7 @@ export class OpenROADManager { async cleanupAll(): Promise { this.logger.info("Starting OpenROAD cleanup"); - await this.terminateAllSessions(true); - - await this.cleanupLock.runExclusive(async () => { - for (const [, session] of this._initializedSessions()) { - try { - await session.cleanup(); - } catch (e) { - this.logger.warn(`Error during session cleanup: ${String(e)}`); - } - } - this.sessions.clear(); - }); - this.logger.info("OpenROAD cleanup completed"); } From e1a3eb132e8ded5d83c72e71ae3818bff8739c54 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 17 Jun 2026 18:53:45 -0600 Subject: [PATCH 45/62] call terminateProcess with force=true in cleanup so a stuck process is not retried with SIGTERM --- typescript/src/interactive/pty_handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/typescript/src/interactive/pty_handler.ts b/typescript/src/interactive/pty_handler.ts index e66f907..ade87b2 100644 --- a/typescript/src/interactive/pty_handler.ts +++ b/typescript/src/interactive/pty_handler.ts @@ -189,7 +189,7 @@ export class PtyHandler { async cleanup(): Promise { if (this._alive) { try { - await this.terminateProcess(); + await this.terminateProcess(true); } catch { // Best effort - don't let terminate errors prevent state reset } From 047aa36da488d0ece0d9e5e18bd6c5b3f6ebeba2 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 17 Jun 2026 18:54:00 -0600 Subject: [PATCH 46/62] guard against null image dimensions before resize so missing metadata does not produce a silent 256x256 output --- typescript/src/tools/report_images.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/typescript/src/tools/report_images.ts b/typescript/src/tools/report_images.ts index 8daf037..2be681b 100644 --- a/typescript/src/tools/report_images.ts +++ b/typescript/src/tools/report_images.ts @@ -191,8 +191,11 @@ async function loadAndCompressImage( const targetBytes = Math.floor((maxSizeKb * 1024 * 3) / 4); const scale = Math.sqrt(targetBytes / originalSize); const meta = await sharp(imagePath).metadata(); - const origW = meta.width ?? 0; - const origH = meta.height ?? 0; + if (!meta.width || !meta.height) { + throw new Error("Image dimensions unavailable"); + } + const origW = meta.width; + const origH = meta.height; const newW = Math.max(Math.round(origW * scale), 256); const newH = Math.max(Math.round(origH * scale), 256); const compressed = await sharp(imagePath) From f2892dfbb01c090b0b37ba47ad52cc23acc7813e Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 17 Jun 2026 18:55:56 -0600 Subject: [PATCH 47/62] rename CommandRecord.id to command_number so the schema matches the live CommandHistoryEntry field --- typescript/src/core/models.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/typescript/src/core/models.ts b/typescript/src/core/models.ts index 9241e03..1703116 100644 --- a/typescript/src/core/models.ts +++ b/typescript/src/core/models.ts @@ -111,7 +111,7 @@ const errorField = z.string().nullable().default(null); export const CommandRecord = z.object({ command: z.string(), timestamp: z.string(), - id: z.number(), + command_number: z.number(), }); export type CommandRecord = z.infer; From 41ab077a988557fbd1824da0a63e5ebc376876cb Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 17 Jun 2026 19:23:50 -0600 Subject: [PATCH 48/62] update integration_check to call checkAlive instead of the renamed isAlive --- typescript/scripts/integration_check.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/typescript/scripts/integration_check.ts b/typescript/scripts/integration_check.ts index ce61330..96a06e9 100644 --- a/typescript/scripts/integration_check.ts +++ b/typescript/scripts/integration_check.ts @@ -38,7 +38,7 @@ async function run() { await session.start(["openroad", "-no_init"]); check("start() succeeds", true); check("state is ACTIVE after start", session.state === "active", session.state); - check("isAlive() returns true", session.isAlive()); + check("isAlive() returns true", session.checkAlive()); check("writer task running", session.isRunning()); } catch (e) { check("start() succeeds", false, String(e)); @@ -98,7 +98,7 @@ async function run() { await session.cleanup(); check("cleanup() does not throw", true); check("state is TERMINATED after cleanup", session.state === "terminated", session.state); - check("isAlive() returns false after cleanup", !session.isAlive()); + check("isAlive() returns false after cleanup", !session.checkAlive()); // ── Summary ───────────────────────────────────────────────────────────────── const passed = results.filter((r) => r.ok).length; From 46aa3be30a2afa5b911a74deda0d5bb1713c7eee Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 17 Jun 2026 19:23:57 -0600 Subject: [PATCH 49/62] extend bracket-scan regex to match [::exec ls] so namespace-qualified commands are not exempt --- typescript/__tests__/config/command_whitelist.test.ts | 4 ++++ typescript/src/config/command_whitelist.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/typescript/__tests__/config/command_whitelist.test.ts b/typescript/__tests__/config/command_whitelist.test.ts index 178b18d..cf5fd57 100644 --- a/typescript/__tests__/config/command_whitelist.test.ts +++ b/typescript/__tests__/config/command_whitelist.test.ts @@ -218,6 +218,10 @@ describe("isQueryCommand", () => { expect(isQueryCommand("set x [exec ls]")).toEqual([false, "exec"]); }); + it("bracket: blocks set x [::exec ls] with namespace-qualified command", () => { + expect(isQueryCommand("set x [::exec ls]")).toEqual([false, "exec"]); + }); + it("bracket: blocks expr {[exec ls]} via bracket scan (finding 2)", () => { expect(isQueryCommand("expr {[exec ls]}")).toEqual([false, "exec"]); }); diff --git a/typescript/src/config/command_whitelist.ts b/typescript/src/config/command_whitelist.ts index bb5f278..553b0eb 100644 --- a/typescript/src/config/command_whitelist.ts +++ b/typescript/src/config/command_whitelist.ts @@ -210,7 +210,7 @@ function* iterVerbs(command: string): Generator { yield verb; } } - for (const match of command.matchAll(/\[(\w+)/g)) { + for (const match of command.matchAll(/\[\s*(?::+)?(\w+)/g)) { yield match[1]!; } } From 23b020bf9ae9f80622bbdf51d154312668939dca Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 17 Jun 2026 19:52:43 -0600 Subject: [PATCH 50/62] code cleanup --- .../config/command_whitelist.test.ts | 13 -- .../__tests__/interactive/buffer.test.ts | 8 +- .../__tests__/interactive/session.test.ts | 13 +- .../__tests__/tools/interactive.test.ts | 45 +------ .../__tests__/tools/report_images.test.ts | 52 +------- typescript/scripts/integration_check.ts | 25 +--- typescript/src/config/command_whitelist.ts | 122 +++++------------- typescript/src/config/settings.ts | 11 -- typescript/src/constants.ts | 14 +- typescript/src/core/manager.ts | 41 +++--- typescript/src/core/models.ts | 23 +--- typescript/src/interactive/buffer.ts | 10 +- typescript/src/interactive/pty_handler.ts | 19 ++- typescript/src/interactive/session.ts | 71 +++++----- typescript/src/main.ts | 5 +- typescript/src/tools/base.ts | 15 +-- typescript/src/tools/interactive.ts | 35 +---- typescript/src/tools/report_images.ts | 44 +------ typescript/src/utils/ansi_decoder.ts | 30 ++--- typescript/src/utils/logging.ts | 21 +-- typescript/src/utils/path_security.ts | 8 +- 21 files changed, 158 insertions(+), 467 deletions(-) diff --git a/typescript/__tests__/config/command_whitelist.test.ts b/typescript/__tests__/config/command_whitelist.test.ts index cf5fd57..6451e78 100644 --- a/typescript/__tests__/config/command_whitelist.test.ts +++ b/typescript/__tests__/config/command_whitelist.test.ts @@ -9,8 +9,6 @@ import { isQueryCommand, } from "../../src/config/command_whitelist.js"; -// extractVerb - describe("extractVerb", () => { it("returns a simple command", () => { expect(extractVerb("report_checks")).toBe("report_checks"); @@ -53,8 +51,6 @@ describe("extractVerb", () => { }); }); -// Pattern set membership - describe("pattern sets", () => { it("READONLY contains report/get/check globs", () => { expect(READONLY_PATTERNS).toContain("report_*"); @@ -119,8 +115,6 @@ describe("pattern sets", () => { }); }); -// isQueryCommand - describe("isQueryCommand", () => { it("allows report_*", () => { expect(isQueryCommand("report_checks -path_delay max")).toEqual([true, null]); @@ -243,8 +237,6 @@ describe("isQueryCommand", () => { }); }); -// isExecCommand - describe("isExecCommand", () => { it("allows set_clock_period", () => { expect(isExecCommand("set_clock_period -name clk 2.0")).toEqual([true, null]); @@ -306,8 +298,6 @@ describe("isExecCommand", () => { }); }); -// isCommandAllowed (backward-compat alias) - describe("isCommandAllowed", () => { it("mirrors isExecCommand for allowed commands", () => { expect(isCommandAllowed("report_checks -path_delay max")).toEqual([true, null]); @@ -328,18 +318,15 @@ describe("isCommandAllowed", () => { }); }); -// minimatch vs fnmatch parity describe("glob parity (minimatch vs fnmatch)", () => { it("matches star-suffix against the empty remainder", () => { - // report_ / set_ / read_ match report_* / set_* / read_* (star matches empty) expect(isQueryCommand("report_")).toEqual([true, null]); expect(isExecCommand("set_")).toEqual([true, null]); expect(isExecCommand("read_")).toEqual([true, null]); }); it("is case-sensitive like POSIX fnmatch on verbs", () => { - // Report_Checks (capitalized) does not match report_* and is unknown -> blocked in query expect(isQueryCommand("Report_Checks")).toEqual([false, "Report_Checks"]); }); }); diff --git a/typescript/__tests__/interactive/buffer.test.ts b/typescript/__tests__/interactive/buffer.test.ts index b3ca8fa..ea0a4d6 100644 --- a/typescript/__tests__/interactive/buffer.test.ts +++ b/typescript/__tests__/interactive/buffer.test.ts @@ -143,9 +143,9 @@ describe("CircularBuffer", () => { // Start waitForData (fast-path sees _dataAvailable = false and enters the Promise) const waiter = buf.waitForData(5000); - // append() fires here — before runExclusive's callback has a chance to push - // wakeUp into _resolvers. Without the re-check inside runExclusive, wakeUp - // would be pushed after append() already drained an empty _resolvers, and + // append() fires before runExclusive's callback can push wakeUp into + // _resolvers. Without the re-check inside runExclusive, wakeUp would + // land in _resolvers after append() already drained an empty list, and // the caller would wait the full 5-second timeout. await buf.append("raced data"); @@ -168,7 +168,7 @@ describe("CircularBuffer", () => { it("wakes pending waitForData() immediately with false so callers do not hang", async () => { const buf = new CircularBuffer(100); - // waitForData with a large timeout — clear() must unblock it before the timeout fires + // Large timeout: clear() must unblock waitForData before it fires. const waiter = buf.waitForData(5000); await buf.clear(); diff --git a/typescript/__tests__/interactive/session.test.ts b/typescript/__tests__/interactive/session.test.ts index e8acb83..ce6a575 100644 --- a/typescript/__tests__/interactive/session.test.ts +++ b/typescript/__tests__/interactive/session.test.ts @@ -217,7 +217,6 @@ describe("InteractiveSession", () => { await session.outputBuffer.append("% Exiting OpenROAD\r\n"); session.state = SessionState.TERMINATED; - // Must NOT throw even though the session is terminated const result = await session.readOutput(100); expect(result.output).toContain("Exiting OpenROAD"); @@ -227,8 +226,8 @@ describe("InteractiveSession", () => { it("signals shutdown when readOutput detects terminated session so writer task does not loop indefinitely", async () => { // Spy on the private method to verify readOutput() calls it directly. - // Scenario: _state was flipped to TERMINATED externally (e.g. via the setter) - // without calling _signalShutdown() — the exact gap @luarss identified. + // Scenario: _state was flipped to TERMINATED externally (e.g. via the + // setter) without calling _signalShutdown(). const signalShutdown = vi.spyOn(session as unknown as { _signalShutdown: () => void }, "_signalShutdown"); session.state = SessionState.TERMINATED; @@ -327,12 +326,11 @@ describe("InteractiveSession", () => { it("calls pty.cleanup() so listeners and pending resolvers are disposed without a subsequent session.cleanup()", async () => { session.state = SessionState.ACTIVE; - // terminate() without any follow-up cleanup() call await session.terminate(false); - // pty.cleanup() must have been called to dispose _dataDisposable, - // _exitDisposable, and drain _exitResolvers — otherwise post-kill - // data bursts keep appending and waitForExit() callers hang forever + // pty.cleanup() must run to dispose _dataDisposable, _exitDisposable, + // and drain _exitResolvers; otherwise post-kill data bursts keep + // appending and waitForExit() callers hang forever. expect(mockPty.cleanup).toHaveBeenCalledOnce(); }); }); @@ -420,7 +418,6 @@ describe("InteractiveSession", () => { await session.start(["echo"]); session.state = SessionState.TERMINATED; - // Should not throw or double-signal shutdown capturedOnExit?.(0); expect(session.state).toBe(SessionState.TERMINATED); }); diff --git a/typescript/__tests__/tools/interactive.test.ts b/typescript/__tests__/tools/interactive.test.ts index bf1ef74..4db43df 100644 --- a/typescript/__tests__/tools/interactive.test.ts +++ b/typescript/__tests__/tools/interactive.test.ts @@ -6,10 +6,6 @@ import { SessionNotFoundError, SessionTerminatedError, SessionError } from "../. import { SessionState } from "../../src/core/models.js"; import type { InteractiveExecResult, InteractiveSessionInfo, SessionDetailedMetrics, ManagerMetrics } from "../../src/core/models.js"; -// --------------------------------------------------------------------------- -// Mock helpers -// --------------------------------------------------------------------------- - const NOW = "2024-01-01T00:00:00.000Z"; function makeExecResult(overrides: Partial = {}): InteractiveExecResult { @@ -87,10 +83,6 @@ function makeMockManager(): MockManager { }; } -// --------------------------------------------------------------------------- -// QueryShellTool -// --------------------------------------------------------------------------- - describe("QueryShellTool", () => { let mgr: MockManager; let tool: QueryShellTool; @@ -149,7 +141,6 @@ describe("QueryShellTool", () => { }); it("blocks dangerous commands when whitelist is enabled", async () => { - // `quit` is in BLOCKED_COMMANDS const raw = await tool.execute("quit"); const result = JSON.parse(raw); expect(result.error).toMatch(/CommandBlocked/); @@ -161,10 +152,6 @@ describe("QueryShellTool", () => { }); }); -// --------------------------------------------------------------------------- -// ExecShellTool -// --------------------------------------------------------------------------- - describe("ExecShellTool", () => { let mgr: MockManager; let tool: ExecShellTool; @@ -195,10 +182,6 @@ describe("ExecShellTool", () => { }); }); -// --------------------------------------------------------------------------- -// ListSessionsTool -// --------------------------------------------------------------------------- - describe("ListSessionsTool", () => { let mgr: MockManager; let tool: ListSessionsTool; @@ -238,10 +221,6 @@ describe("ListSessionsTool", () => { }); }); -// --------------------------------------------------------------------------- -// CreateSessionTool -// --------------------------------------------------------------------------- - describe("CreateSessionTool", () => { let mgr: MockManager; let tool: CreateSessionTool; @@ -278,10 +257,6 @@ describe("CreateSessionTool", () => { }); }); -// --------------------------------------------------------------------------- -// TerminateSessionTool -// --------------------------------------------------------------------------- - describe("TerminateSessionTool", () => { let mgr: MockManager; let tool: TerminateSessionTool; @@ -325,10 +300,6 @@ describe("TerminateSessionTool", () => { }); }); -// --------------------------------------------------------------------------- -// InspectSessionTool -// --------------------------------------------------------------------------- - describe("InspectSessionTool", () => { let mgr: MockManager; let tool: InspectSessionTool; @@ -363,10 +334,6 @@ describe("InspectSessionTool", () => { }); }); -// --------------------------------------------------------------------------- -// SessionHistoryTool -// --------------------------------------------------------------------------- - describe("SessionHistoryTool", () => { let mgr: MockManager; let tool: SessionHistoryTool; @@ -410,10 +377,6 @@ describe("SessionHistoryTool", () => { }); }); -// --------------------------------------------------------------------------- -// SessionMetricsTool -// --------------------------------------------------------------------------- - describe("SessionMetricsTool", () => { let mgr: MockManager; let tool: SessionMetricsTool; @@ -440,12 +403,8 @@ describe("SessionMetricsTool", () => { }); }); -// --------------------------------------------------------------------------- -// Integration: full workflow -// --------------------------------------------------------------------------- - describe("Integration: session workflow", () => { - it("create → execute → list → terminate", async () => { + it("create, execute, list, terminate", async () => { const mgr = makeMockManager(); mgr.listSessions.mockResolvedValue([makeSessionInfo()]); @@ -476,9 +435,7 @@ describe("Integration: session workflow", () => { }); }); -// --------------------------------------------------------------------------- // Snapshot: one representative output per tool -// --------------------------------------------------------------------------- describe("Snapshots: wire format stability", () => { it("QueryShellTool success output", async () => { diff --git a/typescript/__tests__/tools/report_images.test.ts b/typescript/__tests__/tools/report_images.test.ts index 1485588..44a41c6 100644 --- a/typescript/__tests__/tools/report_images.test.ts +++ b/typescript/__tests__/tools/report_images.test.ts @@ -10,10 +10,7 @@ import { } from "../../src/tools/report_images.js"; import type { OpenROADManager } from "../../src/core/manager.js"; -// --------------------------------------------------------------------------- -// Mock getSettings so tests don't depend on the filesystem ORFS installation -// --------------------------------------------------------------------------- - +// Mock getSettings so tests do not depend on a filesystem ORFS install. vi.mock("../../src/config/settings.js", () => { let mockFlowPath = "/mock/flow"; let mockPlatforms: string[] = []; @@ -47,10 +44,6 @@ vi.mock("../../src/config/settings.js", () => { import { getSettings } from "../../src/config/settings.js"; -// --------------------------------------------------------------------------- -// Fixture helpers -// --------------------------------------------------------------------------- - let tmpDir: string; function createFixture( @@ -60,10 +53,8 @@ function createFixture( imageFiles: string[] = ["cts_clk.webp", "final_all.webp"], ) { const flowPath = tmpDir; - // Settings directories (used by platforms / designs accessors) fs.mkdirSync(path.join(flowPath, "platforms", platform), { recursive: true }); fs.mkdirSync(path.join(flowPath, "designs", platform, design), { recursive: true }); - // Reports directory const runPath = path.join(flowPath, "reports", platform, design, runSlug); fs.mkdirSync(runPath, { recursive: true }); for (const img of imageFiles) { @@ -72,11 +63,9 @@ function createFixture( return { flowPath, runPath }; } -// Stub manager (tools don't call manager methods directly, but constructor requires it) +// Constructor requires a manager but the tools never invoke it. const stubManager = {} as unknown as OpenROADManager; -// --------------------------------------------------------------------------- - beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openroad-test-")); }); @@ -86,10 +75,6 @@ afterEach(() => { vi.clearAllMocks(); }); -// --------------------------------------------------------------------------- -// classifyImageType -// --------------------------------------------------------------------------- - describe("classifyImageType", () => { it("classifies CTS images correctly", () => { expect(classifyImageType("cts_clk.webp")).toEqual(["cts", "clock_visualization"]); @@ -114,10 +99,6 @@ describe("classifyImageType", () => { }); }); -// --------------------------------------------------------------------------- -// validatePlatformDesign -// --------------------------------------------------------------------------- - describe("validatePlatformDesign", () => { it("throws on unknown platform", () => { (getSettings as ReturnType).mockReturnValueOnce({ @@ -138,10 +119,6 @@ describe("validatePlatformDesign", () => { }); }); -// --------------------------------------------------------------------------- -// ListReportImagesTool -// --------------------------------------------------------------------------- - describe("ListReportImagesTool", () => { let tool: ListReportImagesTool; @@ -174,7 +151,7 @@ describe("ListReportImagesTool", () => { }); it("returns totalImages 0 when run directory has no .webp files", async () => { - const { flowPath, runPath } = createFixture("nangate45", "gcd", "run-empty", []); + const { flowPath } = createFixture("nangate45", "gcd", "run-empty", []); (getSettings as ReturnType).mockReturnValue({ platforms: ["nangate45"], designs: (p: string) => (p === "nangate45" ? ["gcd"] : []), @@ -222,10 +199,6 @@ describe("ListReportImagesTool", () => { }); }); -// --------------------------------------------------------------------------- -// ReadReportImageTool -// --------------------------------------------------------------------------- - describe("ReadReportImageTool", () => { let tool: ReadReportImageTool; @@ -281,13 +254,10 @@ describe("ReadReportImageTool", () => { }); const raw = await tool.execute("nangate45", "gcd", "run-123", "cts_clk.webp"); const result = JSON.parse(raw); - // Should have image_data as a base64 string expect(typeof result.image_data).toBe("string"); expect(result.image_data.length).toBeGreaterThan(0); - // Round-trip check const decoded = Buffer.from(result.image_data, "base64"); expect(decoded.length).toBeGreaterThan(0); - // Metadata presence expect(result.metadata).toBeTruthy(); expect(result.metadata.filename).toBe("cts_clk.webp"); expect(result.metadata.stage).toBe("cts"); @@ -296,7 +266,6 @@ describe("ReadReportImageTool", () => { it("returns FileTooLarge error when image exceeds 50 MB", async () => { const { flowPath, runPath } = createFixture("nangate45", "gcd", "run-123", []); - // Write a "file" that appears to be 51 MB by mocking statSync const bigPath = path.join(runPath, "huge.webp"); fs.writeFileSync(bigPath, Buffer.from("tiny content")); (getSettings as ReturnType).mockReturnValue({ @@ -330,10 +299,6 @@ describe("ReadReportImageTool", () => { }); }); -// --------------------------------------------------------------------------- -// TestPathTraversalSecurity -// --------------------------------------------------------------------------- - describe("TestPathTraversalSecurity", () => { let tool: ListReportImagesTool; let readTool: ReadReportImageTool; @@ -390,27 +355,22 @@ describe("TestPathTraversalSecurity", () => { }); it("blocks symlink escape from run directory", async () => { - // Create a symlink inside run-123 that points outside const runPath = path.join(flowPath, "reports", "nangate45", "gcd", "run-123"); const linkPath = path.join(runPath, "escape.webp"); try { fs.symlinkSync("/etc/passwd", linkPath); } catch { - // symlink creation may fail in some environments — skip gracefully + // symlink creation may fail in some environments; skip gracefully. return; } const raw = await readTool.execute("nangate45", "gcd", "run-123", "escape.webp"); const result = JSON.parse(raw); - // Should either not find the image, reject path containment, or return an error - // — but must NOT return valid image_data that resolves to /etc/passwd content + // Should not find the image, reject path containment, or return an error + // and must NOT return valid image_data resolving to /etc/passwd content. expect(result.image_data === null || result.error !== null).toBe(true); }); }); -// --------------------------------------------------------------------------- -// TestPlatformDesignValidationInTools -// --------------------------------------------------------------------------- - describe("TestPlatformDesignValidationInTools", () => { beforeEach(() => { (getSettings as ReturnType).mockReturnValue({ diff --git a/typescript/scripts/integration_check.ts b/typescript/scripts/integration_check.ts index 96a06e9..d162fb1 100644 --- a/typescript/scripts/integration_check.ts +++ b/typescript/scripts/integration_check.ts @@ -1,18 +1,13 @@ -/** - * Real OpenROAD REPL integration check. - * Run with: npx tsx scripts/integration_check.ts - */ - import { InteractiveSession } from "../src/interactive/session.js"; import { Settings } from "../src/config/settings.js"; -const PASS = "✓"; -const FAIL = "✗"; +const PASS = "PASS"; +const FAIL = "FAIL"; const results: { label: string; ok: boolean; detail?: string }[] = []; function check(label: string, ok: boolean, detail?: string) { results.push({ label, ok, detail }); - console.log(` ${ok ? PASS : FAIL} ${label}${detail ? ` → ${detail}` : ""}`); + console.log(` ${ok ? PASS : FAIL} ${label}${detail ? ` -> ${detail}` : ""}`); } async function waitForPrompt(session: InteractiveSession, timeoutMs = 5000): Promise { @@ -32,20 +27,18 @@ async function run() { const settings = new Settings({ ENABLE_COMMAND_VALIDATION: false }); const session = new InteractiveSession("integration-check", 256 * 1024, settings); - // ── 1. Spawn ──────────────────────────────────────────────────────────────── console.log("1. Session lifecycle"); try { await session.start(["openroad", "-no_init"]); check("start() succeeds", true); check("state is ACTIVE after start", session.state === "active", session.state); - check("isAlive() returns true", session.checkAlive()); + check("checkAlive() returns true", session.checkAlive()); check("writer task running", session.isRunning()); } catch (e) { check("start() succeeds", false, String(e)); process.exit(1); } - // ── 2. Initial prompt ─────────────────────────────────────────────────────── console.log("\n2. Initial prompt"); const banner = await waitForPrompt(session, 6000); check("received output after spawn", banner.length > 0, `${banner.length} chars`); @@ -55,7 +48,6 @@ async function run() { banner.slice(0, 80).replace(/\n/g, " "), ); - // ── 3. puts echo ──────────────────────────────────────────────────────────── console.log("\n3. Command round-trip"); await session.sendCommand('puts "hello_integration"'); const echoResult = await session.readOutput(3000); @@ -67,7 +59,6 @@ async function run() { ); check("commandCount incremented", session.commandCount >= 1, String(session.commandCount)); - // ── 4. Error detection ────────────────────────────────────────────────────── console.log("\n4. Error detection"); await session.sendCommand("nonexistent_command_xyz"); const errResult = await session.readOutput(3000); @@ -77,7 +68,6 @@ async function run() { errResult.error ?? "(null)", ); - // ── 5. Multiple commands ──────────────────────────────────────────────────── console.log("\n5. Multiple sequential commands"); const before = session.commandCount; await session.sendCommand('puts "cmd1"'); @@ -86,24 +76,21 @@ async function run() { await session.readOutput(1000); check("commandCount advances correctly", session.commandCount === before + 2, String(session.commandCount)); - // ── 6. Buffer ─────────────────────────────────────────────────────────────── console.log("\n6. Output buffer"); const stats = await session.outputBuffer.getStats(); check("buffer maxSize is set", stats.maxSize > 0, `${stats.maxSize} chars`); - // ── 7. Graceful termination ───────────────────────────────────────────────── console.log("\n7. Termination"); await session.sendCommand("exit"); await new Promise((r) => setTimeout(r, 500)); await session.cleanup(); check("cleanup() does not throw", true); check("state is TERMINATED after cleanup", session.state === "terminated", session.state); - check("isAlive() returns false after cleanup", !session.checkAlive()); + check("checkAlive() returns false after cleanup", !session.checkAlive()); - // ── Summary ───────────────────────────────────────────────────────────────── const passed = results.filter((r) => r.ok).length; const total = results.length; - console.log(`\n${"─".repeat(48)}`); + console.log(`\n${"-".repeat(48)}`); console.log(` ${passed}/${total} checks passed`); if (passed < total) { console.log(`\n Failed:`); diff --git a/typescript/src/config/command_whitelist.ts b/typescript/src/config/command_whitelist.ts index 553b0eb..87c1fd1 100644 --- a/typescript/src/config/command_whitelist.ts +++ b/typescript/src/config/command_whitelist.ts @@ -1,25 +1,14 @@ /** * Command filter for OpenROAD PTY session security. * - * Prevents execution of dangerous OS-level Tcl commands by AI agents. - * * Three-tier design: + * BLOCKED_COMMANDS - denied in both tools (OS-level Tcl built-ins). + * EXEC_ONLY_PATTERNS - state-modifying; denied in query, allowed in exec. + * READONLY_PATTERNS - safe read-only commands; allowed in both. + * Unknown commands - treated as exec-only. * - * BLOCKED_COMMANDS - denied in both tools (OS-level Tcl built-ins that can - * escape the EDA sandbox) - * - * EXEC_ONLY_PATTERNS - explicitly known state-modifying commands; denied in - * the query tool, allowed in the exec tool - * - * READONLY_PATTERNS - explicitly known safe read-only commands; allowed in - * both tools - * - * Unknown commands - treated as exec-only: denied in the query tool, - * allowed in the exec tool (they will fail at the Tcl - * level if invalid) - * - * This is distinct from PtyHandler.validateCommand(), which guards the shell - * binary/args. This module guards the Tcl statements sent to the REPL. + * Distinct from PtyHandler.validateCommand(), which guards the shell binary. + * This module guards the Tcl statements sent to the REPL. */ import { minimatch } from "minimatch"; @@ -27,48 +16,38 @@ import { getLogger } from "../utils/logging.js"; const logger = getLogger("command_whitelist"); -// Python uses fnmatch.fnmatch, which (unlike default glob) does not special-case -// a leading dot. `dot: true` makes minimatch's `*` match leading dots too, so -// single-token verb matching stays faithful to the Python implementation. const MINIMATCH_OPTS = { dot: true } as const; function matchVerb(verb: string, pattern: string): boolean { return minimatch(verb, pattern, MINIMATCH_OPTS); } -// Blocked commands - denied in both query and exec tools. export const BLOCKED_COMMANDS: ReadonlySet = new Set([ - "quit", // Terminate the OpenROAD process (ORFS uses exit instead) - "socket", // Network connections - "load", // Load compiled C extensions into the interpreter - "glob", // Filesystem enumeration - "fconfigure", // I/O channel configuration - "chan", // Channel operations - "vwait", // Block the event loop - "rename", // Renames/removes commands, can bypass top-level checks - "after", // Schedules arbitrary code execution - "subst", // Performs substitutions that can invoke arbitrary commands + "quit", + "socket", + "load", + "glob", + "fconfigure", + "chan", + "vwait", + "rename", + "after", + "subst", ]); -// Exec-only commands - denied in the query, allowed in the exec tool. -// Unknown commands are implicitly exec-only and do not need to appear here. export const EXEC_ONLY_PATTERNS: readonly string[] = [ - // ORFS file and process operations - "exec", // Run external tools (Yosys, KLayout, Python helpers) - "source", // Load Tcl scripts (primary ORFS script-loading mechanism) - "exit", // Process exit (used in ORFS error handlers) - "open", // Open file handles (reports, SDC files, metrics) - "close", // Close file handles - "file", // Filesystem ops: mkdir, delete, link, copy - "cd", // Change working directory (used in platform setup scripts) - "uplevel", // Evaluate in parent stack frame (used by ORFS log_cmd) - // OpenROAD constraints / design setup + "exec", + "source", + "exit", + "open", + "close", + "file", + "cd", + "uplevel", "set_*", "create_*", - // File I/O through OpenROAD wrappers "read_*", "write_*", - // OpenROAD flow commands "initialize_floorplan", "place_pins", "global_placement", @@ -79,16 +58,14 @@ export const EXEC_ONLY_PATTERNS: readonly string[] = [ "repair_design", "repair_timing", "repair_clock_nets", - // OpenROAD utility "log_begin", "log_end", ]; -// Safe Tcl built-ins - usable in both tools. // Intentionally excludes body-eval builtins (if, for, foreach, while, proc, // catch, namespace, uplevel) because they accept a script body argument and -// can therefore wrap any dangerous command: `catch { exec ls }` would pass the -// verb check on `catch` alone. Those builtins belong in the exec tool. +// can wrap any dangerous command: `catch { exec ls }` would pass the verb +// check on `catch` alone. export const _TCL_BUILTINS: readonly string[] = [ "puts", "set", @@ -124,30 +101,17 @@ export const _TCL_BUILTINS: readonly string[] = [ "unset", ]; -// Read-only OpenROAD command patterns - allowed in the query tool. export const READONLY_PATTERNS: readonly string[] = [ - // OpenROAD reporting "report_*", - // OpenROAD design queries "get_*", - // OpenROAD validation "check_*", - // OpenROAD analysis "estimate_parasitics", "sta", - // OpenROAD utility "help", "version", ..._TCL_BUILTINS, ]; -/** - * Return the command verb (first token) of a single Tcl statement. - * - * Returns null only for blank lines and comment lines. Lines that start with a - * substitution or grouping character (`$`, `[`, `]`, `{`, `}`) are returned - * as-is so the caller can reject them via the allowlist. - */ export function extractVerb(statement: string): string | null { const stripped = statement.trim(); if (stripped === "" || stripped.startsWith("#")) { @@ -157,11 +121,6 @@ export function extractVerb(statement: string): string | null { return firstToken.replace(/;+$/, ""); } -/** - * Split a Tcl command string into individual statements, respecting quoted - * strings and brace groups so that semicolons inside them are not treated as - * statement separators (e.g. `puts "hello; world"` is one statement). - */ function splitTclStatements(command: string): string[] { const stmts: string[] = []; let depth = 0; @@ -194,14 +153,9 @@ function splitTclStatements(command: string): string[] { } /** - * Iterate the verbs of a Tcl command string. - * - * Two passes: - * 1. Statement verbs — the leading token of each `;`/newline-separated - * statement (Tcl-aware split that skips separators inside quotes/braces). - * 2. Bracket verbs — the word immediately following each `[` (bracket - * substitution). This catches `set x [exec ls]` where the outer verb `set` - * is safe but the substituted command `exec` is not. + * Iterate verbs of a Tcl command. Two passes: statement verbs, then bracket + * substitution verbs. The bracket pass catches `set x [exec ls]` where the + * outer verb is safe but the substituted command is not. */ function* iterVerbs(command: string): Generator { for (const stmt of splitTclStatements(command)) { @@ -215,13 +169,6 @@ function* iterVerbs(command: string): Generator { } } -/** - * Check whether `command` is safe for the read-only query tool. - * - * A verb is allowed only when it matches READONLY_PATTERNS and is not in - * BLOCKED_COMMANDS. Commands in EXEC_ONLY_PATTERNS and unknown commands are - * both treated as exec-only and are rejected here. - */ export function isQueryCommand(command: string): [boolean, string | null] { for (const verb of iterVerbs(command)) { if (BLOCKED_COMMANDS.has(verb)) { @@ -242,13 +189,6 @@ export function isQueryCommand(command: string): [boolean, string | null] { return [true, null]; } -/** - * Check whether `command` is safe for the state-modifying exec tool. - * - * Blocks only BLOCKED_COMMANDS (OS-level danger). All other commands - - * including EXEC_ONLY_PATTERNS, READONLY_PATTERNS, and unknown ones - are - * allowed; they will fail at the Tcl level if invalid. - */ export function isExecCommand(command: string): [boolean, string | null] { for (const verb of iterVerbs(command)) { if (BLOCKED_COMMANDS.has(verb)) { @@ -260,10 +200,6 @@ export function isExecCommand(command: string): [boolean, string | null] { return [true, null]; } -/** - * Check `command` against BLOCKED_COMMANDS only (allow-by-default). - * Equivalent to isExecCommand. Kept for backward compatibility. - */ export function isCommandAllowed(command: string): [boolean, string | null] { return isExecCommand(command); } diff --git a/typescript/src/config/settings.ts b/typescript/src/config/settings.ts index c48b383..30f368b 100644 --- a/typescript/src/config/settings.ts +++ b/typescript/src/config/settings.ts @@ -18,8 +18,6 @@ function parseBool(envKey: string, val: string): boolean { function parseFloat_(envKey: string, val: string): number { if (val.trim() === "") throw new Error(`Invalid value for ${envKey}: (empty string). Expected float.`); const n = Number(val); - // Reject NaN and Infinity: an infinite timeout/delay would disable the very - // limit it configures, and a negative duration is never meaningful here. if (!Number.isFinite(n) || n < 0) { throw new Error(`Invalid value for ${envKey}: ${val}. Expected a non-negative finite float.`); } @@ -30,8 +28,6 @@ function parseInt_(envKey: string, val: string): number { if (val.trim() === "") throw new Error(`Invalid value for ${envKey}: (empty string). Expected int.`); if (!/^-?\d+$/.test(val.trim())) throw new Error(`Invalid value for ${envKey}: ${val}. Expected int.`); const n = Number(val); - // Every integer setting (session/buffer/queue limits) must be non-negative; a - // negative value such as MAX_SESSIONS=-1 would block all session creation. if (n < 0) throw new Error(`Invalid value for ${envKey}: ${val}. Expected a non-negative integer.`); return n; } @@ -94,7 +90,6 @@ export class Settings { } static fromEnv(): Settings { - // Mutable partial — strips readonly so we can build the object incrementally. const overrides: { -readonly [K in keyof Settings]?: Settings[K] } = {}; const floatFields: Array<[keyof Settings, string]> = [ @@ -149,11 +144,6 @@ export class Settings { let _cachedSettings: Settings | null = null; -/** - * Build and cache settings from the environment. Wraps any parsing error with - * context so a misconfigured env var produces an actionable startup message - * instead of a raw error thrown from module initialisation. - */ export function initSettings(): Settings { try { _cachedSettings = Settings.fromEnv(); @@ -164,7 +154,6 @@ export function initSettings(): Settings { return _cachedSettings; } -/** Return the cached settings, initialising them lazily on first access. */ export function getSettings(): Settings { return _cachedSettings ?? initSettings(); } diff --git a/typescript/src/constants.ts b/typescript/src/constants.ts index ff27c14..8fb51eb 100644 --- a/typescript/src/constants.ts +++ b/typescript/src/constants.ts @@ -1,30 +1,22 @@ -// Command completion timing -export const MAX_COMMAND_COMPLETION_WINDOW = 0.1; // seconds +export const MAX_COMMAND_COMPLETION_WINDOW = 0.1; -// Process management export const PROCESS_SHUTDOWN_TIMEOUT = 2.0; export const FORCE_EXIT_DELAY_SECONDS = 2; -// Context display limits export const RECENT_OUTPUT_LINES = 20; export const LAST_COMMANDS_COUNT = 5; -// Memory conversion export const BYTES_TO_MB = 1024 * 1024; -// Buffer management export const UTILIZATION_PERCENTAGE_BASE = 100; export const LARGE_BUFFER_THRESHOLD = 10 * 1024 * 1024; export const SIGNIFICANT_LOG_THRESHOLD = 100_000; -// Performance optimization export const CHUNK_JOIN_THRESHOLD = 100; -// I/O logging thresholds export const LARGE_IO_THRESHOLD = 10_000; export const SLOW_OPERATION_THRESHOLD = 1.0; -// Cap on retained per-session command history entries. Bounds memory on -// long-lived sessions; the oldest entries are dropped once the cap is exceeded. +// Bounds memory on long-lived sessions; oldest entries are dropped when +// exceeded. export const MAX_COMMAND_HISTORY = 1000; - diff --git a/typescript/src/core/manager.ts b/typescript/src/core/manager.ts index a84c539..2ee93df 100644 --- a/typescript/src/core/manager.ts +++ b/typescript/src/core/manager.ts @@ -27,13 +27,9 @@ export interface CreateSessionOptions { /** * Manages OpenROAD subprocess lifecycle and interactive sessions. * - * Node.js is single-threaded, so no reentrant lock is needed for plain state - * access (Python used asyncio.Lock + the GIL). The async-mutex `cleanupLock` - * serialises the multi-await cleanup/creation sections so concurrent callers - * cannot interleave session-map mutations across await points. - * - * The module exports a shared `manager` singleton; the class is exported too so - * tests can construct isolated instances. + * The async-mutex `cleanupLock` serialises the multi-await cleanup/creation + * sections so concurrent callers cannot interleave session-map mutations + * across await points. */ export class OpenROADManager { private readonly logger = getLogger("manager"); @@ -73,8 +69,8 @@ export class OpenROADManager { this.sessions.set(sessionId, null); try { - // Match Python's `buffer_size or default`: 0 (and undefined) fall back - // to the default so a zero-capacity buffer can't silently drop all output. + // 0 (and undefined) fall back to the default so a zero-capacity buffer + // can't silently drop all output. const bufferSize = opts.bufferSize && opts.bufferSize > 0 ? opts.bufferSize : this.defaultBufferSize; const session = new InteractiveSession(sessionId, bufferSize); await session.start(opts.command, opts.env, opts.cwd); @@ -92,8 +88,8 @@ export class OpenROADManager { async executeCommand(sessionId: string, command: string, timeoutMs?: number): Promise { const session = this._getSession(sessionId); - // Match Python's `timeout_ms or default`: 0 (and undefined) fall back to the - // configured default rather than becoming an instant timeout. + // 0 (and undefined) fall back to the default rather than becoming an + // instant timeout. const actualTimeout = timeoutMs && timeoutMs > 0 ? timeoutMs : this.defaultTimeoutMs; await session.sendCommand(command); @@ -121,8 +117,7 @@ export class OpenROADManager { async terminateSession(sessionId: string, force = false): Promise { const session = this._getSession(sessionId); - // terminate() already tears down the PTY and stops the writer task. We do - // not also call cleanup() here: cleanup() clears the output buffer, which + // Do not call cleanup() here: cleanup() clears the output buffer, which // would discard final output a concurrent reader may still need. The // session is dropped from the map below, so its buffer is GC'd anyway. await session.terminate(force); @@ -134,9 +129,9 @@ export class OpenROADManager { } async terminateAllSessions(force = false): Promise { - // Only initialized sessions are terminable. Null placeholders belong to an - // in-flight createSession (which resolves or removes them itself), so - // terminating them would throw "still being created" and be lost. + // Skip null placeholders: they belong to an in-flight createSession + // (which resolves or removes them itself), so terminating them would + // throw "still being created" and be lost. const sessionIds = this._initializedSessions().map(([sid]) => sid); if (sessionIds.length === 0) return 0; @@ -189,8 +184,8 @@ export class OpenROADManager { } } - // Capture counts after the async loop so the snapshot reflects the final - // state rather than a stale pre-loop value. + // Snapshot counts after the async loop so the result reflects the + // post-cleanup state. const totalSessions = this.sessions.size; const activeSessions = this.getActiveSessionCount(); const terminatedSessions = totalSessions - activeSessions; @@ -243,8 +238,6 @@ export class OpenROADManager { return this._countActive(); } - // internals - private _countActive(): number { let count = 0; for (const session of this.sessions.values()) { @@ -282,10 +275,9 @@ export class OpenROADManager { for (const [sessionId, session] of this._initializedSessions()) { if (!session.checkAlive()) { - // Measure from the actual death time. lastActivity is the last command, - // which for a long-idle session is far earlier and would trip the - // force-cleanup timer immediately. Fall back to lastActivity only if the - // death timestamp is somehow unset. + // Measure from death time, not lastActivity: a long-idle session + // dies far after its last command, which would trip force-cleanup + // immediately. const deathTime = (session.terminatedAt ?? session.lastActivity).getTime(); const timeSinceDeath = (now - deathTime) / 1000; terminated.push([sessionId, session, timeSinceDeath > FORCE_CLEANUP_AFTER_SECONDS]); @@ -329,5 +321,4 @@ export class OpenROADManager { } } -/** Shared process-wide manager instance used by the MCP tools. */ export const manager = new OpenROADManager(); diff --git a/typescript/src/core/models.ts b/typescript/src/core/models.ts index 1703116..e30e933 100644 --- a/typescript/src/core/models.ts +++ b/typescript/src/core/models.ts @@ -14,9 +14,8 @@ export enum ProcessState { ERROR = "error", } -// Domain interfaces (camelCase) -// These remain plain interfaces and are converted to the snake_case MCP wire -// format at the tool serialization boundary (BaseTool.formatResult, Part 2). +// camelCase domain interfaces are converted to snake_case MCP wire format +// at the tool serialization boundary (BaseTool.formatResult). export interface InteractiveSessionInfo { sessionId: string; @@ -39,11 +38,9 @@ export interface InteractiveExecResult { error?: string | null; } -// Opaque snake_case payloads -// These are passed straight through to the wire (no camel->snake conversion), -// matching Python's dict output byte-for-byte. +// Opaque snake_case payloads passed straight through to the wire (no +// camel->snake conversion). -/** One entry in a session's command history. */ export interface CommandHistoryEntry { command: string; timestamp: string; @@ -53,7 +50,6 @@ export interface CommandHistoryEntry { output_length?: number; } -/** Detailed per-session metrics returned by InteractiveSession.getDetailedMetrics. */ export interface SessionDetailedMetrics { session_id: string; state: string; @@ -83,7 +79,6 @@ export interface SessionDetailedMetrics { }; } -/** Aggregate metrics across all sessions returned by OpenROADManager.sessionMetrics. */ export interface ManagerMetrics { manager: { total_sessions: number; @@ -101,11 +96,9 @@ export interface ManagerMetrics { sessions: SessionDetailedMetrics[]; } -// Zod result schemas -// BaseResult pattern: every result carries `error: string | null`, defaulting to -// null. Python Pydantic always emits the `error` key (`= None` -> `null`), so we -// use `.nullable().default(null)`, never `.optional()`, to preserve key presence. - +// Every result carries `error: string | null` defaulting to null. Use +// `.nullable().default(null)`, never `.optional()`, to preserve key presence +// on the wire. const errorField = z.string().nullable().default(null); export const CommandRecord = z.object({ @@ -155,8 +148,6 @@ export const SessionMetricsResult = z.object({ }); export type SessionMetricsResult = z.infer; -// Image models - export const ImageInfo = z.object({ filename: z.string(), path: z.string(), diff --git a/typescript/src/interactive/buffer.ts b/typescript/src/interactive/buffer.ts index bc33e60..80435d6 100644 --- a/typescript/src/interactive/buffer.ts +++ b/typescript/src/interactive/buffer.ts @@ -37,8 +37,8 @@ export class CircularBuffer { this._totalSize -= old.length; } - // A single chunk that still exceeds maxSize is truncated to the last - // maxSize bytes so the buffer never permanently exceeds its capacity. + // A single chunk larger than maxSize is truncated to its last maxSize + // bytes so capacity is never permanently exceeded. if (this._totalSize > this.maxSize) { const chunk = this._chunks[0]!; this._chunks[0] = chunk.slice(chunk.length - this.maxSize); @@ -96,9 +96,9 @@ export class CircularBuffer { }; // Re-check _dataAvailable under the mutex: runExclusive is async, so - // append() can fire between the fast-path check above and the push below, - // set _dataAvailable = true, drain an empty _resolvers, and release — - // leaving wakeUp unnoticed and the caller waiting the full timeout. + // append() can fire between the fast-path check above and the push + // below, drain an empty _resolvers, and release, leaving wakeUp + // unnoticed and the caller waiting the full timeout. this._mutex.runExclusive(() => { if (this._dataAvailable) { wakeUp(true); diff --git a/typescript/src/interactive/pty_handler.ts b/typescript/src/interactive/pty_handler.ts index ade87b2..4b5d81d 100644 --- a/typescript/src/interactive/pty_handler.ts +++ b/typescript/src/interactive/pty_handler.ts @@ -15,7 +15,6 @@ export class PtyHandler { constructor(private readonly _settings: Settings = getSettings()) {} - /** PID of the underlying PTY process, or null if no process is active. */ get pid(): number | null { return this._ptyProcess?.pid ?? null; } @@ -89,9 +88,9 @@ export class PtyHandler { this._alive = true; this._exitCode = null; - // Register the exit handler before onData so a fast-exiting process can - // never slip its exit event through before we are listening. The guard - // keeps the handler idempotent against a double-delivered exit. + // Register exit before onData so a fast-exiting process cannot slip + // its exit event through before we are listening. The guard keeps the + // handler idempotent against a double-delivered exit. this._exitDisposable = this._ptyProcess.onExit(({ exitCode }) => { if (!this._alive && this._exitCode !== null) return; this._alive = false; @@ -123,8 +122,8 @@ export class PtyHandler { isProcessAlive(): boolean { if (!this._alive || !this._ptyProcess) return false; - // Defensive liveness probe: if the exit event was somehow missed, signal 0 - // detects a dead/reaped pid (ESRCH) so `_alive` cannot stay true forever. + // Defensive liveness probe in case the exit event was missed; signal 0 + // detects a dead/reaped pid via ESRCH. try { process.kill(this._ptyProcess.pid, 0); return true; @@ -135,8 +134,6 @@ export class PtyHandler { } async waitForExit(timeoutMs?: number): Promise { - // Check the recorded exit code first so callers that arrive after cleanup() - // still get the real code rather than null. if (this._exitCode !== null) return this._exitCode; if (!this._ptyProcess) return null; @@ -191,7 +188,7 @@ export class PtyHandler { try { await this.terminateProcess(true); } catch { - // Best effort - don't let terminate errors prevent state reset + // best effort } } @@ -205,7 +202,7 @@ export class PtyHandler { this._alive = false; this._dataDisposable = null; this._exitDisposable = null; - // Keep _exitCode so a late waitForExit() caller still sees the real exit - // code. createSession() resets it to null when the handler is reused. + // Preserve _exitCode so a late waitForExit() caller still sees the real + // exit code; createSession() resets it on reuse. } } diff --git a/typescript/src/interactive/session.ts b/typescript/src/interactive/session.ts index 2c736c8..ab8db2d 100644 --- a/typescript/src/interactive/session.ts +++ b/typescript/src/interactive/session.ts @@ -46,7 +46,6 @@ export class InteractiveSession { readonly createdAt: Date; commandCount = 0; - // Activity / history / performance tracking (consumed by the manager). lastActivity: Date = new Date(); readonly commandHistory: CommandHistoryEntry[] = []; totalCpuTime = 0; @@ -55,9 +54,9 @@ export class InteractiveSession { sessionTimeoutSeconds: number | null = null; private _state: SessionState; - // Wall-clock time the process actually died, set on the first TERMINATED - // transition. Used by the manager's force-cleanup timer; lastActivity would - // be wrong because a long-idle session dies far after its last command. + // Set on the first TERMINATED transition. Used by the manager's force-cleanup + // timer; lastActivity would be wrong because a long-idle session dies far + // after its last command. private _terminatedAt: Date | null = null; pty: PtyHandler; readonly outputBuffer: CircularBuffer; @@ -89,16 +88,14 @@ export class InteractiveSession { this._state = value; } - /** Wall-clock time the session first became TERMINATED, or null if still alive. */ get terminatedAt(): Date | null { return this._terminatedAt; } /** - * Check whether the session is alive, syncing state as a side effect. - * If the underlying PTY process has died since the last check, this - * transitions _state to TERMINATED and signals the writer to stop. - * Named checkAlive (not isAlive) to signal that it is not a pure predicate. + * Sync state with the underlying PTY: if the process has died since the + * last check, transition to TERMINATED and signal the writer to stop. + * Named checkAlive (not isAlive) because it is not a pure predicate. */ checkAlive(): boolean { if (this._state === SessionState.TERMINATED) return false; @@ -134,9 +131,8 @@ export class InteractiveSession { env, cwd, (data: string) => { - // node-pty delivers data in push-based bursts with no size limit. - // Slicing large deliveries keeps individual buffer chunks small so the - // circular buffer's eviction logic bounds memory correctly. + // node-pty delivers data in unbounded bursts. Slice large deliveries + // so the circular buffer's eviction logic bounds memory correctly. const appendChunk = (chunk: string): void => { this.outputBuffer.append(chunk).catch(() => { this._markDead(); @@ -160,9 +156,9 @@ export class InteractiveSession { }, ); - // Only promote to ACTIVE if the session is still creating. A fast process - // death during startup may already have flipped us to TERMINATED via the - // onData/onExit handlers; do not resurrect it into an undead ACTIVE state. + // Only promote to ACTIVE if still CREATING. A fast process death during + // startup may have already flipped us to TERMINATED via onData/onExit; + // do not resurrect it into an undead ACTIVE state. if (this._state === SessionState.CREATING) { this._state = SessionState.ACTIVE; } @@ -186,16 +182,14 @@ export class InteractiveSession { ); } - // Record the command in history before bumping the counters so the entry's - // command_number matches Python (command_count + 1). + // Push history before bumping commandCount so command_number lines up + // with the post-increment counter. this.commandHistory.push({ command: command.trim(), timestamp: new Date().toISOString(), command_number: this.commandCount + 1, execution_start: Date.now() / 1000, }); - // Bound history so a long-lived session cannot grow it without limit. - // command_number keeps increasing, so dropping the oldest entry is safe. if (this.commandHistory.length > MAX_COMMAND_HISTORY) { this.commandHistory.shift(); } @@ -215,13 +209,10 @@ export class InteractiveSession { if (!this.checkAlive()) { // Drain-before-reject: a fast-exiting command (e.g. "exit") can flip - // _state to TERMINATED between sendCommand and readOutput because - // sendCommand is synchronous and the event loop runs onExit at the - // next await boundary. Node.js drains all microtasks before firing - // onExit, so any preceding onData appends are already in the buffer. - // Return whatever is buffered rather than discarding it. - // Also signal shutdown here so the writer task is guaranteed to stop - // even when readOutput() is the first caller to observe the dead state. + // _state to TERMINATED between sendCommand and readOutput. Any preceding + // onData appends are already in the buffer, so return them rather than + // discarding. signalShutdown ensures the writer task stops if readOutput + // is the first caller to observe the dead state. this._signalShutdown(); const chunks = await this.outputBuffer.drainAll(); if (chunks.length === 0) { @@ -343,9 +334,9 @@ export class InteractiveSession { } /** - * Transition to TERMINATED from any non-terminal state (idempotent). Covers - * CREATING as well as ACTIVE so a session that dies mid-startup is never left - * stranded as an uncollectable CREATING zombie. + * Idempotently transition to TERMINATED from any non-terminal state. + * Covers CREATING so a session that dies mid-startup is never left + * stranded as an uncollectable zombie. */ private _markDead(): void { if (this._state !== SessionState.TERMINATED && this._state !== SessionState.ERROR) { @@ -411,10 +402,9 @@ export class InteractiveSession { } /** - * Update lastActivity and backfill history entries after a read. Walks back - * over every trailing entry still missing execution_time, stopping at the - * first already-recorded one, so commands batched into a single readOutput - * all get timing instead of only the most recent. + * Backfill timing for every trailing history entry still missing + * execution_time, so commands batched into a single readOutput all get + * timing instead of only the most recent. */ private _recordReadResult(outputLength: number, executionTime: number): void { for (let i = this.commandHistory.length - 1; i >= 0; i--) { @@ -426,9 +416,9 @@ export class InteractiveSession { this.lastActivity = new Date(); } - /** Sample CPU/memory from the live process. Best-effort; silently ignores a - * dead or inaccessible PID. CPU time is cumulative (assigned, not summed). - * Returns the sampled current memory so callers avoid a second pidusage call. */ + /** Sample CPU/memory from the live process. CPU time is cumulative, so it + * is assigned rather than summed. Returns the sampled current memory so + * callers avoid a second pidusage call. */ private async _updatePerformanceMetrics(): Promise { const pid = this.pty.pid; if (pid == null) return 0; @@ -443,8 +433,7 @@ export class InteractiveSession { } } - /** True when a configured per-session timeout has been exceeded by uptime. - * Distinct from idle timeout - this is wall-clock lifetime, not inactivity. */ + /** Wall-clock lifetime check, distinct from the idle-inactivity check. */ private _checkSessionTimeout(): boolean { if (this.sessionTimeoutSeconds === null) return false; const uptime = (Date.now() - this.createdAt.getTime()) / 1000; @@ -497,11 +486,10 @@ export class InteractiveSession { history = history.filter((cmd) => cmd.command.toLowerCase().includes(needle)); } - // Sort by timestamp, most recent first. history.sort((a, b) => (a.timestamp < b.timestamp ? 1 : a.timestamp > b.timestamp ? -1 : 0)); - // Only a positive limit slices. A zero or negative limit leaves the list - // intact rather than letting `slice(0, -n)` silently drop recent entries. + // Only a positive limit slices; otherwise `slice(0, -n)` would silently + // drop the most recent entries. if (limit !== undefined && limit > 0) { history = history.slice(0, limit); } @@ -540,7 +528,6 @@ export class InteractiveSession { const regex = new RegExp(pattern, "i"); matching = lines.filter((line) => regex.test(line)); } catch { - // Fallback to a case-insensitive substring search on invalid regex. const needle = pattern.toLowerCase(); matching = lines.filter((line) => line.toLowerCase().includes(needle)); } diff --git a/typescript/src/main.ts b/typescript/src/main.ts index e228fb8..697dd4e 100644 --- a/typescript/src/main.ts +++ b/typescript/src/main.ts @@ -1,8 +1,7 @@ import { initSettings } from "./config/settings.js"; -// Eagerly initialise settings at startup so a misconfigured environment variable -// is reported with useful context and a non-zero exit code, rather than crashing -// later from inside module initialisation when settings are first accessed. +// Initialise settings up front so a misconfigured env var fails fast with +// context rather than crashing later from inside module initialisation. try { initSettings(); } catch (e) { diff --git a/typescript/src/tools/base.ts b/typescript/src/tools/base.ts index 7b34706..83ccab0 100644 --- a/typescript/src/tools/base.ts +++ b/typescript/src/tools/base.ts @@ -5,10 +5,9 @@ function camelToSnakeKey(key: string): string { } /** - * Recursively converts camelCase object keys to snake_case. - * Idempotent on already-snake_case strings (no uppercase → no change), - * so opaque snake_case payloads (SessionDetailedMetrics, ManagerMetrics, - * CommandHistoryEntry) pass through unchanged. + * Recursively convert camelCase object keys to snake_case. Idempotent on + * already-snake_case keys, so opaque snake_case payloads pass through + * unchanged. */ export function toSnakeCase(value: unknown): unknown { if (Array.isArray(value)) return value.map(toSnakeCase); @@ -24,11 +23,9 @@ export function toSnakeCase(value: unknown): unknown { } /** - * Base class for all MCP tool implementations. - * - * Provides the manager dependency and a serialization helper that converts the - * camelCase domain model to the snake_case MCP wire format in one place. - * Each subclass declares its own typed execute() signature. + * Base class for MCP tool implementations. Provides the manager dependency + * and a serialization helper that converts the camelCase domain model to the + * snake_case wire format. */ export abstract class BaseTool { protected constructor(protected readonly manager: OpenROADManager) {} diff --git a/typescript/src/tools/interactive.ts b/typescript/src/tools/interactive.ts index ba0cc99..c153fc6 100644 --- a/typescript/src/tools/interactive.ts +++ b/typescript/src/tools/interactive.ts @@ -25,17 +25,12 @@ import { BaseTool, toSnakeCase } from "./base.js"; const logger = getLogger("tools.interactive"); -// --------------------------------------------------------------------------- -// Module-level helpers -// --------------------------------------------------------------------------- - -/** Emulate Python's repr() for simple strings: single-quoted with escaping. */ +/** Single-quoted Python-style repr for embedding strings in error messages. */ function pyRepr(s: string): string { const escaped = s.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); return `'${escaped}'`; } -/** Build a blank InteractiveExecResult skeleton for error paths. */ function blankExecResult( sessionId: string | null, error: string, @@ -51,10 +46,6 @@ function blankExecResult( }; } -/** - * Returns an InteractiveExecResult representing a session-not-found condition. - * Error message matches the Python server byte-for-byte. - */ function sessionNotFoundExecResult( sessionId: string | null, error: unknown, @@ -70,10 +61,6 @@ function sessionNotFoundExecResult( }; } -/** - * Build and serialize a blocked-command result. - * Matches Python's _blocked_error() output exactly, including repr() quoting. - */ function blockedError( command: string, blockedVerb: string, @@ -93,9 +80,8 @@ function blockedError( } /** - * Gate a command through the Tcl whitelist when WHITELIST_ENABLED is set. - * Returns a serialised blocked-error JSON string when the command is rejected, - * or null when it is allowed (or when the whitelist is disabled). + * Returns a serialised blocked-error JSON string when the command is rejected + * by the Tcl whitelist, or null when it is allowed or the whitelist is off. */ function applyWhitelist( command: string, @@ -114,10 +100,6 @@ function applyWhitelist( return null; } -// --------------------------------------------------------------------------- -// Tool classes -// --------------------------------------------------------------------------- - /** Read-only query tool: report_*, get_*, check_*, sta, help, version, etc. */ export class QueryShellTool extends BaseTool { constructor(manager: OpenROADManager) { @@ -146,8 +128,8 @@ export class QueryShellTool extends BaseTool { ); return this.formatResult(result as unknown as Record); } catch (e) { - // Auto-created sessions must be cleaned up when executeCommand throws to - // avoid leaking the session. + // Tear down an auto-created session so executeCommand failures do not + // leak it. if (sid === null && resolvedId !== null) { this.manager.terminateSession(resolvedId, true).catch(() => { /* best effort */ }); } @@ -234,7 +216,6 @@ export class ExecShellTool extends BaseTool { } } -/** Lists all active and terminated sessions tracked by the manager. */ export class ListSessionsTool extends BaseTool { constructor(manager: OpenROADManager) { super(manager); @@ -261,7 +242,6 @@ export class ListSessionsTool extends BaseTool { } } -/** Creates a new OpenROAD interactive session. */ export class CreateSessionTool extends BaseTool { constructor(manager: OpenROADManager) { super(manager); @@ -299,7 +279,6 @@ export class CreateSessionTool extends BaseTool { } } -/** Terminates an existing session by ID. */ export class TerminateSessionTool extends BaseTool { constructor(manager: OpenROADManager) { super(manager); @@ -347,7 +326,6 @@ export class TerminateSessionTool extends BaseTool { } } -/** Returns detailed metrics for a single session. */ export class InspectSessionTool extends BaseTool { constructor(manager: OpenROADManager) { super(manager); @@ -381,7 +359,6 @@ export class InspectSessionTool extends BaseTool { } } -/** Returns the command history for a session, with optional limit and search. */ export class SessionHistoryTool extends BaseTool { constructor(manager: OpenROADManager) { super(manager); @@ -422,7 +399,6 @@ export class SessionHistoryTool extends BaseTool { } } -/** Returns aggregate metrics across all sessions managed by the manager. */ export class SessionMetricsTool extends BaseTool { constructor(manager: OpenROADManager) { super(manager); @@ -447,5 +423,4 @@ export class SessionMetricsTool extends BaseTool { } } -// Backwards-compat alias matching Python's InteractiveShellTool = QueryShellTool export const InteractiveShellTool = QueryShellTool; diff --git a/typescript/src/tools/report_images.ts b/typescript/src/tools/report_images.ts index 2be681b..8e68663 100644 --- a/typescript/src/tools/report_images.ts +++ b/typescript/src/tools/report_images.ts @@ -1,5 +1,4 @@ import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import sharp from "sharp"; import type { OpenROADManager } from "../core/manager.js"; @@ -20,17 +19,9 @@ import { BaseTool } from "./base.js"; const logger = getLogger("tools.report_images"); -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - const MAX_BASE64_SIZE_KB = 15; const MAX_IMAGE_SIZE_MB = 50; -// --------------------------------------------------------------------------- -// Image type mapping (exact copy of Python dict) -// --------------------------------------------------------------------------- - const IMAGE_TYPE_MAPPING: Record = { cts_clk: "clock_visualization", cts_clk_layout: "clock_layout", @@ -45,13 +36,9 @@ const IMAGE_TYPE_MAPPING: Record = { final_routing: "routing_visualization", }; -// --------------------------------------------------------------------------- -// Helper functions -// --------------------------------------------------------------------------- - /** - * Derive the image stage and semantic type from a filename. - * Returns ["unknown", "unknown"] for files with no underscore or unrecognised keys. + * Derive the image stage and semantic type from a filename. Returns + * ["unknown", "unknown"] for files with no underscore or unrecognised keys. */ export function classifyImageType(filename: string): [string, string] { const basename = path.basename(filename, path.extname(filename)); @@ -69,10 +56,6 @@ export function classifyImageType(filename: string): [string, string] { return [stage, type]; } -/** - * Verify that `platform` and `design` are known in the current ORFS configuration. - * Throws ValidationError when either is not found. - */ export function validatePlatformDesign(platform: string, design: string): void { const settings = getSettings(); const platforms = settings.platforms; @@ -89,10 +72,6 @@ export function validatePlatformDesign(platform: string, design: string): void { } } -/** - * Resolve and validate the reports base path and per-run sub-directory. - * Returns [reportsBase, runPath] as absolute path strings. - */ function resolveRunPath( platform: string, design: string, @@ -107,7 +86,6 @@ function resolveRunPath( return [reportsBase, runPath]; } -/** List available run slugs in reportsBase for error messages. */ function availableRuns(reportsBase: string): string[] { try { return fs @@ -119,16 +97,13 @@ function availableRuns(reportsBase: string): string[] { } } -/** - * Recursively find all .webp files under `dir`, returning absolute paths. - * Requires Node.js ≥ 20 for the `recursive` option on readdirSync. - */ +/** Requires Node.js 20+ for the `recursive` option on readdirSync. */ function findWebpFiles(dir: string): string[] { const entries = fs.readdirSync(dir, { recursive: true, withFileTypes: true }); return entries .filter((e) => e.isFile() && e.name.endsWith(".webp")) .map((e) => { - // `parentPath` is available in Node 20.12+; `path` is the older alias. + // `parentPath` is Node 20.12+; `path` is the older alias. const parent = (e as unknown as { parentPath?: string; path?: string }) .parentPath ?? (e as unknown as { path: string }).path; return path.join(parent, e.name); @@ -147,9 +122,9 @@ interface CompressResult { } /** - * Load an image and compress it to fit within `maxSizeKb` of base64 output. - * Uses sharp for resizing (lanczos3) and WebP encoding (quality=85). - * Falls back to returning raw bytes when sharp fails, with null dimensions. + * Compress an image to fit within `maxSizeKb` of base64 output using sharp + * (lanczos3 resize, WebP quality 85). Falls back to raw bytes with null + * dimensions when sharp fails. */ async function loadAndCompressImage( imagePath: string, @@ -227,10 +202,6 @@ async function loadAndCompressImage( } } -// --------------------------------------------------------------------------- -// Tool classes -// --------------------------------------------------------------------------- - /** Lists .webp report images for a specific platform/design/run. */ export class ListReportImagesTool extends BaseTool { constructor(manager: OpenROADManager) { @@ -316,7 +287,6 @@ export class ListReportImagesTool extends BaseTool { total++; } - // Sort each stage bucket by filename for (const key of Object.keys(imagesByStage)) { imagesByStage[key] = (imagesByStage[key] as Array<{ filename: string }>).sort((a, b) => a.filename.localeCompare(b.filename), diff --git a/typescript/src/utils/ansi_decoder.ts b/typescript/src/utils/ansi_decoder.ts index 91a2271..6610a1f 100644 --- a/typescript/src/utils/ansi_decoder.ts +++ b/typescript/src/utils/ansi_decoder.ts @@ -1,19 +1,17 @@ import stripAnsi from "strip-ansi"; +// Specific private-mode codes are listed before the generic private-mode +// catch-all so they match first. The `?` in the generic patterns is escaped, +// preventing single-char escapes like \x1bh from matching there. const ESCAPE_SEQUENCES: Record = { - // Private mode sequences (DECSET/DECRST). Specific codes are listed first so - // they match before the generic private-mode catch-all below. "\\x1b\\[\\?2004h": "Enable bracketed paste mode", "\\x1b\\[\\?2004l": "Disable bracketed paste mode", "\\x1b\\[\\?1049h": "Enable alternative screen buffer", "\\x1b\\[\\?1049l": "Disable alternative screen buffer", "\\x1b\\[\\?25h": "Show cursor", "\\x1b\\[\\?25l": "Hide cursor", - // Generic private-mode set/reset. The `?` is escaped so `[?` is mandatory, - // preventing single-char escapes like \x1bh / \x1bl from matching here. "\\x1b\\[\\?\\d*h": "Enable terminal mode", "\\x1b\\[\\?\\d*l": "Disable terminal mode", - // Cursor movement "\\x1b\\[\\d*A": "Move cursor up", "\\x1b\\[\\d*B": "Move cursor down", "\\x1b\\[\\d*C": "Move cursor right", @@ -26,7 +24,6 @@ const ESCAPE_SEQUENCES: Record = { "\\x1b\\[0K": "Clear line from cursor to end", "\\x1b\\[1K": "Clear line from start to cursor", "\\x1b\\[2K": "Clear entire line", - // Text formatting "\\x1b\\[0m": "Reset all formatting", "\\x1b\\[1m": "Bold text", "\\x1b\\[2m": "Dim text", @@ -36,7 +33,6 @@ const ESCAPE_SEQUENCES: Record = { "\\x1b\\[7m": "Reverse video", "\\x1b\\[8m": "Hidden text", "\\x1b\\[9m": "Strikethrough text", - // Colors (foreground) "\\x1b\\[30m": "Black text", "\\x1b\\[31m": "Red text", "\\x1b\\[32m": "Green text", @@ -45,7 +41,6 @@ const ESCAPE_SEQUENCES: Record = { "\\x1b\\[35m": "Magenta text", "\\x1b\\[36m": "Cyan text", "\\x1b\\[37m": "White text", - // Colors (background) "\\x1b\\[40m": "Black background", "\\x1b\\[41m": "Red background", "\\x1b\\[42m": "Green background", @@ -56,21 +51,17 @@ const ESCAPE_SEQUENCES: Record = { "\\x1b\\[47m": "White background", }; -// Matches the common ANSI escape sequence families so that non-CSI sequences -// (OSC, charset designation, single/two-char escapes) are detected in every -// mode rather than leaking through as raw bytes. Order matters: longer/anchored -// alternatives come first so they win at a given match position. -// 1. OSC: ESC ] ... (BEL | ST) -// 2. CSI: ESC [ params final -// 3. Charset/desig.: ESC ( | ) | # +// Order matters: longer/anchored alternatives come first so they win at a +// given match position. +// 1. OSC: ESC ] ... (BEL | ST) +// 2. CSI: ESC [ params final +// 3. Charset/desig.: ESC ( | ) | # // 4. Single/two-char: ESC const ESCAPE_PATTERN = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[0-9;?]*[a-zA-Z]|\x1b[()#][0-9A-Za-z]|\x1b[=>NMcDEH78]/g; const VALID_MODES = ["remove", "annotate", "preserve", "decode"]; -// Pre-compile the escape-sequence patterns once at module load instead of -// allocating ~45 RegExp objects on every decodeEscapeSequence() call. const COMPILED_SEQUENCES: Array<[RegExp, string]> = Object.entries(ESCAPE_SEQUENCES).map( ([pattern, description]) => [new RegExp(`^${pattern}`), description], ); @@ -115,9 +106,8 @@ export class ANSIDecoder { if (mode === "annotate") { let result = text; for (const seq of new Set(sequences)) { - // Function replacement: the value is inserted literally, so `$&`/`$1` - // inside an OSC sequence body cannot be reinterpreted as a replacement - // pattern and corrupt the output. + // Use a function replacement so `$&`/`$1` inside an OSC sequence + // body cannot be reinterpreted as a replacement pattern. const annotation = `[${ANSIDecoder.decodeEscapeSequence(seq)}]`; result = result.replaceAll(seq, () => annotation); } diff --git a/typescript/src/utils/logging.ts b/typescript/src/utils/logging.ts index 3266ea3..d8f3f15 100644 --- a/typescript/src/utils/logging.ts +++ b/typescript/src/utils/logging.ts @@ -1,32 +1,21 @@ import pino from "pino"; import { getSettings } from "../config/settings.js"; -/** - * Pino-based logging for the MCP server. - * - * IMPORTANT: all log output goes to stderr (file descriptor 2). stdout is - * reserved exclusively for the MCP stdio transport - writing logs there would - * corrupt the JSON-RPC protocol stream. - * - * The root level is initialised from settings (env-driven) at module load so - * child loggers created eagerly (e.g. the module-level OpenROADManager - * singleton) honour the configured level without depending on setupLogging() - * being called first. setupLogging() mutates the root level for loggers created - * afterwards; note that pino child loggers capture their level at creation time - * and do not dynamically follow the parent. - */ +// All log output goes to stderr (fd 2). stdout is reserved for the MCP stdio +// transport and any log writes there would corrupt the JSON-RPC stream. +// pino child loggers capture their level at creation, so the root level is +// initialised from settings at module load to honour the configured level for +// eagerly created singletons. function createRoot(level: string): pino.Logger { return pino({ name: "openroad_mcp", level: level.toLowerCase() }, pino.destination(2)); } let rootLogger: pino.Logger = createRoot(getSettings().LOG_LEVEL); -/** Configure the root log level. Call once at startup before heavy logging. */ export function setupLogging(level: string): void { rootLogger.level = level.toLowerCase(); } -/** Return a child logger namespaced under `openroad_mcp.`. */ export function getLogger(name: string): pino.Logger { return rootLogger.child({ module: name }); } diff --git a/typescript/src/utils/path_security.ts b/typescript/src/utils/path_security.ts index 2b60008..10507e1 100644 --- a/typescript/src/utils/path_security.ts +++ b/typescript/src/utils/path_security.ts @@ -26,10 +26,10 @@ export function validateSafePathContainment(targetPath: string, basePath: string if ((e as NodeJS.ErrnoException).code !== "ENOENT") { throw new ValidationError(`Failed to resolve ${context} path: ${e}`); } - // Walk up to find the longest existing prefix, resolve its symlinks, then - // re-append the non-existent suffix. A plain path.resolve() is unsafe here - // because it won't resolve symlinks in existing parent directories, allowing - // e.g. base/evil_link/nonexistent to escape containment at runtime. + // Walk up to the longest existing prefix, resolve its symlinks, then + // re-append the non-existent suffix. A plain path.resolve() would not + // resolve symlinks in existing parent directories, allowing e.g. + // base/evil_link/nonexistent to escape containment at runtime. const suffix: string[] = []; let current = path.resolve(targetPath); for (;;) { From 0e27deceeb9b337d1cd47af733e3b960e8ff0895 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 17 Jun 2026 20:52:24 -0600 Subject: [PATCH 51/62] add interactive tool wire-format snapshots so CI snapshot tests pass --- .../__tests__/tools/__snapshots__/interactive.test.ts.snap | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 typescript/__tests__/tools/__snapshots__/interactive.test.ts.snap diff --git a/typescript/__tests__/tools/__snapshots__/interactive.test.ts.snap b/typescript/__tests__/tools/__snapshots__/interactive.test.ts.snap new file mode 100644 index 0000000..c6a9798 --- /dev/null +++ b/typescript/__tests__/tools/__snapshots__/interactive.test.ts.snap @@ -0,0 +1,5 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Snapshots: wire format stability > ListSessionsTool with sessions 1`] = `"{"sessions":[{"session_id":"session-1","created_at":"2024-01-01T00:00:00.000Z","is_alive":true,"command_count":5,"buffer_size":1024,"uptime_seconds":100,"state":"active","error":null}],"total_count":1,"active_count":1,"error":null}"`; + +exports[`Snapshots: wire format stability > QueryShellTool success output 1`] = `"{"output":"test output","session_id":"session-1","timestamp":"2024-01-01T00:00:00.000Z","execution_time":0.1,"command_count":1,"buffer_size":0,"error":null}"`; From 46adca8d69dc8632806610856bf27d6f654cce21 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 24 Jun 2026 23:23:34 -0600 Subject: [PATCH 52/62] add EXIT_CODE_ERROR and EXIT_CODE_KEYBOARD_INTERRUPT so the entrypoint can map config and interrupt failures to distinct exit codes --- typescript/src/constants.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/typescript/src/constants.ts b/typescript/src/constants.ts index 8fb51eb..92d6fc1 100644 --- a/typescript/src/constants.ts +++ b/typescript/src/constants.ts @@ -1,3 +1,8 @@ +// Process exit codes for the CLI entrypoint. 130 follows the shell convention +// for a process terminated by SIGINT (128 + 2). +export const EXIT_CODE_ERROR = 1; +export const EXIT_CODE_KEYBOARD_INTERRUPT = 130; + export const MAX_COMMAND_COMPLETION_WINDOW = 0.1; export const PROCESS_SHUTDOWN_TIMEOUT = 2.0; From 74070f78dba92a5ba7448464847ae703bd0ab891 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 24 Jun 2026 23:24:16 -0600 Subject: [PATCH 53/62] add CleanupManager with SIGTERM/SIGINT handlers and an unref'd force-exit timer so a hung graceful shutdown still exits after FORCE_EXIT_DELAY_SECONDS --- typescript/src/utils/cleanup.ts | 77 +++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 typescript/src/utils/cleanup.ts diff --git a/typescript/src/utils/cleanup.ts b/typescript/src/utils/cleanup.ts new file mode 100644 index 0000000..aec7503 --- /dev/null +++ b/typescript/src/utils/cleanup.ts @@ -0,0 +1,77 @@ +import { FORCE_EXIT_DELAY_SECONDS } from "../constants.js"; +import { getLogger } from "./logging.js"; + +const logger = getLogger("cleanup"); + +type CleanupHandler = () => Promise | void; + +/** + * Coordinates graceful shutdown. Node is single-threaded and event-driven, so + * this collapses the Python atexit/signal/threading version into process signal + * handlers plus a single shutdown promise. + * + * `waitForShutdown()` blocks the server lifecycle until either a signal arrives + * or the transport closes (both call `triggerShutdown()`). A signal also arms an + * unref'd force-exit timer so a hung graceful shutdown still exits the process. + */ +export class CleanupManager { + private shutdownInitiated = false; + private readonly handlers: CleanupHandler[] = []; + + private resolveShutdown: (() => void) | null = null; + private readonly shutdownPromise: Promise = new Promise((resolve) => { + this.resolveShutdown = resolve; + }); + + /** Register a handler to run during graceful shutdown (sync or async). */ + registerAsyncCleanupHandler(handler: CleanupHandler): void { + this.handlers.push(handler); + } + + /** Install SIGTERM/SIGINT handlers that trigger shutdown and arm a force-exit. */ + setupSignalHandlers(): void { + const onSignal = (signal: NodeJS.Signals): void => { + if (this.shutdownInitiated) return; + logger.info( + `Received ${signal}, shutting down (forcing exit in ${FORCE_EXIT_DELAY_SECONDS}s if it hangs)`, + ); + // Force-exit safety net: if graceful shutdown stalls, leave anyway. The + // timer is unref'd so it never keeps the event loop alive on its own. + const timer = setTimeout(() => process.exit(0), FORCE_EXIT_DELAY_SECONDS * 1000); + timer.unref(); + this.triggerShutdown(); + }; + process.on("SIGTERM", onSignal); + process.on("SIGINT", onSignal); + } + + /** + * Unblock `waitForShutdown()`. Idempotent: a second signal or a transport + * close after the first is a no-op. Called by the signal handlers and by the + * stdio transport's onclose. + */ + triggerShutdown(): void { + if (this.shutdownInitiated) return; + this.shutdownInitiated = true; + this.resolveShutdown?.(); + } + + /** Resolves once shutdown is triggered. */ + async waitForShutdown(): Promise { + await this.shutdownPromise; + } + + /** Run every registered cleanup handler, isolating failures. */ + async runHandlers(): Promise { + for (const handler of this.handlers) { + try { + await handler(); + } catch (e) { + logger.error(`Error in cleanup handler: ${e instanceof Error ? e.message : String(e)}`); + } + } + } +} + +// Global cleanup manager instance (matches server.py's module-level singleton). +export const cleanupManager = new CleanupManager(); From db9c53b3ba3a171f84da1b35634d69e98b203e35 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 24 Jun 2026 23:25:41 -0600 Subject: [PATCH 54/62] add tests for CleanupManager covering handler dispatch, triggerShutdown idempotence, and the force-exit timer --- typescript/__tests__/utils/cleanup.test.ts | 97 ++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 typescript/__tests__/utils/cleanup.test.ts diff --git a/typescript/__tests__/utils/cleanup.test.ts b/typescript/__tests__/utils/cleanup.test.ts new file mode 100644 index 0000000..14be832 --- /dev/null +++ b/typescript/__tests__/utils/cleanup.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { CleanupManager } from "../../src/utils/cleanup.js"; +import { FORCE_EXIT_DELAY_SECONDS } from "../../src/constants.js"; + +describe("CleanupManager", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("runs registered handlers on runHandlers", async () => { + const mgr = new CleanupManager(); + const ran: string[] = []; + mgr.registerAsyncCleanupHandler(() => { + ran.push("a"); + }); + mgr.registerAsyncCleanupHandler(async () => { + await Promise.resolve(); + ran.push("b"); + }); + + await mgr.runHandlers(); + + expect(ran).toEqual(["a", "b"]); + }); + + it("isolates a throwing handler so later handlers still run", async () => { + const mgr = new CleanupManager(); + const ran: string[] = []; + mgr.registerAsyncCleanupHandler(() => { + throw new Error("boom"); + }); + mgr.registerAsyncCleanupHandler(() => { + ran.push("after"); + }); + + await expect(mgr.runHandlers()).resolves.toBeUndefined(); + expect(ran).toEqual(["after"]); + }); + + it("triggerShutdown resolves waitForShutdown", async () => { + const mgr = new CleanupManager(); + let resolved = false; + const wait = mgr.waitForShutdown().then(() => { + resolved = true; + }); + + mgr.triggerShutdown(); + await wait; + + expect(resolved).toBe(true); + }); + + it("triggerShutdown is idempotent", async () => { + const mgr = new CleanupManager(); + mgr.triggerShutdown(); + mgr.triggerShutdown(); // second call must be a no-op, not throw + await expect(mgr.waitForShutdown()).resolves.toBeUndefined(); + }); + + it("a signal triggers shutdown and arms the force-exit timer", async () => { + vi.useFakeTimers(); + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation((() => undefined) as never); + + const sigtermBefore = process.listeners("SIGTERM"); + const sigintBefore = process.listeners("SIGINT"); + + try { + const mgr = new CleanupManager(); + mgr.setupSignalHandlers(); + + let resolved = false; + const wait = mgr.waitForShutdown().then(() => { + resolved = true; + }); + + process.emit("SIGTERM", "SIGTERM"); + await wait; + expect(resolved).toBe(true); + + // Force-exit fires only after the configured delay. + expect(exitSpy).not.toHaveBeenCalled(); + vi.advanceTimersByTime(FORCE_EXIT_DELAY_SECONDS * 1000); + expect(exitSpy).toHaveBeenCalledWith(0); + } finally { + // Remove only the listeners this test added, leaving the runner's intact. + for (const l of process.listeners("SIGTERM")) { + if (!sigtermBefore.includes(l)) process.removeListener("SIGTERM", l); + } + for (const l of process.listeners("SIGINT")) { + if (!sigintBefore.includes(l)) process.removeListener("SIGINT", l); + } + } + }); +}); From 3a0e91f15d25168375e655e35cc1f4b962aadd07 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 24 Jun 2026 23:26:58 -0600 Subject: [PATCH 55/62] add commander-based CLI parsing that rejects --host and --port unless --transport is http --- typescript/src/config/cli.ts | 97 ++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 typescript/src/config/cli.ts diff --git a/typescript/src/config/cli.ts b/typescript/src/config/cli.ts new file mode 100644 index 0000000..2bc5a9e --- /dev/null +++ b/typescript/src/config/cli.ts @@ -0,0 +1,97 @@ +import { Command, Option } from "commander"; +import { ValidationError } from "../exceptions.js"; + +export interface TransportConfig { + mode: "stdio" | "http"; + host: string; + port: number; +} + +export interface CLIConfig { + transport: TransportConfig; + verbose: boolean; + logLevel: string; +} + +const DEFAULT_HOST = "localhost"; +const DEFAULT_PORT = 8000; + +function parsePort(value: string): number { + const port = Number.parseInt(value, 10); + if (Number.isNaN(port)) { + throw new ValidationError(`Invalid --port value: ${value}`); + } + return port; +} + +/** + * Parse argv into a CLIConfig. Pass an explicit argument array (without the + * node/script prefix) in tests; omit it to read process.argv. + * + * commander is configured with exitOverride so bad input throws a + * ValidationError instead of calling process.exit, which keeps parsing testable + * and lets main.ts map every config failure to a single exit code. + */ +export function parseCliArgs(argv?: string[]): CLIConfig { + const program = new Command(); + program + .name("openroad-mcp") + .description("OpenROAD Model Context Protocol (MCP) Server") + .addOption( + new Option("-t, --transport ", "Transport mode for the MCP server") + .choices(["stdio", "http"]) + .default("stdio"), + ) + .addOption( + new Option("--host ", "HTTP server host (http mode only)").default(DEFAULT_HOST), + ) + .addOption( + new Option("--port ", "HTTP server port (http mode only)") + .default(DEFAULT_PORT) + .argParser(parsePort), + ) + .option("-v, --verbose", "Enable verbose logging", false) + .addOption( + new Option("--log-level ", "Logging level") + .choices(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]) + .default("INFO"), + ) + .exitOverride((err) => { + // --help / --version already printed their output; exit cleanly rather + // than surfacing them as configuration errors. + if (err.code === "commander.helpDisplayed" || err.code === "commander.version") { + process.exit(0); + } + throw new ValidationError(err.message); + }); + + try { + if (argv === undefined) { + program.parse(); + } else { + program.parse(argv, { from: "user" }); + } + } catch (e) { + if (e instanceof ValidationError) throw e; + throw new ValidationError(e instanceof Error ? e.message : String(e)); + } + + const opts = program.opts(); + const mode = opts.transport as "stdio" | "http"; + const host = opts.host as string; + const port = opts.port as number; + + // HTTP host/port are meaningless for stdio; reject them so a misconfigured + // command fails loudly instead of silently ignoring the flags. + if (mode !== "http" && (host !== DEFAULT_HOST || port !== DEFAULT_PORT)) { + throw new ValidationError( + "--host and --port options are only valid with --transport http", + ); + } + + return { + transport: { mode, host, port }, + verbose: Boolean(opts.verbose), + logLevel: opts.logLevel as string, + }; +} From 6d871c368ba606c48a8194d6c7e1439029a59fb2 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 24 Jun 2026 23:27:54 -0600 Subject: [PATCH 56/62] add tests for CLI parsing covering defaults, http overrides, and the host/port validation error --- typescript/__tests__/config/cli.test.ts | 60 +++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 typescript/__tests__/config/cli.test.ts diff --git a/typescript/__tests__/config/cli.test.ts b/typescript/__tests__/config/cli.test.ts new file mode 100644 index 0000000..ed1186e --- /dev/null +++ b/typescript/__tests__/config/cli.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { parseCliArgs } from "../../src/config/cli.js"; +import { ValidationError } from "../../src/exceptions.js"; + +describe("parseCliArgs", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns stdio defaults with no args", () => { + expect(parseCliArgs([])).toEqual({ + transport: { mode: "stdio", host: "localhost", port: 8000 }, + verbose: false, + logLevel: "INFO", + }); + }); + + it("parses http transport with custom host and port", () => { + expect(parseCliArgs(["-t", "http", "--host", "0.0.0.0", "--port", "8080"])).toEqual({ + transport: { mode: "http", host: "0.0.0.0", port: 8080 }, + verbose: false, + logLevel: "INFO", + }); + }); + + it("parses verbose and log level", () => { + const config = parseCliArgs(["--verbose", "--log-level", "DEBUG"]); + expect(config.verbose).toBe(true); + expect(config.logLevel).toBe("DEBUG"); + }); + + it("rejects --host without http transport", () => { + expect(() => parseCliArgs(["--host", "0.0.0.0"])).toThrow(ValidationError); + expect(() => parseCliArgs(["--host", "0.0.0.0"])).toThrow( + "--host and --port options are only valid with --transport http", + ); + }); + + it("rejects --port without http transport", () => { + expect(() => parseCliArgs(["--port", "9000"])).toThrow( + "--host and --port options are only valid with --transport http", + ); + }); + + it("rejects an invalid transport choice", () => { + // commander prints the error to stderr before throwing; silence it. + vi.spyOn(process.stderr, "write").mockReturnValue(true); + expect(() => parseCliArgs(["--transport", "bogus"])).toThrow(ValidationError); + }); + + it("rejects an invalid log level choice", () => { + vi.spyOn(process.stderr, "write").mockReturnValue(true); + expect(() => parseCliArgs(["--log-level", "TRACE"])).toThrow(ValidationError); + }); + + it("rejects a non-numeric port", () => { + vi.spyOn(process.stderr, "write").mockReturnValue(true); + expect(() => parseCliArgs(["-t", "http", "--port", "abc"])).toThrow(ValidationError); + }); +}); From 62a7bd8ffd7e49ad653bc7d8543ac01136ac65f5 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 24 Jun 2026 23:31:25 -0600 Subject: [PATCH 57/62] add createMcpServer registering all 10 tools with zod schemas and annotations ported from server.py --- typescript/src/server.ts | 241 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 typescript/src/server.ts diff --git a/typescript/src/server.ts b/typescript/src/server.ts new file mode 100644 index 0000000..073b415 --- /dev/null +++ b/typescript/src/server.ts @@ -0,0 +1,241 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { manager as defaultManager } from "./core/manager.js"; +import type { OpenROADManager } from "./core/manager.js"; +import { + CreateSessionTool, + ExecShellTool, + InspectSessionTool, + ListSessionsTool, + QueryShellTool, + SessionHistoryTool, + SessionMetricsTool, + TerminateSessionTool, +} from "./tools/interactive.js"; +import { ListReportImagesTool, ReadReportImageTool } from "./tools/report_images.js"; + +const VERSION = "0.5.0"; + +/** Wrap a tool's JSON-string result in the MCP text-content envelope. */ +function text(value: string): { content: [{ type: "text"; text: string }] } { + return { content: [{ type: "text" as const, text: value }] }; +} + +/** + * Build an McpServer with all 10 tools registered. Accepts an optional manager + * so tests can inject an isolated/mocked one; defaults to the module singleton. + * + * Tool names, descriptions, input params, and annotations mirror the Python + * server.py verbatim so the wire contract is unchanged across the migration. + */ +export function createMcpServer(manager: OpenROADManager = defaultManager): McpServer { + const mcp = new McpServer({ name: "openroad-mcp", version: VERSION }); + + const queryTool = new QueryShellTool(manager); + const execTool = new ExecShellTool(manager); + const listSessionsTool = new ListSessionsTool(manager); + const createSessionTool = new CreateSessionTool(manager); + const terminateSessionTool = new TerminateSessionTool(manager); + const inspectSessionTool = new InspectSessionTool(manager); + const sessionHistoryTool = new SessionHistoryTool(manager); + const sessionMetricsTool = new SessionMetricsTool(manager); + const listReportImagesTool = new ListReportImagesTool(manager); + const readReportImageTool = new ReadReportImageTool(manager); + + mcp.registerTool( + "interactive_openroad_query", + { + description: + "Execute a read-only OpenROAD command (report_*, get_*, check_*, sta, help, etc.). " + + "Use this for querying design state, generating reports, and inspecting timing. " + + "Commands that modify design state are blocked — use interactive_openroad_exec instead.", + inputSchema: { + command: z.string(), + session_id: z.string().optional(), + timeout_ms: z.number().int().optional(), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + }, + async (args) => text(await queryTool.execute(args.command, args.session_id, args.timeout_ms)), + ); + + mcp.registerTool( + "interactive_openroad_exec", + { + description: + "Execute a state-modifying OpenROAD command (set_*, create_*, read_*, write_*, flow commands). " + + "Use this for loading designs, running placement/routing, applying constraints, and writing " + + "output files. Read-only commands are blocked — use interactive_openroad_query instead.", + inputSchema: { + command: z.string(), + session_id: z.string().optional(), + timeout_ms: z.number().int().optional(), + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }, + }, + async (args) => text(await execTool.execute(args.command, args.session_id, args.timeout_ms)), + ); + + mcp.registerTool( + "list_interactive_sessions", + { + description: "List all active interactive OpenROAD sessions.", + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async () => text(await listSessionsTool.execute()), + ); + + mcp.registerTool( + "create_interactive_session", + { + description: "Create a new interactive OpenROAD session.", + inputSchema: { + session_id: z.string().optional(), + command: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), + cwd: z.string().optional(), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + }, + async (args) => + text(await createSessionTool.execute(args.session_id, args.command, args.env, args.cwd)), + ); + + mcp.registerTool( + "terminate_interactive_session", + { + description: "Terminate an interactive OpenROAD session.", + inputSchema: { + session_id: z.string(), + force: z.boolean().optional(), + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + }, + async (args) => text(await terminateSessionTool.execute(args.session_id, args.force ?? false)), + ); + + mcp.registerTool( + "inspect_interactive_session", + { + description: "Get detailed inspection data for an interactive OpenROAD session.", + inputSchema: { session_id: z.string() }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async (args) => text(await inspectSessionTool.execute(args.session_id)), + ); + + mcp.registerTool( + "get_session_history", + { + description: "Get command history for an interactive OpenROAD session.", + inputSchema: { + session_id: z.string(), + limit: z.number().int().optional(), + search: z.string().optional(), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async (args) => + text(await sessionHistoryTool.execute(args.session_id, args.limit, args.search)), + ); + + mcp.registerTool( + "get_session_metrics", + { + description: "Get comprehensive metrics for all interactive OpenROAD sessions.", + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async () => text(await sessionMetricsTool.execute()), + ); + + mcp.registerTool( + "list_report_images", + { + description: "List available report images from ORFS runs organized by stage.", + inputSchema: { + platform: z.string(), + design: z.string(), + run_slug: z.string(), + stage: z.string().optional(), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async (args) => + text(await listReportImagesTool.execute(args.platform, args.design, args.run_slug, args.stage)), + ); + + mcp.registerTool( + "read_report_image", + { + description: "Read a report image and return base64-encoded data with metadata.", + inputSchema: { + platform: z.string(), + design: z.string(), + run_slug: z.string(), + image_name: z.string(), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async (args) => + text( + await readReportImageTool.execute( + args.platform, + args.design, + args.run_slug, + args.image_name, + ), + ), + ); + + return mcp; +} From 9994b8ca6ed2f342f2da1f5239de431f88be0528 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 24 Jun 2026 23:38:20 -0600 Subject: [PATCH 58/62] add runServer with stdio and stateless http transports so the server shuts down on signal or transport close --- typescript/src/server.ts | 94 ++++++++++++++++++++++++++++++++++++++++ typescript/tsconfig.json | 6 ++- 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/typescript/src/server.ts b/typescript/src/server.ts index 073b415..535d157 100644 --- a/typescript/src/server.ts +++ b/typescript/src/server.ts @@ -1,7 +1,14 @@ +import { createServer } from "node:http"; +import type { IncomingMessage, ServerResponse } from "node:http"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { z } from "zod"; +import type { CLIConfig } from "./config/cli.js"; import { manager as defaultManager } from "./core/manager.js"; import type { OpenROADManager } from "./core/manager.js"; +import { cleanupManager } from "./utils/cleanup.js"; +import { getLogger } from "./utils/logging.js"; import { CreateSessionTool, ExecShellTool, @@ -14,6 +21,8 @@ import { } from "./tools/interactive.js"; import { ListReportImagesTool, ReadReportImageTool } from "./tools/report_images.js"; +const logger = getLogger("server"); + const VERSION = "0.5.0"; /** Wrap a tool's JSON-string result in the MCP text-content envelope. */ @@ -239,3 +248,88 @@ export function createMcpServer(manager: OpenROADManager = defaultManager): McpS return mcp; } + +// Module-level server instance for the production entrypoint. Tests build their +// own isolated server via createMcpServer(). +export const mcp = createMcpServer(); + +/** Terminate every live session so shutdown does not leak OpenROAD processes. */ +export async function shutdownOpenroad(): Promise { + logger.info("Initiating graceful shutdown..."); + try { + await defaultManager.cleanupAll(); + logger.info("Graceful shutdown complete"); + } catch (e) { + logger.error(`Error during shutdown: ${e instanceof Error ? e.message : String(e)}`); + } +} + +/** Collect a request body and JSON-parse it, rejecting malformed payloads. */ +async function readJsonBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + const raw = Buffer.concat(chunks).toString("utf8"); + if (raw.length === 0) return undefined; + return JSON.parse(raw); +} + +/** + * Boot the MCP server for the configured transport and block until shutdown. + * + * stdio is the primary npx path. http uses a stateless streamable-HTTP + * transport — session continuity is provided by OpenROADManager keying on its + * own session_id, so no MCP-level session state is needed. Either way the + * lifecycle ends on a signal (SIGTERM/SIGINT) or transport close, after which + * every session is cleaned up. + */ +export async function runServer(config: CLIConfig): Promise { + cleanupManager.registerAsyncCleanupHandler(shutdownOpenroad); + cleanupManager.setupSignalHandlers(); + + try { + if (config.transport.mode === "stdio") { + // A client disconnect / stdin EOF closes the transport; treat that as a + // shutdown so the process does not hang waiting for a signal. + mcp.server.onclose = (): void => cleanupManager.triggerShutdown(); + const transport = new StdioServerTransport(); + await mcp.connect(transport); + logger.info("MCP server running on stdio transport"); + await cleanupManager.waitForShutdown(); + } else { + // Omitting sessionIdGenerator selects stateless mode (no MCP session + // tracking); OpenROADManager owns session continuity via its session_id. + const transport = new StreamableHTTPServerTransport(); + // The SDK's streamable-HTTP transport types its onclose as + // `(() => void) | undefined`, which trips exactOptionalPropertyTypes + // against the Transport interface; the runtime contract is unaffected. + await mcp.connect(transport as unknown as Parameters[0]); + + const httpServer = createServer((req: IncomingMessage, res: ServerResponse): void => { + void (async (): Promise => { + try { + const body = req.method === "POST" ? await readJsonBody(req) : undefined; + await transport.handleRequest(req, res, body); + } catch (e) { + logger.error(`HTTP request error: ${e instanceof Error ? e.message : String(e)}`); + if (!res.headersSent) { + res.writeHead(400, { "Content-Type": "application/json" }).end( + JSON.stringify({ error: "Invalid request body" }), + ); + } + } + })(); + }); + + httpServer.listen(config.transport.port, config.transport.host); + logger.info( + `MCP server running on http transport at ${config.transport.host}:${config.transport.port}`, + ); + await cleanupManager.waitForShutdown(); + await new Promise((resolve) => httpServer.close(() => { resolve(); })); + } + } finally { + await cleanupManager.runHandlers(); + } +} diff --git a/typescript/tsconfig.json b/typescript/tsconfig.json index 7dfd322..1f85c9b 100644 --- a/typescript/tsconfig.json +++ b/typescript/tsconfig.json @@ -37,7 +37,11 @@ "verbatimModuleSyntax": true, "isolatedModules": true, "moduleDetection": "force", - "skipLibCheck": false + // The @modelcontextprotocol/sdk streamable-HTTP transport's .d.ts does not + // satisfy exactOptionalPropertyTypes (its class onclose is typed as + // `(() => void) | undefined`), so lib checking must be skipped to consume + // it. Our own source is still fully type-checked. + "skipLibCheck": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "__tests__"] From dd04877a4046c38f7462786bc876d0438e2e5fff Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 24 Jun 2026 23:40:25 -0600 Subject: [PATCH 59/62] add a smoke test booting the stdio server in-memory so all 10 tools are asserted to enumerate with correct annotations --- typescript/__tests__/server.test.ts | 82 +++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 typescript/__tests__/server.test.ts diff --git a/typescript/__tests__/server.test.ts b/typescript/__tests__/server.test.ts new file mode 100644 index 0000000..9d80f5a --- /dev/null +++ b/typescript/__tests__/server.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { createMcpServer } from "../src/server.js"; +import type { OpenROADManager } from "../src/core/manager.js"; + +// node-pty must never spawn during a boot/list-tools smoke test. +vi.mock("node-pty", () => ({ spawn: vi.fn() })); + +const EXPECTED_TOOLS = [ + "interactive_openroad_query", + "interactive_openroad_exec", + "list_interactive_sessions", + "create_interactive_session", + "terminate_interactive_session", + "inspect_interactive_session", + "get_session_history", + "get_session_metrics", + "list_report_images", + "read_report_image", +].sort(); + +/** Minimal manager stub: listing tools needs no calls; one round-trip uses listSessions. */ +function makeMockManager(): OpenROADManager { + return { + listSessions: vi.fn().mockResolvedValue([]), + } as unknown as OpenROADManager; +} + +async function connectClient(manager: OpenROADManager): Promise { + const server = createMcpServer(manager); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "test-client", version: "0.0.0" }); + await client.connect(clientTransport); + return client; +} + +describe("createMcpServer over MCP", () => { + it("enumerates exactly the 10 expected tools", async () => { + const client = await connectClient(makeMockManager()); + const { tools } = await client.listTools(); + + expect(tools.map((t) => t.name).sort()).toEqual(EXPECTED_TOOLS); + await client.close(); + }); + + it("carries the correct behaviour annotations", async () => { + const client = await connectClient(makeMockManager()); + const { tools } = await client.listTools(); + const byName = new Map(tools.map((t) => [t.name, t])); + + expect(byName.get("interactive_openroad_query")?.annotations).toMatchObject({ + readOnlyHint: true, + destructiveHint: false, + }); + expect(byName.get("interactive_openroad_exec")?.annotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: true, + }); + expect(byName.get("list_interactive_sessions")?.annotations).toMatchObject({ + readOnlyHint: true, + idempotentHint: true, + }); + await client.close(); + }); + + it("round-trips a tool call returning a JSON string in text content", async () => { + const manager = makeMockManager(); + const client = await connectClient(manager); + + const result = await client.callTool({ name: "list_interactive_sessions" }); + const content = result.content as Array<{ type: string; text: string }>; + + expect(content[0]?.type).toBe("text"); + const parsed = JSON.parse(content[0]!.text) as { sessions: unknown[]; total_count: number }; + expect(parsed.sessions).toEqual([]); + expect(parsed.total_count).toBe(0); + expect(manager.listSessions).toHaveBeenCalledOnce(); + await client.close(); + }); +}); From e7159b10457a112518f7c9b6c628a216a9d97dfa Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 24 Jun 2026 23:44:56 -0600 Subject: [PATCH 60/62] replace the main.ts stub with the full entrypoint, dynamically importing logging and server so settings validate before any logger is built and the CLI log level applies first --- typescript/src/main.ts | 46 +++++++++++++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/typescript/src/main.ts b/typescript/src/main.ts index 697dd4e..c49242b 100644 --- a/typescript/src/main.ts +++ b/typescript/src/main.ts @@ -1,10 +1,42 @@ +#!/usr/bin/env node +import { parseCliArgs } from "./config/cli.js"; import { initSettings } from "./config/settings.js"; +import { EXIT_CODE_ERROR } from "./constants.js"; +import { ValidationError } from "./exceptions.js"; -// Initialise settings up front so a misconfigured env var fails fast with -// context rather than crashing later from inside module initialisation. -try { - initSettings(); -} catch (e) { - console.error(e instanceof Error ? e.message : String(e)); - process.exit(1); +/** + * Entry point. The eager work (settings validation, CLI parsing) runs before + * any module that reads settings or builds a logger is imported. + * + * This ordering matters and is why `logging` and `server` are dynamic imports: + * `utils/logging` calls `getSettings()` at module load to seed the root logger, + * and `server` builds the manager (and its child logger) at load. Importing + * either statically would validate settings before main()'s try/catch (turning + * a bad env var into an uncaught stack trace) and create loggers before the CLI + * log level is applied. Only pure modules are imported statically here. + */ +async function main(): Promise { + // Fail fast on a misconfigured env var; surface it as a configuration error. + try { + initSettings(); + } catch (e) { + throw new ValidationError(e instanceof Error ? e.message : String(e)); + } + + const config = parseCliArgs(); + + const { setupLogging } = await import("./utils/logging.js"); + setupLogging(config.verbose ? "DEBUG" : config.logLevel); + + const { runServer } = await import("./server.js"); + await runServer(config); } + +main().catch((e: unknown) => { + if (e instanceof ValidationError) { + console.error(`Configuration error: ${e.message}`); + process.exit(EXIT_CODE_ERROR); + } + console.error(`Unexpected error: ${e instanceof Error ? e.message : String(e)}`); + process.exit(EXIT_CODE_ERROR); +}); From 58885de74ce5db410343240bfe20ce257cdcaf47 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Wed, 24 Jun 2026 23:58:56 -0600 Subject: [PATCH 61/62] create a fresh streamable-http transport and server per request so concurrent stateless clients do not collide on JSON-RPC request ids --- typescript/src/server.ts | 56 +++++++++++++++++++++++++--------------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/typescript/src/server.ts b/typescript/src/server.ts index 535d157..2715bcb 100644 --- a/typescript/src/server.ts +++ b/typescript/src/server.ts @@ -275,6 +275,40 @@ async function readJsonBody(req: IncomingMessage): Promise { return JSON.parse(raw); } +/** + * Handle one HTTP request in stateless mode. The SDK forbids reusing a + * streamable-HTTP transport across requests — a shared transport keys its + * request→stream map by JSON-RPC id, so two clients both numbering from 1 would + * collide. A fresh server + transport per request keeps clients isolated; both + * are torn down when the response closes. Session continuity is unaffected + * because OpenROADManager owns it via its own session_id, independent of MCP. + */ +async function handleHttpRequest(req: IncomingMessage, res: ServerResponse): Promise { + const requestServer = createMcpServer(); + const transport = new StreamableHTTPServerTransport(); + res.on("close", () => { + void transport.close(); + void requestServer.close(); + }); + try { + // The SDK's streamable-HTTP transport types its onclose as + // `(() => void) | undefined`, which trips exactOptionalPropertyTypes against + // the Transport interface; the runtime contract is unaffected. + await requestServer.connect( + transport as unknown as Parameters[0], + ); + const body = req.method === "POST" ? await readJsonBody(req) : undefined; + await transport.handleRequest(req, res, body); + } catch (e) { + logger.error(`HTTP request error: ${e instanceof Error ? e.message : String(e)}`); + if (!res.headersSent) { + res.writeHead(400, { "Content-Type": "application/json" }).end( + JSON.stringify({ error: "Invalid request body" }), + ); + } + } +} + /** * Boot the MCP server for the configured transport and block until shutdown. * @@ -298,28 +332,8 @@ export async function runServer(config: CLIConfig): Promise { logger.info("MCP server running on stdio transport"); await cleanupManager.waitForShutdown(); } else { - // Omitting sessionIdGenerator selects stateless mode (no MCP session - // tracking); OpenROADManager owns session continuity via its session_id. - const transport = new StreamableHTTPServerTransport(); - // The SDK's streamable-HTTP transport types its onclose as - // `(() => void) | undefined`, which trips exactOptionalPropertyTypes - // against the Transport interface; the runtime contract is unaffected. - await mcp.connect(transport as unknown as Parameters[0]); - const httpServer = createServer((req: IncomingMessage, res: ServerResponse): void => { - void (async (): Promise => { - try { - const body = req.method === "POST" ? await readJsonBody(req) : undefined; - await transport.handleRequest(req, res, body); - } catch (e) { - logger.error(`HTTP request error: ${e instanceof Error ? e.message : String(e)}`); - if (!res.headersSent) { - res.writeHead(400, { "Content-Type": "application/json" }).end( - JSON.stringify({ error: "Invalid request body" }), - ); - } - } - })(); + void handleHttpRequest(req, res); }); httpServer.listen(config.transport.port, config.transport.host); From dfff83d65e37913929794c17de1d1b677944da91 Mon Sep 17 00:00:00 2001 From: kartikloops Date: Thu, 25 Jun 2026 00:08:20 -0600 Subject: [PATCH 62/62] cleanup --- typescript/src/main.ts | 1 - typescript/src/server.ts | 21 +++++++-------------- typescript/src/utils/cleanup.ts | 4 ---- 3 files changed, 7 insertions(+), 19 deletions(-) diff --git a/typescript/src/main.ts b/typescript/src/main.ts index c49242b..b8cc382 100644 --- a/typescript/src/main.ts +++ b/typescript/src/main.ts @@ -16,7 +16,6 @@ import { ValidationError } from "./exceptions.js"; * log level is applied. Only pure modules are imported statically here. */ async function main(): Promise { - // Fail fast on a misconfigured env var; surface it as a configuration error. try { initSettings(); } catch (e) { diff --git a/typescript/src/server.ts b/typescript/src/server.ts index 2715bcb..2330dee 100644 --- a/typescript/src/server.ts +++ b/typescript/src/server.ts @@ -25,7 +25,6 @@ const logger = getLogger("server"); const VERSION = "0.5.0"; -/** Wrap a tool's JSON-string result in the MCP text-content envelope. */ function text(value: string): { content: [{ type: "text"; text: string }] } { return { content: [{ type: "text" as const, text: value }] }; } @@ -253,7 +252,6 @@ export function createMcpServer(manager: OpenROADManager = defaultManager): McpS // own isolated server via createMcpServer(). export const mcp = createMcpServer(); -/** Terminate every live session so shutdown does not leak OpenROAD processes. */ export async function shutdownOpenroad(): Promise { logger.info("Initiating graceful shutdown..."); try { @@ -264,7 +262,6 @@ export async function shutdownOpenroad(): Promise { } } -/** Collect a request body and JSON-parse it, rejecting malformed payloads. */ async function readJsonBody(req: IncomingMessage): Promise { const chunks: Buffer[] = []; for await (const chunk of req) { @@ -277,11 +274,11 @@ async function readJsonBody(req: IncomingMessage): Promise { /** * Handle one HTTP request in stateless mode. The SDK forbids reusing a - * streamable-HTTP transport across requests — a shared transport keys its - * request→stream map by JSON-RPC id, so two clients both numbering from 1 would - * collide. A fresh server + transport per request keeps clients isolated; both - * are torn down when the response closes. Session continuity is unaffected - * because OpenROADManager owns it via its own session_id, independent of MCP. + * streamable-HTTP transport across requests: a shared transport keys its + * request-to-stream map by JSON-RPC id, so two clients both numbering from 1 + * would collide. A fresh server + transport per request keeps clients isolated; + * both are torn down when the response closes. OpenROADManager owns session + * continuity via its own session_id, independent of MCP. */ async function handleHttpRequest(req: IncomingMessage, res: ServerResponse): Promise { const requestServer = createMcpServer(); @@ -311,12 +308,8 @@ async function handleHttpRequest(req: IncomingMessage, res: ServerResponse): Pro /** * Boot the MCP server for the configured transport and block until shutdown. - * - * stdio is the primary npx path. http uses a stateless streamable-HTTP - * transport — session continuity is provided by OpenROADManager keying on its - * own session_id, so no MCP-level session state is needed. Either way the - * lifecycle ends on a signal (SIGTERM/SIGINT) or transport close, after which - * every session is cleaned up. + * Lifecycle ends on SIGTERM/SIGINT or transport close, then every session is + * cleaned up. */ export async function runServer(config: CLIConfig): Promise { cleanupManager.registerAsyncCleanupHandler(shutdownOpenroad); diff --git a/typescript/src/utils/cleanup.ts b/typescript/src/utils/cleanup.ts index aec7503..969d5f9 100644 --- a/typescript/src/utils/cleanup.ts +++ b/typescript/src/utils/cleanup.ts @@ -23,12 +23,10 @@ export class CleanupManager { this.resolveShutdown = resolve; }); - /** Register a handler to run during graceful shutdown (sync or async). */ registerAsyncCleanupHandler(handler: CleanupHandler): void { this.handlers.push(handler); } - /** Install SIGTERM/SIGINT handlers that trigger shutdown and arm a force-exit. */ setupSignalHandlers(): void { const onSignal = (signal: NodeJS.Signals): void => { if (this.shutdownInitiated) return; @@ -56,12 +54,10 @@ export class CleanupManager { this.resolveShutdown?.(); } - /** Resolves once shutdown is triggered. */ async waitForShutdown(): Promise { await this.shutdownPromise; } - /** Run every registered cleanup handler, isolating failures. */ async runHandlers(): Promise { for (const handler of this.handlers) { try {