@whyvix/remote 0.1.0 → 0.2.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 +21 -16
- package/package.json +1 -1
- package/packages/agent/src/agent.js +7 -7
- package/packages/agent/src/security.js +37 -5
- package/packages/agent/src/tools.js +9 -0
- package/packages/cli/index.js +27 -7
package/README.md
CHANGED
|
@@ -1,31 +1,36 @@
|
|
|
1
1
|
# WHYVIX Remote
|
|
2
2
|
|
|
3
|
-
WHYVIX Remote
|
|
3
|
+
WHYVIX Remote adalah remote development bridge untuk menghubungkan ChatGPT dengan workstation yang telah diotorisasi melalui akun WHYVIX.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Status produk
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
**WHYVIX Remote R9: GREEN / FROZEN**
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Baseline source: `bb3f46c` -> `feat: close R9 consumer lifecycle`
|
|
10
|
+
Distribusi consumer: `@whyvix/remote@0.1.1`
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
Handbook authority untuk R9 adalah:
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
```text
|
|
15
|
+
docs/HANDBOOK_R9.md
|
|
16
|
+
```
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
Dokumen R9 sebelumnya telah dipindahkan ke `docs/retired-r9/` dan hanya berfungsi sebagai historical evidence. Status seperti `IN PROGRESS`, `PRE-STAGING`, atau readiness lama di dalam arsip tersebut bukan status production saat ini.
|
|
16
19
|
|
|
17
|
-
##
|
|
20
|
+
## Alur consumer
|
|
18
21
|
|
|
19
|
-
|
|
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
|
+
Consumer menggunakan WHYVIX Remote melalui ChatGPT Plugin/App dan OAuth WHYVIX. Pengguna tidak perlu mengatur custom MCP server, MCP URL, Developer Mode, bearer token manual, atau inbound port pada workstation.
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
Runtime lokal dijalankan dengan:
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
```powershell
|
|
27
|
+
npx @whyvix/remote@latest remote
|
|
28
|
+
```
|
|
26
29
|
|
|
27
|
-
|
|
30
|
+
CLI mempertahankan outbound WSS selama terminal tetap berjalan. Browser dapat ditutup setelah authorization. Credential yang masih valid digunakan kembali tanpa pairing ulang. `Ctrl+C` atau penghentian proses CLI berarti runtime lokal berhenti.
|
|
28
31
|
|
|
29
|
-
## Engineering
|
|
32
|
+
## Engineering rule
|
|
30
33
|
|
|
31
|
-
|
|
34
|
+
Baseline GREEN/FROZEN tidak boleh diubah tanpa scope, audit, focused test, regression yang relevan, acceptance, dan commit baru. Jangan menghidupkan kembali legacy transport atau mengubah identity/authorization boundary sebagai shortcut troubleshooting.
|
|
35
|
+
|
|
36
|
+
Untuk arsitektur, lifecycle, production VPS, acceptance evidence, rollback policy, dan prosedur audit, baca `docs/HANDBOOK_R9.md`.
|
package/package.json
CHANGED
|
@@ -6,14 +6,14 @@ import { createSecurity } from "./security.js";
|
|
|
6
6
|
import { ProcessManager } from "./process-manager.js";
|
|
7
7
|
import { createToolExecutor } from "./tools.js";
|
|
8
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=()=>{}}={}) {
|
|
9
|
+
export function createAgent({gatewayUrl="ws://127.0.0.1:8787",deviceId=randomUUID(),deviceName=os.hostname(),token="",workspaces=[process.cwd()],reconnectMs=1000,reconnect=true,audit=()=>{},log=console.log,errorLog=console.error,onRegistered=()=>{}}={}) {
|
|
10
10
|
let ws=null,reconnectTimer=null,stopped=false,generation=0;
|
|
11
11
|
const security=createSecurity({token,workspaces,audit});
|
|
12
12
|
const processManager=new ProcessManager({audit});
|
|
13
13
|
const executeTool=createToolExecutor({security,processManager});
|
|
14
14
|
function scheduleReconnect(reason){
|
|
15
15
|
if(stopped||!reconnect||reconnectTimer)return;
|
|
16
|
-
|
|
16
|
+
log("[agent:reconnect-scheduled]",reason,reconnectMs);
|
|
17
17
|
reconnectTimer=setTimeout(()=>{reconnectTimer=null;connect();},reconnectMs);
|
|
18
18
|
}
|
|
19
19
|
function connect(){
|
|
@@ -21,11 +21,11 @@ export function createAgent({gatewayUrl="ws://127.0.0.1:8787",deviceId=randomUUI
|
|
|
21
21
|
clearTimeout(reconnectTimer); reconnectTimer=null;
|
|
22
22
|
const current=++generation;
|
|
23
23
|
const socket=new WebSocket(gatewayUrl); ws=socket;
|
|
24
|
-
|
|
25
|
-
socket.on("open",()=>{if(ws!==socket)return;
|
|
26
|
-
socket.on("message",async(raw)=>{if(ws!==socket)return;try{const m=parseMessage(raw);if(m.type===TYPES.REGISTERED){
|
|
27
|
-
socket.on("close",(code)=>{
|
|
28
|
-
socket.on("error",(e)=>{
|
|
24
|
+
log("[agent:connecting]",gatewayUrl,"generation="+current);
|
|
25
|
+
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:"0.3.0"}})));});
|
|
26
|
+
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;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){errorLog("[agent:protocol-error]",error.message);}});
|
|
27
|
+
socket.on("close",(code)=>{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");});
|
|
28
|
+
socket.on("error",(e)=>{errorLog("[agent:socket-error]",e.message);scheduleReconnect("error");});
|
|
29
29
|
}
|
|
30
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
31
|
connect(); return{deviceId,deviceName,stop};
|
|
@@ -1,8 +1,26 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import crypto from "node:crypto";
|
|
3
4
|
|
|
5
|
+
function denied() {
|
|
6
|
+
return Object.assign(new Error("Path outside allowed workspace"), { code:"PATH_DENIED" });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function canonicalExisting(input) {
|
|
10
|
+
try { return fs.realpathSync.native(path.resolve(input)); }
|
|
11
|
+
catch (error) {
|
|
12
|
+
if (error?.code === "ENOENT") return null;
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function inside(root, target) {
|
|
18
|
+
const relative = path.relative(root, target);
|
|
19
|
+
return relative === "" || (!relative.startsWith(".."+path.sep) && relative !== ".." && !path.isAbsolute(relative));
|
|
20
|
+
}
|
|
21
|
+
|
|
4
22
|
export function createSecurity({ token, workspaces = [], audit = () => {} } = {}) {
|
|
5
|
-
const roots = workspaces.map((p) =>
|
|
23
|
+
const roots = workspaces.map((p) => canonicalExisting(p)).filter(Boolean);
|
|
6
24
|
function authenticate(candidate) {
|
|
7
25
|
if (!token || !candidate) return false;
|
|
8
26
|
const a = Buffer.from(String(token));
|
|
@@ -11,10 +29,24 @@ export function createSecurity({ token, workspaces = [], audit = () => {} } = {}
|
|
|
11
29
|
}
|
|
12
30
|
function resolveAllowed(input) {
|
|
13
31
|
if (typeof input !== "string" || !input.trim()) throw Object.assign(new Error("path is required"), { code:"INVALID_ARGUMENT" });
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
if (
|
|
17
|
-
|
|
32
|
+
const requested = path.resolve(input);
|
|
33
|
+
const target = canonicalExisting(requested);
|
|
34
|
+
if (target) {
|
|
35
|
+
if (!roots.some((root) => inside(root, target))) throw denied();
|
|
36
|
+
return target;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let ancestor = path.dirname(requested);
|
|
40
|
+
while (ancestor !== path.dirname(ancestor)) {
|
|
41
|
+
const canonicalAncestor = canonicalExisting(ancestor);
|
|
42
|
+
if (canonicalAncestor) {
|
|
43
|
+
if (!roots.some((root) => inside(root, canonicalAncestor))) throw denied();
|
|
44
|
+
const suffix = path.relative(ancestor, requested);
|
|
45
|
+
return path.resolve(canonicalAncestor, suffix);
|
|
46
|
+
}
|
|
47
|
+
ancestor = path.dirname(ancestor);
|
|
48
|
+
}
|
|
49
|
+
throw denied();
|
|
18
50
|
}
|
|
19
51
|
return { authenticate, resolveAllowed, audit, roots };
|
|
20
52
|
}
|
|
@@ -11,6 +11,15 @@ export function createToolExecutor({ security, processManager }) {
|
|
|
11
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
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
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==="fs.create_text"){
|
|
15
|
+
const target=security.resolveAllowed(args.path);
|
|
16
|
+
const content=String(args.content??"");
|
|
17
|
+
const bytes=Buffer.byteLength(content,"utf8");
|
|
18
|
+
if(bytes>262144)throw Object.assign(new Error("content exceeds 262144 UTF-8 bytes"),{code:"CONTENT_TOO_LARGE"});
|
|
19
|
+
try{await fs.writeFile(target,content,{encoding:"utf8",flag:"wx"});}
|
|
20
|
+
catch(error){if(error?.code==="EEXIST")throw Object.assign(new Error("Target already exists"),{code:"FILE_EXISTS"});throw error;}
|
|
21
|
+
return{path:target,bytes};
|
|
22
|
+
}
|
|
14
23
|
if(tool==="process.start"){const cwd=security.resolveAllowed(args.cwd);return processManager.start({...args,cwd});}
|
|
15
24
|
if(tool==="process.output")return processManager.output(args);
|
|
16
25
|
if(tool==="process.stop")return processManager.stop(args);
|
package/packages/cli/index.js
CHANGED
|
@@ -45,10 +45,30 @@ if(!credential){
|
|
|
45
45
|
console.log("Device authorized.");
|
|
46
46
|
}
|
|
47
47
|
console.log("WHYVIX Remote");
|
|
48
|
-
console.log("
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
48
|
+
console.log("");
|
|
49
|
+
const agent=createAgent({
|
|
50
|
+
gatewayUrl,
|
|
51
|
+
deviceId,
|
|
52
|
+
deviceName:os.hostname(),
|
|
53
|
+
token:credential,
|
|
54
|
+
workspaces,
|
|
55
|
+
audit:()=>{},
|
|
56
|
+
log:()=>{},
|
|
57
|
+
onRegistered:({authorizedAccount})=>{
|
|
58
|
+
console.log("Device :",os.hostname());
|
|
59
|
+
console.log("Status : Connected");
|
|
60
|
+
console.log("Authorized :",authorizedAccount||"-");
|
|
61
|
+
console.log("");
|
|
62
|
+
console.log("Keep this terminal open while using WHYVIX Remote.");
|
|
63
|
+
console.log("Press Ctrl+C to disconnect.");
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
let shuttingDown=false;
|
|
67
|
+
async function shutdown(){
|
|
68
|
+
if(shuttingDown)return;
|
|
69
|
+
shuttingDown=true;
|
|
70
|
+
await agent.stop();
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
process.once("SIGINT",shutdown);
|
|
74
|
+
process.once("SIGTERM",shutdown);
|