Skip to content

Commit 6906494

Browse files
NathanFlurryclaude
andcommitted
feat: US-022 - Bridge gap fixes for CLI tool testing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2e4c6bf commit 6906494

11 files changed

Lines changed: 271 additions & 18 deletions

File tree

packages/secure-exec-core/src/kernel/kernel.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,13 +530,21 @@ class KernelImpl implements Kernel {
530530
const parentEntry = callerPid ? this.processTable.get(callerPid) : undefined;
531531
const baseEnv = parentEntry?.env ?? this.env;
532532

533+
// Detect PTY slave on stdio FDs
534+
const stdinIsTTY = this.isFdPtySlave(table, 0);
535+
const stdoutIsTTY = this.isFdPtySlave(table, 1);
536+
const stderrIsTTY = this.isFdPtySlave(table, 2);
537+
533538
// Build process context with pre-wired callbacks
534539
const ctx: ProcessContext = {
535540
pid,
536541
ppid: callerPid ?? 0,
537542
env: { ...baseEnv, ...options?.env },
538543
cwd: options?.cwd ?? this.cwd,
539544
fds: { stdin: 0, stdout: 1, stderr: 2 },
545+
stdinIsTTY,
546+
stdoutIsTTY,
547+
stderrIsTTY,
540548
onStdout: stdoutCb,
541549
onStderr: stderrCb,
542550
};
@@ -1181,6 +1189,13 @@ class KernelImpl implements Kernel {
11811189
return this.pipeManager.isPipe(entry.description.id) || this.ptyManager.isPty(entry.description.id);
11821190
}
11831191

1192+
/** Check if an FD in the given table refers to a PTY slave (terminal). */
1193+
private isFdPtySlave(table: ProcessFDTable, fd: number): boolean {
1194+
const entry = table.get(fd);
1195+
if (!entry) return false;
1196+
return this.ptyManager.isSlave(entry.description.id);
1197+
}
1198+
11841199
/**
11851200
* Create a callback that forwards data through a piped stdio FD.
11861201
* Needed for drivers (like Node) that emit output via callbacks rather

packages/secure-exec-core/src/kernel/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,10 @@ export interface ProcessContext {
202202
env: Record<string, string>;
203203
cwd: string;
204204
fds: { stdin: number; stdout: number; stderr: number };
205+
/** Whether stdin/stdout/stderr are connected to a PTY slave. */
206+
stdinIsTTY?: boolean;
207+
stdoutIsTTY?: boolean;
208+
stderrIsTTY?: boolean;
205209
/** Kernel-provided callback for stdout data emitted during spawn. */
206210
onStdout?: (data: Uint8Array) => void;
207211
/** Kernel-provided callback for stderr data emitted during spawn. */

packages/secure-exec-nodejs/src/bridge/process.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -296,10 +296,18 @@ interface StdioWriteStream {
296296
rows: number;
297297
}
298298

299-
// Resolve isTTY flags from config
300-
const _stdinIsTTY = (typeof _processConfig !== "undefined" && _processConfig.stdinIsTTY) || false;
301-
const _stdoutIsTTY = (typeof _processConfig !== "undefined" && _processConfig.stdoutIsTTY) || false;
302-
const _stderrIsTTY = (typeof _processConfig !== "undefined" && _processConfig.stderrIsTTY) || false;
299+
// Lazy TTY flag readers — __runtimeTtyConfig is set by postRestoreScript
300+
// (cannot use _processConfig because InjectGlobals overwrites it later)
301+
declare const __runtimeTtyConfig: { stdinIsTTY?: boolean; stdoutIsTTY?: boolean; stderrIsTTY?: boolean } | undefined;
302+
function _getStdinIsTTY(): boolean {
303+
return (typeof __runtimeTtyConfig !== "undefined" && __runtimeTtyConfig.stdinIsTTY) || false;
304+
}
305+
function _getStdoutIsTTY(): boolean {
306+
return (typeof __runtimeTtyConfig !== "undefined" && __runtimeTtyConfig.stdoutIsTTY) || false;
307+
}
308+
function _getStderrIsTTY(): boolean {
309+
return (typeof __runtimeTtyConfig !== "undefined" && __runtimeTtyConfig.stderrIsTTY) || false;
310+
}
303311

304312
// Stdout stream
305313
const _stdout: StdioWriteStream = {
@@ -322,7 +330,7 @@ const _stdout: StdioWriteStream = {
322330
return false;
323331
},
324332
writable: true,
325-
isTTY: _stdoutIsTTY,
333+
get isTTY(): boolean { return _getStdoutIsTTY(); },
326334
columns: 80,
327335
rows: 24,
328336
};
@@ -348,7 +356,7 @@ const _stderr: StdioWriteStream = {
348356
return false;
349357
},
350358
writable: true,
351-
isTTY: _stderrIsTTY,
359+
get isTTY(): boolean { return _getStderrIsTTY(); },
352360
columns: 80,
353361
rows: 24,
354362
};
@@ -501,7 +509,7 @@ const _stdin: StdinStream = {
501509
},
502510

