| 1 | // exec + transport safety tests. |
| 2 | import { test } from "node:test"; |
| 3 | import assert from "node:assert/strict"; |
| 4 | import fs from "node:fs"; |
| 5 | import os from "node:os"; |
| 6 | import path from "node:path"; |
| 7 | import net from "node:net"; |
| 8 | import { run, runOk, runInputLease, ExecError, have, trim, withSignal } from "../src/exec.mjs"; |
| 9 | import { safeRemotePath, b64, localExec, hdcExec, executorFor } from "../src/transport.mjs"; |
| 10 | import { ensureApp, writeRegistration } from "../src/app-socket.mjs"; |
| 11 | |
| 12 | test("a missing registered bundle gives a repair path without falling back to host input",async t=>{ |
| 13 | const directory=fs.mkdtempSync(path.join(os.tmpdir(),"cu-missing-app-")); |
| 14 | const keys=["CODEWHALE_CU_STATE_DIR","CODEWHALE_CU_APP_SOCKET","CODEWHALE_CU_APP"]; |
| 15 | const previous=keys.map(key=>process.env[key]); |
| 16 | process.env.CODEWHALE_CU_STATE_DIR=directory; |
| 17 | delete process.env.CODEWHALE_CU_APP_SOCKET; delete process.env.CODEWHALE_CU_APP; |
| 18 | t.after(()=>{ |
| 19 | keys.forEach((key,index)=>{if(previous[index]===undefined) delete process.env[key]; else process.env[key]=previous[index];}); |
| 20 | fs.rmSync(directory,{recursive:true,force:true}); |
| 21 | }); |
| 22 | writeRegistration({path:path.join(directory,"missing.app"),launch:["must-not-launch"]}); |
| 23 | await assert.rejects(ensureApp({launch:false}),error=>error.code==="app_missing"&&/Reinstall/.test(error.message)&&/app\.json/.test(error.message)); |
| 24 | }); |
| 25 | |
| 26 | test("macOS refuses a helper that lacks the background-control contract before any input", {skip:process.platform!=="darwin"}, async t => { |
| 27 | const dir=fs.mkdtempSync(path.join(os.tmpdir(),"cu-old-helper-")); |
| 28 | const savedSocket=process.env.CODEWHALE_CU_APP_SOCKET, savedApp=process.env.CODEWHALE_CU_APP; |
| 29 | process.env.CODEWHALE_CU_APP_SOCKET=path.join(dir,"app.sock"); |
| 30 | delete process.env.CODEWHALE_CU_APP; |
| 31 | const requests=[]; |
| 32 | const server=net.createServer(socket=>socket.once("data",data=>{ |
| 33 | requests.push(JSON.parse(data)); |
| 34 | socket.end(JSON.stringify({ok:true,app:{id:"old-fixture",sessionProtocol:2}})+"\n"); |
| 35 | })); |
| 36 | t.after(async()=>{ |
| 37 | await new Promise(resolve=>server.close(resolve)); |
| 38 | if(savedSocket===undefined) delete process.env.CODEWHALE_CU_APP_SOCKET; else process.env.CODEWHALE_CU_APP_SOCKET=savedSocket; |
| 39 | if(savedApp===undefined) delete process.env.CODEWHALE_CU_APP; else process.env.CODEWHALE_CU_APP=savedApp; |
| 40 | fs.rmSync(dir,{recursive:true,force:true}); |
| 41 | }); |
| 42 | await new Promise(resolve=>server.listen(process.env.CODEWHALE_CU_APP_SOCKET,resolve)); |
| 43 | await assert.rejects(executorFor({id:"local",transport:"local"}),error=>error.code==="app_upgrade_required" && /background/.test(error.message)); |
| 44 | assert.deepEqual(requests.map(request=>request.tool),["hello"]); |
| 45 | }); |
| 46 | |
| 47 | test("run captures stdout/stderr and exit codes without a shell", async () => { |
| 48 | const r = await run("node", ["-e", "console.log('hello'); console.error('boo')"]); |
| 49 | assert.equal(r.code, 0); |
| 50 | assert.equal(r.stdout.trim(), "hello"); |
| 51 | assert.match(r.stderr, /boo/); |
| 52 | }); |
| 53 | |
| 54 | test("run reports missing executables as code -1 with ENOENT, never throws", async () => { |
| 55 | const r = await run("definitely-not-a-real-tool-xyz", ["--version"]); |
| 56 | assert.equal(r.code, -1); |
| 57 | assert.match(r.stderr, /ENOENT/); |
| 58 | }); |
| 59 | |
| 60 | test("runOk throws ExecError on non-zero exit and includes stderr", async () => { |
| 61 | await assert.rejects(() => runOk("node", ["-e", "console.error('reason-here'); process.exit(3)"]), (e) => { |
| 62 | assert.ok(e instanceof ExecError); |
| 63 | assert.match(e.message, /exited 3/); |
| 64 | assert.match(e.message, /reason-here/); |
| 65 | return true; |
| 66 | }); |
| 67 | }); |
| 68 | |
| 69 | test("run enforces timeouts", async () => { |
| 70 | const r = await run("node", ["-e", "setInterval(()=>{},1000)"], { timeoutMs: 300 }); |
| 71 | assert.equal(r.timedOut, true); |
| 72 | }); |
| 73 | |
| 74 | test("run distinguishes an early cancellation from a child that was dispatched", async () => { |
| 75 | const early = await withSignal(AbortSignal.abort(), () => run(process.execPath, ["-e", "process.exit(9)"])); |
| 76 | assert.equal(early.aborted, true); |
| 77 | assert.equal(early.spawned, false); |
| 78 | const dispatched = await run(process.execPath, ["-e", "setInterval(()=>{}, 1000)"], { timeoutMs: 50 }); |
| 79 | assert.equal(dispatched.timedOut, true); |
| 80 | assert.equal(dispatched.spawned, true); |
| 81 | }); |
| 82 | |
| 83 | test("cancelling a later pointer command closes its original input owner promptly", async t => { |
| 84 | const dir=fs.mkdtempSync(path.join(os.tmpdir(),"cu-lease-cancel-")); |
| 85 | t.after(()=>fs.rmSync(dir,{recursive:true,force:true})); |
| 86 | const released=path.join(dir,"released"); |
| 87 | const lease=await runInputLease(process.execPath,["-e",` |
| 88 | const fs=require('node:fs'); |
| 89 | console.log(JSON.stringify({action_sent:true,input_lease:true})); |
| 90 | process.stdin.resume(); |
| 91 | process.stdin.on('data',()=>{}); |
| 92 | process.stdin.on('end',()=>{fs.writeFileSync(process.argv[1],'released');process.exit(0);}); |
| 93 | `,released]); |
| 94 | const controller=new AbortController(); |
| 95 | const started=Date.now(); |
| 96 | const motion=withSignal(controller.signal,()=>lease.send({point:{x:12,y:34}})); |
| 97 | setTimeout(()=>controller.abort(),50); |
| 98 | await assert.rejects(motion,error=>error.code==='cancelled'); |
| 99 | assert.equal(fs.readFileSync(released,'utf8'),'released'); |
| 100 | assert.ok(Date.now()-started<1500,'later request cancellation must not wait for the 20-second motion timeout'); |
| 101 | await lease.release(); |
| 102 | }); |
| 103 | |
| 104 | test("an exited input owner rejects later movement immediately", async () => { |
| 105 | const lease=await runInputLease(process.execPath,["-e",` |
| 106 | console.log(JSON.stringify({action_sent:true,input_lease:true,pid:process.pid})); |
| 107 | setTimeout(()=>process.kill(process.pid,'SIGTERM'),20); |
| 108 | `]); |
| 109 | const deadline=Date.now()+5000; |
| 110 | while(true) { |
| 111 | try { process.kill(lease.receipt.pid,0); } |
| 112 | catch(error) { if(error.code==='ESRCH') break; throw error; } |
| 113 | assert.ok(Date.now()<deadline,'the fixture owner must exit'); |
| 114 | await new Promise(resolve=>setTimeout(resolve,20)); |
| 115 | } |
| 116 | await new Promise(resolve=>setImmediate(resolve)); |
| 117 | const started=Date.now(); |
| 118 | await assert.rejects(lease.send({point:{x:1,y:2}}),error=>error.code==='input_owner_closed'); |
| 119 | assert.ok(Date.now()-started<500); |
| 120 | }); |
| 121 | |
| 122 | test("an unresponsive input helper is force-terminated within the MCP cleanup budget", async () => { |
| 123 | const lease=await runInputLease(process.execPath,["-e",` |
| 124 | process.on('SIGTERM',()=>{}); process.stdin.resume(); |
| 125 | process.stdin.on('data',()=>{}); process.stdin.on('end',()=>{}); |
| 126 | console.log(JSON.stringify({action_sent:true,input_lease:true})); |
| 127 | setInterval(()=>{},1000); |
| 128 | `]); |
| 129 | const started=Date.now(); |
| 130 | await assert.rejects(lease.release(),error=>error.code==='input_release_failed' && error.result.signal===(process.platform==='win32'?'SIGTERM':'SIGKILL')); |
| 131 | assert.ok(Date.now()-started<2500); |
| 132 | }); |
| 133 | |
| 134 | test("have() detects real and missing tools", async () => { |
| 135 | assert.equal(await have("node"), true); |
| 136 | assert.equal(await have("definitely-not-a-real-tool-xyz"), false); |
| 137 | }); |
| 138 | |
| 139 | test("safeRemotePath blocks traversal, metacharacters, and absolute escapes", () => { |
| 140 | assert.equal(safeRemotePath(".codewhale-cu/agent/agent.mjs"), ".codewhale-cu/agent/agent.mjs"); |
| 141 | for (const bad of ["../../etc/passwd", "/etc/passwd", "a;rm -rf /", "a b", "$(id)", "a\nb", "a'b", ".codewhale-cu/../escape"]) { |
| 142 | assert.throws(() => safeRemotePath(bad), ExecError, `should reject: ${bad}`); |
| 143 | } |
| 144 | }); |
| 145 | |
| 146 | test("one-shot SSH agent refuses operations that outlive its request", async () => { |
| 147 | for(const tool of ['left_mouse_down','recordingStart']) { |
| 148 | const result=await run(process.execPath,['agent.mjs',b64({tool,args:{target:{x:1,y:2}}})]); |
| 149 | assert.equal(result.code,0); |
| 150 | assert.equal(JSON.parse(result.stdout).error.code,'persistent_session_required'); |
| 151 | } |
| 152 | }); |
| 153 | |
| 154 | test("b64 round-trips JSON payloads", () => { |
| 155 | const obj = { tool: "screenshot", args: { region: [0, 0, 10, 10] } }; |
| 156 | assert.deepEqual(JSON.parse(Buffer.from(b64(obj), "base64").toString("utf8")), obj); |
| 157 | }); |
| 158 | |
| 159 | test("localExec provides run/runOk/tmpFile", async () => { |
| 160 | const ex = localExec(); |
| 161 | const r = await ex.run("echo", ["hi"]); |
| 162 | assert.equal(r.code, 0); |
| 163 | const f = ex.tmpFile("cu-test-"); |
| 164 | assert.ok(typeof f === "string"); |
| 165 | }); |
| 166 | |
| 167 | test("hdc readFile pulls into a private temp dir and cleans up only that dir", async (t) => { |
| 168 | // Regression guard for the temp-dir deletion bug: readFile used to place the |
| 169 | // pull directly in os.tmpdir() and then rm(dirname(tmp), {recursive}) — |
| 170 | // deleting the ENTIRE user temp directory on every HDC read. The sentinel |
| 171 | // proves sibling temp content now survives, and the pull must land inside a |
| 172 | // private cu-hdc-* mkdtemp dir that is removed afterwards. |
| 173 | const sentinel = path.join(os.tmpdir(), `cu-hdc-sentinel-${process.pid}-${Date.now()}.txt`); |
| 174 | fs.writeFileSync(sentinel, "keep"); |
| 175 | t.after(() => fs.rmSync(sentinel, { force: true })); |
| 176 | |
| 177 | const ex = hdcExec({}); |
| 178 | let seenLocal = null; |
| 179 | ex.pullFile = async (remote, local) => { |
| 180 | seenLocal = local; |
| 181 | fs.writeFileSync(local, Buffer.from("pulled-bytes")); |
| 182 | return local; |
| 183 | }; |
| 184 | const data = await ex.readFile("data/local/tmp/layout.json"); |
| 185 | assert.equal(data.toString(), "pulled-bytes"); |
| 186 | const pullDir = path.dirname(seenLocal); |
| 187 | assert.equal(path.dirname(pullDir), os.tmpdir(), "pull must land in a direct child of tmpdir, never in tmpdir itself"); |
| 188 | assert.match(path.basename(pullDir), /^cu-hdc-/, "pull dir must be a private cu-hdc- mkdtemp dir"); |
| 189 | assert.ok(!fs.existsSync(pullDir), "private temp dir is removed after the read"); |
| 190 | assert.ok(fs.existsSync(sentinel), "sibling files in the user temp dir must survive an hdc read"); |
| 191 | }); |
| 192 | |
| 193 | test("hdc readFile cleans up its private temp dir even when the pull fails", async () => { |
| 194 | const ex = hdcExec({}); |
| 195 | let seenLocal = null; |
| 196 | ex.pullFile = async (remote, local) => { |
| 197 | seenLocal = local; |
| 198 | throw new Error("hdc file recv failed"); |
| 199 | }; |
| 200 | await assert.rejects(() => ex.readFile("data/local/tmp/layout.json"), /hdc file recv failed/); |
| 201 | assert.ok(seenLocal, "pull was attempted"); |
| 202 | assert.ok(!fs.existsSync(path.dirname(seenLocal)), "failed pull still cleans up its private temp dir"); |
| 203 | }); |
| 204 | |
| 205 | test("hdc pullFile rejects traversal and shell metacharacters before execution", async () => { |
| 206 | const ex = hdcExec({}); |
| 207 | for (const remote of ["/data/../secret", "/data/file;touch", "/data/$(touch)", "//data/file", null]) { |
| 208 | await assert.rejects(ex.pullFile(remote, "/unused-fixture-output"), /refusing unsafe remote path/); |
| 209 | } |
| 210 | }); |
| 211 |