@whyvix/remote 0.3.5 → 0.3.7
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/native/gui-helper-manifest.json +8 -0
- package/native/win32-x64/whyvix-gui-helper.exe +0 -0
- package/package.json +3 -1
- package/packages/agent/src/agent.js +7 -4
- package/packages/agent/src/binary-transfer-manager.js +224 -0
- package/packages/agent/src/deployment-manager.js +5 -4
- package/packages/agent/src/deployment-transfer-runner.js +26 -0
- package/packages/agent/src/gui-backend-loader.js +29 -0
- package/packages/agent/src/gui-manager.js +52 -0
- package/packages/agent/src/process-manager.js +11 -0
- package/packages/agent/src/remote-execution-manager.js +4 -5
- package/packages/agent/src/tools.js +46 -1
- package/packages/agent/src/windows-gui-backend.js +30 -0
- package/packages/agent/src/windows-gui-helper-runner.js +13 -0
- package/packages/cli/index.js +7 -1
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@whyvix/remote",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
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=[],deploymentTransferArtifact=null,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
|
-
const deploymentManager=new DeploymentManager({profiles:deploymentProfiles,security,audit});
|
|
17
|
-
const
|
|
17
|
+
const deploymentManager=new DeploymentManager({profiles:deploymentProfiles,security,audit,transferArtifact:deploymentTransferArtifact});
|
|
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:
|
|
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
|
+
}
|
|
@@ -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.
|
|
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
|
+
}
|
|
@@ -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
|
}
|
|
@@ -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";
|
|
@@ -20,8 +18,8 @@ function appendBounded(state,key,chunk,maxBytes){
|
|
|
20
18
|
if(room>0)state[key]=Buffer.concat([current,incoming.subarray(0,room)]).toString("utf8");
|
|
21
19
|
}
|
|
22
20
|
export class RemoteExecutionManager {
|
|
23
|
-
constructor({targets=[],allowedExecutables=
|
|
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;
|
|
21
|
+
constructor({targets=[],allowedExecutables=null,audit=()=>{},spawnFn=spawn,maxOutputBytes=262144,maxRetainedJobs=100,maxRunningJobs=16,jobRetentionMs=300000,now=()=>Date.now()}={}){
|
|
22
|
+
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
23
|
this.maxRetainedJobs=Math.max(1,Math.min(Number(maxRetainedJobs)||100,1000));
|
|
26
24
|
this.maxRunningJobs=Math.max(1,Math.min(Number(maxRunningJobs)||16,128));
|
|
27
25
|
this.jobRetentionMs=Math.max(1000,Math.min(Number(jobRetentionMs)||300000,86400000));
|
|
@@ -39,7 +37,7 @@ export class RemoteExecutionManager {
|
|
|
39
37
|
}
|
|
40
38
|
listTargets(){return [...this.targets.values()].map(({alias,label=""})=>({alias,label}));}
|
|
41
39
|
#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(
|
|
40
|
+
#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
41
|
start({alias,executable,args=[],timeoutMs=30000,ownerId}){
|
|
44
42
|
return this.#start({alias,executable,args,timeoutMs,ownerId,allowDeployHelper:false});
|
|
45
43
|
}
|
|
@@ -64,5 +62,6 @@ export class RemoteExecutionManager {
|
|
|
64
62
|
return{id,pid:child.pid,alias:state.alias};
|
|
65
63
|
}
|
|
66
64
|
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)};}
|
|
65
|
+
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
66
|
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
67
|
}
|
|
@@ -1,14 +1,30 @@
|
|
|
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)});
|
|
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);}
|
|
9
16
|
return async function executeTool(tool,args={}) {
|
|
10
17
|
security.audit({event:"tool.request",tool});
|
|
11
18
|
if(tool==="device.ping") return {pong:true,timestamp:new Date().toISOString()};
|
|
19
|
+
if(tool==="gui.capabilities")return guiManager?.capabilities()??{available:false,backend:null,observation:{displays:false,windows:false,foreground:false,capture:false},interaction:false,accessibility:false};
|
|
20
|
+
if(tool==="gui.displays")return guiManager.displays();
|
|
21
|
+
if(tool==="gui.windows")return guiManager.windows();
|
|
22
|
+
if(tool==="gui.foreground")return guiManager.foreground();
|
|
23
|
+
if(tool==="gui.capture")return guiManager.capture(args);
|
|
24
|
+
if(tool==="fs.begin_binary_write")return binaryTransferManager.begin(args);
|
|
25
|
+
if(tool==="fs.write_binary_session")return binaryTransferManager.write(args);
|
|
26
|
+
if(tool==="fs.finalize_binary_write")return binaryTransferManager.finalize(args);
|
|
27
|
+
if(tool==="fs.abort_binary_write")return binaryTransferManager.abort(args);
|
|
12
28
|
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
29
|
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
30
|
if(tool==="fs.search"){
|
|
@@ -32,6 +48,11 @@ export function createToolExecutor({ security, processManager, remoteExecutionMa
|
|
|
32
48
|
await walk(root,0);return{path:root,query:q,mode,matches,truncated:matches.length>=maxResults};
|
|
33
49
|
}
|
|
34
50
|
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()};}
|
|
51
|
+
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")};}
|
|
52
|
+
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();}}
|
|
53
|
+
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")};}
|
|
54
|
+
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")};}
|
|
55
|
+
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
56
|
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
57
|
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
58
|
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};}
|
|
@@ -71,13 +92,37 @@ export function createToolExecutor({ security, processManager, remoteExecutionMa
|
|
|
71
92
|
return{path:target,bytes,replacements:1};
|
|
72
93
|
}
|
|
73
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
|
+
}
|
|
74
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()};}
|
|
75
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);}
|
|
76
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});}
|
|
77
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);}
|
|
78
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);}
|
|
122
|
+
if(tool==="process.discover")return processManager.discover(args);
|
|
79
123
|
if(tool==="process.start"){const cwd=security.resolveFilesystemPath(args.cwd);return processManager.start({...args,cwd});}
|
|
80
124
|
if(tool==="process.output")return processManager.output(args);
|
|
125
|
+
if(tool==="process.input")return processManager.input(args);
|
|
81
126
|
if(tool==="process.stop")return processManager.stop(args);
|
|
82
127
|
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
128
|
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
|
+
}
|
package/packages/cli/index.js
CHANGED
|
@@ -5,7 +5,10 @@ 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 { transferDeploymentArtifact } from "../agent/src/deployment-transfer-runner.js";
|
|
10
|
+
import { loadGuiBackend } from "../agent/src/gui-backend-loader.js";
|
|
11
|
+
import { runWindowsGuiHelper } from "../agent/src/windows-gui-helper-runner.js";
|
|
9
12
|
import { openBrowser } from "./browser.js";
|
|
10
13
|
|
|
11
14
|
const packageRoot=join(dirname(fileURLToPath(import.meta.url)),"..","..");
|
|
@@ -19,6 +22,7 @@ const remoteTargets=(process.env.WHYVIX_REMOTE_TARGETS||"").split(";").map(alias
|
|
|
19
22
|
const configuredDeploymentProfiles=String(process.env.WHYVIX_DEPLOY_PROFILES_FILE||"").trim();
|
|
20
23
|
const defaultDeploymentProfiles=join(os.homedir(),".whyvix-remote","deployment-profiles.json");
|
|
21
24
|
const deploymentProfiles=await loadDeploymentProfiles(configuredDeploymentProfiles||defaultDeploymentProfiles,{optional:!configuredDeploymentProfiles});
|
|
25
|
+
const guiBackend=await loadGuiBackend({packageRoot,runHelper:runWindowsGuiHelper});
|
|
22
26
|
const capabilities=["filesystem.crud","process.bounded","git.read","git.mutate",...(remoteTargets.length?["remote.bounded"]:[]),...(deploymentProfiles.length?["deployment.profiled"]:[])];
|
|
23
27
|
const deviceId=process.env.WHYVIX_DEVICE_ID||crypto.createHash("sha256").update(os.hostname()+"|"+os.userInfo().username).digest("hex").slice(0,24);
|
|
24
28
|
const credentialDir=join(os.homedir(),".whyvix-remote");
|
|
@@ -65,6 +69,8 @@ const agent=createAgent({
|
|
|
65
69
|
workspaces,
|
|
66
70
|
remoteTargets,
|
|
67
71
|
deploymentProfiles,
|
|
72
|
+
deploymentTransferArtifact:transferDeploymentArtifact,
|
|
73
|
+
guiBackend,
|
|
68
74
|
agentVersion:String(packageMeta.version||"unknown"),
|
|
69
75
|
capabilities,
|
|
70
76
|
audit:(event)=>{
|