返回 CodeWhale
updates.test.mjs
根目录 / crates / tui / plugins / computer-use / tests / updates.test.mjs
1 import { test } from "node:test";
2 import assert from "node:assert/strict";
3 import fs from "node:fs";
4 import path from "node:path";
5 import os from "node:os";
6 import { deflateRawSync } from "node:zlib";
7 import { spawnSync } from "node:child_process";
8 import { fileURLToPath } from "node:url";
9 import { newerVersion, releaseUpdate, validateReleaseZip, readUpdateResult } from "../app/updates.mjs";
10 import { replaceMacBundle } from "../app/install-macos.mjs";
11
12 const release = () => ({ tag_name:"v0.4.0",assets:[{name:"Codewhale-Computer-Use-0.4.0-macos-universal.zip",browser_download_url:"https://github.com/Hmbown/codewhale-cu-plugin/releases/download/v0.4.0/Codewhale-Computer-Use-0.4.0-macos-universal.zip",digest:`sha256:${"a".repeat(64)}`,size:1024}] });
13 test("updates only offer a newer stable installer with the exact release identity",()=>{
14 assert.equal(newerVersion("0.10.0","0.9.13"),true);
15 for(const value of ["0.9.13","0.8.0","0.10.0-beta","v0.10.0","nonsense"]) assert.equal(newerVersion(value,"0.9.13"),false);
16 assert.equal(releaseUpdate(release(),"0.3.0").available,true);
17 for(const change of [{draft:true},{prerelease:true},{tag_name:"v0.2.0"},{assets:[]}]) assert.equal(releaseUpdate({...release(),...change},"0.3.0").available,false);
18 for(const change of [{digest:null},{size:Infinity},{size:512*1024*1024},{browser_download_url:"https://example.org/app.zip"},{name:"unexpected.zip"}]) {
19 const data=release(); Object.assign(data.assets[0],change); assert.equal(releaseUpdate(data,"0.3.0").available,false);
20 }
21 });
22 function zip(name,{kind=0x8000,localName=name,size=1,payload=Buffer.from("x"),method=0}={}) {
23 const local=Buffer.alloc(30); local.writeUInt32LE(0x04034b50); local.writeUInt16LE(Buffer.byteLength(localName),26); local.writeUInt32LE(payload.length,18); local.writeUInt32LE(size,22); local.writeUInt16LE(method,8);
24 const contents=Buffer.concat([local,Buffer.from(localName),payload]);
25 const central=Buffer.alloc(46); central.writeUInt32LE(0x02014b50); central.writeUInt16LE(Buffer.byteLength(name),28); central.writeUInt32LE((kind*65536)>>>0,38); central.writeUInt32LE(payload.length,20); central.writeUInt32LE(size,24); central.writeUInt16LE(method,10);
26 const index=Buffer.concat([central,Buffer.from(name)]),end=Buffer.alloc(22);end.writeUInt32LE(0x06054b50);end.writeUInt16LE(1,8);end.writeUInt16LE(1,10);end.writeUInt32LE(index.length,12);end.writeUInt32LE(contents.length,16);
27 return Buffer.concat([contents,index,end]);
28 }
29 test("the updater refuses traversal, links, bombs and inconsistent ZIP headers before extraction",()=>{
30 const name="Codewhale Computer Use.app/Contents/MacOS/node";
31 assert.equal(validateReleaseZip(zip(name)),1);
32 for(const unsafe of ["/tmp/escape","Codewhale Computer Use.app/../escape","Codewhale Computer Use.app/a/../../escape","Codewhale Computer Use.app/Contents/evil\\path","Codewhale Computer Use.app/Contents/a:b"]) assert.throws(()=>validateReleaseZip(zip(unsafe)));
33 for(const options of [{kind:0xa000},{localName:"../escape"},{size:1024*1024*1024}]) assert.throws(()=>validateReleaseZip(zip(name,options)));
34 assert.throws(()=>validateReleaseZip(Buffer.from("not a ZIP")));
35 const payload=deflateRawSync(Buffer.alloc(1024*1024));
36 assert.throws(()=>validateReleaseZip(zip(name,{method:8,payload,size:1})),/oversized/);
37 assert.equal(validateReleaseZip(zip(name,{method:8,payload,size:1024*1024})),1);
38 });
39 test("failed update preparation or verification preserves the complete previous app",t=>{
40 const directory=fs.mkdtempSync(path.join(os.tmpdir(),"cu-atomic-install-"));t.after(()=>fs.rmSync(directory,{recursive:true,force:true}));
41 const source=path.join(directory,"source.app"),destination=path.join(directory,"installed.app"),relative="Contents/Resources/plugin/app/daemon.mjs";
42 for(const [root,value] of [[source,"new"],[destination,"old"]]) {fs.mkdirSync(path.dirname(path.join(root,relative)),{recursive:true});fs.writeFileSync(path.join(root,relative),value);}
43 assert.throws(()=>replaceMacBundle(source,destination,{verify:()=>{throw new Error("bad signature");}}),/bad signature/);
44 assert.equal(fs.readFileSync(path.join(destination,relative),"utf8"),"old");
45 assert.throws(()=>replaceMacBundle(source,destination,{prepare:()=>{throw new Error("disk error");},verify:()=>{}}),/disk error/);
46 assert.equal(fs.readFileSync(path.join(destination,relative),"utf8"),"old");
47 const result=replaceMacBundle(source,destination,{verify:()=>{}});
48 assert.equal(fs.readFileSync(path.join(destination,relative),"utf8"),"new");
49 assert.equal(fs.readFileSync(path.join(result.backup,relative),"utf8"),"old");
50 });
51
52 test("a rejected apply leaves a readable result for the next launch without changing control consent",t=>{
53 const directory=fs.mkdtempSync(path.join(os.tmpdir(),"cu-update-result-"));
54 const previous=process.env.CODEWHALE_CU_STATE_DIR;
55 process.env.CODEWHALE_CU_STATE_DIR=directory;
56 t.after(()=>{
57 if(previous===undefined) delete process.env.CODEWHALE_CU_STATE_DIR; else process.env.CODEWHALE_CU_STATE_DIR=previous;
58 fs.rmSync(directory,{recursive:true,force:true});
59 });
60 const controls=path.join(directory,"control.json");
61 fs.writeFileSync(controls,JSON.stringify({mode:"stopped"}));
62 assert.equal(readUpdateResult(),null);
63 // Verification of a nonexistent bundle fails before either PID is used;
64 // a nonexistent destination also prevents opening any real application.
65 const result=spawnSync(process.execPath,[fileURLToPath(new URL("../app/updates.mjs",import.meta.url)),"--apply",path.join(directory,"missing-source.app"),path.join(directory,"missing-destination.app"),String(process.pid),String(process.pid)],{encoding:"utf8",env:process.env,timeout:10_000});
66 assert.equal(result.status,1,result.stderr);
67 const status=readUpdateResult();
68 assert.equal(status.available,false);
69 assert.match(status.message,/update could not be completed/i);
70 assert.match(status.message,/sessions remain stopped/i);
71 assert.deepEqual(JSON.parse(fs.readFileSync(controls)),{mode:"stopped"});
72 const resultFile=path.join(directory,"update-result.json");
73 assert.equal(JSON.parse(fs.readFileSync(resultFile)).ok,false);
74 if(process.platform!=="win32") assert.equal(fs.statSync(resultFile).mode&0o777,0o600);
75 for(const invalid of ["not JSON",JSON.stringify({ok:true,message:123}),JSON.stringify({ok:true,message:"x".repeat(5000)})]) {
76 fs.writeFileSync(resultFile,invalid);
77 assert.equal(readUpdateResult(),null);
78 }
79 });
80
80 lines Plain Text