diff --git a/typescript/__tests__/config/command_whitelist.test.ts b/typescript/__tests__/config/command_whitelist.test.ts new file mode 100644 index 0000000..6451e78 --- /dev/null +++ b/typescript/__tests__/config/command_whitelist.test.ts @@ -0,0 +1,332 @@ +import { describe, it, expect } from "vitest"; +import { + BLOCKED_COMMANDS, + EXEC_ONLY_PATTERNS, + READONLY_PATTERNS, + extractVerb, + isCommandAllowed, + isExecCommand, + isQueryCommand, +} from "../../src/config/command_whitelist.js"; + +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"); + }); +}); + +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 safe Tcl builtins", () => { + expect(READONLY_PATTERNS).toContain("puts"); + 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", () => { + 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); + }); +}); + +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"]); + }); + + 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 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"]); + }); + + 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]); + }); +}); + +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"]); + }); +}); + +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"]); + }); +}); + + +describe("glob parity (minimatch vs fnmatch)", () => { + it("matches star-suffix against the empty remainder", () => { + 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", () => { + expect(isQueryCommand("Report_Checks")).toEqual([false, "Report_Checks"]); + }); +}); diff --git a/typescript/__tests__/config/settings.test.ts b/typescript/__tests__/config/settings.test.ts index 1594a74..799cf97 100644 --- a/typescript/__tests__/config/settings.test.ts +++ b/typescript/__tests__/config/settings.test.ts @@ -115,11 +115,26 @@ 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"); }); + 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/__tests__/core/manager.test.ts b/typescript/__tests__/core/manager.test.ts new file mode 100644 index 0000000..976356f --- /dev/null +++ b/typescript/__tests__/core/manager.test.ts @@ -0,0 +1,267 @@ +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; + checkAlive: 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(), + checkAlive: 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); + // 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); + }); + }); + + 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); + }); + + 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", () => { + 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); + }); + }); +}); 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/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(), diff --git a/typescript/__tests__/interactive/session.test.ts b/typescript/__tests__/interactive/session.test.ts index 504b56b..ce6a575 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() })); @@ -39,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(); }); @@ -216,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"); @@ -226,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; @@ -255,17 +255,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); }); @@ -286,13 +286,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); }); }); @@ -326,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(); }); }); @@ -419,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); }); @@ -436,7 +434,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 () => { @@ -541,7 +539,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 () => { @@ -598,4 +596,130 @@ 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("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("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"); + + 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("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(); + + 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/__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}"`; diff --git a/typescript/__tests__/tools/interactive.test.ts b/typescript/__tests__/tools/interactive.test.ts new file mode 100644 index 0000000..4db43df --- /dev/null +++ b/typescript/__tests__/tools/interactive.test.ts @@ -0,0 +1,453 @@ +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"; + +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()), + }; +} + +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 () => { + 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); + }); +}); + +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"); + }); +}); + +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([]); + }); +}); + +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"); + }); +}); + +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"); + }); +}); + +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"); + }); +}); + +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"); + }); +}); + +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"); + }); +}); + +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(); + }); +}); diff --git a/typescript/__tests__/tools/report_images.test.ts b/typescript/__tests__/tools/report_images.test.ts new file mode 100644 index 0000000..44a41c6 --- /dev/null +++ b/typescript/__tests__/tools/report_images.test.ts @@ -0,0 +1,403 @@ +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 do not depend on a filesystem ORFS install. +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"; + +let tmpDir: string; + +function createFixture( + platform = "nangate45", + design = "gcd", + runSlug = "run-123", + imageFiles: string[] = ["cts_clk.webp", "final_all.webp"], +) { + const flowPath = tmpDir; + fs.mkdirSync(path.join(flowPath, "platforms", platform), { recursive: true }); + fs.mkdirSync(path.join(flowPath, "designs", platform, design), { recursive: true }); + 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 }; +} + +// 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-")); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + vi.clearAllMocks(); +}); + +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"); + }); +}); + +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(); + }); +}); + +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 } = 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"); + }); +}); + +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); + expect(typeof result.image_data).toBe("string"); + expect(result.image_data.length).toBeGreaterThan(0); + const decoded = Buffer.from(result.image_data, "base64"); + expect(decoded.length).toBeGreaterThan(0); + 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", []); + 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"); + }); +}); + +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 () => { + 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 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); + }); +}); + +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(); + }); +}); 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/__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/scripts/integration_check.ts b/typescript/scripts/integration_check.ts index ce61330..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.isAlive()); + 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.isAlive()); + 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 new file mode 100644 index 0000000..87c1fd1 --- /dev/null +++ b/typescript/src/config/command_whitelist.ts @@ -0,0 +1,205 @@ +/** + * Command filter for OpenROAD PTY session security. + * + * 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. + * + * Distinct from PtyHandler.validateCommand(), which guards the shell binary. + * 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"); + +const MINIMATCH_OPTS = { dot: true } as const; + +function matchVerb(verb: string, pattern: string): boolean { + return minimatch(verb, pattern, MINIMATCH_OPTS); +} + +export const BLOCKED_COMMANDS: ReadonlySet = new Set([ + "quit", + "socket", + "load", + "glob", + "fconfigure", + "chan", + "vwait", + "rename", + "after", + "subst", +]); + +export const EXEC_ONLY_PATTERNS: readonly string[] = [ + "exec", + "source", + "exit", + "open", + "close", + "file", + "cd", + "uplevel", + "set_*", + "create_*", + "read_*", + "write_*", + "initialize_floorplan", + "place_pins", + "global_placement", + "detailed_placement", + "clock_tree_synthesis", + "global_route", + "detailed_route", + "repair_design", + "repair_timing", + "repair_clock_nets", + "log_begin", + "log_end", +]; + +// Intentionally excludes body-eval builtins (if, for, foreach, while, proc, +// catch, namespace, uplevel) because they accept a script body argument and +// can wrap any dangerous command: `catch { exec ls }` would pass the verb +// check on `catch` alone. +export const _TCL_BUILTINS: readonly string[] = [ + "puts", + "set", + "expr", + "return", + "break", + "continue", + "list", + "llength", + "lindex", + "lappend", + "lrange", + "lsort", + "lsearch", + "lreplace", + "string", + "regexp", + "regsub", + "format", + "scan", + "array", + "dict", + "error", + "upvar", + "global", + "variable", + "concat", + "join", + "split", + "incr", + "append", + "info", + "unset", +]; + +export const READONLY_PATTERNS: readonly string[] = [ + "report_*", + "get_*", + "check_*", + "estimate_parasitics", + "sta", + "help", + "version", + ..._TCL_BUILTINS, +]; + +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(/;+$/, ""); +} + +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 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)) { + const verb = extractVerb(stmt); + if (verb !== null) { + yield verb; + } + } + for (const match of command.matchAll(/\[\s*(?::+)?(\w+)/g)) { + yield match[1]!; + } +} + +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]; +} + +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]; +} + +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 97fbbc6..30f368b 100644 --- a/typescript/src/config/settings.ts +++ b/typescript/src/config/settings.ts @@ -18,14 +18,18 @@ 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.`); + if (!Number.isFinite(n) || n < 0) { + throw new Error(`Invalid value for ${envKey}: ${val}. Expected a non-negative finite float.`); + } return n; } 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); + if (n < 0) throw new Error(`Invalid value for ${envKey}: ${val}. Expected a non-negative integer.`); + return n; } export class Settings { @@ -86,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]> = [ @@ -141,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(); @@ -156,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 2f5e58e..8fb51eb 100644 --- a/typescript/src/constants.ts +++ b/typescript/src/constants.ts @@ -1,26 +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; +// 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 new file mode 100644 index 0000000..2ee93df --- /dev/null +++ b/typescript/src/core/manager.ts @@ -0,0 +1,324 @@ +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. + * + * 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"); + 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 { + // 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); + // 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); + 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); + + // 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); + this.logger.info(`Terminated session ${sessionId}`); + + await this.cleanupLock.runExclusive(() => { + this.sessions.delete(sessionId); + }); + } + + async terminateAllSessions(force = false): Promise { + // 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; + + 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 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)}`); + } + } + + // 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; + + 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); + this.logger.info("OpenROAD cleanup completed"); + } + + getSessionCount(): number { + return this.sessions.size; + } + + getActiveSessionCount(): number { + return this._countActive(); + } + + private _countActive(): number { + let count = 0; + for (const session of this.sessions.values()) { + if (session !== null && session.checkAlive()) 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.checkAlive()) { + // 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]); + } + } + + 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 { + try { + await session.cleanup(); + } finally { + 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; + } +} + +export const manager = new OpenROADManager(); diff --git a/typescript/src/core/models.ts b/typescript/src/core/models.ts index 8d4d00e..e30e933 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,16 @@ export enum SessionState { ERROR = "error", } +export enum ProcessState { + STOPPED = "stopped", + STARTING = "starting", + RUNNING = "running", + ERROR = "error", +} + +// camelCase domain interfaces are converted to snake_case MCP wire format +// at the tool serialization boundary (BaseTool.formatResult). + export interface InteractiveSessionInfo { sessionId: string; createdAt: string; @@ -25,3 +37,156 @@ export interface InteractiveExecResult { bufferSize: number; error?: string | null; } + +// Opaque snake_case payloads passed straight through to the wire (no +// camel->snake conversion). + +export interface CommandHistoryEntry { + command: string; + timestamp: string; + command_number: number; + execution_start: number; + execution_time?: number; + output_length?: number; +} + +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; + }; +} + +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[]; +} + +// 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({ + command: z.string(), + timestamp: z.string(), + command_number: 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; + +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/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 fafa0cf..4b5d81d 100644 --- a/typescript/src/interactive/pty_handler.ts +++ b/typescript/src/interactive/pty_handler.ts @@ -15,6 +15,10 @@ export class PtyHandler { constructor(private readonly _settings: Settings = getSettings()) {} + get pid(): number | null { + return this._ptyProcess?.pid ?? null; + } + validateCommand(command: string[]): void { if (!this._settings.ENABLE_COMMAND_VALIDATION) return; @@ -84,17 +88,21 @@ export class PtyHandler { this._alive = true; this._exitCode = null; - if (onData) { - this._dataDisposable = this._ptyProcess.onData(onData); - } - + // 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; 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}`); @@ -113,12 +121,21 @@ export class PtyHandler { } isProcessAlive(): boolean { - return this._alive; + if (!this._alive || !this._ptyProcess) return false; + // 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; + } catch { + this._alive = false; + return false; + } } async waitForExit(timeoutMs?: number): Promise { - if (!this._ptyProcess) return null; if (this._exitCode !== null) return this._exitCode; + if (!this._ptyProcess) return null; return new Promise((resolve) => { let settled = false; @@ -169,9 +186,9 @@ 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 + // best effort } } @@ -185,6 +202,7 @@ export class PtyHandler { this._alive = false; this._dataDisposable = null; this._exitDisposable = null; - this._exitCode = null; + // 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 95c9644..ab8db2d 100644 --- a/typescript/src/interactive/session.ts +++ b/typescript/src/interactive/session.ts @@ -1,9 +1,22 @@ +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"; -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, + 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"; @@ -33,7 +46,18 @@ export class InteractiveSession { readonly createdAt: Date; commandCount = 0; + lastActivity: Date = new Date(); + readonly commandHistory: CommandHistoryEntry[] = []; + totalCpuTime = 0; + peakMemoryMb = 0; + totalCommandsExecuted = 0; + sessionTimeoutSeconds: number | null = null; + private _state: SessionState; + // 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; @@ -41,6 +65,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; @@ -55,15 +82,27 @@ export class InteractiveSession { } set state(value: SessionState) { + if (value === SessionState.TERMINATED && this._terminatedAt === null) { + this._terminatedAt = new Date(); + } this._state = value; } - isAlive(): boolean { + get terminatedAt(): Date | null { + return this._terminatedAt; + } + + /** + * 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; const processAlive = this.pty.isProcessAlive(); if (!processAlive && this._state === SessionState.ACTIVE) { - this._state = SessionState.TERMINATED; + this.state = SessionState.TERMINATED; this._signalShutdown(); return false; } @@ -92,14 +131,11 @@ 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(() => { - if (this._state === SessionState.ACTIVE) { - this._state = SessionState.TERMINATED; - } + this._markDead(); this._signalShutdown(); }); }; @@ -114,13 +150,18 @@ export class InteractiveSession { }, (_exitCode: number) => { if (this._state !== SessionState.TERMINATED) { - this._state = SessionState.TERMINATED; + this.state = SessionState.TERMINATED; this._signalShutdown(); } }, ); - this._state = SessionState.ACTIVE; + // 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; + } this._writerTask = this._writeInput(); } catch (e) { this._state = SessionState.ERROR; @@ -130,7 +171,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); } @@ -141,9 +182,23 @@ export class InteractiveSession { ); } + // 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, + }); + if (this.commandHistory.length > MAX_COMMAND_HISTORY) { + this.commandHistory.shift(); + } + 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(); @@ -152,15 +207,12 @@ 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 - // 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) { @@ -168,11 +220,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 +260,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, @@ -222,7 +279,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, @@ -231,33 +288,43 @@ export class InteractiveSession { } async terminate(force = false): Promise { - if (this._state === SessionState.TERMINATED) return; - - this._state = SessionState.TERMINATED; - this._signalShutdown(); + 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(); - 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 { @@ -266,6 +333,17 @@ export class InteractiveSession { for (const w of waiters) w(); } + /** + * 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) { + this.state = SessionState.TERMINATED; + } + } + private async _writeInput(): Promise { while (!this._isShutdown) { const data = await this._dequeueInput(1000); @@ -274,9 +352,7 @@ export class InteractiveSession { try { this.pty.writeInput(data); } catch { - if (this._state === SessionState.ACTIVE) { - this._state = SessionState.TERMINATED; - } + this._markDead(); this._signalShutdown(); break; } @@ -324,4 +400,138 @@ export class InteractiveSession { return undefined; } + + /** + * 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--) { + const entry = this.commandHistory[i]; + if (!entry || entry.execution_time !== undefined) break; + entry.execution_time = executionTime; + entry.output_length = outputLength; + } + this.lastActivity = new Date(); + } + + /** 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; + 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); + return currentMemoryMb; + } catch { + return 0; + } + } + + /** 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; + return uptime > this.sessionTimeoutSeconds; + } + + async getDetailedMetrics(): Promise { + const currentMemoryMb = 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.checkAlive(), + 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: currentMemoryMb, + }, + 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)); + } + + history.sort((a, b) => (a.timestamp < b.timestamp ? 1 : a.timestamp > b.timestamp ? -1 : 0)); + + // 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); + } + + 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 { + const needle = pattern.toLowerCase(); + matching = lines.filter((line) => line.toLowerCase().includes(needle)); + } + + return matching.length > 0 ? matching.slice(-maxLines) : []; + } } 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 new file mode 100644 index 0000000..83ccab0 --- /dev/null +++ b/typescript/src/tools/base.ts @@ -0,0 +1,36 @@ +import type { OpenROADManager } from "../core/manager.js"; + +function camelToSnakeKey(key: string): string { + return key.replace(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`); +} + +/** + * 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); + 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 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) {} + + protected formatResult(result: Record): string { + return JSON.stringify(toSnakeCase(result)); + } +} 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"; diff --git a/typescript/src/tools/interactive.ts b/typescript/src/tools/interactive.ts new file mode 100644 index 0000000..c153fc6 --- /dev/null +++ b/typescript/src/tools/interactive.ts @@ -0,0 +1,426 @@ +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"); + +/** 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}'`; +} + +function blankExecResult( + sessionId: string | null, + error: string, +): InteractiveExecResult { + return { + output: "", + sessionId, + timestamp: new Date().toISOString(), + executionTime: 0.0, + commandCount: 0, + bufferSize: 0, + error, + }; +} + +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), + }; +} + +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 })); +} + +/** + * 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, + 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; +} + +/** 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) { + // 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 */ }); + } + 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 (sid === null && resolvedId !== null) { + this.manager.terminateSession(resolvedId, true).catch(() => { /* best effort */ }); + } + 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, + ); + } + } +} + +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, + ); + } + } +} + +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); + } + } +} + +export class TerminateSessionTool extends BaseTool { + constructor(manager: OpenROADManager) { + super(manager); + } + + async execute(sessionId: string, force = false): Promise { + let wasAlive = false; + try { + const info = await this.manager.getSessionInfo(sessionId); + wasAlive = info.isAlive; + } catch (e) { + if (!(e instanceof SessionNotFoundError)) throw e; + } + + 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, + wasAlive, + error: String(e), + }) as unknown as Record, + ); + } + return this.formatResult( + SessionTerminationResult.parse({ + sessionId, + terminated: false, + wasAlive, + error: `Termination failed: ${(e as Error).message ?? String(e)}`, + }) as unknown as Record, + ); + } + } +} + +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, + ); + } + } +} + +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, + ); + } + } +} + +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, + ); + } + } +} + +export const InteractiveShellTool = QueryShellTool; diff --git a/typescript/src/tools/report_images.ts b/typescript/src/tools/report_images.ts new file mode 100644 index 0000000..8e68663 --- /dev/null +++ b/typescript/src/tools/report_images.ts @@ -0,0 +1,474 @@ +import fs from "node:fs"; +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"); + +const MAX_BASE64_SIZE_KB = 15; +const MAX_IMAGE_SIZE_MB = 50; + +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", +}; + +/** + * 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]; +} + +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"}`, + ); + } +} + +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]; +} + +function availableRuns(reportsBase: string): string[] { + try { + return fs + .readdirSync(reportsBase, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name); + } catch { + return []; + } +} + +/** 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 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; +} + +/** + * 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, + 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(); + 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) + .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, + }; + } +} + +/** 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++; + } + + 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, + ); + } + } +} + diff --git a/typescript/src/utils/ansi_decoder.ts b/typescript/src/utils/ansi_decoder.ts index 9e16ce4..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,7 +106,10 @@ export class ANSIDecoder { if (mode === "annotate") { let result = text; for (const seq of new Set(sequences)) { - result = result.replaceAll(seq, `[${ANSIDecoder.decodeEscapeSequence(seq)}]`); + // 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); } return result.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); } @@ -123,7 +117,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; } diff --git a/typescript/src/utils/logging.ts b/typescript/src/utils/logging.ts new file mode 100644 index 0000000..d8f3f15 --- /dev/null +++ b/typescript/src/utils/logging.ts @@ -0,0 +1,21 @@ +import pino from "pino"; +import { getSettings } from "../config/settings.js"; + +// 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); + +export function setupLogging(level: string): void { + rootLogger.level = level.toLowerCase(); +} + +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 0c57409..10507e1 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`); @@ -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 (;;) {