Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
a692277
add pino logger writing to stderr to keep stdout clean for mcp transport
kartikloops Jun 15, 2026
5b0f22a
port command whitelist with minimatch replacing fnmatch for verb matc…
kartikloops Jun 15, 2026
d9140e7
add zod result schemas using nullable default null to match pydantic …
kartikloops Jun 15, 2026
dbaea3c
expose pty process pid getter for session performance sampling
kartikloops Jun 15, 2026
56a08fa
add session metrics, command history, and idle timeout tracking
kartikloops Jun 15, 2026
5f2addb
add openroad manager singleton for session lifecycle and metrics aggr…
kartikloops Jun 15, 2026
686248b
add command whitelist tests ported from python suite
kartikloops Jun 15, 2026
6a02c33
add manager tests mocking the interactive session constructor
kartikloops Jun 15, 2026
ea19694
add tests for session metrics, history, and idle timeout methods
kartikloops Jun 15, 2026
cf72041
Feat/ts pty handler migration (#126)
kartikloops Jun 16, 2026
507ec80
add zod result schemas using nullable default null to match pydantic …
kartikloops Jun 15, 2026
987a69f
expose pty process pid getter for session performance sampling
kartikloops Jun 15, 2026
758958e
add session metrics, command history, and idle timeout tracking
kartikloops Jun 15, 2026
fdc9e1c
add tests for session metrics, history, and idle timeout methods
kartikloops Jun 15, 2026
a03c65a
add base tool class with camelcase to snake case serialization boundary
kartikloops Jun 17, 2026
c00d445
add interactive tool classes porting session management and command e…
kartikloops Jun 17, 2026
a3fb991
add report image tools using sharp for webp compression and path trav…
kartikloops Jun 17, 2026
5fc6a34
add tools barrel export
kartikloops Jun 17, 2026
762b148
add tests for interactive shell and session management tools
kartikloops Jun 17, 2026
5a9c316
add tests for report image tools covering path traversal and platform…
kartikloops Jun 17, 2026
4278870
verify process liveness with kill(0) so a missed exit event cannot le…
kartikloops Jun 17, 2026
d6f4b30
serialize session terminate and cleanup with a lifecycle lock to prev…
kartikloops Jun 17, 2026
23c3950
drop redundant cleanup call in terminateSession so final output buffe…
kartikloops Jun 17, 2026
7327517
use function replacement in annotate and preserve modes so $& in esca…
kartikloops Jun 17, 2026
1a68b91
transition session to terminated from any non-terminal state so a sta…
kartikloops Jun 17, 2026
672b669
ignore non-positive history limit so a negative value cannot drop rec…
kartikloops Jun 17, 2026
73bb018
track session death time and use it for force-cleanup timing instead …
kartikloops Jun 17, 2026
2d5f483
skip null session placeholders in terminateAllSessions so in-progress…
kartikloops Jun 17, 2026
52312b5
reject infinite and negative float settings so a timeout cannot be si…
kartikloops Jun 17, 2026
b976319
reject negative integer settings so a negative session limit cannot b…
kartikloops Jun 17, 2026
f8600be
backfill execution_time for all commands batched into a single readOu…
kartikloops Jun 17, 2026
ce957ee
preserve exit code across cleanup so late waitForExit callers get the…
kartikloops Jun 17, 2026
800a2a3
use a live pid in the pty mock so the kill(0) liveness probe sees an …
kartikloops Jun 17, 2026
85a645a
reject whitespace-only path segments in validatePathSegment
kartikloops Jun 17, 2026
945c7ef
bound per-session command history so long-lived sessions do not leak …
kartikloops Jun 17, 2026
463111a
block body-eval builtins and bracket substitution so the query tool c…
kartikloops Jun 17, 2026
00b1009
terminate auto-created session when executeCommand throws so the sess…
kartikloops Jun 17, 2026
6764ef9
derive wasAlive from info.isAlive and propagate it through error bran…
kartikloops Jun 17, 2026
34e8961
discard queued commands and warn on terminate so pending inputs are n…
kartikloops Jun 17, 2026
515fe17
rename isAlive to checkAlive on InteractiveSession to surface its sta…
kartikloops Jun 18, 2026
b2e95cd
return current memory from _updatePerformanceMetrics so getDetailedMe…
kartikloops Jun 18, 2026
f7baf77
wrap non-force cleanup in finally so a throwing cleanup still removes…
kartikloops Jun 18, 2026
f5ee741
snapshot session counts after the async metrics loop so the totals re…
kartikloops Jun 18, 2026
b075545
remove dead session loop in cleanupAll since terminateAllSessions alr…
kartikloops Jun 18, 2026
e1a3eb1
call terminateProcess with force=true in cleanup so a stuck process i…
kartikloops Jun 18, 2026
047aa36
guard against null image dimensions before resize so missing metadata…
kartikloops Jun 18, 2026
f2892df
rename CommandRecord.id to command_number so the schema matches the l…
kartikloops Jun 18, 2026
41ab077
update integration_check to call checkAlive instead of the renamed is…
kartikloops Jun 18, 2026
46aa3be
extend bracket-scan regex to match [::exec ls] so namespace-qualified…
kartikloops Jun 18, 2026
23b020b
code cleanup
kartikloops Jun 18, 2026
0e27dec
add interactive tool wire-format snapshots so CI snapshot tests pass
kartikloops Jun 18, 2026
46adca8
add EXIT_CODE_ERROR and EXIT_CODE_KEYBOARD_INTERRUPT so the entrypoin…
kartikloops Jun 25, 2026
74070f7
add CleanupManager with SIGTERM/SIGINT handlers and an unref'd force-…
kartikloops Jun 25, 2026
db9c53b
add tests for CleanupManager covering handler dispatch, triggerShutdo…
kartikloops Jun 25, 2026
3a0e91f
add commander-based CLI parsing that rejects --host and --port unless…
kartikloops Jun 25, 2026
6d871c3
add tests for CLI parsing covering defaults, http overrides, and the …
kartikloops Jun 25, 2026
62a7bd8
add createMcpServer registering all 10 tools with zod schemas and ann…
kartikloops Jun 25, 2026
9994b8c
add runServer with stdio and stateless http transports so the server …
kartikloops Jun 25, 2026
dd04877
add a smoke test booting the stdio server in-memory so all 10 tools a…
kartikloops Jun 25, 2026
e7159b1
replace the main.ts stub with the full entrypoint, dynamically import…
kartikloops Jun 25, 2026
58885de
create a fresh streamable-http transport and server per request so co…
kartikloops Jun 25, 2026
dfff83d
cleanup
kartikloops Jun 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions typescript/__tests__/config/cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { parseCliArgs } from "../../src/config/cli.js";
import { ValidationError } from "../../src/exceptions.js";

describe("parseCliArgs", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it("returns stdio defaults with no args", () => {
expect(parseCliArgs([])).toEqual({
transport: { mode: "stdio", host: "localhost", port: 8000 },
verbose: false,
logLevel: "INFO",
});
});

it("parses http transport with custom host and port", () => {
expect(parseCliArgs(["-t", "http", "--host", "0.0.0.0", "--port", "8080"])).toEqual({
transport: { mode: "http", host: "0.0.0.0", port: 8080 },
verbose: false,
logLevel: "INFO",
});
});

it("parses verbose and log level", () => {
const config = parseCliArgs(["--verbose", "--log-level", "DEBUG"]);
expect(config.verbose).toBe(true);
expect(config.logLevel).toBe("DEBUG");
});

it("rejects --host without http transport", () => {
expect(() => parseCliArgs(["--host", "0.0.0.0"])).toThrow(ValidationError);
expect(() => parseCliArgs(["--host", "0.0.0.0"])).toThrow(
"--host and --port options are only valid with --transport http",
);
});

it("rejects --port without http transport", () => {
expect(() => parseCliArgs(["--port", "9000"])).toThrow(
"--host and --port options are only valid with --transport http",
);
});

it("rejects an invalid transport choice", () => {
// commander prints the error to stderr before throwing; silence it.
vi.spyOn(process.stderr, "write").mockReturnValue(true);
expect(() => parseCliArgs(["--transport", "bogus"])).toThrow(ValidationError);
});

it("rejects an invalid log level choice", () => {
vi.spyOn(process.stderr, "write").mockReturnValue(true);
expect(() => parseCliArgs(["--log-level", "TRACE"])).toThrow(ValidationError);
});

it("rejects a non-numeric port", () => {
vi.spyOn(process.stderr, "write").mockReturnValue(true);
expect(() => parseCliArgs(["-t", "http", "--port", "abc"])).toThrow(ValidationError);
});
});
332 changes: 332 additions & 0 deletions typescript/__tests__/config/command_whitelist.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
Loading
Loading