@whyvix/remote 0.3.19 → 0.3.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@whyvix/remote",
3
- "version": "0.3.19",
3
+ "version": "0.3.20",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@whyvix/remote",
9
- "version": "0.3.19",
9
+ "version": "0.3.20",
10
10
  "dependencies": {
11
11
  "@modelcontextprotocol/ext-apps": "^2.0.0",
12
12
  "@modelcontextprotocol/sdk": "^1.30.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whyvix/remote",
3
- "version": "0.3.19",
3
+ "version": "0.3.20",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "files": [
@@ -28,7 +28,7 @@
28
28
  "test:r10-gui-window-focus": "node scripts/r10-gui-window-action-contract-test.js && node scripts/r10-gui-window-action-source-test.js && node scripts/r10-gui-window-action-agent-capability-test.js && node scripts/r10-gui-window-action-authorization-test.js && node scripts/r10-gui-window-action-loader-test.js && node scripts/r10-gui-window-action-mcp-schema-test.js && node scripts/r10-gui-window-action-cli-wiring-test.js && node scripts/r10-deployment-certify-authorization-test.js && node scripts/r10-deployment-certify-protocol-test.js && node scripts/r10-deployment-activation-response-test.js",
29
29
  "test:r10-documents": "node scripts/r10-precision-document-inspect-test.js && node scripts/r10-precision-document-compare-test.js && node scripts/r10-precision-docx-mutation-test.js && node scripts/r10-precision-xlsx-mutation-test.js && node scripts/r10-precision-pptx-mutation-test.js",
30
30
  "test:r10-observability": "node scripts/r10-write-preview-test.js && node scripts/r10-cli-presentation-test.js && node scripts/r10-usage-metering-test.js",
31
- "test:r10-self-update": "node scripts/r10-self-update-manager-test.js && node scripts/r10-update-supervisor-test.js && node scripts/r10-update-lifecycle-test.js && node scripts/r10-update-activation-plan-test.js && node scripts/r10-update-bootstrap-executor-test.js && node scripts/r10-update-supervisor-host-test.js && node scripts/r10-update-archive-policy-test.js && node scripts/r10-update-persistence-test.js"
31
+ "test:r10-self-update": "node scripts/r10-self-update-manager-test.js && node scripts/r10-update-supervisor-test.js && node scripts/r10-update-lifecycle-test.js && node scripts/r10-update-activation-plan-test.js && node scripts/r10-update-bootstrap-executor-test.js && node scripts/r10-update-supervisor-host-test.js && node scripts/r10-update-archive-policy-test.js && node scripts/r10-update-persistence-test.js && node scripts/r10-windows-detached-survival-test.js"
32
32
  },
33
33
  "dependencies": {
34
34
  "@modelcontextprotocol/ext-apps": "^2.0.0",
@@ -2,10 +2,10 @@ import os from "node:os";
2
2
  import path from "node:path";
3
3
  import crypto from "node:crypto";
4
4
  import { gunzipSync } from "node:zlib";
5
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
5
+ import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
6
6
  import { spawn, execFile } from "node:child_process";
7
7
  import { promisify } from "node:util";
8
- import { readUpdateTarEntries, validateUpdateArchiveEntries } from "./update-archive-policy.js";
8
+ import { extractValidatedUpdateTar } from "./update-archive-policy.js";
9
9
  const execFileAsync=promisify(execFile);
10
10
 
11
11
  const PACKAGE_NAME="@whyvix/remote";
@@ -15,6 +15,10 @@ const VERSION=/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+
15
15
  function fail(code,message){throw Object.assign(new Error(message),{code});}
16
16
  function safeVersion(value){const version=String(value||"").trim();if(!VERSION.test(version))fail("UPDATE_VERSION_INVALID","Explicit semver version is required");return version;}
17
17
  function digest(buffer){return crypto.createHash("sha512").update(buffer).digest("base64");}
18
+ async function resolveNpmCli(nodeExecPath){
19
+ const cli=path.join(path.dirname(nodeExecPath),"node_modules","npm","bin","npm-cli.js");
20
+ try{await access(cli);return cli;}catch{fail("UPDATE_NPM_UNAVAILABLE","Trusted npm CLI was not found beside the active Node runtime");}
21
+ }
18
22
 
19
23
  export class SelfUpdateManager{
20
24
  constructor({fetchFn=fetch,spawnFn=spawn,nodeExecPath=process.execPath,homeDir=os.homedir(),registry=REGISTRY,audit=()=>{}}={}){
@@ -56,15 +60,15 @@ export class SelfUpdateManager{
56
60
  async materialize({version}={}){
57
61
  const prepared=await this.verifyPrepared({version});version=prepared.version;
58
62
  const root=this.updateRoot(version),extractRoot=path.join(root,"extracted");
59
- validateUpdateArchiveEntries(readUpdateTarEntries(gunzipSync(await readFile(prepared.artifact))));
63
+ const tarBytes=gunzipSync(await readFile(prepared.artifact));
60
64
  await rm(extractRoot,{recursive:true,force:true});await mkdir(extractRoot,{recursive:true,mode:0o700});
61
- try{await execFileAsync("tar",["-xzf",prepared.artifact,"-C",extractRoot],{windowsHide:true,maxBuffer:1024*1024});}
62
- catch{await rm(extractRoot,{recursive:true,force:true});fail("UPDATE_EXTRACT_FAILED","Verified package could not be extracted");}
65
+ try{await extractValidatedUpdateTar(tarBytes,extractRoot);}
66
+ catch(error){await rm(extractRoot,{recursive:true,force:true});if(String(error?.code||"").startsWith("UPDATE_ARCHIVE_"))throw error;fail("UPDATE_EXTRACT_FAILED","Verified package could not be extracted");}
63
67
  const packageRoot=path.join(extractRoot,"package"),meta=JSON.parse(await readFile(path.join(packageRoot,"package.json"),"utf8"));
64
68
  if(meta?.name!==PACKAGE_NAME||meta?.version!==version||meta?.bin?.["whyvix-remote"]!=="packages/cli/index.js"){await rm(extractRoot,{recursive:true,force:true});fail("UPDATE_PACKAGE_INVALID","Extracted package identity or launcher is invalid");}
65
69
  const launcher=path.join(packageRoot,"packages","cli","index.js");await readFile(launcher);
66
- try{await execFileAsync("npm",["ci","--omit=dev","--ignore-scripts","--no-audit","--no-fund"],{cwd:packageRoot,windowsHide:true,maxBuffer:4*1024*1024});}
67
- catch{await rm(extractRoot,{recursive:true,force:true});fail("UPDATE_DEPENDENCIES_FAILED","Verified package dependencies could not be installed deterministically");}
70
+ try{const npmCli=await resolveNpmCli(this.nodeExecPath);await execFileAsync(this.nodeExecPath,[npmCli,"ci","--omit=dev","--ignore-scripts","--no-audit","--no-fund"],{cwd:packageRoot,windowsHide:true,maxBuffer:4*1024*1024});}
71
+ catch(error){await rm(extractRoot,{recursive:true,force:true});if(error?.code==="UPDATE_NPM_UNAVAILABLE")throw error;fail("UPDATE_DEPENDENCIES_FAILED","Verified package dependencies could not be installed deterministically");}
68
72
  this.audit({event:"update.materialized",version});return{...prepared,packageRoot,launcher};
69
73
  }
70
74
  launchReplacement({version,cwd=process.cwd()}={}){
@@ -1,3 +1,6 @@
1
+ import path from "node:path";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+
1
4
  function fail(code,message){throw Object.assign(new Error(message),{code});}
2
5
 
3
6
  export function validateUpdateArchiveEntries(entries){
@@ -13,7 +16,7 @@ export function validateUpdateArchiveEntries(entries){
13
16
  return{valid:true,count:entries.length};
14
17
  }
15
18
 
16
- export function readUpdateTarEntries(buffer){
19
+ function readUpdateTarEntryDetails(buffer){
17
20
  if(!Buffer.isBuffer(buffer)||buffer.length<512)fail("UPDATE_ARCHIVE_INVALID","Tar archive bytes are required");
18
21
  const entries=[];
19
22
  for(let offset=0;offset+512<=buffer.length;){
@@ -25,11 +28,30 @@ export function readUpdateTarEntries(buffer){
25
28
  if(!Number.isFinite(size)||size<0)fail("UPDATE_ARCHIVE_INVALID","Tar entry size is invalid");
26
29
  const typeFlag=String.fromCharCode(header[156]||48);
27
30
  if(!["0","1","2","5","\u0000"].includes(typeFlag))fail("UPDATE_ARCHIVE_TYPE_DENIED",`Unsupported tar entry type: ${typeFlag}`);
31
+ const checksumText=text(148,8).trim();const expectedChecksum=checksumText?Number.parseInt(checksumText,8):NaN;
32
+ const checksumHeader=Buffer.from(header);checksumHeader.fill(32,148,156);let actualChecksum=0;for(const byte of checksumHeader)actualChecksum+=byte;
33
+ if(!Number.isFinite(expectedChecksum)||expectedChecksum!==actualChecksum)fail("UPDATE_ARCHIVE_INVALID","Tar header checksum is invalid");
28
34
  const type=typeFlag==="2"?"symlink":typeFlag==="1"?"hardlink":typeFlag==="5"?"directory":"file";
29
- entries.push({name:full,type});
35
+ const dataOffset=offset+512;if(dataOffset+size>buffer.length)fail("UPDATE_ARCHIVE_INVALID","Tar entry payload is truncated");
36
+ entries.push({name:full,type,size,dataOffset});
30
37
  offset+=512+Math.ceil(size/512)*512;
31
38
  }
32
39
  if(entries.length<1)fail("UPDATE_ARCHIVE_INVALID","Tar archive has no entries");
33
40
  return entries;
34
41
  }
35
42
 
43
+ export function readUpdateTarEntries(buffer){return readUpdateTarEntryDetails(buffer).map(({name,type})=>({name,type}));}
44
+
45
+ export async function extractValidatedUpdateTar(buffer,destination){
46
+ const entries=readUpdateTarEntryDetails(buffer);validateUpdateArchiveEntries(entries);
47
+ const root=path.resolve(destination);await mkdir(root,{recursive:true,mode:0o700});
48
+ for(const entry of entries){
49
+ const target=path.resolve(root,...entry.name.replaceAll("\\","/").split("/").filter(Boolean));
50
+ if(target!==root&&!target.startsWith(root+path.sep))fail("UPDATE_ARCHIVE_PATH_DENIED","Archive entry escapes extraction root");
51
+ if(entry.type==="directory"){await mkdir(target,{recursive:true,mode:0o700});continue;}
52
+ await mkdir(path.dirname(target),{recursive:true,mode:0o700});
53
+ await writeFile(target,buffer.subarray(entry.dataOffset,entry.dataOffset+entry.size),{flag:"wx",mode:0o600});
54
+ }
55
+ return{count:entries.length};
56
+ }
57
+
@@ -1,18 +1,27 @@
1
1
  import path from "node:path";
2
2
  import { rm } from "node:fs/promises";
3
3
  import { UpdateLifecycle } from "./update-lifecycle.js";
4
- import { acquireExclusiveLock } from "./update-persistence.js";
4
+ import { acquireExclusiveLock, recoverStaleExclusiveLock } from "./update-persistence.js";
5
5
 
6
6
  function fail(code,message){throw Object.assign(new Error(message),{code});}
7
7
 
8
8
  export class UpdateBootstrapExecutor{
9
- constructor({manager,activationPlan,supervisor,waitStable,audit=()=>{}}={}){
9
+ constructor({manager,activationPlan,supervisor,waitStable,recoverLock=recoverStaleExclusiveLock,audit=()=>{}}={}){
10
10
  if(!manager||!activationPlan||!supervisor||!waitStable)fail("UPDATE_BOOTSTRAP_INVALID","Complete bootstrap dependencies are required");
11
- Object.assign(this,{manager,activationPlan,supervisor,waitStable,audit});
11
+ Object.assign(this,{manager,activationPlan,supervisor,waitStable,recoverLock,audit});
12
12
  }
13
13
  async execute(){
14
14
  const lockPath=path.join(this.manager.homeDir,".whyvix-remote","update-activation.lock");
15
- const release=await acquireExclusiveLock(lockPath);
15
+ let release;
16
+ try{release=await acquireExclusiveLock(lockPath);}
17
+ catch(error){
18
+ if(error?.code!=="UPDATE_BUSY")throw error;
19
+ const stateFile=path.join(this.manager.homeDir,".whyvix-remote","update-state.json");
20
+ const recovery=await this.recoverLock(lockPath,{stateFile});
21
+ if(!recovery.recovered)throw error;
22
+ this.audit({event:"update.stale_lock_recovered",reason:recovery.reason});
23
+ release=await acquireExclusiveLock(lockPath);
24
+ }
16
25
  try{
17
26
  const plan=await this.activationPlan.verify();
18
27
  const readyFile=path.join(this.manager.updateRoot(plan.targetVersion),"registered.json");
@@ -23,6 +23,19 @@ export async function writeJsonAtomic(file,value){
23
23
  return value;
24
24
  }
25
25
 
26
+ export function isProcessAlive(pid,{probe=process.kill}={}){
27
+ if(!Number.isInteger(pid)||pid<=0)return false;
28
+ try{probe(pid,0);return true;}catch{return false;}
29
+ }
30
+
31
+ export async function recoverStaleExclusiveLock(file,{stateFile,probe=process.kill}={}){
32
+ const lock=await readJsonStrict(file,{missing:null,invalidCode:"UPDATE_LOCK_INVALID"});if(!lock)return{recovered:false,reason:"missing"};
33
+ if(isProcessAlive(Number(lock.pid),{probe}))return{recovered:false,reason:"owner_alive"};
34
+ const state=stateFile?await readJsonStrict(stateFile,{missing:null,invalidCode:"UPDATE_STATE_INVALID"}):null;
35
+ if(["candidate_started","registered","rollback_started"].includes(String(state?.status||"")))fail("UPDATE_RECOVERY_REQUIRED","Stale lock belongs to an interrupted activation transition");
36
+ await rm(file,{force:true});return{recovered:true,reason:"owner_dead"};
37
+ }
38
+
26
39
  export async function acquireExclusiveLock(file,{busyCode="UPDATE_BUSY"}={}){
27
40
  await mkdir(path.dirname(file),{recursive:true,mode:0o700});
28
41
  let handle;