Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
3339500
add base tool class with camelcase to snake case serialization boundary
kartikloops Jun 17, 2026
417e3d2
add interactive tool classes porting session management and command e…
kartikloops Jun 17, 2026
ad3a44a
add report image tools using sharp for webp compression and path trav…
kartikloops Jun 17, 2026
e517cdb
add tools barrel export
kartikloops Jun 17, 2026
9d2bb0d
add tests for interactive shell and session management tools
kartikloops Jun 17, 2026
deb0317
add tests for report image tools covering path traversal and platform…
kartikloops Jun 17, 2026
2cd2659
verify process liveness with kill(0) so a missed exit event cannot le…
kartikloops Jun 17, 2026
4631ed5
serialize session terminate and cleanup with a lifecycle lock to prev…
kartikloops Jun 17, 2026
9a5257d
drop redundant cleanup call in terminateSession so final output buffe…
kartikloops Jun 17, 2026
da7a25b
use function replacement in annotate and preserve modes so $& in esca…
kartikloops Jun 17, 2026
c3ae1d7
transition session to terminated from any non-terminal state so a sta…
kartikloops Jun 17, 2026
dfedfa4
ignore non-positive history limit so a negative value cannot drop rec…
kartikloops Jun 17, 2026
8c62038
track session death time and use it for force-cleanup timing instead …
kartikloops Jun 17, 2026
7bbe2c5
skip null session placeholders in terminateAllSessions so in-progress…
kartikloops Jun 17, 2026
222ef2e
reject infinite and negative float settings so a timeout cannot be si…
kartikloops Jun 17, 2026
35c4946
reject negative integer settings so a negative session limit cannot b…
kartikloops Jun 17, 2026
51379c4
backfill execution_time for all commands batched into a single readOu…
kartikloops Jun 17, 2026
0b47c85
preserve exit code across cleanup so late waitForExit callers get the…
kartikloops Jun 17, 2026
4735f8d
use a live pid in the pty mock so the kill(0) liveness probe sees an …
kartikloops Jun 17, 2026
4b22474
reject whitespace-only path segments in validatePathSegment
kartikloops Jun 17, 2026
ce3c896
bound per-session command history so long-lived sessions do not leak …
kartikloops Jun 17, 2026
5676300
block body-eval builtins and bracket substitution so the query tool c…
kartikloops Jun 17, 2026
6df597f
terminate auto-created session when executeCommand throws so the sess…
kartikloops Jun 17, 2026
d5b499a
derive wasAlive from info.isAlive and propagate it through error bran…
kartikloops Jun 17, 2026
c132034
discard queued commands and warn on terminate so pending inputs are n…
kartikloops Jun 17, 2026
07e423a
rename isAlive to checkAlive on InteractiveSession to surface its sta…
kartikloops Jun 18, 2026
69df939
wrap non-force cleanup in finally so a throwing cleanup still removes…
kartikloops Jun 18, 2026
569bde7
snapshot session counts after the async metrics loop so the totals re…
kartikloops Jun 18, 2026
47e8e77
remove dead session loop in cleanupAll since terminateAllSessions alr…
kartikloops Jun 18, 2026
4360562
call terminateProcess with force=true in cleanup so a stuck process i…
kartikloops Jun 18, 2026
b147573
guard against null image dimensions before resize so missing metadata…
kartikloops Jun 18, 2026
a1d14ae
update integration_check to call checkAlive instead of the renamed is…
kartikloops Jun 18, 2026
cdddda4
extend bracket-scan regex to match [::exec ls] so namespace-qualified…
kartikloops Jun 18, 2026
587952c
code cleanup
kartikloops Jun 18, 2026
461407a
add interactive tool wire-format snapshots so CI snapshot tests pass
kartikloops Jun 18, 2026
973b6fa
clamp brace depth and gate quotes on word boundaries so an unbalanced…
kartikloops Jun 27, 2026
4df1663
walk the report tree manually and skip symlinks so a linked dir or fi…
kartikloops Jun 27, 2026
0ba6e24
reject zero for env settings where it is degenerate (max sessions, qu…
kartikloops Jun 27, 2026
bd68244
scan bracket substitutions per statement and skip comment/blank lines…
kartikloops Jun 27, 2026
eb4cdfd
treat EPERM from the liveness probe as alive so a pid we cannot signa…
kartikloops Jun 27, 2026
6abbf0a
author the metrics and command-history payloads in camelCase like eve…
kartikloops Jun 29, 2026
bf47001
resolve tcl backslash escapes in the verb and bracket scan so an obfu…
kartikloops Jun 30, 2026
efdf7bc
capture the bracket command word up to a delimiter so a dynamic subst…
kartikloops Jun 30, 2026
a58d30b
use function replacement in _detectErrors so $& or $1 in a captured e…
kartikloops Jun 30, 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
105 changes: 103 additions & 2 deletions typescript/__tests__/config/command_whitelist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,16 @@ describe("pattern sets", () => {
expect(READONLY_PATTERNS).toContain("check_*");
});

it("READONLY contains Tcl builtins", () => {
it("READONLY contains safe Tcl builtins", () => {
expect(READONLY_PATTERNS).toContain("puts");
expect(READONLY_PATTERNS).toContain("foreach");
expect(READONLY_PATTERNS).toContain("set");
expect(READONLY_PATTERNS).toContain("expr");
});

it("READONLY excludes body-eval builtins (if/for/foreach/while/proc/catch/namespace)", () => {
for (const verb of ["if", "for", "foreach", "while", "proc", "catch", "namespace", "uplevel"]) {
expect(READONLY_PATTERNS).not.toContain(verb);
}
});

it("EXEC_ONLY contains set_*/read_*/write_* globs", () => {
Expand Down Expand Up @@ -195,6 +201,86 @@ describe("isQueryCommand", () => {
it("splits on semicolons and rejects the offending verb", () => {
expect(isQueryCommand("report_wns; global_placement")).toEqual([false, "global_placement"]);
});

it("body-eval: blocks catch wrapping exec (finding 1)", () => {
expect(isQueryCommand("catch { exec ls }")).toEqual([false, "catch"]);
});

it("body-eval: blocks if wrapping exec (finding 1)", () => {
expect(isQueryCommand("if 1 { exec ls }")).toEqual([false, "if"]);
});

it("body-eval: blocks foreach wrapping exec (finding 1)", () => {
expect(isQueryCommand("foreach x {a} { exec ls }")).toEqual([false, "foreach"]);
});

it("bracket: blocks set x [exec ls] via bracket scan (finding 2)", () => {
expect(isQueryCommand("set x [exec ls]")).toEqual([false, "exec"]);
});

it("bracket: blocks 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]);
});

it("allows a bracket inside a comment line (never executed)", () => {
expect(isQueryCommand("# harmless [exec ls]")).toEqual([true, null]);
});

it("allows a comment with a bracket after a readonly statement", () => {
expect(isQueryCommand("report_wns\n# harmless [exec ls]")).toEqual([true, null]);
});

it("still blocks a bracket when # is a mid-statement arg, not a comment", () => {
expect(isQueryCommand("report_checks # [exec ls]")).toEqual([false, "exec"]);
});

it("unbalanced close brace cannot hide a trailing exec (depth clamp)", () => {
expect(isQueryCommand("report_wns }; exec ls")).toEqual([false, "exec"]);
});

it("blocks a backslash-escaped exec-only verb (\\glob -> glob)", () => {
expect(isQueryCommand("\\glob *")).toEqual([false, "glob"]);
});

it("blocks a backslash-escaped command in a bracket substitution", () => {
expect(isQueryCommand("puts [\\exec ls]")).toEqual([false, "exec"]);
});

it("blocks a variable-substituted command in a bracket ([$x] -> exec at runtime)", () => {
expect(isQueryCommand("set x exec; puts [$x ls]")).toEqual([false, "$x"]);
});

it("blocks a nested command substitution as the bracket command ([[...] ...])", () => {
expect(isQueryCommand("puts [[set x exec] ls]")).toEqual([false, "[set"]);
});

it("still allows a substituted argument that is a variable ([get_cells $x])", () => {
expect(isQueryCommand("puts [get_property [get_cells $x] area]")).toEqual([true, null]);
});

it("mid-word quote is literal and cannot hide a trailing exec", () => {
expect(isQueryCommand('set x a"b ; exec ls')).toEqual([false, "exec"]);
});
});

// isExecCommand
Expand Down Expand Up @@ -258,6 +344,21 @@ describe("isExecCommand", () => {
it("blocks a multiline command with one blocked verb", () => {
expect(isExecCommand("global_placement\nsocket tcp localhost")).toEqual([false, "socket"]);
});

it("unbalanced close brace cannot hide a trailing quit (depth clamp)", () => {
expect(isExecCommand("set x } ; quit")).toEqual([false, "quit"]);
});

it("blocks a backslash-escaped verb (\\socket -> socket)", () => {
expect(isExecCommand("\\socket localhost 1")).toEqual([false, "socket"]);
expect(isExecCommand("\\quit")).toEqual([false, "quit"]);
expect(isExecCommand("\\load /tmp/x")).toEqual([false, "load"]);
});

it("blocks a hex/octal-escaped verb (\\x73ocket / \\163ocket -> socket)", () => {
expect(isExecCommand("\\x73ocket localhost 1")).toEqual([false, "socket"]);
expect(isExecCommand("\\163ocket localhost 1")).toEqual([false, "socket"]);
});
});

// isCommandAllowed (backward-compat alias)
Expand Down
41 changes: 41 additions & 0 deletions typescript/__tests__/config/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,52 @@ 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 zero for int fields that must be positive", () => {
for (const key of [
"OPENROAD_MAX_SESSIONS",
"OPENROAD_SESSION_QUEUE_SIZE",
"OPENROAD_DEFAULT_BUFFER_SIZE",
"OPENROAD_READ_CHUNK_SIZE",
]) {
process.env[key] = "0";
expect(() => Settings.fromEnv()).toThrow(key);
delete process.env[key];
}
});

it("rejects zero for float fields that must be positive", () => {
for (const key of ["OPENROAD_COMMAND_TIMEOUT", "OPENROAD_SESSION_IDLE_TIMEOUT"]) {
process.env[key] = "0";
expect(() => Settings.fromEnv()).toThrow(key);
delete process.env[key];
}
});

it("allows zero for COMMAND_COMPLETION_DELAY (no delay)", () => {
process.env["OPENROAD_COMMAND_COMPLETION_DELAY"] = "0";
expect(Settings.fromEnv().COMMAND_COMPLETION_DELAY).toBe(0);
});

it("rejects decimal strings for int fields", () => {
process.env["OPENROAD_MAX_SESSIONS"] = "50.0";
expect(() => Settings.fromEnv()).toThrow("OPENROAD_MAX_SESSIONS");
Expand Down
51 changes: 34 additions & 17 deletions typescript/__tests__/core/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ vi.mock("../../src/interactive/session.js", () => {
interface MockSession {
sessionId: string;
lastActivity: Date;
isAlive: Mock;
checkAlive: Mock;
start: Mock;
sendCommand: Mock;
readOutput: Mock;
Expand All @@ -31,23 +31,23 @@ interface MockSession {

function makeMockSession(sessionId: string, alive = true): MockSession {
const metrics: SessionDetailedMetrics = {
session_id: sessionId,
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 },
isAlive: alive,
createdAt: new Date().toISOString(),
lastActivity: new Date().toISOString(),
uptimeSeconds: 1,
idleSeconds: 0,
commands: { totalExecuted: 3, currentCount: 3, historyLength: 3 },
performance: { totalCpuTime: 0.5, peakMemoryMb: 10, currentMemoryMb: 8 },
buffer: { currentSize: 0, maxSize: 1024, utilizationPercent: 0 },
timeout: { configuredSeconds: null, isTimedOut: false },
};

return {
sessionId,
lastActivity: new Date(),
isAlive: vi.fn().mockReturnValue(alive),
checkAlive: vi.fn().mockReturnValue(alive),
start: vi.fn().mockResolvedValue(undefined),
sendCommand: vi.fn().mockResolvedValue(undefined),
readOutput: vi.fn().mockResolvedValue({
Expand Down Expand Up @@ -187,7 +187,9 @@ describe("OpenROADManager", () => {
await manager.createSession({ sessionId: "s1" });
await manager.terminateSession("s1", true);
expect(created[0]!.terminate).toHaveBeenCalledWith(true);
expect(created[0]!.cleanup).toHaveBeenCalledOnce();
// terminate() handles teardown; cleanup() must not be called again here
// (it would clear the buffer and double-tear-down the PTY).
expect(created[0]!.cleanup).not.toHaveBeenCalled();
expect(manager.getSessionCount()).toBe(0);
});
});
Expand All @@ -200,14 +202,29 @@ describe("OpenROADManager", () => {
expect(count).toBe(2);
expect(manager.getSessionCount()).toBe(0);
});

it("skips in-progress placeholders instead of throwing", async () => {
// A session whose start() never resolves leaves a null placeholder in the
// map (createSession holds the lock). terminateAllSessions must not try to
// terminate it (which would throw "still being created").
MockedSession.mockImplementationOnce(function (this: unknown, sessionId: string) {
const mock = makeMockSession(sessionId);
mock.start.mockReturnValue(new Promise<void>(() => {})); // 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");
expect(metrics.sessionId).toBe("s1");
});

it("getSessionHistory forwards limit and search", async () => {
Expand All @@ -222,9 +239,9 @@ describe("OpenROADManager", () => {
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.manager.totalSessions).toBe(2);
expect(metrics.manager.activeSessions).toBe(2);
expect(metrics.aggregate.totalCommands).toBe(6); // 3 per mock session
expect(metrics.sessions).toHaveLength(2);
});
});
Expand Down
8 changes: 4 additions & 4 deletions typescript/__tests__/interactive/buffer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand All @@ -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 timeoutclear() must unblock it before the timeout fires
// Large timeout: clear() must unblock waitForData before it fires.
const waiter = buf.waitForData(5000);
await buf.clear();

Expand Down
22 changes: 21 additions & 1 deletion typescript/__tests__/interactive/pty_handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -161,6 +163,24 @@ describe("PtyHandler", () => {
mockPty._exit(0);
expect(handler.isProcessAlive()).toBe(false);
});

it("returns false when the probe throws ESRCH (pid gone)", async () => {
await handler.createSession(["echo"]);
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
throw Object.assign(new Error("kill ESRCH"), { code: "ESRCH" });
});
expect(handler.isProcessAlive()).toBe(false);
killSpy.mockRestore();
});

it("returns true when the probe throws EPERM (pid exists, not signalable)", async () => {
await handler.createSession(["echo"]);
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
throw Object.assign(new Error("kill EPERM"), { code: "EPERM" });
});
expect(handler.isProcessAlive()).toBe(true);
killSpy.mockRestore();
});
});

describe("waitForExit", () => {
Expand Down
Loading
Loading