@whyvix/remote 0.0.0-stage → 0.1.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/README.md +30 -2
- package/package.json +28 -4
- package/packages/agent/src/agent.js +32 -0
- package/packages/agent/src/identity.js +6 -0
- package/packages/agent/src/index.js +15 -0
- package/packages/agent/src/process-manager.js +19 -0
- package/packages/agent/src/security.js +20 -0
- package/packages/agent/src/tools.js +20 -0
- package/packages/cli/index.js +54 -0
- package/packages/protocol/src/index.js +22 -0
package/README.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
-
#
|
|
1
|
+
# WHYVIX Remote
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
WHYVIX Remote is an IDE-independent remote development bridge designed as a consumer ChatGPT Plugin/App product.
|
|
4
|
+
|
|
5
|
+
## Product direction — LOCKED
|
|
6
|
+
|
|
7
|
+
The target consumer experience is: install WHYVIX Remote from ChatGPT Plugins -> Connect -> OAuth WHYVIX -> use only devices authorized to that WHYVIX identity. The consumer must not configure a custom MCP server, MCP URL, Developer Mode, or MCP bearer token.
|
|
8
|
+
|
|
9
|
+
MCP is an internal backend integration layer for the WHYVIX ChatGPT App. See `docs/R9_PRODUCT_ARCHITECTURE.md` before implementing R9 or later product integration work.
|
|
10
|
+
|
|
11
|
+
## Certified foundation
|
|
12
|
+
|
|
13
|
+
R0-R6 provide the local transport/tool foundation. R7 adds control/reconnect certification. R8-B is the deployed/frozen public transport baseline at `d140da7`: WSS agent connectivity, loopback-only control/public services, TLS public API routing, and stable reconnect/device identity.
|
|
14
|
+
|
|
15
|
+
The local commit `f639056` contains an experimental read-only MCP adapter. It is intentionally NOT DEPLOYED and must not be treated as the final consumer authentication architecture.
|
|
16
|
+
|
|
17
|
+
## Local agent UX
|
|
18
|
+
|
|
19
|
+
Current development entry point: `node packages/cli/index.js remote`.
|
|
20
|
+
Intended published consumer command: `npx @whyvix/remote@latest remote`.
|
|
21
|
+
The published CLI must perform WHYVIX login/device pairing and establish an outbound persistent connection; it must not require an inbound port on the consumer PC.
|
|
22
|
+
|
|
23
|
+
## R9 direction
|
|
24
|
+
|
|
25
|
+
R9 implementation order is identity/OAuth -> device pairing -> per-user device isolation -> authenticated App/MCP tool contract -> consumer CLI UX -> ChatGPT Plugin/App integration -> end-to-end acceptance -> submission readiness.
|
|
26
|
+
|
|
27
|
+
R9 is IN PROGRESS. An MCP endpoint responding is not sufficient for GREEN. Consumer Plugin/App installation, OAuth, user/device isolation, revocation, remote tool execution, and no-custom-MCP setup are acceptance requirements.
|
|
28
|
+
|
|
29
|
+
## Engineering rules
|
|
30
|
+
|
|
31
|
+
Preserve certified R8-B behavior. Do not expose control port 8788 or raw agent ports publicly. Do not use global device enumeration as authorization. Do not make possession of a device ID sufficient for access. Static shared bearer credentials are infrastructure/MVP artifacts, not the production consumer identity model.
|
package/package.json
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@whyvix/remote",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"packages/agent/src",
|
|
8
|
+
"packages/cli/index.js",
|
|
9
|
+
"packages/protocol/src",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"gateway": "node services/gateway/src/index.js",
|
|
14
|
+
"agent": "node packages/agent/src/index.js",
|
|
15
|
+
"test:r0": "node scripts/r0-test.js",
|
|
16
|
+
"test": "npm run test:r0 && npm run test:r1-r2 && npm run test:r3-r5",
|
|
17
|
+
"check": "node --check packages/protocol/src/index.js && node --check packages/agent/src/security.js && node --check packages/agent/src/process-manager.js && node --check packages/agent/src/tools.js && node --check packages/agent/src/agent.js && node --check packages/agent/src/index.js && node --check packages/cli/index.js && node --check services/gateway/src/device-registry.js && node --check services/gateway/src/gateway.js && node --check services/gateway/src/index.js && node --check scripts/r0-test.js && node --check scripts/r1-r2-test.js && node --check scripts/r3-r5-test.js",
|
|
18
|
+
"test:r1-r2": "node scripts/r1-r2-test.js",
|
|
19
|
+
"test:r3-r5": "node scripts/r3-r5-test.js"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@modelcontextprotocol/ext-apps": "^2.0.0",
|
|
23
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
24
|
+
"ws": "^8.18.0",
|
|
25
|
+
"zod": "^4.6.5"
|
|
26
|
+
},
|
|
27
|
+
"bin": {
|
|
28
|
+
"whyvix-remote": "packages/cli/index.js"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import WebSocket from "ws";
|
|
4
|
+
import { envelope, parseMessage, TYPES } from "../../protocol/src/index.js";
|
|
5
|
+
import { createSecurity } from "./security.js";
|
|
6
|
+
import { ProcessManager } from "./process-manager.js";
|
|
7
|
+
import { createToolExecutor } from "./tools.js";
|
|
8
|
+
|
|
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=()=>{}}={}) {
|
|
10
|
+
let ws=null,reconnectTimer=null,stopped=false,generation=0;
|
|
11
|
+
const security=createSecurity({token,workspaces,audit});
|
|
12
|
+
const processManager=new ProcessManager({audit});
|
|
13
|
+
const executeTool=createToolExecutor({security,processManager});
|
|
14
|
+
function scheduleReconnect(reason){
|
|
15
|
+
if(stopped||!reconnect||reconnectTimer)return;
|
|
16
|
+
console.log("[agent:reconnect-scheduled]",reason,reconnectMs);
|
|
17
|
+
reconnectTimer=setTimeout(()=>{reconnectTimer=null;connect();},reconnectMs);
|
|
18
|
+
}
|
|
19
|
+
function connect(){
|
|
20
|
+
if(stopped)return;
|
|
21
|
+
clearTimeout(reconnectTimer); reconnectTimer=null;
|
|
22
|
+
const current=++generation;
|
|
23
|
+
const socket=new WebSocket(gatewayUrl); ws=socket;
|
|
24
|
+
console.log("[agent:connecting]",gatewayUrl,"generation="+current);
|
|
25
|
+
socket.on("open",()=>{if(ws!==socket)return;console.log("[agent:connected]",gatewayUrl);socket.send(JSON.stringify(envelope(TYPES.REGISTER,{auth:{token},device:{id:deviceId,name:deviceName,platform:process.platform,agentVersion:"0.3.0"}})));});
|
|
26
|
+
socket.on("message",async(raw)=>{if(ws!==socket)return;try{const m=parseMessage(raw);if(m.type===TYPES.REGISTERED){console.log("[agent:registered]",m.deviceId);if(m.authorizedAccount)console.log("Authorized:",m.authorizedAccount);return;}if(m.type!==TYPES.REQUEST)return;try{const result=await executeTool(m.tool,m.arguments);if(m.tool==="device.ping")Object.assign(result,{deviceId,deviceName});if(socket.readyState===WebSocket.OPEN)socket.send(JSON.stringify(envelope(TYPES.RESPONSE,{requestId:m.requestId,result})));}catch(error){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){console.error("[agent:protocol-error]",error.message);}});
|
|
27
|
+
socket.on("close",(code)=>{console.log("[agent:disconnected]","generation="+current,"code="+code);if(ws===socket)ws=null;if(code===1008){console.error("[agent:authorization-required] device credential rejected");return;}if(code===4001){console.error("[agent:replaced] newer WHYVIX Remote connection is active");return;}scheduleReconnect("close");});
|
|
28
|
+
socket.on("error",(e)=>{console.error("[agent:socket-error]",e.message);scheduleReconnect("error");});
|
|
29
|
+
}
|
|
30
|
+
async function stop(){stopped=true;clearTimeout(reconnectTimer);reconnectTimer=null;const socket=ws;ws=null;if(socket&&socket.readyState<WebSocket.CLOSING)await new Promise(r=>{socket.once("close",r);socket.close();});}
|
|
31
|
+
connect(); return{deviceId,deviceName,stop};
|
|
32
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
|
|
4
|
+
export function stableDeviceId({ hostname = os.hostname(), username = os.userInfo().username } = {}) {
|
|
5
|
+
return crypto.createHash("sha256").update(hostname + "|" + username).digest("hex").slice(0, 24);
|
|
6
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createAgent } from "./agent.js";
|
|
2
|
+
import { stableDeviceId } from "./identity.js";
|
|
3
|
+
|
|
4
|
+
const workspaces=(process.env.WHYVIX_WORKSPACES||process.cwd()).split(";").filter(Boolean);
|
|
5
|
+
const agent=createAgent({
|
|
6
|
+
gatewayUrl:process.env.WHYVIX_GATEWAY_URL||"ws://127.0.0.1:8787",
|
|
7
|
+
deviceId:process.env.WHYVIX_DEVICE_ID||stableDeviceId(),
|
|
8
|
+
deviceName:process.env.WHYVIX_DEVICE_NAME,
|
|
9
|
+
token:process.env.WHYVIX_DEVICE_TOKEN||"",
|
|
10
|
+
workspaces,
|
|
11
|
+
audit:(e)=>console.log("[audit]",JSON.stringify(e))
|
|
12
|
+
});
|
|
13
|
+
async function shutdown(){await agent.stop();process.exit(0);}
|
|
14
|
+
process.on("SIGINT",shutdown);
|
|
15
|
+
process.on("SIGTERM",shutdown);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export class ProcessManager {
|
|
4
|
+
constructor({ audit = () => {} } = {}) { this.audit=audit; this.processes=new Map(); this.nextId=1; }
|
|
5
|
+
start({ command, args = [], cwd, timeoutMs = 30000 }) {
|
|
6
|
+
if (!command) throw Object.assign(new Error("command is required"), { code:"INVALID_ARGUMENT" });
|
|
7
|
+
const id=String(this.nextId++);
|
|
8
|
+
const child=spawn(command,args,{cwd,windowsHide:true,shell:false});
|
|
9
|
+
const state={ id, command, args, cwd, stdout:"", stderr:"", exitCode:null, running:true, child };
|
|
10
|
+
this.processes.set(id,state); this.audit({event:"process.start",id,command,cwd});
|
|
11
|
+
child.stdout?.on("data",(x)=>state.stdout+=x.toString());
|
|
12
|
+
child.stderr?.on("data",(x)=>state.stderr+=x.toString());
|
|
13
|
+
child.on("close",(code)=>{state.exitCode=code;state.running=false;this.audit({event:"process.exit",id,code});});
|
|
14
|
+
if(timeoutMs>0) setTimeout(()=>{if(state.running) child.kill();},Math.min(timeoutMs,300000)).unref();
|
|
15
|
+
return {id,pid:child.pid};
|
|
16
|
+
}
|
|
17
|
+
output({id}) { const s=this.processes.get(String(id)); if(!s) throw Object.assign(new Error("Process not found"),{code:"PROCESS_NOT_FOUND"}); return {id:s.id,stdout:s.stdout,stderr:s.stderr,running:s.running,exitCode:s.exitCode}; }
|
|
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}; }
|
|
19
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
|
|
4
|
+
export function createSecurity({ token, workspaces = [], audit = () => {} } = {}) {
|
|
5
|
+
const roots = workspaces.map((p) => path.resolve(p));
|
|
6
|
+
function authenticate(candidate) {
|
|
7
|
+
if (!token || !candidate) return false;
|
|
8
|
+
const a = Buffer.from(String(token));
|
|
9
|
+
const b = Buffer.from(String(candidate));
|
|
10
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
11
|
+
}
|
|
12
|
+
function resolveAllowed(input) {
|
|
13
|
+
if (typeof input !== "string" || !input.trim()) throw Object.assign(new Error("path is required"), { code:"INVALID_ARGUMENT" });
|
|
14
|
+
const target = path.resolve(input);
|
|
15
|
+
const ok = roots.some((root) => target === root || target.startsWith(root + path.sep));
|
|
16
|
+
if (!ok) throw Object.assign(new Error("Path outside allowed workspace"), { code:"PATH_DENIED" });
|
|
17
|
+
return target;
|
|
18
|
+
}
|
|
19
|
+
return { authenticate, resolveAllowed, audit, roots };
|
|
20
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
const execFileAsync=promisify(execFile);
|
|
6
|
+
|
|
7
|
+
export function createToolExecutor({ security, processManager }) {
|
|
8
|
+
return async function executeTool(tool,args={}) {
|
|
9
|
+
security.audit({event:"tool.request",tool});
|
|
10
|
+
if(tool==="device.ping") return {pong:true,timestamp:new Date().toISOString()};
|
|
11
|
+
if(tool==="fs.list"){const target=security.resolveAllowed(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"}))};}
|
|
12
|
+
if(tool==="fs.read"){const target=security.resolveAllowed(args.path);const max=Math.min(Number(args.maxBytes||65536),262144);const h=await fs.open(target,"r");try{const stat=await h.stat();const b=Buffer.alloc(max);const {bytesRead}=await h.read(b,0,max,0);return{path:target,content:b.subarray(0,bytesRead).toString("utf8"),truncated:stat.size>bytesRead,size:stat.size};}finally{await h.close();}}
|
|
13
|
+
if(tool==="fs.search"){const root=security.resolveAllowed(args.path);const q=String(args.query||"").toLowerCase();if(!q)throw Object.assign(new Error("query is required"),{code:"INVALID_ARGUMENT"});const entries=await fs.readdir(root,{withFileTypes:true});return{path:root,query:args.query,matches:entries.filter(e=>e.name.toLowerCase().includes(q)).slice(0,Math.min(Number(args.maxResults||50),200)).map(e=>({name:e.name,path:path.join(root,e.name),type:e.isDirectory()?"directory":"file"}))};}
|
|
14
|
+
if(tool==="process.start"){const cwd=security.resolveAllowed(args.cwd);return processManager.start({...args,cwd});}
|
|
15
|
+
if(tool==="process.output")return processManager.output(args);
|
|
16
|
+
if(tool==="process.stop")return processManager.stop(args);
|
|
17
|
+
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};}
|
|
18
|
+
throw Object.assign(new Error("Unsupported tool"),{code:"TOOL_NOT_FOUND"});
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
const command=process.argv[2]||"remote";
|
|
9
|
+
if(command!=="remote"){console.error("Usage: whyvix-remote remote");process.exit(2);}
|
|
10
|
+
const gatewayUrl=process.env.WHYVIX_GATEWAY_URL||"wss://remote.whyvix.com";
|
|
11
|
+
const authUrl=(process.env.WHYVIX_AUTH_URL||"https://auth.whyvix.com").replace(/\/$/,"");
|
|
12
|
+
const workspaces=(process.env.WHYVIX_WORKSPACES||process.cwd()).split(";").filter(Boolean);
|
|
13
|
+
const deviceId=process.env.WHYVIX_DEVICE_ID||crypto.createHash("sha256").update(os.hostname()+"|"+os.userInfo().username).digest("hex").slice(0,24);
|
|
14
|
+
const credentialDir=join(os.homedir(),".whyvix-remote");
|
|
15
|
+
const credentialPath=join(credentialDir,"device.json");
|
|
16
|
+
|
|
17
|
+
async function loadCredential(){
|
|
18
|
+
if(process.env.WHYVIX_DEVICE_CREDENTIAL)return process.env.WHYVIX_DEVICE_CREDENTIAL;
|
|
19
|
+
try{const saved=JSON.parse(await readFile(credentialPath,"utf8"));return saved.deviceId===deviceId?saved.credential:"";}catch{return "";}
|
|
20
|
+
}
|
|
21
|
+
async function saveCredential(credential){
|
|
22
|
+
await mkdir(credentialDir,{recursive:true,mode:0o700});
|
|
23
|
+
await writeFile(credentialPath,JSON.stringify({deviceId,credential}),{encoding:"utf8",mode:0o600});
|
|
24
|
+
}
|
|
25
|
+
async function post(path,fields){
|
|
26
|
+
const response=await fetch(authUrl+path,{method:"POST",headers:{"content-type":"application/x-www-form-urlencoded"},body:new URLSearchParams(fields),redirect:"error"});
|
|
27
|
+
return{status:response.status,body:await response.json()};
|
|
28
|
+
}
|
|
29
|
+
async function pair(){
|
|
30
|
+
const started=await post("/device/pair/start",{device_id:deviceId,device_name:os.hostname()});
|
|
31
|
+
if(started.status!==201)throw new Error("Unable to start device pairing");
|
|
32
|
+
console.log("Open this URL to authorize the device:");
|
|
33
|
+
console.log(started.body.verification_uri);
|
|
34
|
+
while(Date.now()<started.body.expires_at){
|
|
35
|
+
await new Promise(r=>setTimeout(r,2000));
|
|
36
|
+
const result=await post("/device/pair/status",{pairing_id:started.body.pairing_id,pairing_secret:started.body.pairing_secret});
|
|
37
|
+
if(result.status===200&&result.body.status==="approved"){await saveCredential(result.body.device_credential);return result.body.device_credential;}
|
|
38
|
+
}
|
|
39
|
+
throw new Error("Device pairing expired");
|
|
40
|
+
}
|
|
41
|
+
let credential=await loadCredential();
|
|
42
|
+
if(!credential){
|
|
43
|
+
console.log("WHYVIX Remote device authorization required.");
|
|
44
|
+
credential=await pair();
|
|
45
|
+
console.log("Device authorized.");
|
|
46
|
+
}
|
|
47
|
+
console.log("WHYVIX Remote");
|
|
48
|
+
console.log("Device :",os.hostname());
|
|
49
|
+
console.log("Gateway:",gatewayUrl);
|
|
50
|
+
console.log("Workspace:",workspaces.join(", "));
|
|
51
|
+
const agent=createAgent({gatewayUrl,deviceId,deviceName:os.hostname(),token:credential,workspaces,audit:(e)=>console.log("[audit]",JSON.stringify(e))});
|
|
52
|
+
async function shutdown(){await agent.stop();process.exit(0);}
|
|
53
|
+
process.on("SIGINT",shutdown);
|
|
54
|
+
process.on("SIGTERM",shutdown);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export const PROTOCOL_VERSION = 1;
|
|
2
|
+
|
|
3
|
+
export const TYPES = Object.freeze({
|
|
4
|
+
REGISTER: "register",
|
|
5
|
+
REGISTERED: "registered",
|
|
6
|
+
REQUEST: "request",
|
|
7
|
+
RESPONSE: "response",
|
|
8
|
+
ERROR: "error",
|
|
9
|
+
HEARTBEAT: "heartbeat"
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export function envelope(type, fields = {}) {
|
|
13
|
+
return { version: PROTOCOL_VERSION, type, ...fields };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function parseMessage(raw) {
|
|
17
|
+
const message = JSON.parse(raw.toString());
|
|
18
|
+
if (message.version !== PROTOCOL_VERSION) {
|
|
19
|
+
throw new Error("Unsupported protocol version");
|
|
20
|
+
}
|
|
21
|
+
return message;
|
|
22
|
+
}
|