Skip to content

Commit 42f3652

Browse files
authored
Feat/ts tools (#130)
* add base tool class with camelcase to snake case serialization boundary * add interactive tool classes porting session management and command execution from python * add report image tools using sharp for webp compression and path traversal validation * add tools barrel export * add tests for interactive shell and session management tools * add tests for report image tools covering path traversal and platform validation * verify process liveness with kill(0) so a missed exit event cannot leave the pty marked alive * serialize session terminate and cleanup with a lifecycle lock to prevent double kill and stale exit codes * drop redundant cleanup call in terminateSession so final output buffer is not discarded * use function replacement in annotate and preserve modes so $& in escape sequences is not reinterpreted * transition session to terminated from any non-terminal state so a startup failure cannot leave a creating zombie * ignore non-positive history limit so a negative value cannot drop recent commands * track session death time and use it for force-cleanup timing instead of last activity * skip null session placeholders in terminateAllSessions so in-progress creates are not lost * reject infinite and negative float settings so a timeout cannot be silently disabled * reject negative integer settings so a negative session limit cannot block all creation * backfill execution_time for all commands batched into a single readOutput * preserve exit code across cleanup so late waitForExit callers get the real code * use a live pid in the pty mock so the kill(0) liveness probe sees an active process * reject whitespace-only path segments in validatePathSegment * bound per-session command history so long-lived sessions do not leak memory * block body-eval builtins and bracket substitution so the query tool cannot be bypassed via catch/if/foreach or set x [exec ls] * terminate auto-created session when executeCommand throws so the session is not leaked * derive wasAlive from info.isAlive and propagate it through error branches in TerminateSessionTool * discard queued commands and warn on terminate so pending inputs are not silently dropped * rename isAlive to checkAlive on InteractiveSession to surface its state-transition side effect * wrap non-force cleanup in finally so a throwing cleanup still removes the dead session from the map * snapshot session counts after the async metrics loop so the totals reflect post-loop state * remove dead session loop in cleanupAll since terminateAllSessions already empties the map * call terminateProcess with force=true in cleanup so a stuck process is not retried with SIGTERM * guard against null image dimensions before resize so missing metadata does not produce a silent 256x256 output * update integration_check to call checkAlive instead of the renamed isAlive * extend bracket-scan regex to match [::exec ls] so namespace-qualified commands are not exempt * code cleanup * add interactive tool wire-format snapshots so CI snapshot tests pass * clamp brace depth and gate quotes on word boundaries so an unbalanced } or mid-word " cannot hide a trailing statement from the verb check * walk the report tree manually and skip symlinks so a linked dir or file cannot escape the contained run directory * reject zero for env settings where it is degenerate (max sessions, queue/buffer/chunk sizes, command/idle timeouts) so a 0 cannot silently disable sessions or output * scan bracket substitutions per statement and skip comment/blank lines so a bracket inside a # comment is not rejected as an exec * treat EPERM from the liveness probe as alive so a pid we cannot signal is not misreported dead, only ESRCH counts as gone * author the metrics and command-history payloads in camelCase like every other domain model so snake_case is produced only by toSnakeCase at the wire boundary, not hand-written * resolve tcl backslash escapes in the verb and bracket scan so an obfuscated command like \socket or \x73ocket is matched as the command tcl actually runs, not the literal backslash form * capture the bracket command word up to a delimiter so a dynamic substituted command ($x or nested [[...]]) is surfaced and rejected by the read-only tool instead of running exec via runtime variable substitution * use function replacement in _detectErrors so $& or $1 in a captured error message is inserted literally instead of being reinterpreted as a replacement pattern
1 parent 105e2e8 commit 42f3652

28 files changed

Lines changed: 2594 additions & 331 deletions

typescript/__tests__/config/command_whitelist.test.ts

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,16 @@ describe("pattern sets", () => {
6262
expect(READONLY_PATTERNS).toContain("check_*");
6363
});
6464

