@whyvix/remote 0.2.1 → 0.3.0
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 +2 -1
- package/packages/agent/src/agent.js +4 -2
- package/packages/agent/src/deployment-manager.js +51 -0
- package/packages/agent/src/deployment-protocol.js +45 -0
- package/packages/agent/src/process-manager.js +74 -15
- package/packages/agent/src/remote-execution-manager.js +68 -0
- package/packages/agent/src/security.js +17 -1
- package/packages/agent/src/tools.js +88 -5
- package/packages/cli/browser.js +21 -0
- package/packages/cli/index.js +84 -80
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@whyvix/remote",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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/browser.js",
|
|
9
10
|
"packages/protocol/src",
|
|
10
11
|
"README.md"
|
|
11
12
|
],
|
|
@@ -4,13 +4,15 @@ 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 { RemoteExecutionManager } from "./remote-execution-manager.js";
|
|
7
8
|
import { createToolExecutor } from "./tools.js";
|
|
8
9
|
|
|
9
|
-
export function createAgent({gatewayUrl="ws://127.0.0.1:8787",deviceId=randomUUID(),deviceName=os.hostname(),token="",workspaces=[process.cwd()],reconnectMs=1000,reconnect=true,audit=()=>{},log=console.log,errorLog=console.error,onRegistered=()=>{}}={}) {
|
|
10
|
+
export function createAgent({gatewayUrl="ws://127.0.0.1:8787",deviceId=randomUUID(),deviceName=os.hostname(),token="",workspaces=[process.cwd()],remoteTargets=[],reconnectMs=1000,reconnect=true,audit=()=>{},log=console.log,errorLog=console.error,onRegistered=()=>{}}={}) {
|
|
10
11
|
let ws=null,reconnectTimer=null,stopped=false,generation=0;
|
|
11
12
|
const security=createSecurity({token,workspaces,audit});
|
|
12
13
|
const processManager=new ProcessManager({audit});
|
|
13
|
-
const
|
|
14
|
+
const remoteExecutionManager=new RemoteExecutionManager({targets:remoteTargets,audit});
|
|
15
|
+
const executeTool=createToolExecutor({security,processManager,remoteExecutionManager});
|
|
14
16
|
function scheduleReconnect(reason){
|
|
15
17
|
if(stopped||!reconnect||reconnectTimer)return;
|
|
16
18
|
log("[agent:reconnect-scheduled]",reason,reconnectMs);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
|
|
7
|
+
const execFileAsync=promisify(execFile);
|
|
8
|
+
const PROFILE_RE=/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
9
|
+
const SHA_RE=/^[a-f0-9]{40}$/;
|
|
10
|
+
|
|
11
|
+
function fail(code,message){throw Object.assign(new Error(message),{code});}
|
|
12
|
+
async function sha256(file){const hash=crypto.createHash("sha256");const data=await fs.readFile(file);hash.update(data);return hash.digest("hex");}
|
|
13
|
+
|
|
14
|
+
export class DeploymentManager{
|
|
15
|
+
constructor({profiles=[],security,audit=()=>{},execFileFn=execFileAsync}={}){
|
|
16
|
+
if(!security?.resolveAllowed)fail("DEPLOY_PROFILE_DENIED","Deployment security authority is required");
|
|
17
|
+
this.profiles=new Map();
|
|
18
|
+
for(const profile of profiles){
|
|
19
|
+
if(!PROFILE_RE.test(String(profile?.alias||""))||!PROFILE_RE.test(String(profile?.remoteAlias||""))||typeof profile?.repository!=="string"||!profile.repository.trim())fail("DEPLOY_PROFILE_DENIED","Invalid deployment profile");
|
|
20
|
+
if(this.profiles.has(profile.alias))fail("DEPLOY_PROFILE_DENIED","Duplicate deployment profile");
|
|
21
|
+
this.profiles.set(profile.alias,profile);
|
|
22
|
+
}
|
|
23
|
+
this.security=security;this.audit=audit;this.execFileFn=execFileFn;
|
|
24
|
+
}
|
|
25
|
+
listProfiles(){return [...this.profiles.values()].map(({alias,label="",remoteAlias})=>({alias,label,remoteAlias}));}
|
|
26
|
+
#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);}
|
|
27
|
+
async prepareLocal({profile:profileAlias,commit,baseCommit=null}){
|
|
28
|
+
const profile=this.#profile(profileAlias);
|
|
29
|
+
if(!SHA_RE.test(String(commit||"")))fail("DEPLOY_COMMIT_INVALID","Deployment commit must be an exact 40-character lowercase SHA");
|
|
30
|
+
if(baseCommit!==null&&!SHA_RE.test(String(baseCommit)))fail("DEPLOY_COMMIT_INVALID","Base commit must be an exact 40-character lowercase SHA");
|
|
31
|
+
const repo=this.security.resolveAllowed(profile.repository);
|
|
32
|
+
const {stdout:rootRaw}=await this.execFileFn("git",["rev-parse","--show-toplevel"],{cwd:repo,windowsHide:true,maxBuffer:1024*1024});
|
|
33
|
+
const root=this.security.resolveAllowed(rootRaw.trim());
|
|
34
|
+
if(path.resolve(root)!==path.resolve(repo))fail("DEPLOY_PROFILE_DENIED","Deployment repository root does not match profile");
|
|
35
|
+
const {stdout:status}=await this.execFileFn("git",["status","--porcelain"],{cwd:root,windowsHide:true,maxBuffer:1024*1024});
|
|
36
|
+
const dirty=status.split(/\r?\n/).filter(Boolean).filter(line=>!line.startsWith("?? .deploy/"));
|
|
37
|
+
if(dirty.length)fail("DEPLOY_REPOSITORY_DIRTY","Deployment repository must be clean");
|
|
38
|
+
try{await this.execFileFn("git",["cat-file","-e",commit+"^{commit}"],{cwd:root,windowsHide:true,maxBuffer:1024*1024});}catch{fail("DEPLOY_COMMIT_INVALID","Deployment commit does not exist locally");}
|
|
39
|
+
let dependencyChanged=false;
|
|
40
|
+
if(baseCommit){
|
|
41
|
+
try{const {stdout}=await this.execFileFn("git",["diff","--name-only",baseCommit,commit,"--","package-lock.json","npm-shrinkwrap.json","composer.lock"],{cwd:root,windowsHide:true,maxBuffer:1024*1024});dependencyChanged=Boolean(stdout.trim());}
|
|
42
|
+
catch{fail("DEPLOY_COMMIT_INVALID","Base commit does not exist locally");}
|
|
43
|
+
}
|
|
44
|
+
const deployDir=this.security.resolveAllowed(path.join(root,".deploy"));await fs.mkdir(deployDir,{recursive:true});
|
|
45
|
+
const shortSha=commit.slice(0,7),artifact=path.join(deployDir,`release-${shortSha}.tar`);
|
|
46
|
+
await this.execFileFn("git",["archive","--format=tar","-o",artifact,commit],{cwd:root,windowsHide:true,maxBuffer:1024*1024});
|
|
47
|
+
const artifactSha256=await sha256(artifact);
|
|
48
|
+
this.audit({event:"deploy.prepare_local",profile:profile.alias,commit,dependencyChanged});
|
|
49
|
+
return{profile:profile.alias,remoteAlias:profile.remoteAlias,commit,shortSha,artifact,artifactSha256,dependencyChanged};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
const PROFILE_RE=/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
4
|
+
const SHA_RE=/^[a-f0-9]{40}$/;
|
|
5
|
+
const HASH_RE=/^[a-f0-9]{64}$/;
|
|
6
|
+
const OPS=new Set(["stage","verify","prepare","status","activate"]);
|
|
7
|
+
|
|
8
|
+
function fail(code,message){throw Object.assign(new Error(message),{code});}
|
|
9
|
+
function absolutePosix(value){return typeof value==="string"&&value.startsWith("/")&&!value.includes("\0")&&!value.split("/").includes("..");}
|
|
10
|
+
|
|
11
|
+
export function validateDeploymentProfile(profile){
|
|
12
|
+
if(!profile||!PROFILE_RE.test(String(profile.alias||""))||!PROFILE_RE.test(String(profile.remoteAlias||"")))fail("DEPLOY_PROFILE_DENIED","Invalid deployment profile");
|
|
13
|
+
if(!absolutePosix(profile.remoteReleaseRoot)||!absolutePosix(profile.remoteStagingRoot))fail("DEPLOY_PROFILE_DENIED","Invalid deployment profile paths");
|
|
14
|
+
if(typeof profile.serviceName!=="string"||!/^[-A-Za-z0-9_.@]+\.service$/.test(profile.serviceName))fail("DEPLOY_PROFILE_DENIED","Invalid deployment service");
|
|
15
|
+
return Object.freeze({...profile});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function releaseIdFromCommit(commit){
|
|
19
|
+
if(!SHA_RE.test(String(commit||"")))fail("DEPLOY_COMMIT_INVALID","Deployment commit must be an exact 40-character lowercase SHA");
|
|
20
|
+
return "r10-"+commit.slice(0,12);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function buildDeployHelperArgs(input){
|
|
24
|
+
const operation=String(input?.operation||""),commit=String(input?.commit||""),releaseId=releaseIdFromCommit(commit);
|
|
25
|
+
if(!["verify","prepare","status"].includes(operation))fail("DEPLOY_OPERATION_DENIED","Deployment helper operation is not allowed");
|
|
26
|
+
if(input?.releaseId!==undefined&&String(input.releaseId)!==releaseId)fail("DEPLOY_ARGUMENT_INVALID","Release identity does not match commit");
|
|
27
|
+
if(operation==="status"){if(input?.artifactSha256!==undefined)fail("DEPLOY_ARGUMENT_INVALID","Status does not accept artifact checksum");return Object.freeze([operation,releaseId,commit]);}
|
|
28
|
+
const artifactSha256=String(input?.artifactSha256||"");if(!HASH_RE.test(artifactSha256))fail("DEPLOY_CHECKSUM_INVALID","Artifact SHA-256 is required");
|
|
29
|
+
return Object.freeze([operation,releaseId,commit,artifactSha256]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function buildDeploymentOperation(profile,input){
|
|
33
|
+
const p=validateDeploymentProfile(profile),op=String(input?.operation||"");
|
|
34
|
+
if(!OPS.has(op))fail("DEPLOY_OPERATION_DENIED","Deployment operation is not allowed");
|
|
35
|
+
const commit=String(input?.commit||"");const releaseId=releaseIdFromCommit(commit);
|
|
36
|
+
const releasePath=path.posix.join(p.remoteReleaseRoot,releaseId);
|
|
37
|
+
const artifactPath=path.posix.join(p.remoteStagingRoot,releaseId+".tar");
|
|
38
|
+
const result={operation:op,profile:p.alias,remoteAlias:p.remoteAlias,commit,releaseId,releasePath,artifactPath,serviceName:p.serviceName};
|
|
39
|
+
if(["stage","verify","prepare"].includes(op)){
|
|
40
|
+
const artifactSha256=String(input?.artifactSha256||"");
|
|
41
|
+
if(!HASH_RE.test(artifactSha256))fail("DEPLOY_CHECKSUM_INVALID","Artifact SHA-256 is required");
|
|
42
|
+
result.artifactSha256=artifactSha256;
|
|
43
|
+
}
|
|
44
|
+
return Object.freeze(result);
|
|
45
|
+
}
|
|
@@ -1,19 +1,78 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
3
|
+
const DEFAULT_EXECUTABLES = Object.freeze({
|
|
4
|
+
node: process.execPath,
|
|
5
|
+
npm: "npm",
|
|
6
|
+
npx: "npx",
|
|
7
|
+
php: "php",
|
|
8
|
+
composer: "composer"
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
function fail(code, message) { throw Object.assign(new Error(message), { code }); }
|
|
12
|
+
|
|
13
|
+
function appendBounded(state, key, chunk, maxBytes) {
|
|
14
|
+
const current = Buffer.from(state[key], "utf8");
|
|
15
|
+
const incoming = Buffer.from(chunk);
|
|
16
|
+
const remaining = Math.max(0, maxBytes - current.length);
|
|
17
|
+
if (incoming.length > remaining) state.truncated = true;
|
|
18
|
+
if (remaining > 0) state[key] += incoming.subarray(0, remaining).toString("utf8");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeRegistry(value) {
|
|
22
|
+
if (Array.isArray(value)) return new Map(value.map((x) => [String(x).toLowerCase(), String(x)]));
|
|
23
|
+
return new Map(Object.entries(value || {}).map(([name, command]) => [String(name).toLowerCase(), String(command)]));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resolveExecutable(registry, requested) {
|
|
27
|
+
const raw = String(requested || "").trim();
|
|
28
|
+
if (!raw) fail("INVALID_ARGUMENT", "command is required");
|
|
29
|
+
const key = raw.toLowerCase().replace(/\.exe$/, "");
|
|
30
|
+
if (registry.has(key) && !raw.includes("/") && !raw.includes("\\") && !raw.includes(":")) return { name: key, command: registry.get(key) };
|
|
31
|
+
for (const [name, command] of registry) {
|
|
32
|
+
if (raw.toLowerCase() === command.toLowerCase()) return { name, command };
|
|
16
33
|
}
|
|
17
|
-
|
|
18
|
-
stop({id}) { const s=this.processes.get(String(id)); if(!s) throw Object.assign(new Error("Process not found"),{code:"PROCESS_NOT_FOUND"}); if(s.running)s.child.kill(); return {id:s.id,stopping:s.running}; }
|
|
34
|
+
fail("LOCAL_EXEC_NOT_ALLOWED", "Local executable is not allowed");
|
|
19
35
|
}
|
|
36
|
+
|
|
37
|
+
export class ProcessManager {
|
|
38
|
+
constructor({ audit=()=>{}, spawnFn=spawn, allowedExecutables=DEFAULT_EXECUTABLES, maxOutputBytes=262144, maxRetainedJobs=100, maxRunningJobs=16, jobRetentionMs=300000, now=()=>Date.now() }={}) {
|
|
39
|
+
this.audit=audit; this.spawnFn=spawnFn; this.executableRegistry=normalizeRegistry(allowedExecutables);
|
|
40
|
+
this.maxOutputBytes=Math.max(1024,Math.min(Number(maxOutputBytes)||262144,1048576));
|
|
41
|
+
this.maxRetainedJobs=Math.max(1,Math.min(Number(maxRetainedJobs)||100,1000));
|
|
42
|
+
this.maxRunningJobs=Math.max(1,Math.min(Number(maxRunningJobs)||16,128));
|
|
43
|
+
this.jobRetentionMs=Math.max(1000,Math.min(Number(jobRetentionMs)||300000,86400000));
|
|
44
|
+
this.now=now; this.processes=new Map(); this.nextId=1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#cleanup() {
|
|
48
|
+
const now=this.now();
|
|
49
|
+
for(const [id,state] of this.processes) if(!state.running&&state.completedAt!=null&&now-state.completedAt>=this.jobRetentionMs)this.processes.delete(id);
|
|
50
|
+
const completed=[...this.processes.values()].filter(x=>!x.running).sort((a,b)=>(a.completedAt||0)-(b.completedAt||0));
|
|
51
|
+
while(completed.length>this.maxRetainedJobs){const oldest=completed.shift();this.processes.delete(oldest.id);}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
start({command,args=[],cwd,timeoutMs=30000,ownerId}={}) {
|
|
55
|
+
this.#cleanup();
|
|
56
|
+
const executable=resolveExecutable(this.executableRegistry,command);
|
|
57
|
+
if(!Array.isArray(args)||args.some(x=>typeof x!=="string"))fail("INVALID_ARGUMENT","args must be an array of strings");
|
|
58
|
+
if(executable.name==="npx") {
|
|
59
|
+
if(args.some(x=>/^--(?:yes|package|call|shell|install)/i.test(x))) fail("LOCAL_EXEC_NOT_ALLOWED","npx install or shell options are not allowed");
|
|
60
|
+
args=["--no-install",...args];
|
|
61
|
+
}
|
|
62
|
+
if([...this.processes.values()].filter(x=>x.running).length>=this.maxRunningJobs)fail("LOCAL_JOB_LIMIT","Local running job limit reached");
|
|
63
|
+
const id=String(this.nextId++);let child;
|
|
64
|
+
try{child=this.spawnFn(executable.command,args,{cwd,windowsHide:true,shell:false});}catch{fail("LOCAL_EXEC_FAILED","Local process failed to start");}
|
|
65
|
+
const state={id,ownerId:ownerId==null?null:String(ownerId),command:executable.name,cwd,stdout:"",stderr:"",truncated:false,exitCode:null,errorCode:null,timedOut:false,running:true,completedAt:null,child};
|
|
66
|
+
this.processes.set(id,state);this.audit({event:"process.start",id,command:executable.name,cwd});
|
|
67
|
+
child.stdout?.on("data",x=>appendBounded(state,"stdout",x,this.maxOutputBytes));child.stderr?.on("data",x=>appendBounded(state,"stderr",x,this.maxOutputBytes));
|
|
68
|
+
child.on("error",()=>{state.running=false;state.errorCode="LOCAL_EXEC_FAILED";state.completedAt=this.now();this.audit({event:"process.error",id,code:state.errorCode});this.#cleanup();});
|
|
69
|
+
child.on("close",code=>{state.exitCode=code;state.running=false;if(!state.errorCode&&state.timedOut)state.errorCode="LOCAL_EXEC_TIMEOUT";else if(!state.errorCode&&code!==0)state.errorCode="LOCAL_EXEC_FAILED";state.completedAt=this.now();this.audit({event:"process.exit",id,code,errorCode:state.errorCode,truncated:state.truncated,timedOut:state.timedOut});this.#cleanup();});
|
|
70
|
+
const boundedTimeout=Math.max(0,Math.min(Number(timeoutMs)||0,300000));
|
|
71
|
+
if(boundedTimeout>0)setTimeout(()=>{if(!state.running)return;state.timedOut=true;state.errorCode="LOCAL_EXEC_TIMEOUT";child.kill();},boundedTimeout).unref();
|
|
72
|
+
return{id,pid:child.pid};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#owned(id,ownerId){this.#cleanup();const s=this.processes.get(String(id));if(!s)fail("LOCAL_JOB_NOT_FOUND","Local job not found");if(s.ownerId!=null&&String(ownerId||"")!==s.ownerId)fail("LOCAL_JOB_NOT_FOUND","Local job not found");return s;}
|
|
76
|
+
output({id,ownerId}={}){const s=this.#owned(id,ownerId);return{id:s.id,stdout:s.stdout,stderr:s.stderr,running:s.running,exitCode:s.exitCode,errorCode:s.errorCode,truncated:s.truncated,timedOut:s.timedOut};}
|
|
77
|
+
stop({id,ownerId}={}){const s=this.#owned(id,ownerId);if(s.running)s.child.kill();return{id:s.id,stopping:s.running};}
|
|
78
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { buildDeployHelperArgs } from "./deployment-protocol.js";
|
|
3
|
+
|
|
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
|
+
function fail(code,message){throw Object.assign(new Error(message),{code});}
|
|
8
|
+
function classifySshFailure(state,code){
|
|
9
|
+
if(state.timedOut)return "REMOTE_TIMEOUT";
|
|
10
|
+
if(code!==255)return state.errorCode||null;
|
|
11
|
+
const text=state.stderr.toLowerCase();
|
|
12
|
+
if(text.includes("host key verification failed")||text.includes("remote host identification has changed"))return "REMOTE_HOST_KEY_FAILED";
|
|
13
|
+
if(text.includes("permission denied")||text.includes("authentication failed")||text.includes("no supported authentication methods available"))return "REMOTE_AUTH_FAILED";
|
|
14
|
+
return state.errorCode||"REMOTE_EXEC_FAILED";
|
|
15
|
+
}
|
|
16
|
+
function appendBounded(state,key,chunk,maxBytes){
|
|
17
|
+
const current=Buffer.from(state[key],"utf8"),incoming=Buffer.from(chunk);
|
|
18
|
+
const room=Math.max(0,maxBytes-current.length);
|
|
19
|
+
if(incoming.length>room)state.truncated=true;
|
|
20
|
+
if(room>0)state[key]=Buffer.concat([current,incoming.subarray(0,room)]).toString("utf8");
|
|
21
|
+
}
|
|
22
|
+
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;
|
|
25
|
+
this.maxRetainedJobs=Math.max(1,Math.min(Number(maxRetainedJobs)||100,1000));
|
|
26
|
+
this.maxRunningJobs=Math.max(1,Math.min(Number(maxRunningJobs)||16,128));
|
|
27
|
+
this.jobRetentionMs=Math.max(1000,Math.min(Number(jobRetentionMs)||300000,86400000));
|
|
28
|
+
this.now=now;this.jobs=new Map();this.nextId=1;
|
|
29
|
+
}
|
|
30
|
+
#cleanup(){
|
|
31
|
+
const now=this.now();
|
|
32
|
+
for(const [id,state] of this.jobs){
|
|
33
|
+
if(!state.running&&state.completedAt!=null&&now-state.completedAt>=this.jobRetentionMs)this.jobs.delete(id);
|
|
34
|
+
}
|
|
35
|
+
const completed=[...this.jobs.values()].filter(x=>!x.running).sort((a,b)=>(a.completedAt||0)-(b.completedAt||0));
|
|
36
|
+
while(completed.length>this.maxRetainedJobs){
|
|
37
|
+
const oldest=completed.shift();this.jobs.delete(oldest.id);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
listTargets(){return [...this.targets.values()].map(({alias,label=""})=>({alias,label}));}
|
|
41
|
+
#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];}
|
|
43
|
+
start({alias,executable,args=[],timeoutMs=30000,ownerId}){
|
|
44
|
+
return this.#start({alias,executable,args,timeoutMs,ownerId,allowDeployHelper:false});
|
|
45
|
+
}
|
|
46
|
+
startDeployHelper({alias,input={},timeoutMs=30000,ownerId}){
|
|
47
|
+
const args=buildDeployHelperArgs(input);
|
|
48
|
+
return this.#start({alias,executable:"whyvix-deploy",args,timeoutMs,ownerId,allowDeployHelper:true});
|
|
49
|
+
}
|
|
50
|
+
#start({alias,executable,args=[],timeoutMs=30000,ownerId,allowDeployHelper=false}){
|
|
51
|
+
if(!ownerId)fail("REMOTE_OWNER_REQUIRED","Remote job owner is required");
|
|
52
|
+
this.#cleanup();
|
|
53
|
+
let runningJobs=0;
|
|
54
|
+
for(const state of this.jobs.values())if(state.running)runningJobs++;
|
|
55
|
+
if(runningJobs>=this.maxRunningJobs)fail("REMOTE_JOB_LIMIT","Remote running job limit reached");
|
|
56
|
+
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});
|
|
58
|
+
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
|
+
child.stdout?.on("data",x=>appendBounded(state,"stdout",x,this.maxOutputBytes));child.stderr?.on("data",x=>appendBounded(state,"stderr",x,this.maxOutputBytes));
|
|
60
|
+
const finish=()=>{if(state.timer){clearTimeout(state.timer);state.timer=null;}};
|
|
61
|
+
child.on("error",()=>{state.running=false;state.spawnFailed=true;state.errorCode="REMOTE_EXEC_FAILED";state.completedAt=this.now();finish();this.audit({event:"remote.error",id,alias:state.alias,code:state.errorCode});this.#cleanup();});
|
|
62
|
+
child.on("close",code=>{state.exitCode=code;state.running=false;state.errorCode=classifySshFailure(state,code);state.completedAt=this.now();finish();this.audit({event:"remote.exit",id,alias:state.alias,code,errorCode:state.errorCode||null,truncated:state.truncated,timedOut:Boolean(state.timedOut)});this.#cleanup();});
|
|
63
|
+
state.timer=setTimeout(()=>{if(state.running){state.timedOut=true;state.errorCode="REMOTE_TIMEOUT";this.audit({event:"remote.timeout",id,alias:state.alias});child.kill();}},timeout);state.timer.unref?.();
|
|
64
|
+
return{id,pid:child.pid,alias:state.alias};
|
|
65
|
+
}
|
|
66
|
+
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)};}
|
|
67
|
+
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
|
+
}
|
|
@@ -27,6 +27,22 @@ export function createSecurity({ token, workspaces = [], audit = () => {} } = {}
|
|
|
27
27
|
const b = Buffer.from(String(candidate));
|
|
28
28
|
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
29
29
|
}
|
|
30
|
+
function resolveFilesystemPath(input) {
|
|
31
|
+
if (typeof input !== "string" || !input.trim()) throw Object.assign(new Error("path is required"), { code:"INVALID_ARGUMENT" });
|
|
32
|
+
const requested = path.resolve(input);
|
|
33
|
+
const target = canonicalExisting(requested);
|
|
34
|
+
if (target) return target;
|
|
35
|
+
let ancestor = path.dirname(requested);
|
|
36
|
+
while (ancestor !== path.dirname(ancestor)) {
|
|
37
|
+
const canonicalAncestor = canonicalExisting(ancestor);
|
|
38
|
+
if (canonicalAncestor) return path.resolve(canonicalAncestor, path.relative(ancestor, requested));
|
|
39
|
+
ancestor = path.dirname(ancestor);
|
|
40
|
+
}
|
|
41
|
+
const root = path.parse(requested).root;
|
|
42
|
+
const canonicalRoot = canonicalExisting(root);
|
|
43
|
+
if (canonicalRoot) return path.resolve(canonicalRoot, path.relative(root, requested));
|
|
44
|
+
throw denied();
|
|
45
|
+
}
|
|
30
46
|
function resolveAllowed(input) {
|
|
31
47
|
if (typeof input !== "string" || !input.trim()) throw Object.assign(new Error("path is required"), { code:"INVALID_ARGUMENT" });
|
|
32
48
|
const requested = path.resolve(input);
|
|
@@ -48,5 +64,5 @@ export function createSecurity({ token, workspaces = [], audit = () => {} } = {}
|
|
|
48
64
|
}
|
|
49
65
|
throw denied();
|
|
50
66
|
}
|
|
51
|
-
return { authenticate, resolveAllowed, audit, roots };
|
|
67
|
+
return { authenticate, resolveAllowed, resolveFilesystemPath, audit, roots };
|
|
52
68
|
}
|
|
@@ -1,18 +1,52 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
3
4
|
import { execFile } from "node:child_process";
|
|
4
5
|
import { promisify } from "node:util";
|
|
5
6
|
const execFileAsync=promisify(execFile);
|
|
6
7
|
|
|
7
|
-
export function createToolExecutor({ security, processManager }) {
|
|
8
|
+
export function createToolExecutor({ security, processManager, remoteExecutionManager }) {
|
|
8
9
|
return async function executeTool(tool,args={}) {
|
|
9
10
|
security.audit({event:"tool.request",tool});
|
|
10
11
|
if(tool==="device.ping") return {pong:true,timestamp:new Date().toISOString()};
|
|
11
|
-
if(tool==="fs.list"){const target=security.
|
|
12
|
-
if(tool==="fs.read"){const target=security.
|
|
13
|
-
if(tool==="fs.search"){
|
|
12
|
+
if(tool==="fs.list"){const target=security.resolveFilesystemPath(args.path);const entries=await fs.readdir(target,{withFileTypes:true});return{path:target,entries:entries.map(e=>({name:e.name,type:e.isDirectory()?"directory":e.isFile()?"file":"other"}))};}
|
|
13
|
+
if(tool==="fs.read"){const target=security.resolveFilesystemPath(args.path);const max=Math.min(Number(args.maxBytes||65536),262144);const h=await fs.open(target,"r");try{const stat=await h.stat();if(!stat.isFile())throw Object.assign(new Error("Target must be a regular file"),{code:"INVALID_TARGET"});const readBytes=Math.min(stat.size,262144);const snapshot=Buffer.alloc(readBytes);const {bytesRead}=await h.read(snapshot,0,readBytes,0);const data=snapshot.subarray(0,bytesRead);return{path:target,content:data.subarray(0,max).toString("utf8"),truncated:stat.size>max,size:stat.size,...(stat.size<=262144&&bytesRead===stat.size?{sha256:crypto.createHash("sha256").update(data).digest("hex")}:{})};}finally{await h.close();}}
|
|
14
|
+
if(tool==="fs.search"){
|
|
15
|
+
const root=security.resolveFilesystemPath(args.path);const q=String(args.query||"");if(!q)throw Object.assign(new Error("query is required"),{code:"INVALID_ARGUMENT"});
|
|
16
|
+
const mode=args.mode==="content"?"content":"name",maxResults=Math.min(Math.max(Number(args.maxResults||50),1),200),maxDepth=Math.min(Math.max(Number(args.maxDepth??8),0),20),maxFileBytes=Math.min(Math.max(Number(args.maxFileBytes||262144),1),1048576),matches=[];
|
|
17
|
+
const needle=q.toLowerCase();
|
|
18
|
+
async function walk(dir,depth){
|
|
19
|
+
if(matches.length>=maxResults||depth>maxDepth)return;
|
|
20
|
+
const entries=await fs.readdir(dir,{withFileTypes:true});
|
|
21
|
+
for(const entry of entries){
|
|
22
|
+
if(matches.length>=maxResults)break;
|
|
23
|
+
const candidate=path.join(dir,entry.name);let target;try{target=security.resolveFilesystemPath(candidate);}catch{continue;}
|
|
24
|
+
if(entry.isDirectory()){if(mode==="name"&&entry.name.toLowerCase().includes(needle))matches.push({name:entry.name,path:target,type:"directory"});await walk(target,depth+1);continue;}
|
|
25
|
+
if(!entry.isFile())continue;
|
|
26
|
+
if(mode==="name"){if(entry.name.toLowerCase().includes(needle))matches.push({name:entry.name,path:target,type:"file"});continue;}
|
|
27
|
+
const stat=await fs.stat(target);if(stat.size>maxFileBytes)continue;
|
|
28
|
+
const raw=await fs.readFile(target);if(raw.includes(0))continue;const content=raw.toString("utf8");const lines=content.split(/\r?\n/);
|
|
29
|
+
for(let i=0;i<lines.length&&matches.length<maxResults;i++){if(lines[i].toLowerCase().includes(needle))matches.push({name:entry.name,path:target,type:"file",line:i+1,preview:lines[i].slice(0,500)});}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
await walk(root,0);return{path:root,query:q,mode,matches,truncated:matches.length>=maxResults};
|
|
33
|
+
}
|
|
34
|
+
if(tool==="fs.info"){const target=security.resolveFilesystemPath(args.path);const stat=await fs.stat(target);return{path:target,type:stat.isDirectory()?"directory":stat.isFile()?"file":"other",size:stat.size,createdAt:stat.birthtime.toISOString(),modifiedAt:stat.mtime.toISOString()};}
|
|
35
|
+
if(tool==="fs.mkdir"){const target=security.resolveFilesystemPath(args.path);try{const stat=await fs.stat(target);if(!stat.isDirectory())throw Object.assign(new Error("Target exists and is not a directory"),{code:"FILE_EXISTS"});return{path:target,created:false};}catch(error){if(error?.code!=="ENOENT")throw error;}await fs.mkdir(target,{recursive:Boolean(args.recursive)});return{path:target,created:true};}
|
|
36
|
+
if(tool==="fs.move"){const source=security.resolveFilesystemPath(args.source);const destination=security.resolveFilesystemPath(args.destination);try{await fs.lstat(destination);throw Object.assign(new Error("Destination already exists"),{code:"FILE_EXISTS"});}catch(error){if(error?.code!=="ENOENT")throw error;}await fs.rename(source,destination);return{source,destination};}
|
|
37
|
+
if(tool==="fs.delete_file"){const requested=path.resolve(String(args.path||""));const target=security.resolveFilesystemPath(requested);const linkStat=await fs.lstat(requested);if(linkStat.isSymbolicLink())throw Object.assign(new Error("Symbolic links cannot be deleted through delete_file"),{code:"INVALID_TARGET"});if(!linkStat.isFile())throw Object.assign(new Error("Target must be a regular file"),{code:"INVALID_TARGET"});await fs.unlink(requested);return{path:requested,deleted:true};}
|
|
38
|
+
if(tool==="fs.delete_empty_directory"){const requested=path.resolve(String(args.path||""));const target=security.resolveFilesystemPath(requested);security.resolveFilesystemPath(path.dirname(requested));const linkStat=await fs.lstat(requested);if(linkStat.isSymbolicLink())throw Object.assign(new Error("Symbolic links cannot be deleted through delete_empty_directory"),{code:"INVALID_TARGET"});if(!linkStat.isDirectory())throw Object.assign(new Error("Target must be a directory"),{code:"INVALID_TARGET"});const entries=await fs.readdir(requested);if(entries.length)throw Object.assign(new Error("Directory must be empty"),{code:"DIRECTORY_NOT_EMPTY"});await fs.rmdir(requested);return{path:target,deleted:true};}
|
|
39
|
+
if(tool==="fs.write_text"){
|
|
40
|
+
const target=security.resolveFilesystemPath(args.path),content=String(args.content??""),expectedSha256=String(args.expectedSha256||"").toLowerCase();
|
|
41
|
+
const bytes=Buffer.byteLength(content,"utf8");if(bytes>262144)throw Object.assign(new Error("content exceeds 262144 UTF-8 bytes"),{code:"CONTENT_TOO_LARGE"});
|
|
42
|
+
if(!/^[a-f0-9]{64}$/.test(expectedSha256))throw Object.assign(new Error("expectedSha256 is required"),{code:"INVALID_ARGUMENT"});
|
|
43
|
+
const stat=await fs.stat(target);if(!stat.isFile())throw Object.assign(new Error("Target must be a regular file"),{code:"INVALID_TARGET"});if(stat.size>262144)throw Object.assign(new Error("Target exceeds 262144 bytes"),{code:"CONTENT_TOO_LARGE"});
|
|
44
|
+
const current=await fs.readFile(target);const actualSha256=crypto.createHash("sha256").update(current).digest("hex");if(actualSha256!==expectedSha256)throw Object.assign(new Error("File changed since it was read"),{code:"FILE_CHANGED"});
|
|
45
|
+
const beforeWrite=await fs.readFile(target);const beforeWriteSha256=crypto.createHash("sha256").update(beforeWrite).digest("hex");if(beforeWriteSha256!==expectedSha256)throw Object.assign(new Error("File changed before write"),{code:"FILE_CHANGED"});
|
|
46
|
+
await fs.writeFile(target,content,{encoding:"utf8",flag:"w"});return{path:target,bytes,sha256:crypto.createHash("sha256").update(content,"utf8").digest("hex")};
|
|
47
|
+
}
|
|
14
48
|
if(tool==="fs.create_text"){
|
|
15
|
-
const target=security.
|
|
49
|
+
const target=security.resolveFilesystemPath(args.path);
|
|
16
50
|
const content=String(args.content??"");
|
|
17
51
|
const bytes=Buffer.byteLength(content,"utf8");
|
|
18
52
|
if(bytes>262144)throw Object.assign(new Error("content exceeds 262144 UTF-8 bytes"),{code:"CONTENT_TOO_LARGE"});
|
|
@@ -20,10 +54,59 @@ export function createToolExecutor({ security, processManager }) {
|
|
|
20
54
|
catch(error){if(error?.code==="EEXIST")throw Object.assign(new Error("Target already exists"),{code:"FILE_EXISTS"});throw error;}
|
|
21
55
|
return{path:target,bytes};
|
|
22
56
|
}
|
|
57
|
+
if(tool==="fs.replace_text"){
|
|
58
|
+
const target=security.resolveFilesystemPath(args.path);
|
|
59
|
+
const expected=String(args.expected??"");
|
|
60
|
+
const replacement=String(args.replacement??"");
|
|
61
|
+
if(!expected)throw Object.assign(new Error("expected text is required"),{code:"INVALID_ARGUMENT"});
|
|
62
|
+
if(Buffer.byteLength(expected,"utf8")>262144||Buffer.byteLength(replacement,"utf8")>262144)throw Object.assign(new Error("text exceeds 262144 UTF-8 bytes"),{code:"CONTENT_TOO_LARGE"});
|
|
63
|
+
const stat=await fs.stat(target);if(!stat.isFile())throw Object.assign(new Error("Target must be a regular file"),{code:"INVALID_TARGET"});
|
|
64
|
+
if(stat.size>262144)throw Object.assign(new Error("Target exceeds 262144 UTF-8 bytes"),{code:"CONTENT_TOO_LARGE"});
|
|
65
|
+
const content=await fs.readFile(target,"utf8");
|
|
66
|
+
const first=content.indexOf(expected);if(first<0)throw Object.assign(new Error("Expected text was not found"),{code:"TEXT_NOT_FOUND"});
|
|
67
|
+
if(content.indexOf(expected,first+expected.length)>=0)throw Object.assign(new Error("Expected text is not unique"),{code:"TEXT_NOT_UNIQUE"});
|
|
68
|
+
const updated=content.slice(0,first)+replacement+content.slice(first+expected.length);
|
|
69
|
+
const bytes=Buffer.byteLength(updated,"utf8");if(bytes>262144)throw Object.assign(new Error("Updated content exceeds 262144 UTF-8 bytes"),{code:"CONTENT_TOO_LARGE"});
|
|
70
|
+
await fs.writeFile(target,updated,{encoding:"utf8",flag:"w"});
|
|
71
|
+
return{path:target,bytes,replacements:1};
|
|
72
|
+
}
|
|
73
|
+
if(tool==="remote.targets"){if(!remoteExecutionManager)throw Object.assign(new Error("Remote execution is not configured"),{code:"REMOTE_TARGET_DENIED"});return{targets:remoteExecutionManager.listTargets()};}
|
|
74
|
+
if(tool==="remote.start"){if(!remoteExecutionManager)throw Object.assign(new Error("Remote execution is not configured"),{code:"REMOTE_TARGET_DENIED"});return remoteExecutionManager.start(args);}
|
|
75
|
+
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);}
|
|
76
|
+
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);}
|
|
23
77
|
if(tool==="process.start"){const cwd=security.resolveAllowed(args.cwd);return processManager.start({...args,cwd});}
|
|
24
78
|
if(tool==="process.output")return processManager.output(args);
|
|
25
79
|
if(tool==="process.stop")return processManager.stop(args);
|
|
26
80
|
if(tool==="git.status"||tool==="git.diff"||tool==="git.log"){const cwd=security.resolveAllowed(args.cwd);const gitArgs=tool==="git.status"?["status","--porcelain=v1","--branch"]:tool==="git.diff"?["diff","--",...(args.paths||[])]:["log","--oneline","-n",String(Math.min(Number(args.limit||10),50))];const {stdout,stderr}=await execFileAsync("git",gitArgs,{cwd,windowsHide:true,maxBuffer:1024*1024});return{stdout,stderr};}
|
|
81
|
+
if(tool==="git.stage_paths"||tool==="git.commit"){
|
|
82
|
+
const cwd=security.resolveAllowed(args.cwd);
|
|
83
|
+
const {stdout:rootRaw}=await execFileAsync("git",["rev-parse","--show-toplevel"],{cwd,windowsHide:true,maxBuffer:1024*1024});
|
|
84
|
+
const repoRoot=security.resolveAllowed(rootRaw.trim());
|
|
85
|
+
if(tool==="git.stage_paths"){
|
|
86
|
+
const {stdout:alreadyStaged}=await execFileAsync("git",["diff","--cached","--name-only"],{cwd:repoRoot,windowsHide:true,maxBuffer:1024*1024});
|
|
87
|
+
if(alreadyStaged.trim())throw Object.assign(new Error("Repository already has staged changes"),{code:"INDEX_NOT_EMPTY"});
|
|
88
|
+
const paths=Array.isArray(args.paths)?args.paths.map(String):[];
|
|
89
|
+
if(paths.length<1||paths.length>64)throw Object.assign(new Error("paths must contain 1 to 64 explicit paths"),{code:"INVALID_ARGUMENT"});
|
|
90
|
+
const relativePaths=paths.map((item)=>{
|
|
91
|
+
if(!item||item==="."||item==="*"||item.startsWith("-"))throw Object.assign(new Error("Only explicit repository paths may be staged"),{code:"INVALID_ARGUMENT"});
|
|
92
|
+
const absolute=security.resolveAllowed(path.resolve(repoRoot,item));
|
|
93
|
+
const relative=path.relative(repoRoot,absolute);
|
|
94
|
+
if(!relative||relative.startsWith(".."+path.sep)||path.isAbsolute(relative))throw Object.assign(new Error("Path outside repository"),{code:"PATH_DENIED"});
|
|
95
|
+
return relative.split(path.sep).join("/");
|
|
96
|
+
});
|
|
97
|
+
await execFileAsync("git",["add","--",...relativePaths],{cwd:repoRoot,windowsHide:true,maxBuffer:1024*1024});
|
|
98
|
+
const {stdout}=await execFileAsync("git",["diff","--cached","--name-only","--",...relativePaths],{cwd:repoRoot,windowsHide:true,maxBuffer:1024*1024});
|
|
99
|
+
return{staged:stdout.split(/\r?\n/).filter(Boolean)};
|
|
100
|
+
}
|
|
101
|
+
const message=String(args.message||"");
|
|
102
|
+
if(!message.trim()||message.length>500||/[\r\n]/.test(message))throw Object.assign(new Error("Commit message must be 1 to 500 characters on one line"),{code:"INVALID_ARGUMENT"});
|
|
103
|
+
const {stdout:before}=await execFileAsync("git",["diff","--cached","--name-only"],{cwd:repoRoot,windowsHide:true,maxBuffer:1024*1024});
|
|
104
|
+
const committedFiles=before.split(/\r?\n/).filter(Boolean);
|
|
105
|
+
if(!committedFiles.length)throw Object.assign(new Error("No staged changes to commit"),{code:"NOTHING_STAGED"});
|
|
106
|
+
await execFileAsync("git",["commit","-m",message],{cwd:repoRoot,windowsHide:true,maxBuffer:1024*1024});
|
|
107
|
+
const {stdout:sha}=await execFileAsync("git",["rev-parse","HEAD"],{cwd:repoRoot,windowsHide:true,maxBuffer:1024*1024});
|
|
108
|
+
return{sha:sha.trim(),committedFiles};
|
|
109
|
+
}
|
|
27
110
|
throw Object.assign(new Error("Unsupported tool"),{code:"TOOL_NOT_FOUND"});
|
|
28
111
|
};
|
|
29
112
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export function validateVerificationUri(value,expectedAuthUrl){
|
|
4
|
+
const uri=new URL(value);
|
|
5
|
+
const authority=new URL(expectedAuthUrl);
|
|
6
|
+
if(uri.protocol!=="https:"||uri.origin!==authority.origin||uri.pathname!=="/device/pair")throw new Error("Invalid device authorization URL");
|
|
7
|
+
return uri.href;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function openBrowser(url,expectedAuthUrl,{platform=process.platform,spawnProcess=spawn}={}){
|
|
11
|
+
const uri=validateVerificationUri(url,expectedAuthUrl);
|
|
12
|
+
const command=platform==="win32"?"rundll32.exe":platform==="darwin"?"open":"xdg-open";
|
|
13
|
+
const args=platform==="win32"?["url.dll,FileProtocolHandler",uri]:[uri];
|
|
14
|
+
try{
|
|
15
|
+
const child=spawnProcess(command,args,{detached:true,stdio:"ignore",windowsHide:true});
|
|
16
|
+
return await new Promise(resolve=>{
|
|
17
|
+
child.once("spawn",()=>{child.unref();resolve(true);});
|
|
18
|
+
child.once("error",()=>resolve(false));
|
|
19
|
+
});
|
|
20
|
+
}catch{return false;}
|
|
21
|
+
}
|
package/packages/cli/index.js
CHANGED
|
@@ -1,80 +1,84 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import crypto from "node:crypto";
|
|
4
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
|
-
import { join } from "node:path";
|
|
6
|
-
import { createAgent } from "../agent/src/agent.js";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
console.log("");
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
},
|
|
60
|
-
|
|
61
|
-
if(event==="[tool:
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
console.log("
|
|
69
|
-
console.log("
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createAgent } from "../agent/src/agent.js";
|
|
7
|
+
import { openBrowser } from "./browser.js";
|
|
8
|
+
|
|
9
|
+
const command=process.argv[2]||"remote";
|
|
10
|
+
if(command!=="remote"){console.error("Usage: whyvix-remote remote");process.exit(2);}
|
|
11
|
+
const gatewayUrl=process.env.WHYVIX_GATEWAY_URL||"wss://remote.whyvix.com";
|
|
12
|
+
const authUrl=(process.env.WHYVIX_AUTH_URL||"https://auth.whyvix.com").replace(/\/$/,"");
|
|
13
|
+
const workspaces=(process.env.WHYVIX_WORKSPACES||process.cwd()).split(";").filter(Boolean);
|
|
14
|
+
const remoteTargets=(process.env.WHYVIX_REMOTE_TARGETS||"").split(";").map(alias=>alias.trim()).filter(Boolean).map(alias=>({alias}));
|
|
15
|
+
const deviceId=process.env.WHYVIX_DEVICE_ID||crypto.createHash("sha256").update(os.hostname()+"|"+os.userInfo().username).digest("hex").slice(0,24);
|
|
16
|
+
const credentialDir=join(os.homedir(),".whyvix-remote");
|
|
17
|
+
const credentialPath=join(credentialDir,"device.json");
|
|
18
|
+
|
|
19
|
+
async function loadCredential(){
|
|
20
|
+
if(process.env.WHYVIX_DEVICE_CREDENTIAL)return process.env.WHYVIX_DEVICE_CREDENTIAL;
|
|
21
|
+
try{const saved=JSON.parse(await readFile(credentialPath,"utf8"));return saved.deviceId===deviceId?saved.credential:"";}catch{return "";}
|
|
22
|
+
}
|
|
23
|
+
async function saveCredential(credential){
|
|
24
|
+
await mkdir(credentialDir,{recursive:true,mode:0o700});
|
|
25
|
+
await writeFile(credentialPath,JSON.stringify({deviceId,credential}),{encoding:"utf8",mode:0o600});
|
|
26
|
+
}
|
|
27
|
+
async function post(path,fields){
|
|
28
|
+
const response=await fetch(authUrl+path,{method:"POST",headers:{"content-type":"application/x-www-form-urlencoded"},body:new URLSearchParams(fields),redirect:"error"});
|
|
29
|
+
return{status:response.status,body:await response.json()};
|
|
30
|
+
}
|
|
31
|
+
async function pair(){
|
|
32
|
+
const started=await post("/device/pair/start",{device_id:deviceId,device_name:os.hostname()});
|
|
33
|
+
if(started.status!==201)throw new Error("Unable to start device pairing");
|
|
34
|
+
const opened=await openBrowser(started.body.verification_uri,authUrl);
|
|
35
|
+
console.log(opened?"Authorization opened in your browser.":"Open this URL to authorize the device:");
|
|
36
|
+
console.log(started.body.verification_uri);
|
|
37
|
+
while(Date.now()<started.body.expires_at){
|
|
38
|
+
await new Promise(r=>setTimeout(r,2000));
|
|
39
|
+
const result=await post("/device/pair/status",{pairing_id:started.body.pairing_id,pairing_secret:started.body.pairing_secret});
|
|
40
|
+
if(result.status===200&&result.body.status==="approved"){await saveCredential(result.body.device_credential);return result.body.device_credential;}
|
|
41
|
+
}
|
|
42
|
+
throw new Error("Device pairing expired");
|
|
43
|
+
}
|
|
44
|
+
let credential=await loadCredential();
|
|
45
|
+
if(!credential){
|
|
46
|
+
console.log("WHYVIX Remote device authorization required.");
|
|
47
|
+
credential=await pair();
|
|
48
|
+
console.log("Device authorized.");
|
|
49
|
+
}
|
|
50
|
+
console.log("WHYVIX Remote");
|
|
51
|
+
console.log("");
|
|
52
|
+
const agent=createAgent({
|
|
53
|
+
gatewayUrl,
|
|
54
|
+
deviceId,
|
|
55
|
+
deviceName:os.hostname(),
|
|
56
|
+
token:credential,
|
|
57
|
+
workspaces,
|
|
58
|
+
remoteTargets,
|
|
59
|
+
audit:()=>{},
|
|
60
|
+
log:(event,tool)=>{
|
|
61
|
+
if(event==="[tool:request]") console.log("←",tool);
|
|
62
|
+
else if(event==="[tool:success]") console.log("✓",tool);
|
|
63
|
+
},
|
|
64
|
+
errorLog:(event,tool,code,message)=>{
|
|
65
|
+
if(event==="[tool:rejected]") console.error("✗",tool,"·",code,"·",message);
|
|
66
|
+
},
|
|
67
|
+
onRegistered:({authorizedAccount})=>{
|
|
68
|
+
console.log("Device :",os.hostname());
|
|
69
|
+
console.log("Status : Connected");
|
|
70
|
+
console.log("Authorized :",authorizedAccount||"-");
|
|
71
|
+
console.log("");
|
|
72
|
+
console.log("Keep this terminal open while using WHYVIX Remote.");
|
|
73
|
+
console.log("Press Ctrl+C to disconnect.");
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
let shuttingDown=false;
|
|
77
|
+
async function shutdown(){
|
|
78
|
+
if(shuttingDown)return;
|
|
79
|
+
shuttingDown=true;
|
|
80
|
+
await agent.stop();
|
|
81
|
+
process.exit(0);
|
|
82
|
+
}
|
|
83
|
+
process.once("SIGINT",shutdown);
|
|
84
|
+
process.once("SIGTERM",shutdown);
|