@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 CHANGED
@@ -1,31 +1,36 @@
1
1
  # WHYVIX Remote
2
2
 
3
- WHYVIX Remote is an IDE-independent remote development bridge designed as a consumer ChatGPT Plugin/App product.
3
+ WHYVIX Remote adalah remote development bridge untuk menghubungkan ChatGPT dengan workstation yang telah diotorisasi melalui akun WHYVIX.
4
4
 
5
- ## Product direction — LOCKED
5
+ ## Status produk
6
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.
7
+ **WHYVIX Remote R9: GREEN / FROZEN**
8
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.
9
+ Baseline source: `bb3f46c` -> `feat: close R9 consumer lifecycle`
10
+ Distribusi consumer: `@whyvix/remote@0.1.1`
10
11
 
11
- ## Certified foundation
12
+ Handbook authority untuk R9 adalah:
12
13
 
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
+ ```text
15
+ docs/HANDBOOK_R9.md
16
+ ```
14
17
 
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.
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
- ## Local agent UX
20
+ ## Alur consumer
18
21
 
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
+ 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
- ## R9 direction
24
+ Runtime lokal dijalankan dengan:
24
25
 
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
+ ```powershell
27
+ npx @whyvix/remote@latest remote
28
+ ```
26
29
 
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.
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 rules
32
+ ## Engineering rule
30
33
 
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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whyvix/remote",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "files": [
@@ -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
- console.log("[agent:reconnect-scheduled]",reason,reconnectMs);
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
- 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");});
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) => path.resolve(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 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;
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);
@@ -45,10 +45,30 @@ if(!credential){
45
45
  console.log("Device authorized.");
46
46
  }
47
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);
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);