65-
it("READONLY contains Tcl builtins", () => {
65+
it("READONLY contains safe Tcl builtins", () => {
6666
expect(READONLY_PATTERNS).toContain("puts");
67-
expect(READONLY_PATTERNS).toContain("foreach");
6867
expect(READONLY_PATTERNS).toContain("set");
68+
expect(READONLY_PATTERNS).toContain("expr");
69+
});
70+
71+
it("READONLY excludes body-eval builtins (if/for/foreach/while/proc/catch/namespace)", () => {
72+
for (const verb of ["if", "for", "foreach", "while", "proc", "catch", "namespace", "uplevel"]) {
73+
expect(READONLY_PATTERNS).not.toContain(verb);
74+
}
6975
});
7076

7177
it("EXEC_ONLY contains set_*/read_*/write_* globs", () => {
@@ -195,6 +201,86 @@ describe("isQueryCommand", () => {
195201
it("splits on semicolons and rejects the offending verb", () => {
196202
expect(isQueryCommand("report_wns; global_placement")).toEqual([false, "global_placement"]);
197203
});
204+
205+
it("body-eval: blocks catch wrapping exec (finding 1)", () => {
206+
expect(isQueryCommand("catch { exec ls }")).toEqual([false, "catch"]);
207+
});
208+
209+
it("body-eval: blocks if wrapping exec (finding 1)", () => {
210+
expect(isQueryCommand("if 1 { exec ls }")).toEqual([false, "if"]);
211+
});
212+
213+
it("body-eval: blocks foreach wrapping exec (finding 1)", () => {
214+
expect(isQueryCommand("foreach x {a} { exec ls }")).toEqual([false, "foreach"]);
215+
});
216+
217+
it("bracket: blocks set x [exec ls] via bracket scan (finding 2)", () => {
218+
expect(isQueryCommand("set x [exec ls]")).toEqual([false, "exec"]);
219+
});
220+
221+
it("bracket: blocks set x [::exec ls] with namespace-qualified command", () => {
222+
expect(isQueryCommand("set x [::exec ls]")).toEqual([false, "exec"]);
223+
});
224+
225+
it("bracket: blocks expr {[exec ls]} via bracket scan (finding 2)", () => {
226+
expect(isQueryCommand("expr {[exec ls]}")).toEqual([false, "exec"]);
227+
});
228+
229+
it("bracket: allows puts [report_wns] when bracket verb is read-only (finding 2)", () => {
230+
expect(isQueryCommand("puts [report_wns]")).toEqual([true, null]);
231+
});
232+
233+
it("bracket: blocks puts [global_placement] (exec-only in bracket)", () => {
234+
expect(isQueryCommand("puts [global_placement]")).toEqual([false, "global_placement"]);
235+
});
236+
237+
it("semicolon in quoted string is not a statement separator (finding 3)", () => {
238+
expect(isQueryCommand('puts "hello; world"')).toEqual([true, null]);
239+
});
240+
241+
it("semicolon inside braces is not a statement separator (finding 3)", () => {
242+
expect(isQueryCommand("report_checks {a; b}")).toEqual([true, null]);
243+
});
244+
245+
it("allows a bracket inside a comment line (never executed)", () => {
246+
expect(isQueryCommand("# harmless [exec ls]")).toEqual([true, null]);
247+
});
248+
249+
it("allows a comment with a bracket after a readonly statement", () => {
250+
expect(isQueryCommand("report_wns\n# harmless [exec ls]")).toEqual([true, null]);
251+
});
252+
253+
it("still blocks a bracket when # is a mid-statement arg, not a comment", () => {
254+
expect(isQueryCommand("report_checks # [exec ls]")).toEqual([false, "exec"]);
255+
});
256+
257+
it("unbalanced close brace cannot hide a trailing exec (depth clamp)", () => {
258+
expect(isQueryCommand("report_wns }; exec ls")).toEqual([false, "exec"]);
259+
});
260+
261+
it("blocks a backslash-escaped exec-only verb (\\glob -> glob)", () => {
262+
expect(isQueryCommand("\\glob *")).toEqual([false, "glob"]);
263+
});
264+
265+
it("blocks a backslash-escaped command in a bracket substitution", () => {
266+
expect(isQueryCommand("puts [\\exec ls]")).toEqual([false, "exec"]);
267+
});
268+
269+
it("blocks a variable-substituted command in a bracket ([$x] -> exec at runtime)", () => {
270+
expect(isQueryCommand("set x exec; puts [$x ls]")).toEqual([false, "$x"]);
271+
});
272+
273+
it("blocks a nested command substitution as the bracket command ([[...] ...])", () => {
274+
expect(isQueryCommand("puts [[set x exec] ls]")).toEqual([false, "[set"]);
275+
});
276+
277+
it("still allows a substituted argument that is a variable ([get_cells $x])", () => {
278+
expect(isQueryCommand("puts [get_property [get_cells $x] area]")).toEqual([true, null]);
279+
});
280+
281+
it("mid-word quote is literal and cannot hide a trailing exec", () => {
282+
expect(isQueryCommand('set x a"b ; exec ls')).toEqual([false, "exec"]);
283+
});
198284
});
199285

200286
// isExecCommand
@@ -258,6 +344,21 @@ describe("isExecCommand", () => {
258344
it("blocks a multiline command with one blocked verb", () => {
259345
expect(isExecCommand("global_placement\nsocket tcp localhost")).toEqual([false, "socket"]);
260346
});
347+
348+
it("unbalanced close brace cannot hide a trailing quit (depth clamp)", () => {
349+
expect(isExecCommand("set x } ; quit")).toEqual([false, "quit"]);
350+
});
351+
352+
it("blocks a backslash-escaped verb (\\socket -> socket)", () => {
353+
expect(isExecCommand("\\socket localhost 1")).toEqual([false, "socket"]);
354+
expect(isExecCommand("\\quit")).toEqual([false, "quit"]);
355+
expect(isExecCommand("\\load /tmp/x")).toEqual([false, "load"]);
356+
});
357+
358+
it("blocks a hex/octal-escaped verb (\\x73ocket / \\163ocket -> socket)", () => {
359+
expect(isExecCommand("\\x73ocket localhost 1")).toEqual([false, "socket"]);
360+
expect(isExecCommand("\\163ocket localhost 1")).toEqual([false, "socket"]);
361+
});
261362
});
262363

263364
// isCommandAllowed (backward-compat alias)

typescript/__tests__/config/settings.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,11 +115,52 @@ describe("Settings", () => {
115115
expect(() => Settings.fromEnv()).toThrow("OPENROAD_COMMAND_TIMEOUT");
116116
});
117117

118+
it("rejects Infinity for float fields", () => {
119+
process.env["OPENROAD_COMMAND_TIMEOUT"] = "Infinity";
120+
expect(() => Settings.fromEnv()).toThrow("OPENROAD_COMMAND_TIMEOUT");
121+
});
122+
123+
it("rejects negative floats", () => {
124+
process.env["OPENROAD_COMMAND_TIMEOUT"] = "-1";
125+
expect(() => Settings.fromEnv()).toThrow("OPENROAD_COMMAND_TIMEOUT");
126+
});
127+
118128
it("throws on invalid int", () => {
119129
process.env["OPENROAD_MAX_SESSIONS"] = "3.7";
120130
expect(() => Settings.fromEnv()).toThrow("OPENROAD_MAX_SESSIONS");
121131
});
122132

133+
it("rejects negative integers for int fields", () => {
134+
process.env["OPENROAD_MAX_SESSIONS"] = "-1";
135+
expect(() => Settings.fromEnv()).toThrow("OPENROAD_MAX_SESSIONS");
136+
});
137+
138+
it("rejects zero for int fields that must be positive", () => {
139+
for (const key of [
140+
"OPENROAD_MAX_SESSIONS",
141+
"OPENROAD_SESSION_QUEUE_SIZE",
142+
"OPENROAD_DEFAULT_BUFFER_SIZE",
143+
"OPENROAD_READ_CHUNK_SIZE",
144+
]) {
145+
process.env[key] = "0";
146+
expect(() => Settings.fromEnv()).toThrow(key);
147+
delete process.env[key];
148+
}
149+
});
150+
151+
it("rejects zero for float fields that must be positive", () => {
152+
for (const key of ["OPENROAD_COMMAND_TIMEOUT", "OPENROAD_SESSION_IDLE_TIMEOUT"]) {
153+
process.env[key] = "0";
154+
expect(() => Settings.fromEnv()).toThrow(key);
155+
delete process.env[key];
156+
}
157+
});
158+
159+
it("allows zero for COMMAND_COMPLETION_DELAY (no delay)", () => {
160+
process.env["OPENROAD_COMMAND_COMPLETION_DELAY"] = "0";
161+
expect(Settings.fromEnv().COMMAND_COMPLETION_DELAY).toBe(0);
162+
});
163+
123164
it("rejects decimal strings for int fields", () => {
124165
process.env["OPENROAD_MAX_SESSIONS"] = "50.0";
125166
expect(() => Settings.fromEnv()).toThrow("OPENROAD_MAX_SESSIONS");

typescript/__tests__/core/manager.test.ts

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ vi.mock("../../src/interactive/session.js", () => {
1616
interface MockSession {
1717
sessionId: string;
1818
lastActivity: Date;
19-
isAlive: Mock;
19+
checkAlive: Mock;
2020
start: Mock;
2121
sendCommand: Mock;
2222
readOutput: Mock;
@@ -31,23 +31,23 @@ interface MockSession {
3131

3232
function makeMockSession(sessionId: string, alive = true): MockSession {
3333
const metrics: SessionDetailedMetrics = {
34-
session_id: sessionId,
34+
sessionId,
3535
state: SessionState.ACTIVE,
36-
is_alive: alive,
37-
created_at: new Date().toISOString(),
38-
last_activity: new Date().toISOString(),
39-
uptime_seconds: 1,
40-
idle_seconds: 0,
41-
commands: { total_executed: 3, current_count: 3, history_length: 3 },
42-
performance: { total_cpu_time: 0.5, peak_memory_mb: 10, current_memory_mb: 8 },
43-
buffer: { current_size: 0, max_size: 1024, utilization_percent: 0 },
44-
timeout: { configured_seconds: null, is_timed_out: false },
36+
isAlive: alive,
37+
createdAt: new Date().toISOString(),
38+
lastActivity: new Date().toISOString(),
39+
uptimeSeconds: 1,
40+
idleSeconds: 0,
41+
commands: { totalExecuted: 3, currentCount: 3, historyLength: 3 },
42+
performance: { totalCpuTime: 0.5, peakMemoryMb: 10, currentMemoryMb: 8 },
43+
buffer: { currentSize: 0, maxSize: 1024, utilizationPercent: 0 },
44+
timeout: { configuredSeconds: null, isTimedOut: false },
4545
};
4646

4747
return {
4848
sessionId,
4949
lastActivity: new Date(),
50-
isAlive: vi.fn().mockReturnValue(alive),
50+
checkAlive: vi.fn().mockReturnValue(alive),
5151
start: vi.fn().mockResolvedValue(undefined),
5252
sendCommand: vi.fn().mockResolvedValue(undefined),
5353
readOutput: vi.fn().mockResolvedValue({
@@ -187,7 +187,9 @@ describe("OpenROADManager", () => {
187187
await manager.createSession({ sessionId: "s1" });
188188
await manager.terminateSession("s1", true);
189189
expect(created[0]!.terminate).toHaveBeenCalledWith(true);
190-
expect(created[0]!.cleanup).toHaveBeenCalledOnce();
190+
// terminate() handles teardown; cleanup() must not be called again here
191+
// (it would clear the buffer and double-tear-down the PTY).
192+
expect(created[0]!.cleanup).not.toHaveBeenCalled();
191193
expect(manager.getSessionCount()).toBe(0);
192194
});
193195
});
@@ -200,14 +202,29 @@ describe("OpenROADManager", () => {
200202
expect(count).toBe(2);
201203
expect(manager.getSessionCount()).toBe(0);
202204
});
205+
206+
it("skips in-progress placeholders instead of throwing", async () => {
207+
// A session whose start() never resolves leaves a null placeholder in the
208+
// map (createSession holds the lock). terminateAllSessions must not try to
209+
// terminate it (which would throw "still being created").
210+
MockedSession.mockImplementationOnce(function (this: unknown, sessionId: string) {
211+
const mock = makeMockSession(sessionId);
212+
mock.start.mockReturnValue(new Promise<void>(() => {})); // never resolves
213+
created.push(mock);
214+
return mock as unknown as InteractiveSession;
215+
} as unknown as (sessionId: string) => InteractiveSession);
216+
void manager.createSession({ sessionId: "pending" });
217+
218+
await expect(manager.terminateAllSessions()).resolves.toBe(0);
219+
});
203220
});
204221

205222
describe("inspectSession & getSessionHistory", () => {
206223
it("inspectSession delegates to getDetailedMetrics", async () => {
207224
await manager.createSession({ sessionId: "s1" });
208225
const metrics = await manager.inspectSession("s1");
209226
expect(created[0]!.getDetailedMetrics).toHaveBeenCalledOnce();
210-
expect(metrics.session_id).toBe("s1");
227+
expect(metrics.sessionId).toBe("s1");
211228
});
212229

213230
it("getSessionHistory forwards limit and search", async () => {
@@ -222,9 +239,9 @@ describe("OpenROADManager", () => {
222239
await manager.createSession({ sessionId: "s1" });
223240
await manager.createSession({ sessionId: "s2" });
224241
const metrics = await manager.sessionMetrics();
225-
expect(metrics.manager.total_sessions).toBe(2);
226-
expect(metrics.manager.active_sessions).toBe(2);
227-
expect(metrics.aggregate.total_commands).toBe(6); // 3 per mock session
242+
expect(metrics.manager.totalSessions).toBe(2);
243+
expect(metrics.manager.activeSessions).toBe(2);
244+
expect(metrics.aggregate.totalCommands).toBe(6); // 3 per mock session
228245
expect(metrics.sessions).toHaveLength(2);
229246
});
230247
});

typescript/__tests__/interactive/buffer.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,9 @@ describe("CircularBuffer", () => {
143143
// Start waitForData (fast-path sees _dataAvailable = false and enters the Promise)
144144
const waiter = buf.waitForData(5000);
145145

146-
// append() fires here — before runExclusive's callback has a chance to push
147-
// wakeUp into _resolvers. Without the re-check inside runExclusive, wakeUp
148-
// would be pushed after append() already drained an empty _resolvers, and
146+
// append() fires before runExclusive's callback can push wakeUp into
147+
// _resolvers. Without the re-check inside runExclusive, wakeUp would
148+
// land in _resolvers after append() already drained an empty list, and
149149
// the caller would wait the full 5-second timeout.
150150
await buf.append("raced data");
151151

@@ -168,7 +168,7 @@ describe("CircularBuffer", () => {
168168
it("wakes pending waitForData() immediately with false so callers do not hang", async () => {
169169
const buf = new CircularBuffer(100);
170170

171-
// waitForData with a large timeoutclear() must unblock it before the timeout fires
171+
// Large timeout: clear() must unblock waitForData before it fires.
172172
const waiter = buf.waitForData(5000);
173173
await buf.clear();
174174

typescript/__tests__/interactive/pty_handler.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ function makeMockPty(): MockPty {
2626
let capturedOnExit: ((e: { exitCode: number; signal?: number }) => void) | undefined;
2727

2828
return {
29-
pid: 12345,
29+
// Use the test runner's own pid so isProcessAlive()'s `process.kill(pid, 0)`
30+
// liveness probe sees a real, live process for an unterminated mock.
31+
pid: process.pid,
3032
write: vi.fn(),
3133
kill: vi.fn(),
3234
resize: vi.fn(),
@@ -161,6 +163,24 @@ describe("PtyHandler", () => {
161163
mockPty._exit(0);
162164
expect(handler.isProcessAlive()).toBe(false);
163165
});
166+
167+
it("returns false when the probe throws ESRCH (pid gone)", async () => {
168+
await handler.createSession(["echo"]);
169+
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
170+
throw Object.assign(new Error("kill ESRCH"), { code: "ESRCH" });
171+
});
172+
expect(handler.isProcessAlive()).toBe(false);
173+
killSpy.mockRestore();
174+
});
175+
176+
it("returns true when the probe throws EPERM (pid exists, not signalable)", async () => {
177+
await handler.createSession(["echo"]);
178+
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
179+
throw Object.assign(new Error("kill EPERM"), { code: "EPERM" });
180+
});
181+
expect(handler.isProcessAlive()).toBe(true);
182+
killSpy.mockRestore();
183+
});
164184
});
165185

166186
describe("waitForExit", () => {

0 commit comments

Comments
 (0)