@whyvix/remote 0.3.19 → 0.3.21
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/npm-shrinkwrap.json +2 -2
- package/package.json +3 -2
- package/packages/agent/src/rollback-runtime-snapshot.js +40 -0
- package/packages/agent/src/self-update-manager.js +11 -7
- package/packages/agent/src/update-activation-plan.js +9 -7
- package/packages/agent/src/update-archive-policy.js +24 -2
- package/packages/agent/src/update-bootstrap-executor.js +14 -5
- package/packages/agent/src/update-persistence.js +13 -0
- package/packages/agent/src/update-supervisor-host.js +5 -3
- package/packages/agent/src/update-supervisor.js +5 -5
- package/packages/cli/update-supervisor.js +7 -0
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@whyvix/remote",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.21",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@whyvix/remote",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.21",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@modelcontextprotocol/ext-apps": "^2.0.0",
|
|
12
12
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@whyvix/remote",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.21",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
7
7
|
"packages/agent/src",
|
|
8
8
|
"packages/cli/index.js",
|
|
9
|
+
"packages/cli/update-supervisor.js",
|
|
9
10
|
"packages/cli/browser.js",
|
|
10
11
|
"packages/cli/presenter.js",
|
|
11
12
|
"packages/protocol/src",
|
|
@@ -28,7 +29,7 @@
|
|
|
28
29
|
"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
30
|
"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
31
|
"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"
|
|
32
|
+
"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-rollback-runtime-snapshot-test.js && node scripts/r10-windows-detached-survival-test.js"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
35
|
"@modelcontextprotocol/ext-apps": "^2.0.0",
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import { access, readFile } from "node:fs/promises";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
function fail(code,message){throw Object.assign(new Error(message),{code});}
|
|
7
|
+
function sha256(bytes){return crypto.createHash("sha256").update(bytes).digest("hex");}
|
|
8
|
+
function inside(root,file){const rel=path.relative(path.resolve(root),path.resolve(file));return rel&&!rel.startsWith("..")&&!path.isAbsolute(rel);}
|
|
9
|
+
|
|
10
|
+
export class RollbackRuntimeSnapshot{
|
|
11
|
+
constructor({homeDir,nodeExecPath=process.execPath,spawnFn=spawn,audit=()=>{}}={}){
|
|
12
|
+
if(!homeDir)fail("UPDATE_SNAPSHOT_INVALID","Home directory is required");
|
|
13
|
+
this.homeDir=homeDir;this.nodeExecPath=nodeExecPath;this.spawnFn=spawnFn;this.audit=audit;
|
|
14
|
+
}
|
|
15
|
+
snapshotPath(version){return path.join(this.homeDir,".whyvix-remote","updates",String(version),"runtime-snapshot.json");}
|
|
16
|
+
async verify({version}={}){
|
|
17
|
+
const file=this.snapshotPath(version);let snapshot;
|
|
18
|
+
try{snapshot=JSON.parse(await readFile(file,"utf8"));}catch{fail("UPDATE_SNAPSHOT_INVALID","Rollback runtime snapshot is missing or invalid");}
|
|
19
|
+
if(snapshot?.schema!==1||snapshot.version!==version||!snapshot.packageRoot||!snapshot.launcher||!snapshot.packageLock||!snapshot.packageLockSha256||!snapshot.criticalFiles)fail("UPDATE_SNAPSHOT_INVALID","Rollback runtime snapshot identity is invalid");
|
|
20
|
+
if(!inside(snapshot.sourceRoot,snapshot.packageRoot)||!inside(snapshot.packageRoot,snapshot.launcher)||!inside(snapshot.sourceRoot,snapshot.packageLock))fail("UPDATE_SNAPSHOT_INVALID","Rollback runtime snapshot paths escape their trusted roots");
|
|
21
|
+
const lock=await readFile(snapshot.packageLock);if(sha256(lock)!==snapshot.packageLockSha256)fail("UPDATE_SNAPSHOT_TAMPERED","Rollback runtime lockfile hash mismatch");
|
|
22
|
+
const meta=JSON.parse(await readFile(path.join(snapshot.packageRoot,"package.json"),"utf8"));
|
|
23
|
+
if(meta?.name!=="@whyvix/remote"||meta?.version!==version||meta?.bin?.["whyvix-remote"]!=="packages/cli/index.js")fail("UPDATE_SNAPSHOT_INVALID","Rollback runtime package identity is invalid");
|
|
24
|
+
const modulesRoot=path.join(snapshot.sourceRoot,"node_modules");for(const name of Object.keys(meta.dependencies||{})){const parts=name.startsWith("@")?name.split("/"): [name];try{await access(path.join(modulesRoot,...parts));}catch{fail("UPDATE_SNAPSHOT_DEPENDENCY_MISSING",`Rollback runtime dependency is missing: ${name}`);}}
|
|
25
|
+
for(const [relative,expected] of Object.entries(snapshot.criticalFiles)){
|
|
26
|
+
const target=path.resolve(snapshot.packageRoot,relative);if(!inside(snapshot.packageRoot,target))fail("UPDATE_SNAPSHOT_INVALID","Rollback critical file escapes package root");
|
|
27
|
+
const bytes=await readFile(target);if(sha256(bytes)!==expected)fail("UPDATE_SNAPSHOT_TAMPERED",`Rollback runtime critical file changed: ${relative}`);
|
|
28
|
+
}
|
|
29
|
+
await access(snapshot.launcher);
|
|
30
|
+
const fingerprint=sha256(Buffer.from(JSON.stringify({schema:snapshot.schema,version:snapshot.version,sourceRoot:snapshot.sourceRoot,packageRoot:snapshot.packageRoot,launcher:snapshot.launcher,packageLock:snapshot.packageLock,packageLockSha256:snapshot.packageLockSha256,criticalFiles:snapshot.criticalFiles})));
|
|
31
|
+
return{...snapshot,fingerprint};
|
|
32
|
+
}
|
|
33
|
+
async launch({version,cwd=process.cwd()}={}){
|
|
34
|
+
const snapshot=await this.verify({version});
|
|
35
|
+
const child=this.spawnFn(this.nodeExecPath,[snapshot.launcher,"remote"],{cwd,windowsHide:true,shell:false,detached:true,stdio:"ignore",env:{...process.env}});
|
|
36
|
+
if(!Number.isInteger(child?.pid)||child.pid<=0)fail("UPDATE_ROLLBACK_START_FAILED","Rollback runtime did not return a valid pid");
|
|
37
|
+
child.unref?.();this.audit({event:"update.rollback_runtime_started",version,pid:child.pid,fingerprint:snapshot.fingerprint});
|
|
38
|
+
return{version,pid:child.pid,launcher:snapshot.launcher,snapshotFingerprint:snapshot.fingerprint};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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(
|
|
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()}={}){
|
|
@@ -7,18 +7,18 @@ function semver(value){const v=String(value||"").trim();if(!/^(?:0|[1-9]\d*)\.(?
|
|
|
7
7
|
function canonical(value){return JSON.stringify(value,Object.keys(value).sort());}
|
|
8
8
|
|
|
9
9
|
export class UpdateActivationPlan{
|
|
10
|
-
constructor({manager,homeDir,now=()=>Date.now(),audit=()=>{}}={}){
|
|
10
|
+
constructor({manager,rollbackRuntime=null,homeDir,now=()=>Date.now(),audit=()=>{}}={}){
|
|
11
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;
|
|
12
|
+
this.manager=manager;this.rollbackRuntime=rollbackRuntime;this.homeDir=homeDir||manager.homeDir;this.now=now;this.audit=audit;
|
|
13
13
|
}
|
|
14
14
|
planPath(){return path.join(this.homeDir,".whyvix-remote","activation-plan.json");}
|
|
15
15
|
async create({currentVersion,targetVersion,deviceId}={}){
|
|
16
16
|
currentVersion=semver(currentVersion);targetVersion=semver(targetVersion);
|
|
17
17
|
if(currentVersion===targetVersion)fail("UPDATE_TARGET_SAME","Target version must differ from current version");
|
|
18
18
|
if(!String(deviceId||"").trim())fail("UPDATE_DEVICE_INVALID","Device id is required");
|
|
19
|
-
const current=await this.manager.verifyPrepared({version:currentVersion});
|
|
19
|
+
const current=this.rollbackRuntime?await this.rollbackRuntime.verify({version:currentVersion}):await this.manager.verifyPrepared({version:currentVersion});
|
|
20
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()};
|
|
21
|
+
const plan={schema:this.rollbackRuntime?2:1,deviceId:String(deviceId),currentVersion,targetVersion,currentIntegrity:current.integrity||null,currentSnapshotFingerprint:current.fingerprint||null,targetIntegrity:target.integrity,createdAt:this.now()};
|
|
22
22
|
const fingerprint=crypto.createHash("sha256").update(canonical(plan)).digest("hex");
|
|
23
23
|
const envelope={...plan,fingerprint};
|
|
24
24
|
await writeJsonAtomic(this.planPath(),envelope);
|
|
@@ -29,13 +29,15 @@ export class UpdateActivationPlan{
|
|
|
29
29
|
const envelope=await readJsonStrict(this.planPath(),{missing:null,invalidCode:"UPDATE_PLAN_INVALID"});
|
|
30
30
|
if(!envelope)fail("UPDATE_PLAN_INVALID","Activation plan is missing");
|
|
31
31
|
const {fingerprint,...plan}=envelope;
|
|
32
|
-
if(plan.schema
|
|
32
|
+
if(![1,2].includes(plan.schema)||!String(plan.deviceId||"").trim())fail("UPDATE_PLAN_INVALID","Activation plan identity is invalid");
|
|
33
33
|
plan.currentVersion=semver(plan.currentVersion);plan.targetVersion=semver(plan.targetVersion);
|
|
34
34
|
const expected=crypto.createHash("sha256").update(canonical(plan)).digest("hex");
|
|
35
35
|
if(fingerprint!==expected)fail("UPDATE_PLAN_INVALID","Activation plan fingerprint mismatch");
|
|
36
|
-
const current=await this.manager.verifyPrepared({version:plan.currentVersion});
|
|
36
|
+
const current=plan.schema===2&&this.rollbackRuntime?await this.rollbackRuntime.verify({version:plan.currentVersion}):await this.manager.verifyPrepared({version:plan.currentVersion});
|
|
37
37
|
const target=await this.manager.verifyPrepared({version:plan.targetVersion});
|
|
38
|
-
if(
|
|
38
|
+
if(plan.schema===2&¤t.fingerprint!==plan.currentSnapshotFingerprint)fail("UPDATE_PLAN_INVALID","Rollback runtime snapshot no longer matches activation plan");
|
|
39
|
+
if(plan.schema===1&¤t.integrity!==plan.currentIntegrity)fail("UPDATE_PLAN_INVALID","Prepared current package integrity no longer matches activation plan");
|
|
40
|
+
if(target.integrity!==plan.targetIntegrity)fail("UPDATE_PLAN_INVALID","Prepared target package integrity no longer matches activation plan");
|
|
39
41
|
return envelope;
|
|
40
42
|
}
|
|
41
43
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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,rollbackRuntime=null,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,rollbackRuntime,waitStable,recoverLock,audit});
|
|
12
12
|
}
|
|
13
13
|
async execute(){
|
|
14
14
|
const lockPath=path.join(this.manager.homeDir,".whyvix-remote","update-activation.lock");
|
|
15
|
-
|
|
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");
|
|
@@ -22,7 +31,7 @@ export class UpdateBootstrapExecutor{
|
|
|
22
31
|
startCandidate:({version})=>this.manager.launchReplacement({version}),
|
|
23
32
|
waitRegistered:({version,candidate})=>this.manager.waitForRegistration({version,candidate,planFingerprint:plan.fingerprint}),
|
|
24
33
|
waitStable:args=>this.waitStable(args),
|
|
25
|
-
startRollback:({version})=>this.manager.launchReplacement({version}),
|
|
34
|
+
startRollback:({version,authority})=>authority==="runtime_snapshot"&&this.rollbackRuntime?this.rollbackRuntime.launch({version}):this.manager.launchReplacement({version}),
|
|
26
35
|
audit:this.audit
|
|
27
36
|
});
|
|
28
37
|
this.audit({event:"update.bootstrap_started",currentVersion:plan.currentVersion,targetVersion:plan.targetVersion,deviceId:plan.deviceId});
|
|
@@ -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;
|
|
@@ -5,14 +5,16 @@ import { SelfUpdateManager } from "./self-update-manager.js";
|
|
|
5
5
|
import { UpdateSupervisor } from "./update-supervisor.js";
|
|
6
6
|
import { UpdateActivationPlan } from "./update-activation-plan.js";
|
|
7
7
|
import { UpdateBootstrapExecutor } from "./update-bootstrap-executor.js";
|
|
8
|
+
import { RollbackRuntimeSnapshot } from "./rollback-runtime-snapshot.js";
|
|
8
9
|
|
|
9
10
|
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
|
10
11
|
function fail(code,message){throw Object.assign(new Error(message),{code});}
|
|
11
12
|
|
|
12
13
|
export async function runUpdateSupervisorHost({homeDir,nodeExecPath=process.execPath,spawnFn=spawn,graceMs=15000,pollMs=250,audit=()=>{}}={}){
|
|
13
14
|
const manager=new SelfUpdateManager({homeDir,nodeExecPath,spawnFn,audit});
|
|
14
|
-
const
|
|
15
|
-
const
|
|
15
|
+
const rollbackRuntime=new RollbackRuntimeSnapshot({homeDir:homeDir||manager.homeDir,nodeExecPath,spawnFn,audit});
|
|
16
|
+
const activationPlan=new UpdateActivationPlan({manager,rollbackRuntime,homeDir,audit});
|
|
17
|
+
const supervisor=new UpdateSupervisor({manager,rollbackRuntime,homeDir,audit});
|
|
16
18
|
const waitStable=async({candidate})=>{
|
|
17
19
|
const pid=Number(candidate?.pid);
|
|
18
20
|
if(!Number.isInteger(pid)||pid<=0)fail("UPDATE_PID_INVALID","Candidate pid is required for stability monitoring");
|
|
@@ -23,7 +25,7 @@ export async function runUpdateSupervisorHost({homeDir,nodeExecPath=process.exec
|
|
|
23
25
|
}
|
|
24
26
|
return true;
|
|
25
27
|
};
|
|
26
|
-
return new UpdateBootstrapExecutor({manager,activationPlan,supervisor,waitStable,audit}).execute();
|
|
28
|
+
return new UpdateBootstrapExecutor({manager,activationPlan,supervisor,rollbackRuntime,waitStable,audit}).execute();
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
export function launchDetachedUpdateSupervisorHost({nodeExecPath=process.execPath,spawnFn=spawn}={}){
|
|
@@ -5,9 +5,9 @@ function fail(code,message){throw Object.assign(new Error(message),{code});}
|
|
|
5
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
6
|
|
|
7
7
|
export class UpdateSupervisor{
|
|
8
|
-
constructor({manager,homeDir,audit=()=>{},now=()=>Date.now()}={}){
|
|
8
|
+
constructor({manager,rollbackRuntime=null,homeDir,audit=()=>{},now=()=>Date.now()}={}){
|
|
9
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;
|
|
10
|
+
this.manager=manager;this.rollbackRuntime=rollbackRuntime;this.homeDir=homeDir||manager.homeDir;this.audit=audit;this.now=now;
|
|
11
11
|
}
|
|
12
12
|
statePath(){return path.join(this.homeDir,".whyvix-remote","update-state.json");}
|
|
13
13
|
async readState(){return readJsonStrict(this.statePath(),{missing:null,invalidCode:"UPDATE_STATE_INVALID"});}
|
|
@@ -15,7 +15,7 @@ export class UpdateSupervisor{
|
|
|
15
15
|
async arm({currentVersion,targetVersion}={}){
|
|
16
16
|
currentVersion=version(currentVersion);targetVersion=version(targetVersion);
|
|
17
17
|
if(currentVersion===targetVersion)fail("UPDATE_TARGET_SAME","Target version must differ from current version");
|
|
18
|
-
await this.manager.verifyPrepared({version:currentVersion});
|
|
18
|
+
if(this.rollbackRuntime)await this.rollbackRuntime.verify({version:currentVersion});else await this.manager.verifyPrepared({version:currentVersion});
|
|
19
19
|
await this.manager.verifyPrepared({version:targetVersion});
|
|
20
20
|
const state={status:"armed",currentVersion,targetVersion,armedAt:this.now()};
|
|
21
21
|
await this.writeState(state);this.audit({event:"update.armed",currentVersion,targetVersion});return state;
|
|
@@ -43,10 +43,10 @@ export class UpdateSupervisor{
|
|
|
43
43
|
async rollbackPlan(){
|
|
44
44
|
const state=await this.readState();
|
|
45
45
|
if(!state?.currentVersion)fail("UPDATE_ROLLBACK_UNAVAILABLE","No rollback version is recorded");
|
|
46
|
-
await this.manager.verifyPrepared({version:state.currentVersion});
|
|
46
|
+
if(this.rollbackRuntime)await this.rollbackRuntime.verify({version:state.currentVersion});else await this.manager.verifyPrepared({version:state.currentVersion});
|
|
47
47
|
const reason=state.status||"unknown";
|
|
48
48
|
await this.writeState({...state,status:"rollback_started",rollbackVersion:state.currentVersion,rollbackStartedAt:this.now()});
|
|
49
49
|
this.audit({event:"update.rollback_planned",version:state.currentVersion,reason});
|
|
50
|
-
return{version:state.currentVersion,reason};
|
|
50
|
+
return{version:state.currentVersion,reason,authority:this.rollbackRuntime?"runtime_snapshot":"prepared_package"};
|
|
51
51
|
}
|
|
52
52
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runUpdateSupervisorHost } from "../agent/src/update-supervisor-host.js";
|
|
3
|
+
|
|
4
|
+
runUpdateSupervisorHost().then(
|
|
5
|
+
()=>process.exit(0),
|
|
6
|
+
error=>{console.error("[update-supervisor]",error?.code||"UPDATE_FAILED",error?.message||String(error));process.exit(1);}
|
|
7
|
+
);
|