diff --git a/test/e2e/live/launch-agent-turn.ts b/test/e2e/live/launch-agent-turn.ts index 2faaf27fa04..7d0373307fb 100644 --- a/test/e2e/live/launch-agent-turn.ts +++ b/test/e2e/live/launch-agent-turn.ts @@ -11,16 +11,366 @@ import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; export const OPENCLAW_LAUNCH_RUNTIME_ENV_SCRIPT = 'if [ -r "/tmp/nemoclaw-proxy-env.sh" ]; then builtin source "/tmp/nemoclaw-proxy-env.sh" || exit $?; fi; builtin unset OPENCLAW_GATEWAY_TOKEN; builtin exec -- "$@"'; -// This script runs inside the same PTY process that will become OpenClaw. It -// publishes fd 0 identity before execve preserves that descriptor for the -// unchanged production command. -export const OPENCLAW_PTY_RECORD_WRITER_SCRIPT = String.raw` +// OpenShell creates the PTY before it drops to the sandbox user. This child +// process inherits fd 0, so it can observe PTY input mode without reopening the +// root-owned device path from a separate sandbox command. +export const OPENCLAW_PTY_INPUT_MODE_MONITOR_SCRIPT = String.raw` +const childProcess = require("node:child_process"); +const crypto = require("node:crypto"); const fs = require("node:fs"); +const net = require("node:net"); const path = require("node:path"); -const [runId, recordRoot, ...originalArgv] = process.argv.slice(1); -const recordPath = path.join(recordRoot, "pty-record.json"); -const temporaryPath = path.join(recordRoot, "pty-record.json.tmp"); +const [role, parentPidText, runId, publicKeyBase64, runRoot, ttyPath, dev, ino, rdev, sttyCommand] = + process.argv.slice(1); +const socketPath = path.join(runRoot, "pty-input-mode.sock"); +const MAX_REQUEST_BYTES = 1024; +const MAX_RESPONSE_BYTES = 1024; +const MAX_STDERR_BYTES = 256; +const parentPid = Number(parentPidText); +const clients = new Set(); +let pendingClient; +let ready = false; +let retired = false; +let socketDev; +let socketIno; + +function exactKeys(value, expected) { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + return JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...expected].sort()); +} + +function response(requestId, state, result = null, fallbackCode = null) { + const status = + Number.isInteger(result && result.status) && result.status >= 0 && result.status <= 255 + ? result.status + : null; + const resultSignal = result && result.signal; + const signal = + typeof resultSignal === "string" && /^SIG[A-Z0-9]{1,24}$/.test(resultSignal) + ? resultSignal + : null; + const resultCode = result && result.error && result.error.code; + const errorCode = + typeof resultCode === "string" && /^[A-Z0-9_]{1,64}$/.test(resultCode) + ? resultCode + : fallbackCode; + const unsigned = { + schemaVersion: 1, + runId, + requestId, + ttyPath, + dev, + ino, + rdev, + state, + status, + signal, + errorCode, + stderr: String((result && result.stderr) || "") + .replace(/[^\x20-\x7e]/g, " ") + .trim() + .slice(0, MAX_STDERR_BYTES), + }; + return { + ...unsigned, + signature: crypto.sign(null, Buffer.from(JSON.stringify(unsigned)), privateKey).toString("base64"), + }; +} + +function closeInput() { + try { + fs.closeSync(0); + } catch {} +} + +function sameRoot() { + try { + const stats = fs.lstatSync(runRoot, { bigint: true }); + return ( + stats.isDirectory() && + !stats.isSymbolicLink() && + stats.uid === BigInt(process.getuid()) && + (stats.mode & 0o777n) === 0o700n && + stats.dev === rootStats.dev && + stats.ino === rootStats.ino + ); + } catch { + return false; + } +} + +function sameSocket() { + try { + const stats = fs.lstatSync(socketPath, { bigint: true }); + return ( + stats.isSocket() && + stats.uid === BigInt(process.getuid()) && + (stats.mode & 0o777n) === 0o600n && + stats.nlink === 1n && + stats.dev === socketDev && + stats.ino === socketIno + ); + } catch { + return false; + } +} + +function sameTty() { + try { + const stats = fs.fstatSync(0, { bigint: true }); + return ( + stats.isCharacterDevice() && + stats.dev.toString() === dev && + stats.ino.toString() === ino && + stats.rdev.toString() === rdev + ); + } catch { + return false; + } +} + +function retire() { + if (retired) return; + retired = true; + pendingClient?.destroy(); + pendingClient = undefined; + closeInput(); +} + +function send(client, observation) { + if (process.ppid !== parentPid) return client.destroy(); + const body = JSON.stringify(observation) + "\n"; + if (Buffer.byteLength(body) > MAX_RESPONSE_BYTES) { + client.destroy(); + return retire(); + } + client.end(body); + if (observation.state !== "canonical") retire(); +} + +function observeRequest(client, requestId) { + if (process.ppid !== parentPid || retired || !sameRoot() || !sameSocket()) { + return client.destroy(); + } + if (!sameTty()) { + return send(client, response(requestId, "unavailable", null, "PTY_IDENTITY_CHANGED")); + } + const result = childProcess.spawnSync(sttyCommand, ["-a"], { + encoding: "utf8", + env: { LC_ALL: "C" }, + stdio: [0, "pipe", "pipe"], + timeout: 1_000, + killSignal: "SIGKILL", + maxBuffer: 64 * 1024, + }); + if (process.ppid !== parentPid) return client.destroy(); + if (!sameTty()) { + return send(client, response(requestId, "unavailable", null, "PTY_IDENTITY_CHANGED")); + } + if (result.error) { + return send(client, response(requestId, "unavailable", result, "PTY_TERMIOS_QUERY_FAILED")); + } + if (result.status !== 0) return send(client, response(requestId, "unavailable", result)); + if (/(^|[\s;])-icanon([\s;]|$)/.test(result.stdout)) { + return send(client, response(requestId, "noncanonical")); + } + if (/(^|[\s;])icanon([\s;]|$)/.test(result.stdout)) { + return send(client, response(requestId, "canonical")); + } + return send(client, response(requestId, "unavailable", result, "PTY_TERMIOS_OUTPUT_INVALID")); +} + +function observe(client) { + clients.add(client); + client.once("close", () => clients.delete(client)); + client.on("error", () => {}); + client.setEncoding("utf8"); + client.setTimeout(3_000, () => client.destroy()); + let raw = ""; + client.on("data", (chunk) => { + raw += chunk; + if (Buffer.byteLength(raw) > MAX_REQUEST_BYTES) return client.destroy(); + if (!raw.endsWith("\n")) return; + let request; + try { + request = JSON.parse(raw); + } catch { + return client.destroy(); + } + if ( + !exactKeys(request, ["schemaVersion", "runId", "requestId"]) || + request.schemaVersion !== 1 || + request.runId !== runId || + !/^[0-9a-f]{32}$/.test(request.requestId || "") + ) { + return client.destroy(); + } + client.removeAllListeners("data"); + observeRequest(client, request.requestId); + }); + client.resume(); +} + +function accept(client) { + client.on("error", () => {}); + if (ready) return observe(client); + if (pendingClient) return client.destroy(); + pendingClient = client; +} + +if (role !== "nemoclaw-pty-input-mode-monitor") process.exit(74); +if (!Number.isSafeInteger(parentPid) || parentPid < 2) process.exit(74); +if (!/^[0-9a-f]{32}$/.test(runId || "")) process.exit(74); +if (!/^[A-Za-z0-9+/]{40,256}={0,2}$/.test(publicKeyBase64 || "")) process.exit(74); +if (runRoot !== "/tmp/nemoclaw-launch-turn-" + runId) process.exit(74); +if (!/^\/dev\/pts\/\d+$/.test(ttyPath || "")) process.exit(74); +if (![dev, ino, rdev].every((value) => /^(0|[1-9]\d{0,24})$/.test(value || ""))) { + process.exit(74); +} +if (sttyCommand !== "/usr/bin/stty" && !path.isAbsolute(sttyCommand || "")) process.exit(74); + +let privateKey; +try { + const privateKeyBase64 = fs.readFileSync(3, "utf8").trim(); + fs.closeSync(3); + privateKey = crypto.createPrivateKey({ + key: Buffer.from(privateKeyBase64, "base64"), + format: "der", + type: "pkcs8", + }); + const derivedPublicKey = crypto + .createPublicKey(privateKey) + .export({ format: "der", type: "spki" }) + .toString("base64"); + if (derivedPublicKey !== publicKeyBase64) process.exit(74); +} catch { + process.exit(74); +} + +const rootStats = fs.lstatSync(runRoot, { bigint: true }); +if ( + !rootStats.isDirectory() || + rootStats.isSymbolicLink() || + rootStats.uid !== BigInt(process.getuid()) || + (rootStats.mode & 0o777n) !== 0o700n +) { + process.exit(74); +} +try { + fs.lstatSync(socketPath); + process.exit(74); +} catch (error) { + if (!error || error.code !== "ENOENT") process.exit(74); +} + +const server = net.createServer({ pauseOnConnect: true }, accept); +server.on("error", retire); +process.umask(0o177); +server.listen(socketPath, () => { + try { + fs.chmodSync(socketPath, 0o600); + const stats = fs.lstatSync(socketPath, { bigint: true }); + if ( + !stats.isSocket() || + stats.uid !== BigInt(process.getuid()) || + (stats.mode & 0o777n) !== 0o600n || + stats.nlink !== 1n + ) { + return retire(); + } + socketDev = stats.dev; + socketIno = stats.ino; + ready = true; + if (pendingClient) { + const client = pendingClient; + pendingClient = undefined; + observe(client); + } + process.stdout.write("READY\n"); + } catch { + retire(); + } +}); +const parentWatcher = setInterval(() => { + if (process.ppid === parentPid) return; + clearInterval(parentWatcher); + pendingClient?.destroy(); + for (const client of clients) client.destroy(); + closeInput(); + if (!server.listening || !sameRoot() || !sameSocket()) process.exit(0); + server.close(() => process.exit(0)); +}, 25); +for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { + process.on(signal, () => {}); +} +`; + +// The host sends the private key through this process's standard input. The +// starter opens and unlinks the resulting file before candidate code runs. +export const OPENCLAW_PTY_MONITOR_KEY_WRITER_SCRIPT = String.raw` +const fs = require("node:fs"); +const path = require("node:path"); + +const [runId, runRoot, publicKeyBase64] = process.argv.slice(1); +const privateKeyPath = path.join(runRoot, "pty-monitor-private-key"); + +function fail(reason) { + process.stderr.write(JSON.stringify({ reason }) + "\n"); + process.exit(74); +} + +function exactMode(stats, mode) { + return (stats.mode & 0o777) === mode; +} + +if (!/^[0-9a-f]{32}$/.test(runId || "")) fail("pty_run_id_invalid"); +if (runRoot !== "/tmp/nemoclaw-launch-turn-" + runId) fail("pty_monitor_root_invalid"); +if (!/^[A-Za-z0-9+/]{40,256}={0,2}$/.test(publicKeyBase64 || "")) { + fail("pty_public_key_invalid"); +} + +let privateKeyBase64; +try { + privateKeyBase64 = fs.readFileSync(0, "utf8"); +} catch { + fail("pty_private_key_read_failed"); +} +if (!/^[A-Za-z0-9+/]{40,256}={0,2}$/.test(privateKeyBase64 || "")) { + fail("pty_private_key_invalid"); +} + +try { + fs.mkdirSync(runRoot, { mode: 0o700 }); + fs.chmodSync(runRoot, 0o700); + const rootStats = fs.lstatSync(runRoot); + if ( + !rootStats.isDirectory() || + rootStats.isSymbolicLink() || + rootStats.uid !== process.getuid() || + !exactMode(rootStats, 0o700) + ) { + fail("pty_monitor_root_invalid"); + } + fs.writeFileSync(privateKeyPath, privateKeyBase64, { flag: "wx", mode: 0o600 }); + fs.chmodSync(privateKeyPath, 0o600); +} catch { + fail("pty_private_key_write_failed"); +} +`; + +// This starter and its monitor both inherit PTY fd 0. The starter then replaces +// itself with the unchanged production command while the monitor retains its +// descriptor. +export const OPENCLAW_PTY_MONITOR_STARTER_SCRIPT = String.raw` +const childProcess = require("node:child_process"); +const fs = require("node:fs"); + +const monitorScript = ${JSON.stringify(OPENCLAW_PTY_INPUT_MODE_MONITOR_SCRIPT)}; +const termiosCommand = "/usr/bin/stty"; + +const [runId, runRoot, publicKeyBase64, privateKeyPath, ...originalArgv] = + process.argv.slice(1); function fail(reason) { process.stderr.write(JSON.stringify({ reason }) + "\n"); @@ -31,12 +381,12 @@ function exactMode(stats, mode) { return (stats.mode & 0o777) === mode; } -function validateRecordRoot() { +function validateRunRoot() { let stats; try { - stats = fs.lstatSync(recordRoot); + stats = fs.lstatSync(runRoot); } catch { - fail("pty_record_root_unavailable"); + fail("pty_monitor_root_unavailable"); } if ( !stats.isDirectory() || @@ -44,64 +394,55 @@ function validateRecordRoot() { stats.uid !== process.getuid() || !exactMode(stats, 0o700) ) { - fail("pty_record_root_invalid"); - } -} - -function writeRecord(record) { - const body = JSON.stringify(record) + "\n"; - if (Buffer.byteLength(body) > 1024) fail("pty_record_invalid"); - let fd; - let created = false; - try { - fd = fs.openSync( - temporaryPath, - fs.constants.O_CREAT | - fs.constants.O_EXCL | - fs.constants.O_WRONLY | - fs.constants.O_NOFOLLOW, - 0o600, - ); - created = true; - fs.fchmodSync(fd, 0o600); - fs.writeFileSync(fd, body, "utf8"); - fs.fsyncSync(fd); - fs.closeSync(fd); - fd = undefined; - fs.renameSync(temporaryPath, recordPath); - const directoryFd = fs.openSync(recordRoot, fs.constants.O_RDONLY); - try { - fs.fsyncSync(directoryFd); - } finally { - fs.closeSync(directoryFd); - } - } catch { - if (fd !== undefined) { - try { - fs.closeSync(fd); - } catch {} - } - if (created) { - try { - fs.unlinkSync(temporaryPath); - } catch {} - } - fail("pty_record_write_failed"); + fail("pty_monitor_root_invalid"); } } if (!/^[0-9a-f]{32}$/.test(runId || "")) fail("pty_run_id_invalid"); -if (recordRoot !== "/tmp/nemoclaw-launch-turn-" + runId) fail("pty_record_root_invalid"); +if (!/^[A-Za-z0-9+/]{40,256}={0,2}$/.test(publicKeyBase64 || "")) { + fail("pty_public_key_invalid"); +} +if (runRoot !== "/tmp/nemoclaw-launch-turn-" + runId) fail("pty_monitor_root_invalid"); +if (privateKeyPath !== runRoot + "/pty-monitor-private-key") { + fail("pty_private_key_path_invalid"); +} if (originalArgv.length === 0) fail("pty_original_argv_invalid"); if (typeof process.execve !== "function") fail("pty_execve_unavailable"); +validateRunRoot(); + +let privateKeyBase64; +let privateKeyFd; try { - fs.mkdirSync(recordRoot, { mode: 0o700 }); - fs.chmodSync(recordRoot, 0o700); + privateKeyFd = fs.openSync( + privateKeyPath, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW, + ); + const privateKeyStats = fs.fstatSync(privateKeyFd); + if ( + !privateKeyStats.isFile() || + privateKeyStats.uid !== process.getuid() || + !exactMode(privateKeyStats, 0o600) || + privateKeyStats.nlink !== 1 || + privateKeyStats.size < 40 || + privateKeyStats.size > 256 + ) { + fail("pty_private_key_file_invalid"); + } + privateKeyBase64 = fs.readFileSync(privateKeyFd, "utf8"); + fs.unlinkSync(privateKeyPath); } catch { - fail("pty_record_root_create_failed"); + fail("pty_private_key_file_invalid"); +} finally { + if (privateKeyFd !== undefined) { + try { + fs.closeSync(privateKeyFd); + } catch {} + } +} +if (!/^[A-Za-z0-9+/]{40,256}={0,2}$/.test(privateKeyBase64 || "")) { + fail("pty_private_key_invalid"); } -validateRecordRoot(); let ttyPath; let ttyStats; @@ -114,17 +455,52 @@ try { if (!/^\/dev\/pts\/\d+$/.test(ttyPath)) fail("pty_stdin_not_pty"); if (!ttyStats.isCharacterDevice()) fail("pty_stdin_not_character_device"); -writeRecord({ - schemaVersion: 1, - runId, - ttyPath, - dev: ttyStats.dev.toString(), - ino: ttyStats.ino.toString(), - rdev: ttyStats.rdev.toString(), +const monitor = childProcess.spawn( + process.execPath, + [ + "-e", + monitorScript, + "nemoclaw-pty-input-mode-monitor", + process.pid.toString(), + runId, + publicKeyBase64, + runRoot, + ttyPath, + ttyStats.dev.toString(), + ttyStats.ino.toString(), + ttyStats.rdev.toString(), + termiosCommand, + ], + { + detached: false, + env: { LC_ALL: "C" }, + stdio: [0, "pipe", "ignore", "pipe"], + }, +); +if (!Number.isSafeInteger(monitor.pid) || monitor.pid < 2) fail("pty_monitor_spawn_failed"); +monitor.stdio[3].end(privateKeyBase64); +let ready = ""; +let started = false; +const startupTimer = setTimeout(() => fail("pty_monitor_start_timeout"), 3_000); +monitor.stdout.setEncoding("utf8"); +monitor.stdout.on("data", (chunk) => { + if (started) return; + ready += chunk; + if (Buffer.byteLength(ready) > 64 || !"READY\n".startsWith(ready)) { + fail("pty_monitor_start_invalid"); + } + if (ready !== "READY\n") return; + started = true; + clearTimeout(startupTimer); + monitor.stdout.destroy(); + monitor.unref(); + process.execve("/usr/bin/env", ["/usr/bin/env", ...originalArgv], process.env); + fail("pty_execve_failed"); +}); +monitor.on("error", () => fail("pty_monitor_spawn_failed")); +monitor.on("exit", () => { + if (!started) fail("pty_monitor_start_failed"); }); - -process.execve("/usr/bin/env", ["/usr/bin/env", ...originalArgv], process.env); -fail("pty_execve_failed"); `; // The host shim replaces argv only for the matching OpenClaw launch. It removes @@ -147,8 +523,10 @@ const realOpenShell = process.env.OPENSHELL_NEMOCLAW_LAUNCH_REAL_COMMAND; const sandboxName = process.env.OPENSHELL_NEMOCLAW_LAUNCH_SANDBOX; const runId = process.env.OPENSHELL_NEMOCLAW_LAUNCH_RUN_ID; const interceptPath = process.env.OPENSHELL_NEMOCLAW_LAUNCH_INTERCEPT_PATH; -const writerScript = process.env.OPENSHELL_NEMOCLAW_LAUNCH_PTY_RECORD_WRITER_SCRIPT; +const monitorStarterScript = process.env.OPENSHELL_NEMOCLAW_LAUNCH_PTY_MONITOR_STARTER_SCRIPT; const runtimeEnvScript = process.env.OPENSHELL_NEMOCLAW_LAUNCH_RUNTIME_ENV_SCRIPT; +const keyPath = process.env.OPENSHELL_NEMOCLAW_LAUNCH_PTY_MONITOR_KEY_PATH; +const keyWriterScript = ${JSON.stringify(OPENCLAW_PTY_MONITOR_KEY_WRITER_SCRIPT)}; function fail(reason) { process.stderr.write(JSON.stringify({ reason }) + "\n"); @@ -159,24 +537,62 @@ function arraysEqual(left, right) { return left.length === right.length && left.every((value, index) => value === right[index]); } -function runRealOpenShell(nextArgv) { +function invokeRealOpenShell(nextArgv, input) { const env = { ...process.env }; for (const name of authorityNames) delete env[name]; const result = childProcess.spawnSync(realOpenShell, nextArgv, { env, - stdio: "inherit", + input, + stdio: input === undefined ? "inherit" : ["pipe", "inherit", "inherit"], timeout: 240_000, killSignal: "SIGKILL", }); if (result.error) fail("openshell_shim_invocation_failed"); if (result.status === null) fail("openshell_shim_signaled"); - process.exit(result.status); + return result.status; +} + +function runRealOpenShell(nextArgv) { + process.exit(invokeRealOpenShell(nextArgv)); } if (!path.isAbsolute(realOpenShell || "")) fail("openshell_shim_authority_invalid"); if (!/^[0-9a-f]{32}$/.test(runId || "")) fail("openshell_shim_run_id_invalid"); if (!path.isAbsolute(interceptPath || "")) fail("openshell_shim_intercept_path_invalid"); -if (!writerScript || !runtimeEnvScript) fail("openshell_shim_script_missing"); +if (!monitorStarterScript || !runtimeEnvScript) fail("openshell_shim_script_missing"); +if (!path.isAbsolute(keyPath || "")) fail("openshell_shim_key_path_invalid"); +let keyRecord; +try { + const stats = fs.lstatSync(keyPath); + if ( + !stats.isFile() || + stats.isSymbolicLink() || + stats.uid !== process.getuid() || + (stats.mode & 0o777) !== 0o600 || + stats.nlink !== 1 || + stats.size < 2 || + stats.size > 1024 + ) { + fail("openshell_shim_key_file_invalid"); + } + keyRecord = JSON.parse(fs.readFileSync(keyPath, "utf8")); +} catch { + fail("openshell_shim_key_file_invalid"); +} +if ( + !keyRecord || + JSON.stringify(Object.keys(keyRecord).sort()) !== JSON.stringify(["privateKey", "publicKey"]) +) { + fail("openshell_shim_key_file_invalid"); +} +const publicKeyBase64 = keyRecord.publicKey; +const privateKeyBase64 = keyRecord.privateKey; +if (!/^[A-Za-z0-9+/]{40,256}={0,2}$/.test(publicKeyBase64 || "")) { + fail("openshell_shim_public_key_invalid"); +} +if (!/^[A-Za-z0-9+/]{40,256}={0,2}$/.test(privateKeyBase64 || "")) { + fail("openshell_shim_private_key_invalid"); +} const sameSandbox = argv[0] === "sandbox" && @@ -228,14 +644,30 @@ try { fail("openshell_launch_intercept_failed"); } -const recordRoot = "/tmp/nemoclaw-launch-turn-" + runId; +const monitorRoot = "/tmp/nemoclaw-launch-turn-" + runId; +const privateKeyPath = monitorRoot + "/pty-monitor-private-key"; +const keyWriterArgv = [ + ...argv.slice(0, optionIndex), + "--", + "node", + "-e", + keyWriterScript, + runId, + monitorRoot, + publicKeyBase64, +]; +if (invokeRealOpenShell(keyWriterArgv, privateKeyBase64) !== 0) { + fail("openshell_shim_private_key_write_failed"); +} const replacement = [ ...argv.slice(0, separator + 1), "node", "-e", - writerScript, + monitorStarterScript, runId, - recordRoot, + monitorRoot, + publicKeyBase64, + privateKeyPath, ...remoteArgv, ]; runRealOpenShell(replacement); @@ -247,17 +679,17 @@ runRealOpenShell(replacement); // baseline. Session content never moves to the host. export const OPENCLAW_SESSION_EVIDENCE_SCRIPT = String.raw` const crypto = require("node:crypto"); -const childProcess = require("node:child_process"); const fs = require("node:fs"); +const net = require("node:net"); const path = require("node:path"); -const [mode, sessionRoot, baselinePath, expectedTurnsText, ptyRecordRoot, runId] = +const [mode, sessionRoot, baselinePath, expectedTurnsText, ptyMonitorRoot, runId, publicKeyBase64] = process.argv.slice(1); const baselineTemporaryPath = baselinePath + ".tmp"; -const ptyRecordPath = path.join(ptyRecordRoot, "pty-record.json"); -const ptyRecordTemporaryPath = path.join(ptyRecordRoot, "pty-record.json.tmp"); +const ptyMonitorSocketPath = path.join(ptyMonitorRoot, "pty-input-mode.sock"); const MAX_BASELINE_BYTES = 1024 * 1024; -const MAX_PTY_RECORD_BYTES = 1024; +const MAX_PTY_RESPONSE_BYTES = 1024; +const PTY_RESPONSE_TIMEOUT_MS = 3_000; function finish(exitCode, reason, detail = {}) { if (reason) process.stderr.write(JSON.stringify({ reason, ...detail }) + "\n"); @@ -273,13 +705,97 @@ function exactMode(stats, mode) { return (stats.mode & 0o777) === mode; } +function validPtyResponse(response, requestId) { + if ( + !exactKeys(response, [ + "schemaVersion", + "runId", + "requestId", + "ttyPath", + "dev", + "ino", + "rdev", + "state", + "status", + "signal", + "errorCode", + "stderr", + "signature", + ]) || + response.schemaVersion !== 1 || + response.runId !== runId || + response.requestId !== requestId || + !/^[A-Za-z0-9+/]{40,256}={0,2}$/.test(response.signature || "") || + !["canonical", "noncanonical", "unavailable"].includes(response.state) || + !/^\/dev\/pts\/\d+$/.test(response.ttyPath || "") || + ![response.dev, response.ino, response.rdev].every( + (value) => typeof value === "string" && /^(0|[1-9]\d{0,24})$/.test(value), + ) + ) { + return false; + } + if ( + response.status !== null && + (!Number.isInteger(response.status) || response.status < 0 || response.status > 255) + ) { + return false; + } + if (response.signal !== null && !/^SIG[A-Z0-9]{1,24}$/.test(response.signal)) return false; + if (response.errorCode !== null && !/^[A-Z0-9_]{1,64}$/.test(response.errorCode)) return false; + if ( + typeof response.stderr !== "string" || + Buffer.byteLength(response.stderr) > 256 || + !/^[\x20-\x7e]*$/.test(response.stderr) + ) { + return false; + } + const diagnosticIsEmpty = + response.status === null && + response.signal === null && + response.errorCode === null && + response.stderr === ""; + if (response.state === "unavailable" ? diagnosticIsEmpty : !diagnosticIsEmpty) return false; + const unsigned = { + schemaVersion: response.schemaVersion, + runId: response.runId, + requestId: response.requestId, + ttyPath: response.ttyPath, + dev: response.dev, + ino: response.ino, + rdev: response.rdev, + state: response.state, + status: response.status, + signal: response.signal, + errorCode: response.errorCode, + stderr: response.stderr, + }; + try { + const publicKey = crypto.createPublicKey({ + key: Buffer.from(publicKeyBase64, "base64"), + format: "der", + type: "spki", + }); + return crypto.verify( + null, + Buffer.from(JSON.stringify(unsigned)), + publicKey, + Buffer.from(response.signature, "base64"), + ); + } catch { + return false; + } +} + function validateRunContext() { if (!/^[0-9a-f]{32}$/.test(runId || "")) finish(2, "run_id_invalid"); + if (!/^[A-Za-z0-9+/]{40,256}={0,2}$/.test(publicKeyBase64 || "")) { + finish(2, "pty_public_key_invalid"); + } if (baselinePath !== "/tmp/nemoclaw-launch-session-" + runId + ".json") { finish(2, "baseline_path_invalid"); } - if (ptyRecordRoot !== "/tmp/nemoclaw-launch-turn-" + runId) { - finish(2, "pty_record_root_invalid"); + if (ptyMonitorRoot !== "/tmp/nemoclaw-launch-turn-" + runId) { + finish(2, "pty_monitor_root_invalid"); } } @@ -381,68 +897,125 @@ function sessionFileNames() { } } -function qualifyTuiInputMode() { - let rootStats; +function validatePtyIdentity(response) { + let ttyStats; try { - rootStats = fs.lstatSync(ptyRecordRoot); - } catch (error) { - if (error && error.code === "ENOENT") finish(1, "pty_record_missing"); - finish(2, "pty_record_root_unavailable"); + ttyStats = fs.lstatSync(response.ttyPath, { bigint: true }); + } catch { + finish(2, "pty_identity_changed"); } + if (!ttyStats.isCharacterDevice()) finish(2, "pty_not_character_device"); if ( - !rootStats.isDirectory() || - rootStats.isSymbolicLink() || - rootStats.uid !== process.getuid() || - !exactMode(rootStats, 0o700) + ttyStats.dev.toString() !== response.dev || + ttyStats.ino.toString() !== response.ino || + ttyStats.rdev.toString() !== response.rdev ) { - finish(2, "pty_record_root_invalid"); - } - const record = readPrivateJson( - ptyRecordPath, - MAX_PTY_RECORD_BYTES, - "pty_record_unavailable", - "pty_record_invalid", - "pty_record_missing", - ); + finish(2, "pty_identity_changed"); + } +} + +function readPtyMonitorRoot(startup) { + let stats; + try { + stats = fs.lstatSync(ptyMonitorRoot, { bigint: true }); + } catch (error) { + if (startup && error && error.code === "ENOENT") finish(1, "pty_socket_missing"); + finish(2, "pty_monitor_root_unavailable"); + } if ( - !exactKeys(record, ["schemaVersion", "runId", "ttyPath", "dev", "ino", "rdev"]) || - record.schemaVersion !== 1 || - record.runId !== runId || - !/^\/dev\/pts\/\d+$/.test(record.ttyPath || "") || - ![record.dev, record.ino, record.rdev].every( - (value) => typeof value === "string" && /^(0|[1-9]\d{0,24})$/.test(value), - ) + !stats.isDirectory() || + stats.isSymbolicLink() || + stats.uid !== BigInt(process.getuid()) || + (stats.mode & 0o777n) !== 0o700n ) { - finish(2, "pty_record_invalid"); + finish(2, "pty_monitor_root_invalid"); } - let ttyStats; + return stats; +} + +function readPtyMonitorSocket(startup) { + let stats; try { - ttyStats = fs.lstatSync(record.ttyPath, { bigint: true }); - } catch { - finish(2, "pty_identity_changed"); + stats = fs.lstatSync(ptyMonitorSocketPath, { bigint: true }); + } catch (error) { + if (startup && error && error.code === "ENOENT") finish(1, "pty_socket_missing"); + finish(2, "pty_socket_unavailable"); } - if (!ttyStats.isCharacterDevice()) finish(2, "pty_not_character_device"); if ( - ttyStats.dev.toString() !== record.dev || - ttyStats.ino.toString() !== record.ino || - ttyStats.rdev.toString() !== record.rdev + !stats.isSocket() || + stats.uid !== BigInt(process.getuid()) || + (stats.mode & 0o777n) !== 0o600n || + stats.nlink !== 1n ) { - finish(2, "pty_identity_changed"); + finish(2, "pty_socket_invalid"); + } + return stats; +} + +function sameIdentity(left, right) { + return left.dev === right.dev && left.ino === right.ino; +} + +function qualifyPtyResponse(raw, rootBefore, socketBefore, requestId) { + if ( + Buffer.byteLength(raw) < 2 || + Buffer.byteLength(raw) > MAX_PTY_RESPONSE_BYTES || + !raw.endsWith("\n") || + raw.slice(0, -1).includes("\n") + ) { + finish(2, "pty_termios_response_invalid"); } - let state; + let response; try { - state = childProcess.execFileSync("stty", ["-F", record.ttyPath, "-a"], { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - timeout: 5_000, - killSignal: "SIGKILL", - maxBuffer: 64 * 1024, - }); + response = JSON.parse(raw); } catch { - finish(2, "pty_termios_unavailable"); + finish(2, "pty_termios_response_invalid"); } - if (!/(^|[\s;])-icanon([\s;]|$)/.test(state)) finish(1, "pty_input_canonical"); - finish(0); + if (!validPtyResponse(response, requestId)) finish(2, "pty_termios_response_invalid"); + const rootAfter = readPtyMonitorRoot(false); + const socketAfter = readPtyMonitorSocket(false); + if (!sameIdentity(rootBefore, rootAfter) || !sameIdentity(socketBefore, socketAfter)) { + finish(2, "pty_socket_identity_changed"); + } + validatePtyIdentity(response); + if (response.state === "canonical") finish(1, "pty_input_canonical"); + if (response.state === "noncanonical") finish(0); + if (response.state === "unavailable") { + finish(2, "pty_termios_unavailable", { + sttyStatus: response.status, + sttySignal: response.signal, + sttyErrorCode: response.errorCode, + sttyStderr: response.stderr, + }); + } + finish(2, "pty_termios_response_invalid"); +} + +function qualifyTuiInputMode() { + const rootBefore = readPtyMonitorRoot(true); + const socketBefore = readPtyMonitorSocket(true); + const requestId = crypto.randomBytes(16).toString("hex"); + let raw = ""; + const client = net.createConnection({ path: ptyMonitorSocketPath }); + const responseDeadline = setTimeout( + () => finish(1, "pty_termios_response_timeout"), + PTY_RESPONSE_TIMEOUT_MS, + ); + client.setEncoding("utf8"); + client.on("connect", () => { + client.write(JSON.stringify({ schemaVersion: 1, runId, requestId }) + "\n"); + }); + client.on("data", (chunk) => { + raw += chunk; + if (Buffer.byteLength(raw) > MAX_PTY_RESPONSE_BYTES) { + finish(2, "pty_termios_response_invalid"); + } + }); + client.on("end", () => { + clearTimeout(responseDeadline); + qualifyPtyResponse(raw, rootBefore, socketBefore, requestId); + }); + client.on("error", () => finish(1, "pty_socket_unavailable")); } function readCompleteSession(fileName) { @@ -564,13 +1137,13 @@ function removeBaseline() { finish(0); } -function removePtyRecordRoot() { +function removePtyMonitorRoot() { let before; try { - before = fs.lstatSync(ptyRecordRoot, { bigint: true }); + before = fs.lstatSync(ptyMonitorRoot, { bigint: true }); } catch (error) { if (error && error.code === "ENOENT") finish(0); - finish(2, "pty_record_cleanup_failed"); + finish(2, "pty_monitor_cleanup_failed"); } if ( !before.isDirectory() || @@ -578,57 +1151,56 @@ function removePtyRecordRoot() { before.uid !== BigInt(process.getuid()) || (before.mode & 0o777n) !== 0o700n ) { - finish(2, "pty_record_cleanup_failed"); + finish(2, "pty_monitor_cleanup_failed"); } let names; try { - names = fs.readdirSync(ptyRecordRoot).sort(); + names = fs.readdirSync(ptyMonitorRoot).sort(); } catch { - finish(2, "pty_record_cleanup_failed"); + finish(2, "pty_monitor_cleanup_failed"); } - const allowedNames = ["pty-record.json", "pty-record.json.tmp"]; + const privateKeyName = "pty-monitor-private-key"; + const allowedNames = ["pty-input-mode.sock", privateKeyName]; if (names.some((name) => !allowedNames.includes(name))) { - finish(2, "pty_record_cleanup_unknown_entry"); + finish(2, "pty_monitor_cleanup_unknown_entry"); } - for (const name of names) { - const filePath = path.join(ptyRecordRoot, name); - if (!validateCleanupFile(filePath, MAX_PTY_RECORD_BYTES, "pty_record_cleanup_failed")) { - finish(2, "pty_record_cleanup_failed"); - } - if (name === "pty-record.json") { - const record = readPrivateJson( - filePath, - MAX_PTY_RECORD_BYTES, - "pty_record_cleanup_failed", - "pty_record_cleanup_failed", - ); - if (!exactKeys(record, ["schemaVersion", "runId", "ttyPath", "dev", "ino", "rdev"])) { - finish(2, "pty_record_cleanup_failed"); - } - if (record.schemaVersion !== 1 || record.runId !== runId) { - finish(2, "pty_record_cleanup_failed"); - } - } + if (names.includes(privateKeyName)) { + const privateKeyPath = path.join(ptyMonitorRoot, privateKeyName); try { - fs.unlinkSync(filePath); + const stats = fs.lstatSync(privateKeyPath, { bigint: true }); + if ( + !stats.isFile() || + stats.isSymbolicLink() || + stats.uid !== BigInt(process.getuid()) || + (stats.mode & 0o777n) !== 0o600n || + stats.nlink !== 1n || + stats.size < 40n || + stats.size > 256n + ) { + finish(2, "pty_monitor_cleanup_failed"); + } + fs.unlinkSync(privateKeyPath); + fsyncParent(privateKeyPath); + names = names.filter((name) => name !== privateKeyName); } catch { - finish(2, "pty_record_cleanup_failed"); + finish(2, "pty_monitor_cleanup_failed"); } } + if (names.length !== 0) finish(2, "pty_monitor_cleanup_failed"); let after; try { - after = fs.lstatSync(ptyRecordRoot, { bigint: true }); + after = fs.lstatSync(ptyMonitorRoot, { bigint: true }); } catch { - finish(2, "pty_record_cleanup_failed"); + finish(2, "pty_monitor_cleanup_failed"); } if (after.dev !== before.dev || after.ino !== before.ino) { - finish(2, "pty_record_cleanup_failed"); + finish(2, "pty_monitor_cleanup_failed"); } try { - fs.rmdirSync(ptyRecordRoot); - fsyncParent(ptyRecordRoot); + fs.rmdirSync(ptyMonitorRoot); + fsyncParent(ptyMonitorRoot); } catch { - finish(2, "pty_record_cleanup_failed"); + finish(2, "pty_monitor_cleanup_failed"); } finish(0); } @@ -706,14 +1278,14 @@ function qualifyTurns() { try { validateRunContext(); if (mode === "baseline") recordBaseline(); - if (mode === "input-mode") qualifyTuiInputMode(); - if (mode === "qualify") qualifyTurns(); - if (mode === "cleanup-baseline") removeBaseline(); - if (mode === "cleanup-pty") removePtyRecordRoot(); + else if (mode === "input-mode") qualifyTuiInputMode(); + else if (mode === "qualify") qualifyTurns(); + else if (mode === "cleanup-baseline") removeBaseline(); + else if (mode === "cleanup-pty") removePtyMonitorRoot(); + else finish(2, "mode_invalid"); } catch { finish(2, "verifier_failed"); } -finish(2, "mode_invalid"); `; export const LAUNCH_TURN_SCRIPT = String.raw`set -euo pipefail @@ -721,6 +1293,19 @@ umask 077 command -v script >/dev/null 2>&1 command -v timeout >/dev/null 2>&1 +read -r pty_monitor_public_key pty_monitor_private_key < <( + node -e ' + const crypto = require("node:crypto"); + const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519"); + process.stdout.write( + publicKey.export({ format: "der", type: "spki" }).toString("base64") + " " + + privateKey.export({ format: "der", type: "pkcs8" }).toString("base64") + "\n", + ); + ' +) +[[ "$pty_monitor_public_key" =~ ^[A-Za-z0-9+/]{40,256}={0,2}$ ]] +[[ "$pty_monitor_private_key" =~ ^[A-Za-z0-9+/]{40,256}={0,2}$ ]] + openshell_command="$NEMOCLAW_OPENSHELL_COMMAND" openshell_environment=(env) while IFS= read -r authority_name; do @@ -732,6 +1317,11 @@ done < <( ) session_dir="$(mktemp -d "$NEMOCLAW_LAUNCH_HOST_TMP_ROOT/nemoclaw-launch-host.XXXXXX")" +pty_monitor_key_path="$session_dir/pty-monitor-key.json" +printf '{"publicKey":"%s","privateKey":"%s"}\n' \ + "$pty_monitor_public_key" "$pty_monitor_private_key" >"$pty_monitor_key_path" +chmod 600 "$pty_monitor_key_path" +unset pty_monitor_private_key capture="$session_dir/terminal.log" driver_error="$session_dir/pty-driver.err" evidence_error="$session_dir/session-evidence.err" @@ -739,7 +1329,7 @@ input="$session_dir/input" openshell_shim="$session_dir/openshell-launch-shim" intercept_path="$session_dir/launch-intercept.json" baseline_path="/tmp/nemoclaw-launch-session-$NEMOCLAW_LAUNCH_RUN_ID.json" -pty_record_root="/tmp/nemoclaw-launch-turn-$NEMOCLAW_LAUNCH_RUN_ID" +pty_monitor_root="/tmp/nemoclaw-launch-turn-$NEMOCLAW_LAUNCH_RUN_ID" session_pid="" session_deadline="" @@ -747,10 +1337,20 @@ remove_session_baseline() { session_evidence cleanup-baseline } -remove_pty_record() { +remove_pty_monitor() { session_evidence cleanup-pty } +wait_for_pty_monitor_exit() { + for _ in {1..20}; do + if [[ ! -S "$pty_monitor_root/pty-input-mode.sock" ]]; then + return 0 + fi + sleep 0.05 + done + return 0 +} + cleanup() { local original_status=$? local cleanup_status=0 @@ -764,8 +1364,9 @@ cleanup() { if [[ -n "$session_pid" ]]; then wait "$session_pid" 2>/dev/null || true fi - if ! remove_pty_record >/dev/null 2>&1; then - echo "launch PTY record cleanup failed" >&2 + wait_for_pty_monitor_exit + if ! remove_pty_monitor >/dev/null 2>&1; then + echo "launch PTY monitor cleanup failed" >&2 cleanup_status=1 fi if ! remove_session_baseline >/dev/null 2>&1; then @@ -827,8 +1428,9 @@ session_evidence() { "$NEMOCLAW_LAUNCH_SESSION_ROOT" \ "$baseline_path" \ "$expected_turns" \ - "$pty_record_root" \ - "$NEMOCLAW_LAUNCH_RUN_ID" + "$pty_monitor_root" \ + "$NEMOCLAW_LAUNCH_RUN_ID" \ + "$pty_monitor_public_key" } wait_for_turn_count() { @@ -867,7 +1469,7 @@ wait_for_pty_input_mode() { fi sleep 0.1 done - fail_launch_session "launch did not provide a recorded PTY in noncanonical input mode before the session deadline or PTY child exit" + fail_launch_session "launch did not observe noncanonical PTY input mode before the session deadline or before the PTY child process exited" } if ! session_evidence baseline >/dev/null 2>"$evidence_error"; then @@ -892,7 +1494,8 @@ OPENSHELL_NEMOCLAW_LAUNCH_REAL_COMMAND="$NEMOCLAW_OPENSHELL_COMMAND" \ OPENSHELL_NEMOCLAW_LAUNCH_SANDBOX="$NEMOCLAW_LAUNCH_SANDBOX" \ OPENSHELL_NEMOCLAW_LAUNCH_RUN_ID="$NEMOCLAW_LAUNCH_RUN_ID" \ OPENSHELL_NEMOCLAW_LAUNCH_INTERCEPT_PATH="$intercept_path" \ -OPENSHELL_NEMOCLAW_LAUNCH_PTY_RECORD_WRITER_SCRIPT="$NEMOCLAW_LAUNCH_PTY_RECORD_WRITER_SCRIPT" \ +OPENSHELL_NEMOCLAW_LAUNCH_PTY_MONITOR_STARTER_SCRIPT="$NEMOCLAW_LAUNCH_PTY_MONITOR_STARTER_SCRIPT" \ +OPENSHELL_NEMOCLAW_LAUNCH_PTY_MONITOR_KEY_PATH="$pty_monitor_key_path" \ OPENSHELL_NEMOCLAW_LAUNCH_RUNTIME_ENV_SCRIPT="$NEMOCLAW_LAUNCH_RUNTIME_ENV_SCRIPT" \ timeout --kill-after=5s 250s \ script --quiet --return --flush --command "$launch_command" "$capture" \ @@ -961,8 +1564,9 @@ fi if ! remove_session_baseline >/dev/null 2>"$evidence_error"; then fail_launch_session "launch could not remove the structured session baseline" fi -if ! remove_pty_record >/dev/null 2>"$evidence_error"; then - fail_launch_session "launch could not remove the PTY record" +wait_for_pty_monitor_exit +if ! remove_pty_monitor >/dev/null 2>"$evidence_error"; then + fail_launch_session "launch could not remove the PTY monitor" fi `; @@ -1010,7 +1614,7 @@ export async function runOpenClawLaunchSession( NEMOCLAW_LAUNCH_SESSION_BUDGET_SECONDS: "230", NEMOCLAW_LAUNCH_SECOND_INPUT: inputs.second, NEMOCLAW_LAUNCH_OPENSHELL_SHIM_SCRIPT: OPENCLAW_LAUNCH_OPENSHELL_SHIM_SCRIPT, - NEMOCLAW_LAUNCH_PTY_RECORD_WRITER_SCRIPT: OPENCLAW_PTY_RECORD_WRITER_SCRIPT, + NEMOCLAW_LAUNCH_PTY_MONITOR_STARTER_SCRIPT: OPENCLAW_PTY_MONITOR_STARTER_SCRIPT, NEMOCLAW_LAUNCH_RUNTIME_ENV_SCRIPT: OPENCLAW_LAUNCH_RUNTIME_ENV_SCRIPT, NEMOCLAW_LAUNCH_SESSION_EVIDENCE_SCRIPT: OPENCLAW_SESSION_EVIDENCE_SCRIPT, NEMOCLAW_LAUNCH_SESSION_ROOT: "/sandbox/.openclaw/agents/main/sessions", diff --git a/test/e2e/support/launch-agent-turn.test.ts b/test/e2e/support/launch-agent-turn.test.ts index dcbd439cf52..e52c3c077bd 100644 --- a/test/e2e/support/launch-agent-turn.test.ts +++ b/test/e2e/support/launch-agent-turn.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { spawnSync } from "node:child_process"; -import { randomUUID } from "node:crypto"; +import { generateKeyPairSync, randomUUID } from "node:crypto"; import { appendFileSync, chmodSync, @@ -35,13 +35,21 @@ import { LAUNCH_TURN_SCRIPT, OPENCLAW_LAUNCH_OPENSHELL_SHIM_SCRIPT, OPENCLAW_LAUNCH_RUNTIME_ENV_SCRIPT, - OPENCLAW_PTY_RECORD_WRITER_SCRIPT, + OPENCLAW_PTY_MONITOR_KEY_WRITER_SCRIPT, + OPENCLAW_PTY_MONITOR_STARTER_SCRIPT, OPENCLAW_SESSION_EVIDENCE_SCRIPT, runOpenClawLaunchSession, runOpenClawLaunchReadinessLeaseTurns, } from "../live/launch-agent-turn.ts"; const PROCESS_EXIT_WAIT = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)); +const TEST_PTY_MONITOR_KEY_PAIR = generateKeyPairSync("ed25519"); +const TEST_PTY_MONITOR_PUBLIC_KEY = TEST_PTY_MONITOR_KEY_PAIR.publicKey + .export({ format: "der", type: "spki" }) + .toString("base64"); +const TEST_PTY_MONITOR_PRIVATE_KEY = TEST_PTY_MONITOR_KEY_PAIR.privateKey + .export({ format: "der", type: "pkcs8" }) + .toString("base64"); type SessionRecords = Record; type FixtureMode = @@ -55,12 +63,13 @@ type FixtureMode = | "nonzero-pty-cleanup-failure" | "pty-cleanup-failure" | "pty-cleanup-unknown-entry" - | "pty-record-identity" - | "pty-record-invalid" - | "pty-record-permission" - | "pty-record-timeout" + | "pty-response-forgery" + | "pty-socket-permission" + | "pty-socket-timeout" + | "pty-path-unreadable" | "pty-termios-unavailable" | "recording-timeout" + | "restored-canonical-timeout" | "valid"; function message(role: "assistant" | "user", content = "nonempty"): string { @@ -117,7 +126,7 @@ function runEvidenceFixture(input: { const fixtureRoot = mkdtempSync(join(tmpdir(), "nemoclaw-launch-evidence-")); const runId = randomUUID().replaceAll("-", ""); const baselinePath = `/tmp/nemoclaw-launch-session-${runId}.json`; - const ptyRecordRoot = `/tmp/nemoclaw-launch-turn-${runId}`; + const ptyMonitorRoot = `/tmp/nemoclaw-launch-turn-${runId}`; const sessionRoot = join(fixtureRoot, "sessions"); mkdirSync(sessionRoot); try { @@ -131,8 +140,9 @@ function runEvidenceFixture(input: { sessionRoot, baselinePath, "", - ptyRecordRoot, + ptyMonitorRoot, runId, + TEST_PTY_MONITOR_PUBLIC_KEY, ], { encoding: "utf8" }, ); @@ -146,8 +156,9 @@ function runEvidenceFixture(input: { sessionRoot, baselinePath, String(input.expectedTurns), - ptyRecordRoot, + ptyMonitorRoot, runId, + TEST_PTY_MONITOR_PUBLIC_KEY, ], { encoding: "utf8" }, ); @@ -168,7 +179,7 @@ function runEvidenceFixture(input: { rmSync(fixtureRoot, { force: true, recursive: true }); rmSync(baselinePath, { force: true }); rmSync(`${baselinePath}.tmp`, { force: true }); - rmSync(ptyRecordRoot, { force: true, recursive: true }); + rmSync(ptyMonitorRoot, { force: true, recursive: true }); } } @@ -176,7 +187,7 @@ function runBaselineMutationFixture(mutation: "invalid" | "removed" | "rewritten const fixtureRoot = mkdtempSync(join(tmpdir(), "nemoclaw-launch-baseline-")); const runId = randomUUID().replaceAll("-", ""); const baselinePath = `/tmp/nemoclaw-launch-session-${runId}.json`; - const ptyRecordRoot = `/tmp/nemoclaw-launch-turn-${runId}`; + const ptyMonitorRoot = `/tmp/nemoclaw-launch-turn-${runId}`; const sessionRoot = join(fixtureRoot, "sessions"); const sessionPath = join(sessionRoot, "session-a.jsonl"); mkdirSync(sessionRoot); @@ -191,8 +202,9 @@ function runBaselineMutationFixture(mutation: "invalid" | "removed" | "rewritten sessionRoot, baselinePath, "", - ptyRecordRoot, + ptyMonitorRoot, runId, + TEST_PTY_MONITOR_PUBLIC_KEY, ], { encoding: "utf8" }, ); @@ -221,8 +233,9 @@ function runBaselineMutationFixture(mutation: "invalid" | "removed" | "rewritten sessionRoot, baselinePath, "1", - ptyRecordRoot, + ptyMonitorRoot, runId, + TEST_PTY_MONITOR_PUBLIC_KEY, ], { encoding: "utf8" }, ); @@ -231,24 +244,28 @@ function runBaselineMutationFixture(mutation: "invalid" | "removed" | "rewritten rmSync(fixtureRoot, { force: true, recursive: true }); rmSync(baselinePath, { force: true }); rmSync(`${baselinePath}.tmp`, { force: true }); - rmSync(ptyRecordRoot, { force: true, recursive: true }); + rmSync(ptyMonitorRoot, { force: true, recursive: true }); } } function runLaunchSessionFixture(mode: FixtureMode, terminalCopy: "absent" | "ansi" | "reordered") { const fixtureRoot = mkdtempSync(join(tmpdir(), "nemoclaw-launch-turn-")); + const canonicalRestoredMarker = join(fixtureRoot, "canonical-restored"); + const earlyInputMarker = join(fixtureRoot, "early-input"); const fakeLaunch = join(fixtureRoot, "openclaw"); const fakeOpenshell = join(fixtureRoot, "openshell"); const fakeStty = join(fixtureRoot, "stty"); + const monitorPidPath = join(fixtureRoot, "monitor-pid"); const sessionRoot = join(fixtureRoot, "sessions"); const tuiPidsPath = join(fixtureRoot, "tui-pids"); const ttyMarker = join(fixtureRoot, "tty-observed"); const openshellCallsRoot = join(fixtureRoot, "openshell-calls"); const pendingQualificationMarker = join(fixtureRoot, "pending-qualification-observed"); - const ptyRecordReceiptPath = join(fixtureRoot, "pty-record-receipt.json"); + const ptyPathUnreadableMarker = join(fixtureRoot, "pty-path-unreadable"); + const ptySocketReceiptPath = join(fixtureRoot, "pty-socket-receipt.json"); const runId = randomUUID().replaceAll("-", ""); const baselinePath = `/tmp/nemoclaw-launch-session-${runId}.json`; - const ptyRecordRoot = `/tmp/nemoclaw-launch-turn-${runId}`; + const ptyMonitorRoot = `/tmp/nemoclaw-launch-turn-${runId}`; mkdirSync(sessionRoot); mkdirSync(openshellCallsRoot, { mode: 0o700 }); writeFileSync( @@ -259,15 +276,13 @@ function runLaunchSessionFixture(mode: FixtureMode, terminalCopy: "absent" | "an try { writeFileSync( fakeStty, - String.raw`#!/usr/bin/env bash -if [[ "$NEMOCLAW_FIXTURE_MODE" == "pty-termios-unavailable" ]]; then - for _ in {1..200}; do - [[ ! -e "$NEMOCLAW_FIXTURE_TTY_MARKER" ]] || exit 1 - sleep 0.01 - done - exit 72 + String.raw`#!/bin/bash +marker=${JSON.stringify(ttyMarker)} +if [[ ! -e "$marker" ]]; then + exec /usr/bin/stty "$@" fi -exec /usr/bin/stty "$@" +printf 'fixture stty denied\n' >&2 +exit 1 `, ); writeFileSync( @@ -275,6 +290,7 @@ exec /usr/bin/stty "$@" String.raw`#!/usr/bin/env node const childProcess = require("node:child_process"); const fs = require("node:fs"); +const net = require("node:net"); const readline = require("node:readline"); const mode = process.env.NEMOCLAW_FIXTURE_MODE; @@ -324,27 +340,100 @@ if (process.argv[2] !== "tui") { (async () => { if (!process.stdin.isTTY || !process.stdout.isTTY) process.exit(64); fs.appendFileSync(process.env.NEMOCLAW_FIXTURE_TUI_PIDS, process.pid + "\n"); - const recordPath = process.env.NEMOCLAW_FIXTURE_PTY_RECORD_ROOT + "/pty-record.json"; - const record = JSON.parse(fs.readFileSync(recordPath, "utf8")); - const recordStats = fs.lstatSync(recordPath); - const rootStats = fs.lstatSync(process.env.NEMOCLAW_FIXTURE_PTY_RECORD_ROOT); - fs.writeFileSync(process.env.NEMOCLAW_FIXTURE_PTY_RECORD_RECEIPT, JSON.stringify({ - record, - recordMode: recordStats.mode & 0o777, - recordNlink: recordStats.nlink, - recordUid: recordStats.uid, + const monitorRoot = process.env.NEMOCLAW_FIXTURE_PTY_MONITOR_ROOT; + const socketPath = monitorRoot + "/pty-input-mode.sock"; + const socketDeadline = Date.now() + 2_000; + while (!fs.existsSync(socketPath)) { + if (Date.now() >= socketDeadline) process.exit(70); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + const socketStats = fs.lstatSync(socketPath); + const rootStats = fs.lstatSync(monitorRoot); + fs.writeFileSync(process.env.NEMOCLAW_FIXTURE_PTY_SOCKET_RECEIPT, JSON.stringify({ rootMode: rootStats.mode & 0o777, rootUid: rootStats.uid, - temporaryExists: fs.existsSync(process.env.NEMOCLAW_FIXTURE_PTY_RECORD_ROOT + "/pty-record.json.tmp"), + socketIsSocket: socketStats.isSocket(), + socketMode: socketStats.mode & 0o777, + socketNlink: socketStats.nlink, + socketUid: socketStats.uid, })); - if (mode === "pty-record-invalid") fs.writeFileSync(recordPath, "{}\n"); - if (mode === "pty-record-identity") { - record.rdev = record.rdev === "0" ? "1" : "0"; - fs.writeFileSync(recordPath, JSON.stringify(record) + "\n"); + const monitorPid = fs.readdirSync("/proc").find((name) => { + try { + const commandLine = fs.readFileSync("/proc/" + name + "/cmdline", "utf8"); + const argv = commandLine.split("\0"); + return argv.includes("nemoclaw-pty-input-mode-monitor") && + argv.includes(process.env.NEMOCLAW_FIXTURE_RUN_ID); + } catch { + return false; + } + }); + if (!monitorPid) process.exit(71); + fs.writeFileSync(process.env.NEMOCLAW_FIXTURE_MONITOR_PID, monitorPid); + if (mode === "pty-response-forgery") { + process.stdin.on("data", () => { + fs.writeFileSync(process.env.NEMOCLAW_FIXTURE_EARLY_INPUT_MARKER, ""); + }); + fs.unlinkSync(socketPath); + const ttyPath = fs.realpathSync("/proc/self/fd/0"); + const ttyStats = fs.fstatSync(0, { bigint: true }); + const replacement = net.createServer((client) => { + client.setEncoding("utf8"); + let raw = ""; + client.on("data", (chunk) => { + raw += chunk; + if (!raw.endsWith("\n")) return; + const request = JSON.parse(raw); + client.removeAllListeners("data"); + client.end(JSON.stringify({ + schemaVersion: 1, + runId: process.env.NEMOCLAW_FIXTURE_RUN_ID, + requestId: request.requestId, + ttyPath, + dev: ttyStats.dev.toString(), + ino: ttyStats.ino.toString(), + rdev: ttyStats.rdev.toString(), + state: "noncanonical", + status: null, + signal: null, + errorCode: null, + stderr: "", + signature: Buffer.alloc(64).toString("base64"), + }) + "\n"); + }); + }); + await new Promise((resolve, reject) => { + replacement.once("error", reject); + replacement.listen(socketPath, resolve); + }); + fs.chmodSync(socketPath, 0o600); + } + if (mode === "pty-socket-permission") fs.chmodSync(socketPath, 0); + switch (mode) { + case "pty-path-unreadable": { + const ttyPath = fs.realpathSync("/proc/self/fd/0"); + const ttyMode = fs.lstatSync(ttyPath).mode & 0o777; + fs.chmodSync(ttyPath, 0); + const pathQuery = childProcess.spawnSync( + "/usr/bin/stty", + ["-F", ttyPath, "-a"], + { encoding: "utf8" }, + ); + fs.writeFileSync( + process.env.NEMOCLAW_FIXTURE_PTY_PATH_UNREADABLE_MARKER, + JSON.stringify({ + errorCode: pathQuery.error?.code ?? null, + status: pathQuery.status, + }), + ); + if (pathQuery.status === 0) process.exit(69); + process.on("exit", () => { + try { fs.chmodSync(ttyPath, ttyMode); } catch {} + }); + break; + } } - if (mode === "pty-record-permission") fs.chmodSync(recordPath, 0); if (mode === "pty-cleanup-unknown-entry") { - fs.writeFileSync(process.env.NEMOCLAW_FIXTURE_PTY_RECORD_ROOT + "/unexpected", "owned test residue"); + fs.writeFileSync(monitorRoot + "/unexpected", "owned test residue"); } fs.writeFileSync(process.env.NEMOCLAW_FIXTURE_TTY_MARKER, ""); const sessionFile = process.env.NEMOCLAW_FIXTURE_SESSION_FILE; @@ -353,6 +442,17 @@ if (process.argv[2] !== "tui") { sessionFile, JSON.stringify({ message: { content: [{ text: content, type: "text" }], role }, type: "message" }) + "\n", ); + if (mode === "restored-canonical-timeout") { + process.stdin.setRawMode(true); + await new Promise((resolve) => setTimeout(resolve, 750)); + process.stdin.setRawMode(false); + const recordUnexpectedInput = () => + fs.writeFileSync(process.env.NEMOCLAW_FIXTURE_EARLY_INPUT_MARKER, ""); + process.stdin.on("data", recordUnexpectedInput); + fs.writeFileSync(process.env.NEMOCLAW_FIXTURE_CANONICAL_RESTORED_MARKER, ""); + await new Promise((resolve) => setTimeout(resolve, 10_000)); + process.stdin.off("data", recordUnexpectedInput); + } if (mode === "delayed-input-attachment" || mode === "input-mode-timeout") { let inputBeforeAttachment = false; const recordEarlyInput = () => { inputBeforeAttachment = true; }; @@ -367,6 +467,11 @@ if (process.argv[2] !== "tui") { if (terminalCopy === "reordered") process.stdout.write("idle | gateway connected\n"); const first = await ask(); + process.kill(Number(monitorPid), "SIGTERM"); + await new Promise((resolve) => setTimeout(resolve, 50)); + const monitorStat = fs.readFileSync("/proc/" + monitorPid + "/stat", "utf8"); + const monitorState = monitorStat ? monitorStat.slice(monitorStat.lastIndexOf(") ") + 2)[0] : null; + if (!monitorState || monitorState === "Z") process.exit(71); const delayedInputs = []; if (mode === "delayed-recording") { const recordDelayedInput = (line) => delayedInputs.push(line); @@ -432,17 +537,24 @@ while [[ "$#" -gt 0 && "$1" != "--" ]]; do shift; done [[ "$#" -gt 0 ]] shift case "$NEMOCLAW_FIXTURE_MODE:$4" in - pty-record-invalid:input-mode|pty-record-permission:input-mode|pty-record-identity:input-mode) + pty-response-forgery:input-mode|pty-socket-permission:input-mode) [[ -e "$NEMOCLAW_FIXTURE_TTY_MARKER" ]] || exit 1 ;; esac +if [[ "$NEMOCLAW_FIXTURE_MODE" == "restored-canonical-timeout" && "$4" == "input-mode" ]]; then + for _ in {1..200}; do + [[ ! -e "$NEMOCLAW_FIXTURE_CANONICAL_RESTORED_MARKER" ]] || break + sleep 0.01 + done + [[ -e "$NEMOCLAW_FIXTURE_CANONICAL_RESTORED_MARKER" ]] || exit 1 +fi if [[ "$NEMOCLAW_FIXTURE_MODE" == "cleanup-failure" && "$4" == "cleanup-baseline" ]]; then exit 71 fi if [[ ( "$NEMOCLAW_FIXTURE_MODE" == "pty-cleanup-failure" || "$NEMOCLAW_FIXTURE_MODE" == "nonzero-pty-cleanup-failure" ) && "$4" == "cleanup-pty" ]]; then exit 71 fi -if [[ "$NEMOCLAW_FIXTURE_MODE" == "pty-record-timeout" && "$4" == "$NEMOCLAW_FIXTURE_RUN_ID" ]]; then +if [[ "$NEMOCLAW_FIXTURE_MODE" == "pty-socket-timeout" && "$4" == "$NEMOCLAW_FIXTURE_RUN_ID" ]]; then exec node -e 'setTimeout(() => process.exit(0), 10_000)' fi if [[ "$NEMOCLAW_FIXTURE_MODE" == "delayed-recording" && "$4" == "qualify" && "$7" == "1" ]]; then @@ -460,6 +572,16 @@ exec "$@" chmodSync(fakeOpenshell, 0o755); chmodSync(fakeStty, 0o755); + const unavailablePtyMonitorStarterScript = OPENCLAW_PTY_MONITOR_STARTER_SCRIPT.replace( + 'const termiosCommand = "/usr/bin/stty";', + `const termiosCommand = ${JSON.stringify(fakeStty)};`, + ); + expect(unavailablePtyMonitorStarterScript).not.toBe(OPENCLAW_PTY_MONITOR_STARTER_SCRIPT); + const ptyMonitorStarterScript = + mode === "pty-termios-unavailable" + ? unavailablePtyMonitorStarterScript + : OPENCLAW_PTY_MONITOR_STARTER_SCRIPT; + const result = spawnSync("bash", ["-c", LAUNCH_TURN_SCRIPT], { encoding: "utf8", killSignal: "SIGKILL", @@ -467,13 +589,17 @@ exec "$@" ...process.env, HOME: fixtureRoot, NEMOCLAW_FIXTURE_BIN_ROOT: fixtureRoot, + NEMOCLAW_FIXTURE_CANONICAL_RESTORED_MARKER: canonicalRestoredMarker, + NEMOCLAW_FIXTURE_EARLY_INPUT_MARKER: earlyInputMarker, NEMOCLAW_FIXTURE_MODE: mode, + NEMOCLAW_FIXTURE_MONITOR_PID: monitorPidPath, NEMOCLAW_FIXTURE_OPENSHELL_CALLS: openshellCallsRoot, NEMOCLAW_FIXTURE_PENDING_QUALIFICATION_MARKER: pendingQualificationMarker, - NEMOCLAW_FIXTURE_PTY_RECORD_ROOT: ptyRecordRoot, + NEMOCLAW_FIXTURE_PTY_MONITOR_ROOT: ptyMonitorRoot, + NEMOCLAW_FIXTURE_PTY_PATH_UNREADABLE_MARKER: ptyPathUnreadableMarker, + NEMOCLAW_FIXTURE_PTY_SOCKET_RECEIPT: ptySocketReceiptPath, NEMOCLAW_FIXTURE_SESSION_FILE: join(sessionRoot, "session-a.jsonl"), NEMOCLAW_FIXTURE_TERMINAL_COPY: terminalCopy, - NEMOCLAW_FIXTURE_PTY_RECORD_RECEIPT: ptyRecordReceiptPath, NEMOCLAW_FIXTURE_RUN_ID: runId, NEMOCLAW_FIXTURE_TUI_PIDS: tuiPidsPath, NEMOCLAW_FIXTURE_TTY_MARKER: ttyMarker, @@ -483,11 +609,12 @@ exec "$@" NEMOCLAW_LAUNCH_FIRST_INPUT: "first input", NEMOCLAW_LAUNCH_HOST_TMP_ROOT: fixtureRoot, NEMOCLAW_LAUNCH_OPENSHELL_SHIM_SCRIPT: OPENCLAW_LAUNCH_OPENSHELL_SHIM_SCRIPT, - NEMOCLAW_LAUNCH_PTY_RECORD_WRITER_SCRIPT: OPENCLAW_PTY_RECORD_WRITER_SCRIPT, + NEMOCLAW_LAUNCH_PTY_MONITOR_STARTER_SCRIPT: ptyMonitorStarterScript, NEMOCLAW_LAUNCH_RUN_ID: runId, NEMOCLAW_LAUNCH_RUNTIME_ENV_SCRIPT: OPENCLAW_LAUNCH_RUNTIME_ENV_SCRIPT, NEMOCLAW_LAUNCH_SANDBOX: "sandbox", - NEMOCLAW_LAUNCH_SESSION_BUDGET_SECONDS: mode.endsWith("-timeout") ? "2" : "230", + NEMOCLAW_LAUNCH_SESSION_BUDGET_SECONDS: + mode === "restored-canonical-timeout" ? "5" : mode.endsWith("-timeout") ? "2" : "230", NEMOCLAW_LAUNCH_SECOND_INPUT: "second input", NEMOCLAW_LAUNCH_SESSION_EVIDENCE_SCRIPT: OPENCLAW_SESSION_EVIDENCE_SCRIPT, NEMOCLAW_LAUNCH_SESSION_ROOT: sessionRoot, @@ -501,18 +628,25 @@ exec "$@" const tuiProcessIds = existsSync(tuiPidsPath) ? readFileSync(tuiPidsPath, "utf8").trim().split("\n").filter(Boolean) : []; - const processExitDeadline = Date.now() + 1_000; + const monitorProcessIds = existsSync(monitorPidPath) + ? [readFileSync(monitorPidPath, "utf8").trim()].filter(Boolean) + : []; + const processExitDeadline = Date.now() + 1_500; while ( - tuiProcessIds.some((pid) => existsSync(`/proc/${pid}`)) && + (tuiProcessIds.some((pid) => existsSync(`/proc/${pid}`)) || + monitorProcessIds.some((pid) => existsSync(`/proc/${pid}`))) && Date.now() < processExitDeadline ) { Atomics.wait(PROCESS_EXIT_WAIT, 0, 0, 25); } return { baselineRemoved: !existsSync(baselinePath), + canonicalRestored: existsSync(canonicalRestoredMarker), + earlyInputObserved: existsSync(earlyInputMarker), hostSessionResidue: readdirSync(fixtureRoot).filter((name) => name.startsWith("nemoclaw-launch-host."), ), + orphanedMonitorProcessIds: monitorProcessIds.filter((pid) => existsSync(`/proc/${pid}`)), orphanedTuiProcessIds: tuiProcessIds.filter((pid) => existsSync(`/proc/${pid}`)), openshellCalls: readdirSync(openshellCallsRoot) .sort() @@ -524,22 +658,25 @@ exec "$@" }, ), pendingQualificationObserved: existsSync(pendingQualificationMarker), - ptyRecordRemoved: !existsSync(ptyRecordRoot), - ptyRecordReceipt: existsSync(ptyRecordReceiptPath) - ? JSON.parse(readFileSync(ptyRecordReceiptPath, "utf8")) + ptyPathQueryResult: existsSync(ptyPathUnreadableMarker) + ? JSON.parse(readFileSync(ptyPathUnreadableMarker, "utf8")) + : null, + ptyMonitorRemoved: !existsSync(ptyMonitorRoot), + ptySocketReceipt: existsSync(ptySocketReceiptPath) + ? JSON.parse(readFileSync(ptySocketReceiptPath, "utf8")) : null, result, tuiProcessIds, ttyObserved: existsSync(ttyMarker), }; } finally { - const ptyRecordPath = join(ptyRecordRoot, "pty-record.json"); - existsSync(ptyRecordRoot) ? chmodSync(ptyRecordRoot, 0o700) : undefined; - existsSync(ptyRecordPath) ? chmodSync(ptyRecordPath, 0o600) : undefined; + const ptySocketPath = join(ptyMonitorRoot, "pty-input-mode.sock"); + existsSync(ptyMonitorRoot) ? chmodSync(ptyMonitorRoot, 0o700) : undefined; + existsSync(ptySocketPath) ? chmodSync(ptySocketPath, 0o600) : undefined; rmSync(fixtureRoot, { force: true, recursive: true }); rmSync(baselinePath, { force: true }); rmSync(`${baselinePath}.tmp`, { force: true }); - rmSync(ptyRecordRoot, { force: true, recursive: true }); + rmSync(ptyMonitorRoot, { force: true, recursive: true }); } } @@ -573,6 +710,7 @@ function runOpenShellShimFixture(gatewayArgs: string[]) { const shim = join(fixtureRoot, "openshell-shim"); const callsPath = join(fixtureRoot, "calls.jsonl"); const interceptPath = join(fixtureRoot, "intercept.json"); + const keyPath = join(fixtureRoot, "pty-monitor-key.json"); const runId = randomUUID().replaceAll("-", ""); const sandboxName = "sandbox"; writeFileSync( @@ -596,6 +734,14 @@ require("node:fs").appendFileSync( `, ); writeFileSync(shim, OPENCLAW_LAUNCH_OPENSHELL_SHIM_SCRIPT); + writeFileSync( + keyPath, + JSON.stringify({ + publicKey: TEST_PTY_MONITOR_PUBLIC_KEY, + privateKey: TEST_PTY_MONITOR_PRIVATE_KEY, + }), + { mode: 0o600 }, + ); chmodSync(realOpenShell, 0o755); chmodSync(shim, 0o755); const hostEnv = { @@ -603,7 +749,7 @@ require("node:fs").appendFileSync( NEMOCLAW_LAUNCH_COMMAND: "nemoclaw", NEMOCLAW_LAUNCH_FIRST_INPUT: "fixture input", NEMOCLAW_LAUNCH_INTERCEPT_PATH: interceptPath, - NEMOCLAW_LAUNCH_PTY_RECORD_WRITER_SCRIPT: OPENCLAW_PTY_RECORD_WRITER_SCRIPT, + NEMOCLAW_LAUNCH_PTY_MONITOR_STARTER_SCRIPT: OPENCLAW_PTY_MONITOR_STARTER_SCRIPT, NEMOCLAW_LAUNCH_RUN_ID: runId, NEMOCLAW_LAUNCH_RUNTIME_ENV_SCRIPT: OPENCLAW_LAUNCH_RUNTIME_ENV_SCRIPT, NEMOCLAW_LAUNCH_SANDBOX: sandboxName, @@ -611,7 +757,8 @@ require("node:fs").appendFileSync( NEMOCLAW_OPENSHELL_BIN: shim, NEMOCLAW_OPENSHELL_COMMAND: realOpenShell, OPENSHELL_NEMOCLAW_LAUNCH_INTERCEPT_PATH: interceptPath, - OPENSHELL_NEMOCLAW_LAUNCH_PTY_RECORD_WRITER_SCRIPT: OPENCLAW_PTY_RECORD_WRITER_SCRIPT, + OPENSHELL_NEMOCLAW_LAUNCH_PTY_MONITOR_KEY_PATH: keyPath, + OPENSHELL_NEMOCLAW_LAUNCH_PTY_MONITOR_STARTER_SCRIPT: OPENCLAW_PTY_MONITOR_STARTER_SCRIPT, OPENSHELL_NEMOCLAW_LAUNCH_REAL_COMMAND: realOpenShell, OPENSHELL_NEMOCLAW_LAUNCH_RUN_ID: runId, OPENSHELL_NEMOCLAW_LAUNCH_RUNTIME_ENV_SCRIPT: OPENCLAW_LAUNCH_RUNTIME_ENV_SCRIPT, @@ -671,7 +818,9 @@ require("node:fs").appendFileSync( passThroughArgv, ttyPassThrough, ttyPassThroughArgv, - recordRoot: `/tmp/nemoclaw-launch-turn-${runId}`, + monitorRoot: `/tmp/nemoclaw-launch-turn-${runId}`, + privateKey: TEST_PTY_MONITOR_PRIVATE_KEY, + publicKey: TEST_PTY_MONITOR_PUBLIC_KEY, runId, }; } finally { @@ -781,56 +930,83 @@ it.each([[], ["-g", "fixture-gateway"]].map((gatewayArgs) => [gatewayArgs] as co expect(fixture.duplicate.status).toBe(73); expect(fixture.duplicate.stderr).toContain('"reason":"openshell_launch_intercept_duplicate"'); expect(fixture.interceptMode).toBe(0o600); - expect(fixture.calls).toHaveLength(3); - expect(fixture.authorityNames).toEqual([[], [], []]); + expect(fixture.calls).toHaveLength(4); + expect(fixture.authorityNames).toEqual([[], [], [], []]); expect(fixture.calls[0]).toEqual(fixture.passThroughArgv); expect(fixture.calls[1]).toEqual(fixture.ttyPassThroughArgv); - expect(fixture.calls[2]?.slice(0, separator + 1)).toEqual( + const optionIndex = fixture.exactArgv.indexOf("--tty"); + expect(fixture.calls[2]).toEqual([ + ...fixture.exactArgv.slice(0, optionIndex), + "--", + "node", + "-e", + OPENCLAW_PTY_MONITOR_KEY_WRITER_SCRIPT, + fixture.runId, + fixture.monitorRoot, + fixture.publicKey, + ]); + expect(fixture.calls[3]?.slice(0, separator + 1)).toEqual( fixture.exactArgv.slice(0, separator + 1), ); - expect(fixture.calls[2]?.slice(separator + 1)).toEqual([ + expect(fixture.calls[3]?.slice(separator + 1)).toEqual([ "node", "-e", - OPENCLAW_PTY_RECORD_WRITER_SCRIPT, + OPENCLAW_PTY_MONITOR_STARTER_SCRIPT, fixture.runId, - fixture.recordRoot, + fixture.monitorRoot, + fixture.publicKey, + `${fixture.monitorRoot}/pty-monitor-private-key`, ...expectedRemote, ]); + expect(fixture.calls.flat()).not.toContain(fixture.privateKey); }, ); it.runIf(process.platform === "linux")( - "rejects a record writer whose standard input is not a PTY (#9160)", + "rejects a monitor starter whose standard input is not a PTY (#9160)", () => { const runId = randomUUID().replaceAll("-", ""); - const recordRoot = `/tmp/nemoclaw-launch-turn-${runId}`; + const monitorRoot = `/tmp/nemoclaw-launch-turn-${runId}`; + const privateKeyPath = join(monitorRoot, "pty-monitor-private-key"); try { + mkdirSync(monitorRoot, { mode: 0o700 }); + writeFileSync(privateKeyPath, TEST_PTY_MONITOR_PRIVATE_KEY, { mode: 0o600 }); const result = spawnSync( process.execPath, - ["-e", OPENCLAW_PTY_RECORD_WRITER_SCRIPT, runId, recordRoot, "/usr/bin/env", "true"], + [ + "-e", + OPENCLAW_PTY_MONITOR_STARTER_SCRIPT, + runId, + monitorRoot, + TEST_PTY_MONITOR_PUBLIC_KEY, + privateKeyPath, + "/usr/bin/env", + "true", + ], { encoding: "utf8", timeout: 2_000, killSignal: "SIGKILL" }, ); expect(result.status).toBe(72); expect(result.stderr).toContain('"reason":"pty_stdin_not_pty"'); - expect(statSync(recordRoot).mode & 0o777).toBe(0o700); - expect(existsSync(join(recordRoot, "pty-record.json"))).toBe(false); + expect(statSync(monitorRoot).mode & 0o777).toBe(0o700); + expect(existsSync(join(monitorRoot, "pty-input-mode.sock"))).toBe(false); } finally { - rmSync(recordRoot, { force: true, recursive: true }); + rmSync(monitorRoot, { force: true, recursive: true }); } }, ); it.runIf(process.platform === "linux").each(["absent", "ansi", "reordered"] as const)( - "sends two inputs and /exit through a real PTY, strips launch authority from OpenShell calls, and ignores terminal copy evidence [%s] (#9160)", + "keeps the monitor alive through SIGTERM, sends two PTY inputs and /exit, strips launch authority, and ignores terminal copy evidence [%s] (#9160)", (terminalCopy) => { const { baselineRemoved, hostSessionResidue, openshellCalls, + orphanedMonitorProcessIds, orphanedTuiProcessIds, - ptyRecordReceipt, - ptyRecordRemoved, + ptyMonitorRemoved, + ptySocketReceipt, result, tuiProcessIds, ttyObserved, @@ -838,33 +1014,25 @@ it.runIf(process.platform === "linux").each(["absent", "ansi", "reordered"] as c expect(ttyObserved, result.stderr).toBe(true); expect(baselineRemoved).toBe(true); - expect(ptyRecordRemoved).toBe(true); + expect(ptyMonitorRemoved).toBe(true); expect(hostSessionResidue).toEqual([]); expect(openshellCalls.length).toBeGreaterThan(3); expect(openshellCalls.every((call) => call.authorityNames.length === 0)).toBe(true); expect(openshellCalls.some((call) => call.argv.includes("baseline"))).toBe(true); expect( - openshellCalls.some((call) => call.argv.includes(OPENCLAW_PTY_RECORD_WRITER_SCRIPT)), + openshellCalls.some((call) => call.argv.includes(OPENCLAW_PTY_MONITOR_STARTER_SCRIPT)), ).toBe(true); expect(tuiProcessIds).toHaveLength(1); + expect(orphanedMonitorProcessIds).toEqual([]); expect(orphanedTuiProcessIds).toEqual([]); - expect(ptyRecordReceipt).toMatchObject({ - recordMode: 0o600, - recordNlink: 1, - recordUid: process.getuid?.(), + expect(ptySocketReceipt).toEqual({ rootMode: 0o700, rootUid: process.getuid?.(), - temporaryExists: false, + socketIsSocket: true, + socketMode: 0o600, + socketNlink: 1, + socketUid: process.getuid?.(), }); - expect(Object.keys(ptyRecordReceipt.record).sort()).toEqual([ - "dev", - "ino", - "rdev", - "runId", - "schemaVersion", - "ttyPath", - ]); - expect(ptyRecordReceipt.record.ttyPath).toMatch(/^\/dev\/pts\/\d+$/); expect(result.signal).toBeNull(); expect(result.status).toBe(0); }, @@ -885,41 +1053,96 @@ it.runIf(process.platform === "linux")( }, ); -it.runIf(process.platform === "linux").each([ - { - mode: "pty-record-invalid", - reason: "pty_record_invalid", - behavior: "invalid PTY record", +it.runIf(process.platform === "linux" && process.getuid?.() !== 0)( + "uses the inherited PTY descriptor when the sandbox user cannot reopen the device path (#9384)", + () => { + const { + baselineRemoved, + hostSessionResidue, + orphanedMonitorProcessIds, + orphanedTuiProcessIds, + ptyPathQueryResult, + ptyMonitorRemoved, + result, + ttyObserved, + } = runLaunchSessionFixture("pty-path-unreadable", "absent"); + + expect(ttyObserved, result.stderr).toBe(true); + expect(ptyPathQueryResult, result.stderr).toEqual({ errorCode: null, status: 1 }); + expect(baselineRemoved, result.stderr).toBe(true); + expect(ptyMonitorRemoved, result.stderr).toBe(true); + expect(hostSessionResidue, result.stderr).toEqual([]); + expect(orphanedMonitorProcessIds, result.stderr).toEqual([]); + expect(orphanedTuiProcessIds, result.stderr).toEqual([]); + expect(result.signal, result.stderr).toBeNull(); + expect(result.status, result.stderr).toBe(0); }, +); + +it.runIf(process.platform === "linux").each([ { - mode: "pty-record-permission", - reason: "pty_record_unavailable", - behavior: "unreadable PTY record", + mode: "pty-response-forgery", + reason: "pty_termios_response_invalid", + behavior: "forged same-UID noncanonical response", + expectedDiagnostic: { reason: "pty_termios_response_invalid" }, + monitorRemoved: false, }, { - mode: "pty-record-identity", - reason: "pty_identity_changed", - behavior: "changed PTY device identity", + mode: "pty-socket-permission", + reason: "pty_socket_invalid", + behavior: "PTY monitor socket whose mode is not 0600", + expectedDiagnostic: { reason: "pty_socket_invalid" }, + monitorRemoved: false, }, { mode: "pty-termios-unavailable", reason: "pty_termios_unavailable", - behavior: "unavailable PTY terminal state", + behavior: "unavailable PTY input-mode evidence", + expectedDiagnostic: { + reason: "pty_termios_unavailable", + sttyStatus: 1, + sttySignal: null, + sttyErrorCode: null, + sttyStderr: "fixture stty denied", + }, + monitorRemoved: true, }, -] as const)("rejects $behavior before PTY input (#9160)", ({ mode, reason }) => { - const { baselineRemoved, orphanedTuiProcessIds, result, ttyObserved } = runLaunchSessionFixture( - mode, - "absent", - ); - const failureEvidence = `${mode}: ${result.stderr}`; - - expect(ttyObserved, failureEvidence).toBe(true); - expect(orphanedTuiProcessIds, failureEvidence).toEqual([]); - expect(baselineRemoved, failureEvidence).toBe(true); - expect(result.signal, failureEvidence).toBeNull(); - expect(result.status, failureEvidence).toBe(1); - expect(result.stderr, failureEvidence).toContain(`"reason":"${reason}"`); -}); +] as const)( + "rejects $behavior before PTY input (#9160, #9384)", + ({ expectedDiagnostic, mode, monitorRemoved, reason }) => { + const { + baselineRemoved, + earlyInputObserved, + orphanedMonitorProcessIds, + orphanedTuiProcessIds, + ptyMonitorRemoved, + result, + ttyObserved, + } = runLaunchSessionFixture(mode, "absent"); + const failureEvidence = `${mode}: ${result.stderr}`; + const diagnostics = result.stderr.split("\n").flatMap((line) => { + try { + return [JSON.parse(line)]; + } catch { + return []; + } + }); + + expect(ttyObserved, failureEvidence).toBe(true); + expect(orphanedMonitorProcessIds, failureEvidence).toEqual([]); + expect(orphanedTuiProcessIds, failureEvidence).toEqual([]); + expect(baselineRemoved, failureEvidence).toBe(true); + expect(earlyInputObserved, failureEvidence).toBe(false); + expect(result.signal, failureEvidence).toBeNull(); + expect(result.status, failureEvidence).toBe(1); + expect(result.stderr, failureEvidence).toContain(`"reason":"${reason}"`); + expect(diagnostics, failureEvidence).toEqual( + expect.arrayContaining([expect.objectContaining(expectedDiagnostic)]), + ); + expect(ptyMonitorRemoved, failureEvidence).toBe(monitorRemoved); + }, + 15_000, +); it.runIf(process.platform === "linux")( "submits each PTY turn once while structured recording is delayed (#9160)", @@ -938,36 +1161,62 @@ it.runIf(process.platform === "linux")( it.runIf(process.platform === "linux")( "fails when the PTY remains in canonical input mode until the session deadline (#9160)", () => { - const { baselineRemoved, result, ttyObserved } = runLaunchSessionFixture( - "input-mode-timeout", - "absent", - ); + const { baselineRemoved, orphanedMonitorProcessIds, ptyMonitorRemoved, result, ttyObserved } = + runLaunchSessionFixture("input-mode-timeout", "absent"); expect(ttyObserved).toBe(true); expect(baselineRemoved).toBe(true); + expect(ptyMonitorRemoved).toBe(true); + expect(orphanedMonitorProcessIds).toEqual([]); expect(result.signal).toBeNull(); expect(result.status).toBe(1); expect(result.stderr).toContain( - "launch did not provide a recorded PTY in noncanonical input mode before the session deadline or PTY child exit", + "launch did not observe noncanonical PTY input mode before the session deadline or before the PTY child process exited", ); expect(result.stderr).toContain('"reason":"pty_input_canonical"'); }, ); it.runIf(process.platform === "linux")( - "fails when the PTY record remains missing until the session deadline (#9160)", + "requires a current noncanonical observation after the PTY returns to canonical mode (#9384)", + () => { + const { + baselineRemoved, + canonicalRestored, + earlyInputObserved, + orphanedMonitorProcessIds, + ptyMonitorRemoved, + result, + ttyObserved, + } = runLaunchSessionFixture("restored-canonical-timeout", "absent"); + + expect(ttyObserved).toBe(true); + expect(canonicalRestored).toBe(true); + expect(earlyInputObserved).toBe(false); + expect(baselineRemoved).toBe(true); + expect(ptyMonitorRemoved).toBe(true); + expect(orphanedMonitorProcessIds).toEqual([]); + expect(result.signal).toBeNull(); + expect(result.status).toBe(1); + expect(result.stderr).toContain('"reason":"pty_input_canonical"'); + }, + 15_000, +); + +it.runIf(process.platform === "linux")( + "fails when the PTY monitor socket remains missing until the session deadline (#9160)", () => { - const { baselineRemoved, ptyRecordRemoved, result, ttyObserved } = runLaunchSessionFixture( - "pty-record-timeout", + const { baselineRemoved, ptyMonitorRemoved, result, ttyObserved } = runLaunchSessionFixture( + "pty-socket-timeout", "absent", ); expect(ttyObserved).toBe(false); expect(baselineRemoved).toBe(true); - expect(ptyRecordRemoved).toBe(true); + expect(ptyMonitorRemoved).toBe(true); expect(result.signal).toBeNull(); expect(result.status).toBe(1); - expect(result.stderr).toContain('"reason":"pty_record_missing"'); + expect(result.stderr).toContain('"reason":"pty_socket_missing"'); }, ); @@ -1034,33 +1283,33 @@ it.runIf(process.platform === "linux")( ); it.runIf(process.platform === "linux")( - "fails when an accepted PTY session cannot run PTY record cleanup (#9160)", + "fails when an accepted PTY session cannot run PTY monitor cleanup (#9160)", () => { - const { hostSessionResidue, orphanedTuiProcessIds, ptyRecordRemoved, result } = + const { hostSessionResidue, orphanedTuiProcessIds, ptyMonitorRemoved, result } = runLaunchSessionFixture("pty-cleanup-failure", "absent"); - expect(ptyRecordRemoved).toBe(false); + expect(ptyMonitorRemoved).toBe(false); expect(hostSessionResidue).toEqual([]); expect(orphanedTuiProcessIds).toEqual([]); expect(result.signal).toBeNull(); expect(result.status).toBe(1); - expect(result.stderr).toContain("launch could not remove the PTY record"); + expect(result.stderr).toContain("launch could not remove the PTY monitor"); }, ); it.runIf(process.platform === "linux")( - "refuses to remove an unknown entry from the private PTY record directory (#9160)", + "refuses to remove an unknown entry from the private PTY monitor directory (#9160)", () => { - const { orphanedTuiProcessIds, ptyRecordRemoved, result } = runLaunchSessionFixture( + const { orphanedTuiProcessIds, ptyMonitorRemoved, result } = runLaunchSessionFixture( "pty-cleanup-unknown-entry", "absent", ); - expect(ptyRecordRemoved).toBe(false); + expect(ptyMonitorRemoved).toBe(false); expect(orphanedTuiProcessIds).toEqual([]); expect(result.signal).toBeNull(); expect(result.status).toBe(1); - expect(result.stderr).toContain('"reason":"pty_record_cleanup_unknown_entry"'); + expect(result.stderr).toContain('"reason":"pty_monitor_cleanup_unknown_entry"'); }, ); @@ -1080,16 +1329,16 @@ it.runIf(process.platform === "linux")( ); it.runIf(process.platform === "linux")( - "preserves a nonzero PTY exit when PTY record cleanup also fails (#9160)", + "preserves a nonzero PTY exit when PTY monitor cleanup also fails (#9160)", () => { - const { baselineRemoved, ptyRecordRemoved, result, ttyObserved } = runLaunchSessionFixture( + const { baselineRemoved, ptyMonitorRemoved, result, ttyObserved } = runLaunchSessionFixture( "nonzero-pty-cleanup-failure", "absent", ); expect(ttyObserved).toBe(true); expect(baselineRemoved).toBe(true); - expect(ptyRecordRemoved).toBe(false); + expect(ptyMonitorRemoved).toBe(false); expect(result.signal).toBeNull(); expect(result.status).toBe(23); }, @@ -1213,8 +1462,8 @@ it.runIf(process.platform === "linux")( expect(call.env?.NEMOCLAW_LAUNCH_OPENSHELL_SHIM_SCRIPT).toBe( OPENCLAW_LAUNCH_OPENSHELL_SHIM_SCRIPT, ); - expect(call.env?.NEMOCLAW_LAUNCH_PTY_RECORD_WRITER_SCRIPT).toBe( - OPENCLAW_PTY_RECORD_WRITER_SCRIPT, + expect(call.env?.NEMOCLAW_LAUNCH_PTY_MONITOR_STARTER_SCRIPT).toBe( + OPENCLAW_PTY_MONITOR_STARTER_SCRIPT, ); expect(call.env?.NEMOCLAW_LAUNCH_RUNTIME_ENV_SCRIPT).toBe(OPENCLAW_LAUNCH_RUNTIME_ENV_SCRIPT); });