@whyvix/remote 0.3.6 → 0.3.8

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.6",
3
+ "version": "0.3.8",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "files": [
@@ -9,12 +9,12 @@ import { DeploymentManager } from "./deployment-manager.js";
9
9
  import { GuiManager } from "./gui-manager.js";
10
10
  import { createToolExecutor } from "./tools.js";
11
11
 
12
- export function createAgent({gatewayUrl="ws://127.0.0.1:8787",deviceId=randomUUID(),deviceName=os.hostname(),token="",workspaces=[process.cwd()],remoteTargets=[],deploymentProfiles=[],guiBackend=null,agentVersion="unknown",capabilities=[],reconnectMs=1000,reconnect=true,heartbeatMs=30000,audit=()=>{},log=console.log,errorLog=console.error,onRegistered=()=>{}}={}) {
12
+ export function createAgent({gatewayUrl="ws://127.0.0.1:8787",deviceId=randomUUID(),deviceName=os.hostname(),token="",workspaces=[process.cwd()],remoteTargets=[],deploymentProfiles=[],deploymentTransferArtifact=null,guiBackend=null,agentVersion="unknown",capabilities=[],reconnectMs=1000,reconnect=true,heartbeatMs=30000,audit=()=>{},log=console.log,errorLog=console.error,onRegistered=()=>{}}={}) {
13
13
  let ws=null,reconnectTimer=null,stopped=false,generation=0;
14
14
  const security=createSecurity({token,workspaces,audit});
15
15
  const processManager=new ProcessManager({audit});
16
16
  const remoteExecutionManager=new RemoteExecutionManager({targets:remoteTargets,audit});
17
- const deploymentManager=new DeploymentManager({profiles:deploymentProfiles,security,audit});
17
+ const deploymentManager=new DeploymentManager({profiles:deploymentProfiles,security,audit,transferArtifact:deploymentTransferArtifact});
18
18
  const guiManager=new GuiManager({backend:guiBackend,audit});
19
19
  const advertisedCapabilities=[...new Set([...capabilities,...(guiManager.capabilities().available?["gui.observe"]:[])])].sort();
20
20
  const executeTool=createToolExecutor({security,processManager,remoteExecutionManager,deploymentManager,guiManager});
@@ -13,7 +13,7 @@ function fail(code,message){throw Object.assign(new Error(message),{code});}
13
13
  async function sha256(file){const hash=crypto.createHash("sha256");const data=await fs.readFile(file);hash.update(data);return hash.digest("hex");}
14
14
 
15
15
  export class DeploymentManager{
16
- constructor({profiles=[],security,audit=()=>{},execFileFn=execFileAsync}={}){
16
+ constructor({profiles=[],security,audit=()=>{},execFileFn=execFileAsync,transferArtifact=null}={}){
17
17
  if(!security?.resolveAllowed)fail("DEPLOY_PROFILE_DENIED","Deployment security authority is required");
18
18
  this.profiles=new Map();
19
19
  for(const profile of profiles){
@@ -21,7 +21,7 @@ export class DeploymentManager{
21
21
  if(this.profiles.has(profile.alias))fail("DEPLOY_PROFILE_DENIED","Duplicate deployment profile");
22
22
  this.profiles.set(profile.alias,profile);
23
23
  }
24
- this.security=security;this.audit=audit;this.execFileFn=execFileFn;
24
+ this.security=security;this.audit=audit;this.execFileFn=execFileFn;this.transferArtifact=transferArtifact;
25
25
  }
26
26
  listProfiles(){return [...this.profiles.values()].map(({alias,label="",remoteAlias})=>({alias,label,remoteAlias}));}
27
27
  #profile(alias){const value=String(alias||"");if(!PROFILE_RE.test(value)||!this.profiles.has(value))fail("DEPLOY_PROFILE_DENIED","Deployment profile is not registered");return this.profiles.get(value);}
@@ -57,9 +57,10 @@ export class DeploymentManager{
57
57
  if(!stat.isFile())fail("DEPLOY_TRANSFER_FAILED","Deployment artifact must be a regular file");
58
58
  const actualSha256=await sha256(localArtifact);
59
59
  if(actualSha256!==artifactSha256)fail("DEPLOY_CHECKSUM_MISMATCH","Local deployment artifact checksum changed");
60
+ if(typeof this.transferArtifact!=="function")fail("DEPLOY_TRANSFER_FAILED","Bounded deployment transfer authority is not configured");
60
61
  try{
61
- await this.execFileFn("scp",["--",localArtifact,`${operation.remoteAlias}:${operation.artifactPath}`],{windowsHide:true,maxBuffer:1024*1024});
62
- }catch{fail("DEPLOY_TRANSFER_FAILED","Deployment artifact transfer failed");}
62
+ await this.transferArtifact({remoteAlias:operation.remoteAlias,localArtifact,artifactPath:operation.artifactPath,commit,releaseId:operation.releaseId,artifactSha256});
63
+ }catch(error){if(error?.code==="DEPLOY_TRANSFER_FAILED")throw error;fail("DEPLOY_TRANSFER_FAILED","Deployment artifact transfer failed");}
63
64
  this.audit({event:"deploy.stage",profile:profile.alias,commit,artifactSha256});
64
65
  return{profile:profile.alias,remoteAlias:operation.remoteAlias,commit,releaseId:operation.releaseId,artifactPath:operation.artifactPath,artifactSha256};
65
66
  }
@@ -0,0 +1,26 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ const ALIAS_RE=/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
4
+ function fail(code,message){throw Object.assign(new Error(message),{code});}
5
+ function validRemotePath(value){
6
+ return typeof value==="string"&&value.startsWith("/")&&!value.includes("\0")&&!value.split("/").includes("..");
7
+ }
8
+
9
+ export async function transferDeploymentArtifact({remoteAlias,localArtifact,artifactPath,spawnFn=spawn,timeoutMs=120000}={}){
10
+ if(!ALIAS_RE.test(String(remoteAlias||"")))fail("DEPLOY_TRANSFER_FAILED","Invalid deployment remote alias");
11
+ if(typeof localArtifact!=="string"||!localArtifact)fail("DEPLOY_TRANSFER_FAILED","Invalid local deployment artifact");
12
+ if(!validRemotePath(artifactPath))fail("DEPLOY_TRANSFER_FAILED","Invalid deployment staging path");
13
+ const timeout=Math.max(1,Math.min(Number(timeoutMs)||120000,300000));
14
+ return await new Promise((resolve,reject)=>{
15
+ const child=spawnFn("scp",["--",localArtifact,`${remoteAlias}:${artifactPath}`],{windowsHide:true,shell:false});
16
+ let settled=false;
17
+ const finish=(error)=>{
18
+ if(settled)return;settled=true;clearTimeout(timer);
19
+ if(error)reject(error);else resolve({remoteAlias,artifactPath});
20
+ };
21
+ child.on("error",()=>finish(Object.assign(new Error("Deployment artifact transfer failed"),{code:"DEPLOY_TRANSFER_FAILED"})));
22
+ child.on("close",(code)=>code===0?finish():finish(Object.assign(new Error("Deployment artifact transfer failed"),{code:"DEPLOY_TRANSFER_FAILED"})));
23
+ const timer=setTimeout(()=>{if(!settled){child.kill();finish(Object.assign(new Error("Deployment artifact transfer timed out"),{code:"DEPLOY_TRANSFER_FAILED"}));}},timeout);
24
+ timer.unref?.();
25
+ });
26
+ }
@@ -2,8 +2,6 @@ import { spawn } from "node:child_process";
2
2
  import { buildDeployHelperArgs } from "./deployment-protocol.js";
3
3
 
4
4
  const ALIAS_RE=/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
5
- const DEFAULT_EXECUTABLES=new Set(["uname","systemctl","journalctl","cat","ls","sha256sum","node","npm"]);
6
-
7
5
  function fail(code,message){throw Object.assign(new Error(message),{code});}
8
6
  function classifySshFailure(state,code){
9
7
  if(state.timedOut)return "REMOTE_TIMEOUT";
@@ -13,6 +11,11 @@ function classifySshFailure(state,code){
13
11
  if(text.includes("permission denied")||text.includes("authentication failed")||text.includes("no supported authentication methods available"))return "REMOTE_AUTH_FAILED";
14
12
  return state.errorCode||"REMOTE_EXEC_FAILED";
15
13
  }
14
+ function quoteRemoteArg(value){
15
+ const text=String(value);
16
+ return "'" + text.replaceAll("'", "'\"'\"'") + "'";
17
+ }
18
+ function serializeRemoteCommand(argv){return argv.map(quoteRemoteArg).join(" ");}
16
19
  function appendBounded(state,key,chunk,maxBytes){
17
20
  const current=Buffer.from(state[key],"utf8"),incoming=Buffer.from(chunk);
18
21
  const room=Math.max(0,maxBytes-current.length);
@@ -20,8 +23,8 @@ function appendBounded(state,key,chunk,maxBytes){
20
23
  if(room>0)state[key]=Buffer.concat([current,incoming.subarray(0,room)]).toString("utf8");
21
24
  }
22
25
  export class RemoteExecutionManager {
23
- constructor({targets=[],allowedExecutables=DEFAULT_EXECUTABLES,audit=()=>{},spawnFn=spawn,maxOutputBytes=262144,maxRetainedJobs=100,maxRunningJobs=16,jobRetentionMs=300000,now=()=>Date.now()}={}){
24
- this.targets=new Map(targets.map(x=>[x.alias,x]));this.allowedExecutables=new Set(allowedExecutables);this.audit=audit;this.spawnFn=spawnFn;this.maxOutputBytes=maxOutputBytes;
26
+ constructor({targets=[],allowedExecutables=null,audit=()=>{},spawnFn=spawn,maxOutputBytes=262144,maxRetainedJobs=100,maxRunningJobs=16,jobRetentionMs=300000,now=()=>Date.now()}={}){
27
+ this.targets=new Map(targets.map(x=>[x.alias,x]));this.allowedExecutables=allowedExecutables?new Set(allowedExecutables):null;this.audit=audit;this.spawnFn=spawnFn;this.maxOutputBytes=maxOutputBytes;
25
28
  this.maxRetainedJobs=Math.max(1,Math.min(Number(maxRetainedJobs)||100,1000));
26
29
  this.maxRunningJobs=Math.max(1,Math.min(Number(maxRunningJobs)||16,128));
27
30
  this.jobRetentionMs=Math.max(1000,Math.min(Number(jobRetentionMs)||300000,86400000));
@@ -39,7 +42,7 @@ export class RemoteExecutionManager {
39
42
  }
40
43
  listTargets(){return [...this.targets.values()].map(({alias,label=""})=>({alias,label}));}
41
44
  #target(alias){const value=String(alias||"");if(!ALIAS_RE.test(value)||!this.targets.has(value))fail("REMOTE_TARGET_DENIED","Remote target is not registered");return this.targets.get(value);}
42
- #argv(executable,args=[]){if(!this.allowedExecutables.has(executable))fail("REMOTE_COMMAND_DENIED","Remote executable denied");if(!Array.isArray(args)||args.length>64)fail("INVALID_ARGUMENT","Invalid remote arguments");for(const arg of args){if(typeof arg!=="string"||Buffer.byteLength(arg,"utf8")>4096||arg.includes("\0"))fail("INVALID_ARGUMENT","Invalid remote argument");}return [executable,...args];}
45
+ #argv(executable,args=[]){if(typeof executable!=="string"||!executable||Buffer.byteLength(executable,"utf8")>1024||executable.includes("\0"))fail("INVALID_ARGUMENT","Invalid remote executable");if(this.allowedExecutables&&!this.allowedExecutables.has(executable))fail("REMOTE_COMMAND_DENIED","Remote executable denied");if(!Array.isArray(args)||args.length>64)fail("INVALID_ARGUMENT","Invalid remote arguments");for(const arg of args){if(typeof arg!=="string"||Buffer.byteLength(arg,"utf8")>4096||arg.includes("\0"))fail("INVALID_ARGUMENT","Invalid remote argument");}return [executable,...args];}
43
46
  start({alias,executable,args=[],timeoutMs=30000,ownerId}){
44
47
  return this.#start({alias,executable,args,timeoutMs,ownerId,allowDeployHelper:false});
45
48
  }
@@ -54,7 +57,7 @@ export class RemoteExecutionManager {
54
57
  for(const state of this.jobs.values())if(state.running)runningJobs++;
55
58
  if(runningJobs>=this.maxRunningJobs)fail("REMOTE_JOB_LIMIT","Remote running job limit reached");
56
59
  const target=this.#target(alias),remoteArgv=allowDeployHelper?["whyvix-deploy",...args]:this.#argv(executable,args),timeout=Math.max(1,Math.min(Number(timeoutMs)||30000,300000)),id=String(this.nextId++);
57
- const sshArgs=["--",target.alias,...remoteArgv],child=this.spawnFn("ssh",sshArgs,{windowsHide:true,shell:false});
60
+ const sshArgs=["--",target.alias,serializeRemoteCommand(remoteArgv)],child=this.spawnFn("ssh",sshArgs,{windowsHide:true,shell:false});
58
61
  const state={id,ownerId:String(ownerId),alias:target.alias,executable,stdout:"",stderr:"",truncated:false,running:true,exitCode:null,child};this.jobs.set(id,state);this.audit({event:"remote.start",id,alias:state.alias,executable});
59
62
  child.stdout?.on("data",x=>appendBounded(state,"stdout",x,this.maxOutputBytes));child.stderr?.on("data",x=>appendBounded(state,"stderr",x,this.maxOutputBytes));
60
63
  const finish=()=>{if(state.timer){clearTimeout(state.timer);state.timer=null;}};
@@ -64,5 +67,6 @@ export class RemoteExecutionManager {
64
67
  return{id,pid:child.pid,alias:state.alias};
65
68
  }
66
69
  output({id,ownerId}){this.#cleanup();const s=this.jobs.get(String(id));if(!s||!ownerId||s.ownerId!==String(ownerId))fail("REMOTE_JOB_NOT_FOUND","Remote job not found");return{id:s.id,alias:s.alias,stdout:s.stdout,stderr:s.stderr,running:s.running,exitCode:s.exitCode,errorCode:s.errorCode||null,truncated:s.truncated,timedOut:Boolean(s.timedOut)};}
70
+ input({id,ownerId,data,end=false}){this.#cleanup();const s=this.jobs.get(String(id));if(!s||!ownerId||s.ownerId!==String(ownerId))fail("REMOTE_JOB_NOT_FOUND","Remote job not found");if(!s.running||!s.child.stdin)fail("REMOTE_INPUT_CLOSED","Remote job input is not available");if(typeof data!=="string"||Buffer.byteLength(data,"utf8")>65536||data.includes("\0"))fail("INVALID_ARGUMENT","Invalid remote input");s.child.stdin.write(data);if(end)s.child.stdin.end();this.audit({event:"remote.input",id:s.id,alias:s.alias,bytes:Buffer.byteLength(data,"utf8"),end:Boolean(end)});return{id:s.id,written:Buffer.byteLength(data,"utf8"),ended:Boolean(end)};}
67
71
  stop({id,ownerId}){this.#cleanup();const s=this.jobs.get(String(id));if(!s||!ownerId||s.ownerId!==String(ownerId))fail("REMOTE_JOB_NOT_FOUND","Remote job not found");if(s.running)s.child.kill();return{id:s.id,stopping:s.running};}
68
72
  }
@@ -9,6 +9,10 @@ const execFileAsync=promisify(execFile);
9
9
 
10
10
  export function createToolExecutor({ security, processManager, remoteExecutionManager, deploymentManager, guiManager }) {
11
11
  const binaryTransferManager=new BinaryTransferManager({security,audit:(entry)=>security.audit(entry)});
12
+ const pendingDeployments=new Map();
13
+ const pendingDeploymentTtlMs=10*60*1000;
14
+ function deploymentOwner(args){return String(args.ownerId||"");}
15
+ function prunePendingDeployments(){const now=Date.now();for(const [id,state] of pendingDeployments)if(now-state.createdAt>pendingDeploymentTtlMs)pendingDeployments.delete(id);}
12
16
  return async function executeTool(tool,args={}) {
13
17
  security.audit({event:"tool.request",tool});
14
18
  if(tool==="device.ping") return {pong:true,timestamp:new Date().toISOString()};
@@ -88,10 +92,32 @@ export function createToolExecutor({ security, processManager, remoteExecutionMa
88
92
  return{path:target,bytes,replacements:1};
89
93
  }
90
94
  if(tool==="deploy.prepare_local"){if(!deploymentManager)throw Object.assign(new Error("Deployment is not configured"),{code:"DEPLOY_PROFILE_DENIED"});return deploymentManager.prepareLocal({profile:args.profile,commit:args.commit,baseCommit:args.baseCommit??null});}
95
+ if(tool==="deploy.prepare_release"){
96
+ if(!deploymentManager)throw Object.assign(new Error("Deployment is not configured"),{code:"DEPLOY_PROFILE_DENIED"});
97
+ if(!remoteExecutionManager)throw Object.assign(new Error("Remote execution is not configured"),{code:"REMOTE_TARGET_DENIED"});
98
+ const local=await deploymentManager.prepareLocal({profile:args.profile,commit:args.commit,baseCommit:args.baseCommit??null});
99
+ const staged=await deploymentManager.stageArtifact({profile:args.profile,commit:local.commit,artifact:local.artifact,artifactSha256:local.artifactSha256});
100
+ const ownerId=deploymentOwner(args);if(!ownerId)throw Object.assign(new Error("Deployment owner is required"),{code:"DEPLOY_OWNER_REQUIRED"});
101
+ const verify=remoteExecutionManager.startDeployHelper({alias:staged.remoteAlias,input:{operation:"verify",commit:local.commit,artifactSha256:local.artifactSha256},timeoutMs:args.timeoutMs,ownerId});
102
+ prunePendingDeployments();pendingDeployments.set(verify.id,{ownerId,remoteAlias:staged.remoteAlias,commit:local.commit,artifactSha256:local.artifactSha256,createdAt:Date.now()});
103
+ return{profile:local.profile,commit:local.commit,shortSha:local.shortSha,artifactSha256:local.artifactSha256,dependencyChanged:local.dependencyChanged,artifactPath:staged.artifactPath,verifyJobId:verify.id};
104
+ }
105
+ if(tool==="deploy.continue_prepare"){
106
+ if(!remoteExecutionManager)throw Object.assign(new Error("Remote execution is not configured"),{code:"REMOTE_TARGET_DENIED"});
107
+ prunePendingDeployments();const state=pendingDeployments.get(args.verifyJobId);const ownerId=deploymentOwner(args);
108
+ if(!state||!ownerId||state.ownerId!==ownerId)throw Object.assign(new Error("Deployment prepare state is unavailable"),{code:"DEPLOY_NOT_PREPARED"});
109
+ const verified=remoteExecutionManager.output({id:args.verifyJobId,ownerId});
110
+ if(verified.running)throw Object.assign(new Error("Deployment verification is still running"),{code:"DEPLOY_VERIFY_PENDING"});
111
+ if(verified.exitCode!==0||verified.errorCode){pendingDeployments.delete(args.verifyJobId);throw Object.assign(new Error("Deployment verification failed"),{code:"DEPLOY_VERIFY_FAILED"});}
112
+ pendingDeployments.delete(args.verifyJobId);
113
+ const prepared=remoteExecutionManager.startDeployHelper({alias:state.remoteAlias,input:{operation:"prepare",commit:state.commit,artifactSha256:state.artifactSha256},timeoutMs:args.timeoutMs,ownerId});
114
+ return{prepareJobId:prepared.id,remoteAlias:prepared.alias};
115
+ }
91
116
  if(tool==="remote.targets"){if(!remoteExecutionManager)throw Object.assign(new Error("Remote execution is not configured"),{code:"REMOTE_TARGET_DENIED"});return{targets:remoteExecutionManager.listTargets()};}
92
117
  if(tool==="remote.start"){if(!remoteExecutionManager)throw Object.assign(new Error("Remote execution is not configured"),{code:"REMOTE_TARGET_DENIED"});return remoteExecutionManager.start(args);}
93
118
  if(tool==="remote.deploy_status"){if(!remoteExecutionManager)throw Object.assign(new Error("Remote execution is not configured"),{code:"REMOTE_TARGET_DENIED"});return remoteExecutionManager.startDeployHelper({alias:args.alias,input:{operation:"status",commit:args.commit},timeoutMs:args.timeoutMs,ownerId:args.ownerId});}
94
119
  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);}
120
+ 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);}
95
121
  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);}
96
122
  if(tool==="process.discover")return processManager.discover(args);
97
123
  if(tool==="process.start"){const cwd=security.resolveFilesystemPath(args.cwd);return processManager.start({...args,cwd});}
@@ -6,6 +6,7 @@ import { join, dirname } 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";
9
+ import { transferDeploymentArtifact } from "../agent/src/deployment-transfer-runner.js";
9
10
  import { loadGuiBackend } from "../agent/src/gui-backend-loader.js";
10
11
  import { runWindowsGuiHelper } from "../agent/src/windows-gui-helper-runner.js";
11
12
  import { openBrowser } from "./browser.js";
@@ -68,6 +69,7 @@ const agent=createAgent({
68
69
  workspaces,
69
70
  remoteTargets,
70
71
  deploymentProfiles,
72
+ deploymentTransferArtifact:transferDeploymentArtifact,
71
73
  guiBackend,
72
74
  agentVersion:String(packageMeta.version||"unknown"),
73
75
  capabilities,