503511
setRawMode(mode: boolean): StdinStream {
504-
if (!_stdinIsTTY) {
512+
if (!_getStdinIsTTY()) {
505513
throw new Error("setRawMode is not supported when stdin is not a TTY");
506514
}
507515
if (typeof _ptySetRawMode !== "undefined") {
@@ -510,7 +518,7 @@ const _stdin: StdinStream = {
510518
return this;
511519
},
512520

513-
isTTY: _stdinIsTTY,
521+
get isTTY(): boolean { return _getStdinIsTTY(); },
514522

515523
// For readline compatibility
516524
[Symbol.asyncIterator]: async function* () {

packages/secure-exec-nodejs/src/execution-driver.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,7 @@ export class NodeExecutionDriver implements RuntimeDriver {
353353
activeChildProcesses: new Map(),
354354
activeHostTimers: new Set(),
355355
resolutionCache: createResolutionCache(),
356+
onPtySetRawMode: options.onPtySetRawMode,
356357
};
357358

358359
// Validate and flatten bindings once at construction time
@@ -761,6 +762,16 @@ function buildPostRestoreScript(
761762
parts.push(`globalThis.${getProcessConfigGlobalKey()} = ${JSON.stringify(processConfig)};`);
762763
parts.push(`globalThis.${getOsConfigGlobalKey()} = ${JSON.stringify(osConfig)};`);
763764

765+
// Inject TTY config separately — InjectGlobals overwrites _processConfig,
766+
// so TTY flags need their own global that persists
767+
if (processConfig.stdinIsTTY || processConfig.stdoutIsTTY || processConfig.stderrIsTTY) {
768+
parts.push(`globalThis.__runtimeTtyConfig = ${JSON.stringify({
769+
stdinIsTTY: processConfig.stdinIsTTY,
770+
stdoutIsTTY: processConfig.stdoutIsTTY,
771+
stderrIsTTY: processConfig.stderrIsTTY,
772+
})};`);
773+
}
774+
764775
// Inject timer/handle limits
765776
if (bridgeConfig.maxTimers !== undefined) {
766777
parts.push(`globalThis._maxTimers = ${bridgeConfig.maxTimers};`);

packages/secure-exec-nodejs/src/isolate-bootstrap.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import type { BindingTree } from "./bindings.js";
2121
export interface NodeExecutionDriverOptions extends RuntimeDriverOptions {
2222
createIsolate?(memoryLimit: number): unknown;
2323
bindings?: BindingTree;
24+
/** Callback to toggle PTY raw mode — wired by kernel runtime when PTY is attached. */
25+
onPtySetRawMode?: (mode: boolean) => void;
2426
}
2527

2628
export interface BudgetState {

packages/secure-exec-nodejs/src/kernel-runtime.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,11 @@ class NodeRuntimeDriver implements RuntimeDriver {
445445
permissions = { ...permissions, ...allowAllFs };
446446
}
447447

448+
// Detect PTY on stdio FDs
449+
const stdinIsTTY = ctx.stdinIsTTY ?? false;
450+
const stdoutIsTTY = ctx.stdoutIsTTY ?? false;
451+
const stderrIsTTY = ctx.stderrIsTTY ?? false;
452+
448453
const systemDriver = createNodeDriver({
449454
filesystem,
450455
commandExecutor,
@@ -453,15 +458,29 @@ class NodeRuntimeDriver implements RuntimeDriver {
453458
cwd: ctx.cwd,
454459
env: ctx.env,
455460
argv: [process.execPath, filePath ?? command, ...args],
461+
stdinIsTTY,
462+
stdoutIsTTY,
463+
stderrIsTTY,
456464
},
457465
});
458466

467+
// Wire PTY raw mode callback when stdin is a terminal
468+
const onPtySetRawMode = stdinIsTTY
469+
? (mode: boolean) => {
470+
kernel.ptySetDiscipline(ctx.pid, 0, {
471+
canonical: !mode,
472+
echo: !mode,
473+
});
474+
}
475+
: undefined;
476+
459477
// Create a per-process isolate
460478
const executionDriver = new NodeExecutionDriver({
461479
system: systemDriver,
462480
runtime: systemDriver.runtime,
463481
memoryLimit: this._memoryLimit,
464482
bindings: this._bindings,
483+
onPtySetRawMode,
465484
});
466485
this._activeDrivers.set(ctx.pid, executionDriver);
467486

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* Bridge gap tests for CLI tool support: isTTY, setRawMode, HTTPS, streams.
3+
*
4+
* Exercises PTY-backed process TTY detection and raw mode toggling through
5+
* the kernel PTY line discipline. Uses openShell({ command: 'node', ... })
6+
* to spawn Node directly on a PTY — no WasmVM shell needed.
7+
*/
8+
9+
import { describe, it, expect, afterEach } from 'vitest';
10+
import { createKernel } from '../../../secure-exec-core/src/kernel/index.ts';
11+
import type { Kernel } from '../../../secure-exec-core/src/kernel/index.ts';
12+
import { InMemoryFileSystem } from '../../../secure-exec-browser/src/os-filesystem.ts';
13+
import { createNodeRuntime } from '../../../secure-exec-nodejs/src/kernel-runtime.ts';
14+
15+
async function createNodeKernel(): Promise<{ kernel: Kernel; dispose: () => Promise<void> }> {
16+
const vfs = new InMemoryFileSystem();
17+
const kernel = createKernel({ filesystem: vfs });
18+
await kernel.mount(createNodeRuntime());
19+
return { kernel, dispose: () => kernel.dispose() };
20+
}
21+
22+
/** Collect all output from a PTY-backed process spawned via openShell. */
23+
async function runNodeOnPty(
24+
kernel: Kernel,
25+
code: string,
26+
timeout = 10_000,
27+
): Promise<string> {
28+
const shell = kernel.openShell({
29+
command: 'node',
30+
args: ['-e', code],
31+
});
32+
33+
const chunks: Uint8Array[] = [];
34+
shell.onData = (data) => chunks.push(data);
35+
36+
const exitCode = await Promise.race([
37+
shell.wait(),
38+
new Promise<number>((_, reject) =>
39+
setTimeout(() => reject(new Error('PTY process timed out')), timeout),
40+
),
41+
]);
42+
43+
const output = new TextDecoder().decode(
44+
Buffer.concat(chunks),
45+
);
46+
return output;
47+
}
48+
49+
// ---------------------------------------------------------------------------
50+
// PTY isTTY detection
51+
// ---------------------------------------------------------------------------
52+
53+
describe('bridge gap: isTTY via PTY', () => {
54+
let ctx: { kernel: Kernel; dispose: () => Promise<void> };
55+
56+
afterEach(async () => {
57+
await ctx?.dispose();
58+
});
59+
60+
it('process.stdin.isTTY returns true when spawned with PTY', async () => {
61+
ctx = await createNodeKernel();
62+
const output = await runNodeOnPty(ctx.kernel, "console.log('STDIN_TTY:' + process.stdin.isTTY)");
63+
expect(output).toContain('STDIN_TTY:true');
64+
}, 15_000);
65+
66+
it('process.stdout.isTTY returns true when spawned with PTY', async () => {
67+
ctx = await createNodeKernel();
68+
const output = await runNodeOnPty(ctx.kernel, "console.log('STDOUT_TTY:' + process.stdout.isTTY)");
69+
expect(output).toContain('STDOUT_TTY:true');
70+
}, 15_000);
71+
72+
it('process.stderr.isTTY returns true when spawned with PTY', async () => {
73+
ctx = await createNodeKernel();
74+
const output = await runNodeOnPty(ctx.kernel, "console.log('STDERR_TTY:' + process.stderr.isTTY)");
75+
expect(output).toContain('STDERR_TTY:true');
76+
}, 15_000);
77+
78+
it('isTTY remains false for non-PTY sandbox processes', async () => {
79+
ctx = await createNodeKernel();
80+
81+
// Spawn node directly via kernel.spawn (no PTY)
82+
const stdout: string[] = [];
83+
const proc = ctx.kernel.spawn('node', ['-e', "console.log('STDIN_TTY:' + process.stdin.isTTY + ',STDOUT_TTY:' + process.stdout.isTTY)"], {
84+
onStdout: (data) => stdout.push(new TextDecoder().decode(data)),
85+
});
86+
const exitCode = await proc.wait();
87+
88+
expect(exitCode).toBe(0);
89+
const output = stdout.join('');
90+
expect(output).toMatch(/STDIN_TTY:(false|undefined)/);
91+
expect(output).toMatch(/STDOUT_TTY:(false|undefined)/);
92+
}, 15_000);
93+
});
94+
95+
// ---------------------------------------------------------------------------
96+
// PTY setRawMode
97+
// ---------------------------------------------------------------------------
98+
99+
describe('bridge gap: setRawMode via PTY', () => {
100+
let ctx: { kernel: Kernel; dispose: () => Promise<void> };
101+
102+
afterEach(async () => {
103+
await ctx?.dispose();
104+
});
105+
106+
it('setRawMode(true) succeeds when stdin is a TTY', async () => {
107+
ctx = await createNodeKernel();
108+
const output = await runNodeOnPty(ctx.kernel, "process.stdin.setRawMode(true); console.log('RAW_OK')");
109+
expect(output).toContain('RAW_OK');
110+
}, 15_000);
111+
112+
it('setRawMode(false) restores PTY defaults', async () => {
113+
ctx = await createNodeKernel();
114+
const output = await runNodeOnPty(
115+
ctx.kernel,
116+
"process.stdin.setRawMode(true); process.stdin.setRawMode(false); console.log('RESTORE_OK')",
117+
);
118+
expect(output).toContain('RESTORE_OK');
119+
}, 15_000);
120+
121+
it('setRawMode throws when stdin is not a TTY', async () => {
122+
ctx = await createNodeKernel();
123+
124+
// Spawn node directly via kernel.spawn (no PTY)
125+
const stderr: string[] = [];
126+
const proc = ctx.kernel.spawn('node', ['-e', `
127+
try {
128+
process.stdin.setRawMode(true);
129+
console.log('SHOULD_NOT_REACH');
130+
} catch (e) {
131+
console.error('ERR:' + e.message);
132+
}
133+
`], {
134+
onStderr: (data) => stderr.push(new TextDecoder().decode(data)),
135+
});
136+
await proc.wait();
137+
138+
const output = stderr.join('');
139+
expect(output).toContain('ERR:');
140+
expect(output).toContain('not a TTY');
141+
}, 15_000);
142+
});

packages/secure-exec/tests/kernel/cross-runtime-terminal.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*/
99

1010
import { describe, it, expect, afterEach } from 'vitest';
11-
import { TerminalHarness } from '../../../kernel/test/terminal-harness.ts';
11+
import { TerminalHarness } from '../../../secure-exec-core/test/kernel/terminal-harness.ts';
1212
import {
1313
createIntegrationKernel,
1414
skipUnlessWasmBuilt,

packages/secure-exec/tests/kernel/helpers.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,19 @@ import { existsSync } from 'node:fs';
1212
import { createRequire } from 'node:module';
1313
import { resolve, dirname } from 'node:path';
1414
import { fileURLToPath } from 'node:url';
15-
import { createKernel } from '../../../kernel/src/index.ts';
16-
import type { Kernel, VirtualFileSystem } from '../../../kernel/src/index.ts';
17-
import { InMemoryFileSystem } from '../../../os/browser/src/index.ts';
18-
import { createWasmVmRuntime } from '../../../runtime/wasmvm/src/index.ts';
19-
import { createNodeRuntime } from '../../../runtime/node/src/index.ts';
20-
import { createPythonRuntime } from '../../../runtime/python/src/index.ts';
15+
import { createKernel } from '../../../secure-exec-core/src/kernel/index.ts';
16+
import type { Kernel, VirtualFileSystem } from '../../../secure-exec-core/src/kernel/index.ts';
17+
import { InMemoryFileSystem } from '../../../secure-exec-browser/src/os-filesystem.ts';
18+
import { createWasmVmRuntime } from '../../../secure-exec-wasmvm/src/index.ts';
19+
import { createNodeRuntime } from '../../../secure-exec-nodejs/src/kernel-runtime.ts';
20+
import { createPythonRuntime } from '../../../secure-exec-python/src/kernel-runtime.ts';
2121

2222
const __dirname = dirname(fileURLToPath(import.meta.url));
2323

2424
// WASM standalone binaries directory (relative to this file → repo root)
2525
const COMMANDS_DIR = resolve(
2626
__dirname,
27-
'../../../../wasmvm/target/wasm32-wasip1/release/commands',
27+
'../../../../native/wasmvm/target/wasm32-wasip1/release/commands',
2828
);
2929

3030
export interface IntegrationKernelResult {

scripts/ralph/prd.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@
342342
"Typecheck passes"
343343
],
344344
"priority": 21,
345-
"passes": false,
345+
"passes": true,
346346
"notes": "Custom bindings Phase 3. ~200 LOC tests. Depends on US-019 and US-020."
347347
},
348348
{
@@ -360,7 +360,7 @@
360360
"Typecheck passes"
361361
],
362362
"priority": 22,
363-
"passes": false,
363+
"passes": true,
364364
"notes": "CLI Tool E2E Phase 0 — bridge prerequisites. Must be completed before interactive CLI tool tests (US-025, US-027, US-029)."
365365
},
366366
{

0 commit comments

Comments
 (0)