@whyvix/remote 0.3.17 → 0.3.18

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whyvix/remote",
3
- "version": "0.3.17",
3
+ "version": "0.3.18",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "files": [
@@ -13,20 +13,22 @@
13
13
  "native/win32-x64/whyvix-gui-helper.exe",
14
14
  "native/gui-window-action-helper-manifest.json",
15
15
  "native/win32-x64/whyvix-gui-window-action-helper.exe",
16
- "README.md"
16
+ "README.md",
17
+ "npm-shrinkwrap.json"
17
18
  ],
18
19
  "scripts": {
19
20
  "gateway": "node services/gateway/src/index.js",
20
21
  "agent": "node packages/agent/src/index.js",
21
22
  "test:r0": "node scripts/r0-test.js",
22
- "test": "npm run test:r0 && npm run test:r4-desktop-connect && npm run test:r1-r2 && npm run test:r3-r5 && npm run test:r10-gui-window-focus && npm run test:r10-documents && npm run test:r10-observability",
23
- "check": "node --check packages/protocol/src/index.js && node --check packages/agent/src/security.js && node --check packages/agent/src/process-manager.js && node --check packages/agent/src/precision-document-manager.js && node --check packages/agent/src/tools.js && node --check packages/agent/src/agent.js && node --check packages/agent/src/index.js && node --check packages/cli/index.js && node --check services/gateway/src/device-registry.js && node --check services/gateway/src/gateway.js && node --check services/gateway/src/index.js && node --check services/gateway/src/authorization/sqlite-desktop-connect-repository.js && node --check services/gateway/src/web/desktop-connect-api.js && node --check services/gateway/src/production-authority.js && node --check services/gateway/src/production-runtime.js && node --check services/gateway/src/web/identity-web-server.js && node --check scripts/r4-desktop-connect-authorization-test.js && node --check scripts/r4-desktop-connect-transaction-test.js && node --check scripts/r4-desktop-connect-api-test.js && node --check scripts/r0-test.js && node --check scripts/r1-r2-test.js && node --check scripts/r3-r5-test.js",
23
+ "test": "npm run test:r0 && npm run test:r4-desktop-connect && npm run test:r1-r2 && npm run test:r3-r5 && npm run test:r10-gui-window-focus && npm run test:r10-documents && npm run test:r10-observability && npm run test:r10-self-update",
24
+ "check": "node --check packages/protocol/src/index.js && node --check packages/agent/src/security.js && node --check packages/agent/src/process-manager.js && node --check packages/agent/src/self-update-manager.js && node --check packages/agent/src/update-supervisor.js && node --check packages/agent/src/update-lifecycle.js && node --check packages/agent/src/update-activation-plan.js && node --check packages/agent/src/update-bootstrap-executor.js && node --check packages/agent/src/update-supervisor-host.js && node --check packages/agent/src/update-archive-policy.js && node --check packages/agent/src/update-persistence.js && node --check packages/cli/update-supervisor.js && node --check packages/agent/src/precision-document-manager.js && node --check packages/agent/src/tools.js && node --check packages/agent/src/agent.js && node --check packages/agent/src/index.js && node --check packages/cli/index.js && node --check services/gateway/src/device-registry.js && node --check services/gateway/src/gateway.js && node --check services/gateway/src/index.js && node --check services/gateway/src/authorization/sqlite-desktop-connect-repository.js && node --check services/gateway/src/web/desktop-connect-api.js && node --check services/gateway/src/production-authority.js && node --check services/gateway/src/production-runtime.js && node --check services/gateway/src/web/identity-web-server.js && node --check scripts/r4-desktop-connect-authorization-test.js && node --check scripts/r4-desktop-connect-transaction-test.js && node --check scripts/r4-desktop-connect-api-test.js && node --check scripts/r0-test.js && node --check scripts/r1-r2-test.js && node --check scripts/r3-r5-test.js",
24
25
  "test:r4-desktop-connect": "node scripts/r4-desktop-connect-authorization-test.js && node scripts/r4-desktop-connect-transaction-test.js && node scripts/r4-desktop-connect-api-test.js && node scripts/r4-desktop-connect-browser-test.js",
25
26
  "test:r1-r2": "node scripts/r1-r2-test.js",
26
27
  "test:r3-r5": "node scripts/r3-r5-test.js",
27
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",
28
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",
29
- "test:r10-observability": "node scripts/r10-write-preview-test.js && node scripts/r10-cli-presentation-test.js && node scripts/r10-usage-metering-test.js"
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"
30
32
  },
