@whyvix/remote 0.3.5 → 0.3.6

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.
@@ -0,0 +1,8 @@
1
+ {
2
+ "artifacts": {
3
+ "win32-x64": {
4
+ "path": "native/win32-x64/whyvix-gui-helper.exe",
5
+ "sha256": "c9532bc51695d8d1afa93f56425b0142da78fbadc8719008d0b66f7e585669a5"
6
+ }
7
+ }
8
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whyvix/remote",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "files": [
@@ -8,6 +8,8 @@
8
8
  "packages/cli/index.js",
9
9
  "packages/cli/browser.js",
10
10
  "packages/protocol/src",
11
+ "native/gui-helper-manifest.json",
12
+ "native/win32-x64/whyvix-gui-helper.exe",
11
13
  "README.md"
12
14
  ],
13
15
  "scripts": {
@@ -6,15 +6,18 @@ import { createSecurity } from "./security.js";
6
6
  import { ProcessManager } from "./process-manager.js";
7
7
  import { RemoteExecutionManager } from "./remote-execution-manager.js";
8
8
  import { DeploymentManager } from "./deployment-manager.js";
9
+ import { GuiManager } from "./gui-manager.js";
9
10
  import { createToolExecutor } from "./tools.js";
10
11
 
11
- export function createAgent({gatewayUrl="ws://127.0.0.1:8787",deviceId=randomUUID(),deviceName=os.hostname(),token="",workspaces=[process.cwd()],remoteTargets=[],deploymentProfiles=[],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=[],guiBackend=null,agentVersion="unknown",capabilities=[],reconnectMs=1000,reconnect=true,heartbeatMs=30000,audit=()=>{},log=console.log,errorLog=console.error,onRegistered=()=>{}}={}) {
12
13
  let ws=null,reconnectTimer=null,stopped=false,generation=0;
13
14
  const security=createSecurity({token,workspaces,audit});
14
15
  const processManager=new ProcessManager({audit});
15
16
  const remoteExecutionManager=new RemoteExecutionManager({targets:remoteTargets,audit});
16
17
  const deploymentManager=new DeploymentManager({profiles:deploymentProfiles,security,audit});
17
- const executeTool=createToolExecutor({security,processManager,remoteExecutionManager,deploymentManager});
18
+ const guiManager=new GuiManager({backend:guiBackend,audit});
19
+ const advertisedCapabilities=[...new Set([...capabilities,...(guiManager.capabilities().available?["gui.observe"]:[])])].sort();
20
+ const executeTool=createToolExecutor({security,processManager,remoteExecutionManager,deploymentManager,guiManager});
18
21
  function scheduleReconnect(reason){
19
22
  if(stopped||!reconnect||reconnectTimer)return;
20
23
  log("[agent:reconnect-scheduled]",reason,reconnectMs);
@@ -37,7 +40,7 @@ export function createAgent({gatewayUrl="ws://127.0.0.1:8787",deviceId=randomUUI
37
40
  heartbeatTimer.unref?.();
38
41
  }
39
42
  log("[agent:connecting]",gatewayUrl,"generation="+current);
40
- socket.on("open",()=>{if(ws!==socket)return;log("[agent:connected]",gatewayUrl);socket.send(JSON.stringify(envelope(TYPES.REGISTER,{auth:{token},device:{id:deviceId,name:deviceName,platform:process.platform,agentVersion,capabilities:[...new Set(capabilities.map(String))].sort()}})));});
43
+ socket.on("open",()=>{if(ws!==socket)return;log("[agent:connected]",gatewayUrl);socket.send(JSON.stringify(envelope(TYPES.REGISTER,{auth:{token},device:{id:deviceId,name:deviceName,platform:process.platform,agentVersion,capabilities:advertisedCapabilities}})));});
41
44
  socket.on("message",async(raw)=>{if(ws!==socket)return;try{const m=parseMessage(raw);if(m.type===TYPES.REGISTERED){log("[agent:registered]",m.deviceId);onRegistered({deviceId:m.deviceId,authorizedAccount:m.authorizedAccount||""});return;}if(m.type!==TYPES.REQUEST)return;log("[tool:request]",m.tool,{path:m.arguments?.path,cwd:m.arguments?.cwd});try{const result=await executeTool(m.tool,m.arguments);if(m.tool==="device.ping")Object.assign(result,{deviceId,deviceName});log("[tool:success]",m.tool);if(socket.readyState===WebSocket.OPEN)socket.send(JSON.stringify(envelope(TYPES.RESPONSE,{requestId:m.requestId,result})));}catch(error){errorLog("[tool:rejected]",m.tool,error.code||"TOOL_ERROR",error.message);if(socket.readyState===WebSocket.OPEN)socket.send(JSON.stringify(envelope(TYPES.ERROR,{requestId:m.requestId,error:{code:error.code||"TOOL_ERROR",message:error.message}})));}}catch(error){errorLog("[agent:protocol-error]",error.message);}});
42
45
  socket.on("close",(code)=>{stopHeartbeat();log("[agent:disconnected]","generation="+current,"code="+code);if(ws===socket)ws=null;if(code===1008){errorLog("[agent:authorization-required] device credential rejected");return;}if(code===4001){errorLog("[agent:replaced] newer WHYVIX Remote connection is active");return;}scheduleReconnect("close");});
43
46
  socket.on("error",(e)=>{errorLog("[agent:socket-error]",e.message);scheduleReconnect("error");});
@@ -0,0 +1,224 @@
1
+ import fs from "node:fs/promises";
2
+ import crypto from "node:crypto";
3
+
4
+ const MAX_CHUNK = 32768;
5
+
6
+ function fail(message, code) {
7
+ throw Object.assign(new Error(message), { code });
8
+ }
9
+
10
+ function decodeChunk(encoded) {
11
+ const value = String(encoded ?? "");
12
+
13
+ if (
14
+ value.length > 43692 ||
15
+ value.length % 4 !== 0 ||
16
+ !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)
17
+ ) {
18
+ fail("Invalid binary chunk", "INVALID_ARGUMENT");
19
+ }
20
+
21
+ const data = Buffer.from(value, "base64");
22
+
23
+ if (data.length > MAX_CHUNK) {
24
+ fail("Binary chunk exceeds 32768 bytes", "CONTENT_TOO_LARGE");
25
+ }
26
+
27
+ return data;
28
+ }
29
+
30
+ export class BinaryTransferManager {
31
+ constructor({ security, audit = () => {} } = {}) {
32
+ if (!security) throw new TypeError("security is required");
33
+
34
+ this.security = security;
35
+ this.audit = audit;
36
+ this.sessions = new Map();
37
+ }
38
+
39
+ async begin({ path, ownerId } = {}) {
40
+ const target = this.security.resolveFilesystemPath(path);
41
+
42
+ if (!ownerId) {
43
+ fail("ownerId is required", "INVALID_ARGUMENT");
44
+ }
45
+
46
+ try {
47
+ await fs.stat(target);
48
+ fail("Target already exists", "FILE_EXISTS");
49
+ } catch (error) {
50
+ if (error?.code !== "ENOENT") throw error;
51
+ }
52
+
53
+ const sessionId = crypto.randomUUID();
54
+ const temp = `${target}.whyvix-${sessionId}.part`;
55
+ const handle = await fs.open(temp, "wx");
56
+
57
+ const session = {
58
+ sessionId,
59
+ target,
60
+ temp,
61
+ ownerId,
62
+ handle,
63
+ size: 0,
64
+ hash: crypto.createHash("sha256"),
65
+ };
66
+
67
+ this.sessions.set(sessionId, session);
68
+
69
+ this.audit({
70
+ event: "binary.transfer.begin",
71
+ sessionId,
72
+ path: target,
73
+ ownerId,
74
+ });
75
+
76
+ return {
77
+ sessionId,
78
+ size: 0,
79
+ sha256: session.hash.copy().digest("hex"),
80
+ };
81
+ }
82
+
83
+ get(sessionId, ownerId) {
84
+ const session = this.sessions.get(String(sessionId));
85
+
86
+ if (!session) {
87
+ fail(
88
+ "Binary transfer session not found",
89
+ "TRANSFER_NOT_FOUND",
90
+ );
91
+ }
92
+
93
+ if (session.ownerId !== ownerId) {
94
+ fail(
95
+ "Binary transfer session belongs to another authorization",
96
+ "TRANSFER_OWNER_MISMATCH",
97
+ );
98
+ }
99
+
100
+ return session;
101
+ }
102
+
103
+ async write({
104
+ sessionId,
105
+ ownerId,
106
+ offset,
107
+ dataBase64,
108
+ } = {}) {
109
+ const session = this.get(sessionId, ownerId);
110
+ const data = decodeChunk(dataBase64);
111
+ const numericOffset = Number(offset);
112
+
113
+ if (
114
+ !Number.isSafeInteger(numericOffset) ||
115
+ numericOffset !== session.size
116
+ ) {
117
+ fail(
118
+ "Binary session requires sequential offset",
119
+ "INVALID_ARGUMENT",
120
+ );
121
+ }
122
+
123
+ await session.handle.write(
124
+ data,
125
+ 0,
126
+ data.length,
127
+ session.size,
128
+ );
129
+
130
+ session.hash.update(data);
131
+ session.size += data.length;
132
+
133
+ return {
134
+ sessionId: session.sessionId,
135
+ size: session.size,
136
+ sha256: session.hash.copy().digest("hex"),
137
+ };
138
+ }
139
+
140
+ async finalize({
141
+ sessionId,
142
+ ownerId,
143
+ expectedSha256,
144
+ } = {}) {
145
+ const session = this.get(sessionId, ownerId);
146
+ const expected = String(expectedSha256 || "").toLowerCase();
147
+
148
+ if (!/^[a-f0-9]{64}$/.test(expected)) {
149
+ fail(
150
+ "expectedSha256 is required",
151
+ "INVALID_ARGUMENT",
152
+ );
153
+ }
154
+
155
+ const actual = session.hash.copy().digest("hex");
156
+
157
+ if (actual !== expected) {
158
+ fail(
159
+ "Final SHA-256 does not match transfer",
160
+ "FILE_CHANGED",
161
+ );
162
+ }
163
+
164
+ await session.handle.sync();
165
+ await session.handle.close();
166
+ session.handle = null;
167
+
168
+ try {
169
+ await fs.link(session.temp, session.target);
170
+ await fs.unlink(session.temp);
171
+ } catch (error) {
172
+ await fs.rm(session.temp, { force: true });
173
+ this.sessions.delete(session.sessionId);
174
+
175
+ if (error?.code === "EEXIST") {
176
+ fail(
177
+ "Target already exists",
178
+ "FILE_EXISTS",
179
+ );
180
+ }
181
+
182
+ throw error;
183
+ }
184
+
185
+ this.sessions.delete(session.sessionId);
186
+
187
+ this.audit({
188
+ event: "binary.transfer.finalize",
189
+ sessionId: session.sessionId,
190
+ path: session.target,
191
+ size: session.size,
192
+ ownerId,
193
+ });
194
+
195
+ return {
196
+ sessionId: session.sessionId,
197
+ path: session.target,
198
+ size: session.size,
199
+ sha256: actual,
200
+ };
201
+ }
202
+
203
+ async abort({ sessionId, ownerId } = {}) {
204
+ const session = this.get(sessionId, ownerId);
205
+
206
+ if (session.handle) {
207
+ await session.handle.close();
208
+ }
209
+
210
+ await fs.rm(session.temp, { force: true });
211
+ this.sessions.delete(session.sessionId);
212
+
213
+ this.audit({
214
+ event: "binary.transfer.abort",
215
+ sessionId: session.sessionId,
216
+ ownerId,
217
+ });
218
+
219
+ return {
220
+ sessionId: session.sessionId,
221
+ aborted: true,
222
+ };
223
+ }
224
+ }
@@ -0,0 +1,29 @@
1
+ import path from "node:path";
2
+ import { access, readFile } from "node:fs/promises";
3
+ import { createReadStream } from "node:fs";
4
+ import { createHash } from "node:crypto";
5
+ import { fileURLToPath } from "node:url";
6
+ import { WindowsGuiBackend } from "./windows-gui-backend.js";
7
+
8
+ async function sha256(file){
9
+ const hash=createHash("sha256");
10
+ for await(const chunk of createReadStream(file))hash.update(chunk);
11
+ return hash.digest("hex");
12
+ }
13
+
14
+ export async function loadGuiBackend({platform=process.platform,arch=process.arch,runHelper,packageRoot}={}){
15
+ if(platform!=="win32")return null;
16
+ const root=packageRoot??path.resolve(path.dirname(fileURLToPath(import.meta.url)),"..","..","..");
17
+ const key=`${platform}-${arch}`;
18
+ const manifestPath=path.join(root,"native","gui-helper-manifest.json");
19
+ let manifest;
20
+ try{manifest=JSON.parse(await readFile(manifestPath,"utf8"));}catch{return null;}
21
+ const entry=manifest?.artifacts?.[key];
22
+ if(!entry||typeof entry.path!=="string"||!/^[a-f0-9]{64}$/.test(entry.sha256??""))return null;
23
+ const helperPath=path.resolve(root,entry.path);
24
+ if(!helperPath.startsWith(path.resolve(root)+path.sep))return null;
25
+ try{await access(helperPath);}catch{return null;}
26
+ if(await sha256(helperPath)!==entry.sha256)return null;
27
+ if(typeof runHelper!=="function")return null;
28
+ return new WindowsGuiBackend({helperPath,runHelper});
29
+ }
@@ -0,0 +1,52 @@
1
+ function fail(message,code){throw Object.assign(new Error(message),{code});}
2
+
3
+ export class GuiManager {
4
+ constructor({backend=null,audit=()=>{}}={}){this.backend=backend;this.audit=audit;}
5
+
6
+ capabilities(){
7
+ const backend=this.backend;
8
+ return {
9
+ available:Boolean(backend),
10
+ backend:backend?.name||null,
11
+ observation:{
12
+ displays:Boolean(backend?.displays),
13
+ windows:Boolean(backend?.windows),
14
+ foreground:Boolean(backend?.foreground),
15
+ capture:Boolean(backend?.capture)
16
+ },
17
+ interaction:false,
18
+ accessibility:false
19
+ };
20
+ }
21
+
22
+ #require(operation){
23
+ if(!this.backend)fail("GUI backend is not available on this device","GUI_NOT_AVAILABLE");
24
+ const fn=this.backend[operation];
25
+ if(typeof fn!=="function")fail(`GUI operation is not supported: ${operation}`,"GUI_OPERATION_NOT_AVAILABLE");
26
+ return fn.bind(this.backend);
27
+ }
28
+
29
+ async displays(){
30
+ const result=await this.#require("displays")();
31
+ this.audit({event:"gui.displays"});
32
+ return result;
33
+ }
34
+
35
+ async windows(){
36
+ const result=await this.#require("windows")();
37
+ this.audit({event:"gui.windows"});
38
+ return result;
39
+ }
40
+
41
+ async foreground(){
42
+ const result=await this.#require("foreground")();
43
+ this.audit({event:"gui.foreground"});
44
+ return result;
45
+ }
46
+
47
+ async capture(args={}){
48
+ const result=await this.#require("capture")(args);
49
+ this.audit({event:"gui.capture",displayId:args.displayId??null,windowId:args.windowId??null});
50
+ return result;
51
+ }
52
+ }
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
2
3
  import path from "node:path";
3
4
 
4
5
  const DEFAULT_EXECUTABLES = null;
@@ -55,6 +56,15 @@ export class ProcessManager {
55
56
  while(completed.length>this.maxRetainedJobs){const oldest=completed.shift();this.processes.delete(oldest.id);}
56
57
  }
57
58
 
59
+ discover({command,pathValue=process.env.PATH||"",pathExtValue=process.env.PATHEXT||".COM;.EXE;.BAT;.CMD"}={}) {
60
+ const executable=resolveExecutable(this.executableRegistry,command);
61
+ const dirs=String(pathValue).split(path.delimiter).filter(Boolean);
62
+ const extensions=this.platform==="win32"?(path.extname(executable.command)?[""]:String(pathExtValue).split(";").filter(Boolean)):[""];
63
+ const matches=[];
64
+ for(const dir of dirs)for(const ext of extensions){const candidate=path.resolve(dir,executable.command+ext);try{const stat=fs.statSync(candidate);if(stat.isFile()&&!matches.includes(candidate))matches.push(candidate);}catch{}}
65
+ return{command:executable.name,available:matches.length>0,matches:matches.slice(0,16)};
66
+ }
67
+
58
68
  start({command,args=[],cwd,timeoutMs=30000,ownerId}={}) {
59
69
  this.#cleanup();
60
70
  const executable=resolveExecutable(this.executableRegistry,command);
@@ -83,5 +93,6 @@ export class ProcessManager {
83
93
 
84
94
  #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;}
85
95
  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};}
96
+ input({id,ownerId,data,end=false}={}){const s=this.#owned(id,ownerId);if(!s.running)fail("LOCAL_JOB_NOT_RUNNING","Local job is not running");if(typeof data!=="string")fail("INVALID_ARGUMENT","data must be a string");if(Buffer.byteLength(data,"utf8")>65536)fail("CONTENT_TOO_LARGE","stdin data exceeds 65536 UTF-8 bytes");if(!s.child.stdin||typeof s.child.stdin.write!=="function")fail("LOCAL_STDIN_UNAVAILABLE","Local process stdin is unavailable");if(data.length)s.child.stdin.write(data);if(end){if(typeof s.child.stdin.end!=="function")fail("LOCAL_STDIN_UNAVAILABLE","Local process stdin cannot be closed");s.child.stdin.end();}this.audit({event:"process.input",id,bytes:Buffer.byteLength(data,"utf8"),end:Boolean(end)});return{id:s.id,writtenBytes:Buffer.byteLength(data,"utf8"),ended:Boolean(end)};}
86
97
  stop({id,ownerId}={}){const s=this.#owned(id,ownerId);if(s.running)s.child.kill();return{id:s.id,stopping:s.running};}
87
98
  }
@@ -1,14 +1,26 @@
1
1
  import fs from "node:fs/promises";
2
+ import { createReadStream } from "node:fs";
3
+ import { BinaryTransferManager } from "./binary-transfer-manager.js";
2
4
  import path from "node:path";
3
5
  import crypto from "node:crypto";
4
6
  import { execFile } from "node:child_process";
5
7
  import { promisify } from "node:util";
6
8
  const execFileAsync=promisify(execFile);
7
9
 
8
- export function createToolExecutor({ security, processManager, remoteExecutionManager, deploymentManager }) {
10
+ export function createToolExecutor({ security, processManager, remoteExecutionManager, deploymentManager, guiManager }) {
11
+ const binaryTransferManager=new BinaryTransferManager({security,audit:(entry)=>security.audit(entry)});
9
12
  return async function executeTool(tool,args={}) {
10
13
  security.audit({event:"tool.request",tool});
11
14
  if(tool==="device.ping") return {pong:true,timestamp:new Date().toISOString()};
15
+ if(tool==="gui.capabilities")return guiManager?.capabilities()??{available:false,backend:null,observation:{displays:false,windows:false,foreground:false,capture:false},interaction:false,accessibility:false};
16
+ if(tool==="gui.displays")return guiManager.displays();
17
+ if(tool==="gui.windows")return guiManager.windows();
18
+ if(tool==="gui.foreground")return guiManager.foreground();
19
+ if(tool==="gui.capture")return guiManager.capture(args);
20
+ if(tool==="fs.begin_binary_write")return binaryTransferManager.begin(args);
21
+ if(tool==="fs.write_binary_session")return binaryTransferManager.write(args);
22
+ if(tool==="fs.finalize_binary_write")return binaryTransferManager.finalize(args);
23
+ if(tool==="fs.abort_binary_write")return binaryTransferManager.abort(args);
12
24
  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
25
  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
26
  if(tool==="fs.search"){
@@ -32,6 +44,11 @@ export function createToolExecutor({ security, processManager, remoteExecutionMa
32
44
  await walk(root,0);return{path:root,query:q,mode,matches,truncated:matches.length>=maxResults};
33
45
  }
34
46
  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()};}
47
+ if(tool==="fs.hash"){const target=security.resolveFilesystemPath(args.path);const stat=await fs.stat(target);if(!stat.isFile())throw Object.assign(new Error("Target must be a regular file"),{code:"INVALID_TARGET"});const hash=crypto.createHash("sha256");await new Promise((resolve,reject)=>{const stream=createReadStream(target);stream.on("data",(chunk)=>hash.update(chunk));stream.on("error",reject);stream.on("end",resolve);});return{path:target,size:stat.size,sha256:hash.digest("hex")};}
48
+ if(tool==="fs.read_binary"){const target=security.resolveFilesystemPath(args.path);const stat=await fs.stat(target);if(!stat.isFile())throw Object.assign(new Error("Target must be a regular file"),{code:"INVALID_TARGET"});const offset=Number(args.offset||0),maxBytes=Number(args.maxBytes||32768);if(!Number.isSafeInteger(offset)||offset<0||!Number.isSafeInteger(maxBytes)||maxBytes<1||maxBytes>32768||offset>stat.size)throw Object.assign(new Error("Invalid binary range"),{code:"INVALID_ARGUMENT"});const bytesToRead=Math.min(maxBytes,stat.size-offset),handle=await fs.open(target,"r");try{const buffer=Buffer.alloc(bytesToRead),{bytesRead}=await handle.read(buffer,0,bytesToRead,offset);return{path:target,offset,bytesRead,size:stat.size,dataBase64:buffer.subarray(0,bytesRead).toString("base64"),eof:offset+bytesRead>=stat.size};}finally{await handle.close();}}
49
+ if(tool==="fs.create_binary"){const target=security.resolveFilesystemPath(args.path),encoded=String(args.dataBase64??"");if(encoded.length>43692||encoded.length%4!==0||!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded))throw Object.assign(new Error("Invalid or oversized binary chunk"),{code:"INVALID_ARGUMENT"});const data=Buffer.from(encoded,"base64");if(data.length>32768)throw Object.assign(new Error("Binary chunk exceeds 32768 bytes"),{code:"CONTENT_TOO_LARGE"});try{await fs.writeFile(target,data,{flag:"wx"});}catch(error){if(error?.code==="EEXIST")throw Object.assign(new Error("Target already exists"),{code:"FILE_EXISTS"});throw error;}return{path:target,size:data.length,sha256:crypto.createHash("sha256").update(data).digest("hex")};}
50
+ if(tool==="fs.write_binary"){const target=security.resolveFilesystemPath(args.path),offset=Number(args.offset),expectedSize=Number(args.expectedSize),expectedSha256=String(args.expectedSha256||"").toLowerCase(),encoded=String(args.dataBase64??"");if(!Number.isSafeInteger(offset)||offset<0||!Number.isSafeInteger(expectedSize)||expectedSize<0||offset!==expectedSize||!/^[a-f0-9]{64}$/.test(expectedSha256))throw Object.assign(new Error("Invalid guarded binary write"),{code:"INVALID_ARGUMENT"});if(encoded.length>43692||encoded.length%4!==0||!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded))throw Object.assign(new Error("Invalid or oversized binary chunk"),{code:"INVALID_ARGUMENT"});const data=Buffer.from(encoded,"base64");if(data.length>32768)throw Object.assign(new Error("Binary chunk exceeds 32768 bytes"),{code:"CONTENT_TOO_LARGE"});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!==expectedSize)throw Object.assign(new Error("File changed since previous chunk"),{code:"FILE_CHANGED"});const hash=crypto.createHash("sha256");await new Promise((resolve,reject)=>{const stream=createReadStream(target);stream.on("data",(chunk)=>hash.update(chunk));stream.on("error",reject);stream.on("end",resolve);});if(hash.digest("hex")!==expectedSha256)throw Object.assign(new Error("File changed since previous chunk"),{code:"FILE_CHANGED"});await fs.appendFile(target,data);const updatedHash=crypto.createHash("sha256");await new Promise((resolve,reject)=>{const stream=createReadStream(target);stream.on("data",(chunk)=>updatedHash.update(chunk));stream.on("error",reject);stream.on("end",resolve);});return{path:target,size:expectedSize+data.length,sha256:updatedHash.digest("hex")};}
51
+ if(tool==="fs.copy"){const source=security.resolveFilesystemPath(args.source),destination=security.resolveFilesystemPath(args.destination),stat=await fs.stat(source);if(!stat.isFile())throw Object.assign(new Error("Source must be a regular file"),{code:"INVALID_TARGET"});try{await fs.copyFile(source,destination,fs.constants?.COPYFILE_EXCL??1);}catch(error){if(error?.code==="EEXIST")throw Object.assign(new Error("Destination already exists"),{code:"FILE_EXISTS"});throw error;}const copied=await fs.stat(destination),hash=crypto.createHash("sha256");await new Promise((resolve,reject)=>{const stream=createReadStream(destination);stream.on("data",(chunk)=>hash.update(chunk));stream.on("error",reject);stream.on("end",resolve);});return{source,destination,size:copied.size,sha256:hash.digest("hex")};}
35
52
  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
53
  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
54
  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};}
@@ -76,8 +93,10 @@ export function createToolExecutor({ security, processManager, remoteExecutionMa
76
93
  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});}