31
33
  "dependencies": {
32
34
  "@modelcontextprotocol/ext-apps": "^2.0.0",
@@ -4,6 +4,7 @@ import WebSocket from "ws";
4
4
  import { envelope, parseMessage, TYPES } from "../../protocol/src/index.js";
5
5
  import { createSecurity } from "./security.js";
6
6
  import { ProcessManager } from "./process-manager.js";
7
+ import { SelfUpdateManager } from "./self-update-manager.js";
7
8
  import { RemoteExecutionManager } from "./remote-execution-manager.js";
8
9
  import { DeploymentManager } from "./deployment-manager.js";
9
10
  import { GuiManager } from "./gui-manager.js";
@@ -37,13 +38,14 @@ export function createAgent({gatewayUrl="ws://127.0.0.1:8787",deviceId=randomUUI
37
38
  let ws=null,reconnectTimer=null,stopped=false,generation=0;
38
39
  const security=createSecurity({token,workspaces,audit});
39
40
  const processManager=new ProcessManager({audit});
41
+ const selfUpdateManager=new SelfUpdateManager({audit});
40
42
  const remoteExecutionManager=new RemoteExecutionManager({targets:remoteTargets,audit});
41
43
  const deploymentManager=new DeploymentManager({profiles:deploymentProfiles,security,audit,transferArtifact:deploymentTransferArtifact});
42
44
  const guiManager=new GuiManager({backend:guiBackend,audit});
43
45
  const guiWindowActionManager=new GuiWindowActionManager({backend:guiWindowActionBackend,audit});
44
46
  const precisionDocumentManager=new PrecisionDocumentManager({security,audit});
45
47
  const advertisedCapabilities=[...new Set([...capabilities,...(guiManager.capabilities().available?["gui.observe"]:[]),...(guiWindowActionManager.capabilities().available?["gui.window.focus"]:[]),...(precisionDocumentManager.capabilities().available?["document.precision.inspect","document.precision.compare","document.precision.mutate.docx","document.precision.mutate.xlsx","document.precision.mutate.pptx"]:[])])].sort();
46
- const executeTool=createToolExecutor({security,processManager,remoteExecutionManager,deploymentManager,guiManager,guiWindowActionManager,precisionDocumentManager});
48
+ const executeTool=createToolExecutor({security,processManager,selfUpdateManager,remoteExecutionManager,deploymentManager,guiManager,guiWindowActionManager,precisionDocumentManager});
47
49
  function scheduleReconnect(reason){
48
50
  if(stopped||!reconnect||reconnectTimer)return;
49
51
  log("[agent:reconnect-scheduled]",reason,reconnectMs);
@@ -0,0 +1,83 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import { gunzipSync } from "node:zlib";
5
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
6
+ import { spawn, execFile } from "node:child_process";
7
+ import { promisify } from "node:util";
8
+ import { readUpdateTarEntries, validateUpdateArchiveEntries } from "./update-archive-policy.js";
9
+ const execFileAsync=promisify(execFile);
10
+
11
+ const PACKAGE_NAME="@whyvix/remote";
12
+ const REGISTRY="https://registry.npmjs.org";
13
+ const VERSION=/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/;
14
+
15
+ function fail(code,message){throw Object.assign(new Error(message),{code});}
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
+ function digest(buffer){return crypto.createHash("sha512").update(buffer).digest("base64");}
18
+
19
+ export class SelfUpdateManager{
20
+ constructor({fetchFn=fetch,spawnFn=spawn,nodeExecPath=process.execPath,homeDir=os.homedir(),registry=REGISTRY,audit=()=>{}}={}){
21
+ if(registry!==REGISTRY)fail("UPDATE_REGISTRY_DENIED","Only the official npm registry is allowed");
22
+ this.fetchFn=fetchFn;this.spawnFn=spawnFn;this.nodeExecPath=nodeExecPath;this.homeDir=homeDir;this.registry=registry;this.audit=audit;
23
+ }
24
+ updateRoot(version){return path.join(this.homeDir,".whyvix-remote","updates",safeVersion(version));}
25
+ async prepare({version}={}){
26
+ version=safeVersion(version);
27
+ const encoded=encodeURIComponent(PACKAGE_NAME);
28
+ const metadataResponse=await this.fetchFn(`${this.registry}/${encoded}/${version}`,{headers:{accept:"application/json"}});
29
+ if(!metadataResponse.ok)fail("UPDATE_METADATA_FAILED",`Registry metadata request failed: ${metadataResponse.status}`);
30
+ const metadata=await metadataResponse.json();
31
+ if(metadata?.name!==PACKAGE_NAME||metadata?.version!==version)fail("UPDATE_METADATA_INVALID","Registry metadata identity mismatch");
32
+ const tarball=String(metadata?.dist?.tarball||"");
33
+ const integrity=String(metadata?.dist?.integrity||"");
34
+ if(!tarball.startsWith(`${this.registry}/`)||!integrity.startsWith("sha512-"))fail("UPDATE_METADATA_INVALID","Registry tarball authority or integrity is invalid");
35
+ const response=await this.fetchFn(tarball,{headers:{accept:"application/octet-stream"}});
36
+ if(!response.ok)fail("UPDATE_DOWNLOAD_FAILED",`Package download failed: ${response.status}`);
37
+ const bytes=Buffer.from(await response.arrayBuffer());
38
+ const actual=`sha512-${digest(bytes)}`;if(actual!==integrity)fail("UPDATE_INTEGRITY_FAILED","Downloaded package integrity mismatch");
39
+ const root=this.updateRoot(version);
40
+ await mkdir(root,{recursive:true,mode:0o700});
41
+ const artifact=path.join(root,`whyvix-remote-${version}.tgz`);
42
+ await writeFile(artifact,bytes,{flag:"w",mode:0o600});
43
+ await writeFile(path.join(root,"manifest.json"),JSON.stringify({package:PACKAGE_NAME,version,registry:this.registry,integrity,artifact},null,2),{encoding:"utf8",flag:"w",mode:0o600});
44
+ this.audit({event:"update.prepared",version,bytes:bytes.length});
45
+ return{package:PACKAGE_NAME,version,artifact,integrity,bytes:bytes.length};
46
+ }
47
+ async verifyPrepared({version}={}){
48
+ version=safeVersion(version);const root=this.updateRoot(version);
49
+ const manifest=JSON.parse(await readFile(path.join(root,"manifest.json"),"utf8"));
50
+ if(manifest.package!==PACKAGE_NAME||manifest.version!==version||manifest.registry!==this.registry||!String(manifest.integrity).startsWith("sha512-"))fail("UPDATE_PREPARED_INVALID","Prepared update manifest is invalid");
51
+ const expectedArtifact=path.join(root,`whyvix-remote-${version}.tgz`);
52
+ if(path.resolve(manifest.artifact)!==path.resolve(expectedArtifact))fail("UPDATE_PREPARED_INVALID","Prepared artifact path is invalid");
53
+ const bytes=await readFile(expectedArtifact);if(`sha512-${digest(bytes)}`!==manifest.integrity)fail("UPDATE_INTEGRITY_FAILED","Prepared package integrity mismatch");
54
+ return{package:PACKAGE_NAME,version,artifact:expectedArtifact,integrity:manifest.integrity,bytes:bytes.length};
55
+ }
56
+ async materialize({version}={}){
57
+ const prepared=await this.verifyPrepared({version});version=prepared.version;
58
+ const root=this.updateRoot(version),extractRoot=path.join(root,"extracted");
59
+ validateUpdateArchiveEntries(readUpdateTarEntries(gunzipSync(await readFile(prepared.artifact))));
60
+ 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");}
63
+ const packageRoot=path.join(extractRoot,"package"),meta=JSON.parse(await readFile(path.join(packageRoot,"package.json"),"utf8"));
64
+ 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
+ const launcher=path.join(packageRoot,"packages","cli","index.js");await readFile(launcher);
66
+ this.audit({event:"update.materialized",version});return{...prepared,packageRoot,launcher};
67
+ }
68
+ launchReplacement({version,cwd=process.cwd()}={}){
69
+ version=safeVersion(version);const root=this.updateRoot(version),launcher=path.join(root,"extracted","package","packages","cli","index.js"),readyFile=path.join(root,"registered.json");
70
+ const child=this.spawnFn(this.nodeExecPath,[launcher,"remote"],{cwd,windowsHide:true,shell:false,detached:false,stdio:["ignore","ignore","ignore"],env:{...process.env}});
71
+ child.unref?.();this.audit({event:"update.replacement_started",version,pid:child.pid});return{version,pid:child.pid,readyFile};
72
+ }
73
+ async waitForRegistration({version,candidate,planFingerprint,timeoutMs=15000,pollMs=200}={}){
74
+ version=safeVersion(version);const readyFile=path.join(this.updateRoot(version),"registered.json"),deadline=Date.now()+Math.max(1000,Math.min(Number(timeoutMs)||15000,60000));
75
+ const expectedPid=Number(candidate?.pid);if(!Number.isInteger(expectedPid)||expectedPid<=0)fail("UPDATE_PID_INVALID","Candidate pid is required for registration proof");
76
+ if(!String(planFingerprint||"").trim())fail("UPDATE_PLAN_INVALID","Activation plan fingerprint is required for registration proof");
77
+ while(Date.now()<deadline){try{const ready=JSON.parse(await readFile(readyFile,"utf8"));if(ready.version===version&&ready.deviceId&&ready.pid===expectedPid&&ready.planFingerprint===planFingerprint)return ready;}catch{}await new Promise(r=>setTimeout(r,Math.max(50,Math.min(Number(pollMs)||200,1000))));}
78
+ fail("UPDATE_REGISTRATION_TIMEOUT","Replacement did not confirm trusted gateway registration before timeout");
79
+ }
80
+ async discard({version}={}){version=safeVersion(version);await rm(this.updateRoot(version),{recursive:true,force:true});this.audit({event:"update.discarded",version});return{version,discarded:true};}
81
+ }
82
+ export const SELF_UPDATE_PACKAGE=PACKAGE_NAME;
83
+ export const SELF_UPDATE_REGISTRY=REGISTRY;
@@ -7,7 +7,7 @@ import { execFile } from "node:child_process";
7
7
  import { promisify } from "node:util";
8
8
  const execFileAsync=promisify(execFile);
9
9
 
10
- export function createToolExecutor({ security, processManager, remoteExecutionManager, deploymentManager, guiManager, guiWindowActionManager, precisionDocumentManager }) {
10
+ export function createToolExecutor({ security, processManager, selfUpdateManager, remoteExecutionManager, deploymentManager, guiManager, guiWindowActionManager, precisionDocumentManager }) {
11
11
  const binaryTransferManager=new BinaryTransferManager({security,audit:(entry)=>security.audit(entry)});
12
12
  const pendingDeployments=new Map();
13
13
  const pendingDeploymentTtlMs=10*60*1000;
@@ -127,6 +127,9 @@ export function createToolExecutor({ security, processManager, remoteExecutionMa
127
127
  if(tool==="remote.output"){if(!remoteExecutionManager)throw Object.assign(new Error("Remote execution is not configured"),{code:"REMOTE_JOB_NOT_FOUND"});return remoteExecutionManager.output(args);}
128
128
  if(tool==="remote.input"){if(!remoteExecutionManager)throw Object.assign(new Error("Remote execution is not configured"),{code:"REMOTE_JOB_NOT_FOUND"});return remoteExecutionManager.input(args);}
129
129
  if(tool==="remote.stop"){if(!remoteExecutionManager)throw Object.assign(new Error("Remote execution is not configured"),{code:"REMOTE_JOB_NOT_FOUND"});return remoteExecutionManager.stop(args);}
130
+ if(tool==="agent.update_prepare"){if(!selfUpdateManager)throw Object.assign(new Error("Self-update is not configured"),{code:"UPDATE_NOT_AVAILABLE"});const prepared=await selfUpdateManager.prepare(args);const materialized=await selfUpdateManager.materialize(args);return{package:prepared.package,version:prepared.version,integrity:prepared.integrity,bytes:prepared.bytes,materialized:Boolean(materialized.launcher)};}
131
+ if(tool==="agent.update_verify"){if(!selfUpdateManager)throw Object.assign(new Error("Self-update is not configured"),{code:"UPDATE_NOT_AVAILABLE"});return selfUpdateManager.verifyPrepared(args);}
132
+ if(tool==="agent.update_discard"){if(!selfUpdateManager)throw Object.assign(new Error("Self-update is not configured"),{code:"UPDATE_NOT_AVAILABLE"});return selfUpdateManager.discard(args);}
130
133
  if(tool==="process.discover")return processManager.discover(args);
131
134
  if(tool==="process.start"){const cwd=security.resolveFilesystemPath(args.cwd);return processManager.start({...args,cwd});}
132
135
  if(tool==="process.output")return processManager.output(args);
@@ -0,0 +1,41 @@
1
+ import path from "node:path";
2
+ import crypto from "node:crypto";
3
+ import { readJsonStrict, writeJsonAtomic } from "./update-persistence.js";
4
+
5
+ function fail(code,message){throw Object.assign(new Error(message),{code});}
6
+ function semver(value){const v=String(value||"").trim();if(!/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/.test(v))fail("UPDATE_VERSION_INVALID","Explicit semver version is required");return v;}
7
+ function canonical(value){return JSON.stringify(value,Object.keys(value).sort());}
8
+
9
+ export class UpdateActivationPlan{
10
+ constructor({manager,homeDir,now=()=>Date.now(),audit=()=>{}}={}){
11
+ if(!manager)fail("UPDATE_MANAGER_REQUIRED","Self-update manager is required");
12
+ this.manager=manager;this.homeDir=homeDir||manager.homeDir;this.now=now;this.audit=audit;
13
+ }
14
+ planPath(){return path.join(this.homeDir,".whyvix-remote","activation-plan.json");}
15
+ async create({currentVersion,targetVersion,deviceId}={}){
16
+ currentVersion=semver(currentVersion);targetVersion=semver(targetVersion);
17
+ if(currentVersion===targetVersion)fail("UPDATE_TARGET_SAME","Target version must differ from current version");
18
+ if(!String(deviceId||"").trim())fail("UPDATE_DEVICE_INVALID","Device id is required");
19
+ const current=await this.manager.verifyPrepared({version:currentVersion});
20
+ const target=await this.manager.verifyPrepared({version:targetVersion});
21
+ const plan={schema:1,deviceId:String(deviceId),currentVersion,targetVersion,currentIntegrity:current.integrity,targetIntegrity:target.integrity,createdAt:this.now()};
22
+ const fingerprint=crypto.createHash("sha256").update(canonical(plan)).digest("hex");
23
+ const envelope={...plan,fingerprint};
24
+ await writeJsonAtomic(this.planPath(),envelope);
25
+ this.audit({event:"update.activation_plan_created",currentVersion,targetVersion,deviceId:String(deviceId),fingerprint});
26
+ return envelope;
27
+ }
28
+ async verify(){
29
+ const envelope=await readJsonStrict(this.planPath(),{missing:null,invalidCode:"UPDATE_PLAN_INVALID"});
30
+ if(!envelope)fail("UPDATE_PLAN_INVALID","Activation plan is missing");
31
+ const {fingerprint,...plan}=envelope;
32
+ if(plan.schema!==1||!String(plan.deviceId||"").trim())fail("UPDATE_PLAN_INVALID","Activation plan identity is invalid");
33
+ plan.currentVersion=semver(plan.currentVersion);plan.targetVersion=semver(plan.targetVersion);
34
+ const expected=crypto.createHash("sha256").update(canonical(plan)).digest("hex");
35
+ if(fingerprint!==expected)fail("UPDATE_PLAN_INVALID","Activation plan fingerprint mismatch");
36
+ const current=await this.manager.verifyPrepared({version:plan.currentVersion});
37
+ const target=await this.manager.verifyPrepared({version:plan.targetVersion});
38
+ if(current.integrity!==plan.currentIntegrity||target.integrity!==plan.targetIntegrity)fail("UPDATE_PLAN_INVALID","Prepared package integrity no longer matches activation plan");
39
+ return envelope;
40
+ }
41
+ }
@@ -0,0 +1,35 @@
1
+ function fail(code,message){throw Object.assign(new Error(message),{code});}
2
+
3
+ export function validateUpdateArchiveEntries(entries){
4
+ if(!Array.isArray(entries)||entries.length<1)fail("UPDATE_ARCHIVE_INVALID","Archive entry list is required");
5
+ for(const raw of entries){
6
+ const name=String(raw?.name||"").replaceAll("\\","/");
7
+ const type=String(raw?.type||"file").toLowerCase();
8
+ if(!name||name.startsWith("/")||/^[A-Za-z]:\//.test(name)||name.startsWith("//"))fail("UPDATE_ARCHIVE_PATH_DENIED","Archive contains an absolute path");
9
+ const parts=name.split("/").filter(Boolean);
10
+ if(parts.includes("..")||parts[0]!=="package")fail("UPDATE_ARCHIVE_PATH_DENIED","Archive entry escapes package root");
11
+ if(type==="symlink"||type==="hardlink"||type==="link")fail("UPDATE_ARCHIVE_LINK_DENIED","Archive links are not allowed");
12
+ }
13
+ return{valid:true,count:entries.length};
14
+ }
15
+
16
+ export function readUpdateTarEntries(buffer){
17
+ if(!Buffer.isBuffer(buffer)||buffer.length<512)fail("UPDATE_ARCHIVE_INVALID","Tar archive bytes are required");
18
+ const entries=[];
19
+ for(let offset=0;offset+512<=buffer.length;){
20
+ const header=buffer.subarray(offset,offset+512);
21
+ if(header.every(byte=>byte===0))break;
22
+ const text=(start,length)=>header.subarray(start,start+length).toString("utf8").replace(/\0.*$/s,"");
23
+ const name=text(0,100),prefix=text(345,155),full=prefix?`${prefix}/${name}`:name;
24
+ const sizeText=text(124,12).trim();const size=sizeText?Number.parseInt(sizeText,8):0;
25
+ if(!Number.isFinite(size)||size<0)fail("UPDATE_ARCHIVE_INVALID","Tar entry size is invalid");
26
+ const typeFlag=String.fromCharCode(header[156]||48);
27
+ if(!["0","1","2","5","\u0000"].includes(typeFlag))fail("UPDATE_ARCHIVE_TYPE_DENIED",`Unsupported tar entry type: ${typeFlag}`);
28
+ const type=typeFlag==="2"?"symlink":typeFlag==="1"?"hardlink":typeFlag==="5"?"directory":"file";
29
+ entries.push({name:full,type});
30
+ offset+=512+Math.ceil(size/512)*512;
31
+ }
32
+ if(entries.length<1)fail("UPDATE_ARCHIVE_INVALID","Tar archive has no entries");
33
+ return entries;
34
+ }
35
+
@@ -0,0 +1,32 @@
1
+ import path from "node:path";
2
+ import { rm } from "node:fs/promises";
3
+ import { UpdateLifecycle } from "./update-lifecycle.js";
4
+ import { acquireExclusiveLock } from "./update-persistence.js";
5
+
6
+ function fail(code,message){throw Object.assign(new Error(message),{code});}
7
+
8
+ export class UpdateBootstrapExecutor{
9
+ constructor({manager,activationPlan,supervisor,waitStable,audit=()=>{}}={}){
10
+ if(!manager||!activationPlan||!supervisor||!waitStable)fail("UPDATE_BOOTSTRAP_INVALID","Complete bootstrap dependencies are required");
11
+ Object.assign(this,{manager,activationPlan,supervisor,waitStable,audit});
12
+ }
13
+ async execute(){
14
+ const lockPath=path.join(this.manager.homeDir,".whyvix-remote","update-activation.lock");
15
+ const release=await acquireExclusiveLock(lockPath);
16
+ try{
17
+ const plan=await this.activationPlan.verify();
18
+ const readyFile=path.join(this.manager.updateRoot(plan.targetVersion),"registered.json");
19
+ await rm(readyFile,{force:true});
20
+ const lifecycle=new UpdateLifecycle({
21
+ supervisor:this.supervisor,
22
+ startCandidate:({version})=>this.manager.launchReplacement({version}),
23
+ waitRegistered:({version,candidate})=>this.manager.waitForRegistration({version,candidate,planFingerprint:plan.fingerprint}),
24
+ waitStable:args=>this.waitStable(args),
25
+ startRollback:({version})=>this.manager.launchReplacement({version}),
26
+ audit:this.audit
27
+ });
28
+ this.audit({event:"update.bootstrap_started",currentVersion:plan.currentVersion,targetVersion:plan.targetVersion,deviceId:plan.deviceId});
29
+ return await lifecycle.activate({currentVersion:plan.currentVersion,targetVersion:plan.targetVersion});
30
+ }finally{await release();}
31
+ }
32
+ }
@@ -0,0 +1,31 @@
1
+ function fail(code,message,cause){const error=Object.assign(new Error(message),{code});if(cause)error.cause=cause;throw error;}
2
+
3
+ export class UpdateLifecycle{
4
+ constructor({supervisor,startCandidate,waitRegistered,waitStable,startRollback,audit=()=>{}}={}){
5
+ if(!supervisor||!startCandidate||!waitRegistered||!waitStable||!startRollback)fail("UPDATE_LIFECYCLE_INVALID","Complete lifecycle dependencies are required");
6
+ Object.assign(this,{supervisor,startCandidate,waitRegistered,waitStable,startRollback,audit});
7
+ }
8
+ async rollback(cause){
9
+ const plan=await this.supervisor.rollbackPlan();
10
+ const result=await this.startRollback(plan);
11
+ this.audit({event:"update.rollback_started",version:plan.version,reason:cause?.code||"UPDATE_FAILED"});
12
+ return result;
13
+ }
14
+ async activate({currentVersion,targetVersion}={}){
15
+ await this.supervisor.arm({currentVersion,targetVersion});
16
+ let candidate;
17
+ try{
18
+ candidate=await this.startCandidate({version:targetVersion});
19
+ if(!candidate||!Number.isInteger(candidate.pid)||candidate.pid<=0)fail("UPDATE_CANDIDATE_START_FAILED","Candidate did not return a valid pid");
20
+ await this.supervisor.markCandidateStarted({targetVersion,pid:candidate.pid});
21
+ const registered=await this.waitRegistered({version:targetVersion,candidate});
22
+ await this.supervisor.markRegistered({targetVersion,deviceId:registered?.deviceId});
23
+ const stable=await this.waitStable({version:targetVersion,candidate,registered});
24
+ if(stable!==true)fail("UPDATE_CANDIDATE_UNSTABLE","Candidate did not survive the stability window");
25
+ return await this.supervisor.commit({targetVersion});
26
+ }catch(error){
27
+ try{await this.rollback(error);}catch(rollbackError){fail("UPDATE_ROLLBACK_FAILED","Candidate activation failed and rollback could not be started",rollbackError);}
28
+ throw error;
29
+ }
30
+ }
31
+ }
@@ -0,0 +1,34 @@
1
+ import path from "node:path";
2
+ import crypto from "node:crypto";
3
+ import { mkdir, open, readFile, rename, rm, writeFile } from "node:fs/promises";
4
+
5
+ function fail(code,message,cause){throw Object.assign(new Error(message),{code,cause});}
6
+
7
+ export async function readJsonStrict(file,{missing=null,invalidCode="UPDATE_STATE_INVALID"}={}){
8
+ try{return JSON.parse(await readFile(file,"utf8"));}
9
+ catch(error){
10
+ if(error?.code==="ENOENT")return missing;
11
+ if(error instanceof SyntaxError)fail(invalidCode,"Persisted update JSON is malformed",error);
12
+ throw error;
13
+ }
14
+ }
15
+
16
+ export async function writeJsonAtomic(file,value){
17
+ const dir=path.dirname(file);await mkdir(dir,{recursive:true,mode:0o700});
18
+ const tmp=path.join(dir,`.${path.basename(file)}.${process.pid}.${crypto.randomBytes(8).toString("hex")}.tmp`);
19
+ try{
20
+ await writeFile(tmp,JSON.stringify(value,null,2),{encoding:"utf8",mode:0o600,flag:"wx"});
21
+ await rename(tmp,file);
22
+ }finally{await rm(tmp,{force:true}).catch(()=>{});}
23
+ return value;
24
+ }
25
+
26
+ export async function acquireExclusiveLock(file,{busyCode="UPDATE_BUSY"}={}){
27
+ await mkdir(path.dirname(file),{recursive:true,mode:0o700});
28
+ let handle;
29
+ try{handle=await open(file,"wx",0o600);}
30
+ catch(error){if(error?.code==="EEXIST")fail(busyCode,"Another update activation already holds the lock",error);throw error;}
31
+ await handle.writeFile(JSON.stringify({pid:process.pid,createdAt:Date.now()}),"utf8");
32
+ let released=false;
33
+ return async()=>{if(released)return;released=true;await handle.close();await rm(file,{force:true});};
34
+ }
@@ -0,0 +1,35 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { spawn } from "node:child_process";
4
+ import { SelfUpdateManager } from "./self-update-manager.js";
5
+ import { UpdateSupervisor } from "./update-supervisor.js";
6
+ import { UpdateActivationPlan } from "./update-activation-plan.js";
7
+ import { UpdateBootstrapExecutor } from "./update-bootstrap-executor.js";
8
+
9
+ const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
10
+ function fail(code,message){throw Object.assign(new Error(message),{code});}
11
+
12
+ export async function runUpdateSupervisorHost({homeDir,nodeExecPath=process.execPath,spawnFn=spawn,graceMs=15000,pollMs=250,audit=()=>{}}={}){
13
+ const manager=new SelfUpdateManager({homeDir,nodeExecPath,spawnFn,audit});
14
+ const activationPlan=new UpdateActivationPlan({manager,homeDir,audit});
15
+ const supervisor=new UpdateSupervisor({manager,homeDir,audit});
16
+ const waitStable=async({candidate})=>{
17
+ const pid=Number(candidate?.pid);
18
+ if(!Number.isInteger(pid)||pid<=0)fail("UPDATE_PID_INVALID","Candidate pid is required for stability monitoring");
19
+ const deadline=Date.now()+Math.max(1000,Math.min(Number(graceMs)||15000,60000));
20
+ while(Date.now()<deadline){
21
+ try{process.kill(pid,0);}catch{return false;}
22
+ await sleep(Math.max(50,Math.min(Number(pollMs)||250,1000)));
23
+ }
24
+ return true;
25
+ };
26
+ return new UpdateBootstrapExecutor({manager,activationPlan,supervisor,waitStable,audit}).execute();
27
+ }
28
+
29
+ export function launchDetachedUpdateSupervisorHost({nodeExecPath=process.execPath,spawnFn=spawn}={}){
30
+ const hostScript=path.resolve(path.dirname(fileURLToPath(import.meta.url)),"../../cli/update-supervisor.js");
31
+ const child=spawnFn(nodeExecPath,[hostScript],{cwd:path.dirname(hostScript),windowsHide:true,shell:false,detached:true,stdio:"ignore"});
32
+ if(!Number.isInteger(child?.pid)||child.pid<=0)fail("UPDATE_SUPERVISOR_START_FAILED","Detached update supervisor did not return a valid pid");
33
+ child.unref?.();
34
+ return{pid:child.pid};
35
+ }
@@ -0,0 +1,52 @@
1
+ import path from "node:path";
2
+ import { readJsonStrict, writeJsonAtomic } from "./update-persistence.js";
3
+
4
+ function fail(code,message){throw Object.assign(new Error(message),{code});}
5
+ function version(value){const v=String(value||"").trim();if(!/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/.test(v))fail("UPDATE_VERSION_INVALID","Explicit semver version is required");return v;}
6
+
7
+ export class UpdateSupervisor{
8
+ constructor({manager,homeDir,audit=()=>{},now=()=>Date.now()}={}){
9
+ if(!manager)fail("UPDATE_MANAGER_REQUIRED","Self-update manager is required");
10
+ this.manager=manager;this.homeDir=homeDir||manager.homeDir;this.audit=audit;this.now=now;
11
+ }
12
+ statePath(){return path.join(this.homeDir,".whyvix-remote","update-state.json");}
13
+ async readState(){return readJsonStrict(this.statePath(),{missing:null,invalidCode:"UPDATE_STATE_INVALID"});}
14
+ async writeState(state){return writeJsonAtomic(this.statePath(),state);}
15
+ async arm({currentVersion,targetVersion}={}){
16
+ currentVersion=version(currentVersion);targetVersion=version(targetVersion);
17
+ if(currentVersion===targetVersion)fail("UPDATE_TARGET_SAME","Target version must differ from current version");
18
+ await this.manager.verifyPrepared({version:currentVersion});
19
+ await this.manager.verifyPrepared({version:targetVersion});
20
+ const state={status:"armed",currentVersion,targetVersion,armedAt:this.now()};
21
+ await this.writeState(state);this.audit({event:"update.armed",currentVersion,targetVersion});return state;
22
+ }
23
+ async markCandidateStarted({targetVersion,pid}={}){
24
+ targetVersion=version(targetVersion);const state=await this.readState();
25
+ if(state?.status!=="armed"||state.targetVersion!==targetVersion)fail("UPDATE_NOT_ARMED","Update activation is not armed for this target");
26
+ if(!Number.isInteger(pid)||pid<=0)fail("UPDATE_PID_INVALID","Candidate pid is invalid");
27
+ const next={...state,status:"candidate_started",candidatePid:pid,startedAt:this.now()};
28
+ await this.writeState(next);return next;
29
+ }
30
+ async markRegistered({targetVersion,deviceId}={}){
31
+ targetVersion=version(targetVersion);const state=await this.readState();
32
+ if(state?.status!=="candidate_started"||state.targetVersion!==targetVersion)fail("UPDATE_STATE_INVALID","Candidate was not started for this target");
33
+ if(!String(deviceId||"").trim())fail("UPDATE_DEVICE_INVALID","Registered device id is required");
34
+ const next={...state,status:"registered",deviceId:String(deviceId),registeredAt:this.now()};
35
+ await this.writeState(next);this.audit({event:"update.registered",targetVersion,deviceId:String(deviceId)});return next;
36
+ }
37
+ async commit({targetVersion}={}){
38
+ targetVersion=version(targetVersion);const state=await this.readState();
39
+ if(state?.status!=="registered"||state.targetVersion!==targetVersion)fail("UPDATE_NOT_REGISTERED","Candidate registration must be proven before commit");
40
+ const next={...state,status:"committed",activeVersion:targetVersion,lastKnownGood:state.currentVersion,committedAt:this.now()};
41
+ await this.writeState(next);this.audit({event:"update.committed",targetVersion,lastKnownGood:state.currentVersion});return next;
42
+ }
43
+ async rollbackPlan(){
44
+ const state=await this.readState();
45
+ if(!state?.currentVersion)fail("UPDATE_ROLLBACK_UNAVAILABLE","No rollback version is recorded");
46
+ await this.manager.verifyPrepared({version:state.currentVersion});
47
+ const reason=state.status||"unknown";
48
+ await this.writeState({...state,status:"rollback_started",rollbackVersion:state.currentVersion,rollbackStartedAt:this.now()});
49
+ this.audit({event:"update.rollback_planned",version:state.currentVersion,reason});
50
+ return{version:state.currentVersion,reason};
51
+ }
52
+ }
@@ -2,7 +2,7 @@
2
2
  import os from "node:os";
3
3
  import crypto from "node:crypto";
4
4
  import { mkdir, readFile, writeFile } from "node:fs/promises";
5
- import { join, dirname } from "node:path";
5
+ import { join, dirname, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { createAgent } from "../agent/src/agent.js";
8
8
  import { loadDeploymentProfiles } from "../agent/src/deployment-profile-loader.js";
@@ -88,7 +88,17 @@ const agent=createAgent({
88
88
  },
89
89
  log:(event,tool,meta)=>presenter.log(event,tool,meta),
90
90
  errorLog:(event,tool,code,message)=>presenter.error(event,tool,code,message),
91
- onRegistered:({authorizedAccount})=>presenter.registered(authorizedAccount)
91
+ onRegistered:async({authorizedAccount})=>{
92
+ presenter.registered(authorizedAccount);
93
+ const version=String(packageMeta.version||"unknown");
94
+ const managedPackageRoot=resolve(credentialDir,"updates",version,"extracted","package");
95
+ if(resolve(packageRoot)===managedPackageRoot){
96
+ const plan=JSON.parse(await readFile(join(credentialDir,"activation-plan.json"),"utf8"));
97
+ if(plan?.targetVersion!==version||plan?.deviceId!==deviceId||!String(plan?.fingerprint||"").trim())throw new Error("Managed update activation plan does not match this candidate");
98
+ const readyFile=join(credentialDir,"updates",version,"registered.json");
99
+ await writeFile(readyFile,JSON.stringify({deviceId,version,pid:process.pid,planFingerprint:plan.fingerprint,registeredAt:new Date().toISOString()}),{encoding:"utf8",flag:"wx",mode:0o600});
100
+ }
101
+ }
92
102
  });
93
103
  let shuttingDown=false;
94
104
  async function shutdown(){