77
94
  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);}
78
95
  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
+ if(tool==="process.discover")return processManager.discover(args);
79
97
  if(tool==="process.start"){const cwd=security.resolveFilesystemPath(args.cwd);return processManager.start({...args,cwd});}
80
98
  if(tool==="process.output")return processManager.output(args);
99
+ if(tool==="process.input")return processManager.input(args);
81
100
  if(tool==="process.stop")return processManager.stop(args);
82
101
  if(tool==="git.status"||tool==="git.diff"||tool==="git.log"){const cwd=security.resolveFilesystemPath(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};}
83
102
  if(tool==="git.stage_paths"||tool==="git.commit"||tool==="git.push"){
@@ -0,0 +1,30 @@
1
+ import path from "node:path";
2
+
3
+ const OPERATIONS=new Set(["displays","windows","foreground","capture"]);
4
+ function fail(message,code){throw Object.assign(new Error(message),{code});}
5
+
6
+ export class WindowsGuiBackend {
7
+ constructor({helperPath,runHelper,maxOutputBytes=8*1024*1024}={}){
8
+ if(typeof runHelper!=="function")fail("Windows GUI helper runner is required","GUI_HELPER_NOT_CONFIGURED");
9
+ if(typeof helperPath!=="string"||!helperPath.trim())fail("Windows GUI helper path is required","GUI_HELPER_NOT_CONFIGURED");
10
+ this.name="windows-native";
11
+ this.helperPath=path.resolve(helperPath);
12
+ this.runHelper=runHelper;
13
+ this.maxOutputBytes=Math.max(65536,Math.min(Number(maxOutputBytes)||8*1024*1024,16*1024*1024));
14
+ }
15
+
16
+ async call(operation,args={}){
17
+ if(!OPERATIONS.has(operation))fail("GUI helper operation is not allowed","GUI_OPERATION_NOT_AVAILABLE");
18
+ let response;
19
+ try{response=await this.runHelper({helperPath:this.helperPath,operation,args,maxOutputBytes:this.maxOutputBytes});}
20
+ catch(error){if(error?.code==="GUI_NOT_AVAILABLE")throw error;fail("Windows GUI helper failed","GUI_BACKEND_FAILED");}
21
+ if(!response||typeof response!=="object"||Array.isArray(response))fail("Windows GUI helper returned invalid payload","GUI_BACKEND_PROTOCOL_ERROR");
22
+ if(response.error)fail(String(response.error.message||"Windows GUI helper rejected operation"),String(response.error.code||"GUI_BACKEND_FAILED"));
23
+ return response;
24
+ }
25
+
26
+ displays(){return this.call("displays");}
27
+ windows(){return this.call("windows");}
28
+ foreground(){return this.call("foreground");}
29
+ capture(args={}){return this.call("capture",args);}
30
+ }
@@ -0,0 +1,13 @@
1
+ import { execFile } from "node:child_process";
2
+
3
+ const OPERATIONS=new Set(["displays","windows","foreground","capture"]);
4
+
5
+ export function runWindowsGuiHelper({helperPath,operation,maxOutputBytes=8*1024*1024}){
6
+ if(!OPERATIONS.has(operation))return Promise.reject(Object.assign(new Error("GUI operation is not available"),{code:"GUI_OPERATION_NOT_AVAILABLE"}));
7
+ return new Promise((resolve,reject)=>{
8
+ execFile(helperPath,[operation],{windowsHide:true,timeout:15000,maxBuffer:maxOutputBytes,encoding:"utf8"},(error,stdout)=>{
9
+ if(error)return reject(Object.assign(new Error("Windows GUI helper failed"),{code:error.killed?"GUI_BACKEND_TIMEOUT":"GUI_BACKEND_FAILED"}));
10
+ try{resolve(JSON.parse(stdout));}catch{reject(Object.assign(new Error("Windows GUI helper returned invalid JSON"),{code:"GUI_BACKEND_PROTOCOL_ERROR"}));}
11
+ });
12
+ });
13
+ }
@@ -5,7 +5,9 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
5
5
  import { join, dirname } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { createAgent } from "../agent/src/agent.js";
8
- import { loadDeploymentProfiles } from "../agent/src/deployment-profile-loader.js";
8
+ import { loadDeploymentProfiles } from "../agent/src/deployment-profile-loader.js";
9
+ import { loadGuiBackend } from "../agent/src/gui-backend-loader.js";
10
+ import { runWindowsGuiHelper } from "../agent/src/windows-gui-helper-runner.js";
9
11
  import { openBrowser } from "./browser.js";
10
12
 
11
13
  const packageRoot=join(dirname(fileURLToPath(import.meta.url)),"..","..");
@@ -19,6 +21,7 @@ const remoteTargets=(process.env.WHYVIX_REMOTE_TARGETS||"").split(";").map(alias
19
21
  const configuredDeploymentProfiles=String(process.env.WHYVIX_DEPLOY_PROFILES_FILE||"").trim();
20
22
  const defaultDeploymentProfiles=join(os.homedir(),".whyvix-remote","deployment-profiles.json");
21
23
  const deploymentProfiles=await loadDeploymentProfiles(configuredDeploymentProfiles||defaultDeploymentProfiles,{optional:!configuredDeploymentProfiles});
24
+ const guiBackend=await loadGuiBackend({packageRoot,runHelper:runWindowsGuiHelper});
22
25
  const capabilities=["filesystem.crud","process.bounded","git.read","git.mutate",...(remoteTargets.length?["remote.bounded"]:[]),...(deploymentProfiles.length?["deployment.profiled"]:[])];
23
26
  const deviceId=process.env.WHYVIX_DEVICE_ID||crypto.createHash("sha256").update(os.hostname()+"|"+os.userInfo().username).digest("hex").slice(0,24);
24
27
  const credentialDir=join(os.homedir(),".whyvix-remote");
@@ -65,6 +68,7 @@ const agent=createAgent({
65
68
  workspaces,
66
69
  remoteTargets,
67
70
  deploymentProfiles,
71
+ guiBackend,
68
72
  agentVersion:String(packageMeta.version||"unknown"),
69
73
  capabilities,
70
74
  audit:(event)=>{