@vo1d7bin/remote-mcp-agent 0.1.0 → 0.3.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 +66 -42
- package/bin/cli-ux.mjs +70 -0
- package/bin/management.mjs +104 -0
- package/bin/vo1d-agent.mjs +218 -112
- package/package.json +32 -32
package/README.md
CHANGED
|
@@ -1,42 +1,66 @@
|
|
|
1
|
-
# vo1d Remote MCP device agent
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
###
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
1
|
+
# vo1d Remote MCP device agent
|
|
2
|
+
|
|
3
|
+
Connect Windows, Linux or macOS to your account at **https://mcp.thelinuxserver.cloud**. Independent of Desktop Commander.
|
|
4
|
+
|
|
5
|
+
Node.js 22.14+ required.
|
|
6
|
+
|
|
7
|
+
## Connect in one command
|
|
8
|
+
|
|
9
|
+
```powershell
|
|
10
|
+
npx -y @vo1d7bin/remote-mcp-agent@latest remote
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
First run opens a browser to approve your device through your existing Google account. Compare the verification code in the browser and terminal, then approve. The agent stores its credentials under your user profile and connects automatically. Subsequent runs restore that authorization; you do not need to pair every time.
|
|
14
|
+
|
|
15
|
+
A compact connection screen confirms the device name, account, unique device ID and connection status, with a short reminder to keep the terminal open.
|
|
16
|
+
|
|
17
|
+
### Commands
|
|
18
|
+
|
|
19
|
+
| Command | Purpose |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| `remote` | Connect, or authorize in browser the first time |
|
|
22
|
+
| `status` | Check the server-recorded online/offline state, account and device ID |
|
|
23
|
+
| `logout` | Revoke this device in the server and remove saved local credentials |
|
|
24
|
+
| `open` | Open the dashboard in your browser |
|
|
25
|
+
| `help` / `--help` | Show available commands and examples |
|
|
26
|
+
| `--version` | Display the agent version |
|
|
27
|
+
| `enroll` | Manual code-based pairing for advanced/headless setups |
|
|
28
|
+
|
|
29
|
+
Use the same `npx -y @vo1d7bin/remote-mcp-agent@latest` prefix for each command. For example:
|
|
30
|
+
|
|
31
|
+
```powershell
|
|
32
|
+
npx -y @vo1d7bin/remote-mcp-agent@latest status
|
|
33
|
+
npx -y @vo1d7bin/remote-mcp-agent@latest open
|
|
34
|
+
npx -y @vo1d7bin/remote-mcp-agent@latest logout
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`remote --help` and `remote --logout` are accepted as convenience aliases.
|
|
38
|
+
|
|
39
|
+
**Logout revokes the device credential on the server**, disconnecting a running agent. For safety, if the relay is unreachable, the CLI preserves local credentials and tells you how to revoke through the dashboard. `logout --local` is a last-resort local cleanup only: it does **not** revoke server authorization, and you must also revoke the device in the dashboard.
|
|
40
|
+
|
|
41
|
+
## Share a specific folder (optional)
|
|
42
|
+
|
|
43
|
+
By default, the agent exposes basic system information only. To allow a client to list names in a safe directory:
|
|
44
|
+
|
|
45
|
+
```powershell
|
|
46
|
+
mkdir "$HOME\mcp-share" -Force
|
|
47
|
+
npx -y @vo1d7bin/remote-mcp-agent@latest remote --allow-root "$HOME\mcp-share"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Never use a secrets directory or the entire system drive as an allowed root. File contents, file modifications and terminal commands are **not** enabled in this version. The public MCP endpoint also remains gated until the end-to-end OAuth integration is ready.
|
|
51
|
+
|
|
52
|
+
The process needs to keep running to remain online. Closing the terminal makes the device offline but does not log it out.
|
|
53
|
+
|
|
54
|
+
## Advanced options
|
|
55
|
+
|
|
56
|
+
- `--server URL`: use a custom HTTPS server (local HTTP development is allowed only for localhost)
|
|
57
|
+
- `--name NAME`: set a name during first-time authorization
|
|
58
|
+
- `--no-browser`: print the verification link for another computer instead of attempting to open a local browser
|
|
59
|
+
- `--allow-root PATH`: allow directory listing inside the specified safe path
|
|
60
|
+
- `VO1D_ALLOWED_ROOTS`: additional allowed paths separated by the system path delimiter
|
|
61
|
+
- `VO1D_CONFIG_DIR`: isolate a second authorized device profile
|
|
62
|
+
- `NO_COLOR=1`: suppress terminal ANSI colors
|
|
63
|
+
|
|
64
|
+
Future installers can run the same background agent without requiring users to install Node themselves.
|
|
65
|
+
|
|
66
|
+
Source: https://github.com/THE-L1NUX-USER/vo1d-remote-mcp
|
package/bin/cli-ux.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const colors=Boolean(process.stdout.isTTY && !process.env.NO_COLOR && process.env.TERM!=='dumb');
|
|
2
|
+
const tint=(s,c)=>colors?'['+c+'m'+s+'[0m':s;
|
|
3
|
+
const cyan=s=>tint(s,'96'), green=s=>tint(s,'92'), dim=s=>tint(s,'90');
|
|
4
|
+
export const version='0.3.0';
|
|
5
|
+
export function banner() {
|
|
6
|
+
console.log('');
|
|
7
|
+
console.log(cyan(' __ __ ___ _ ____'));
|
|
8
|
+
console.log(cyan(' \\ \\ / / / _ \\ / | | _ \\'));
|
|
9
|
+
console.log(cyan(' \\ V / | (_) || | | | | |'));
|
|
10
|
+
console.log(cyan(' \\_/ \\___/ |_| |____/'));
|
|
11
|
+
console.log('');
|
|
12
|
+
console.log(' '+cyan('REMOTE MCP')+dim(' · v'+version));
|
|
13
|
+
console.log(' '+dim('Your machines. Your control.'));
|
|
14
|
+
console.log('');
|
|
15
|
+
}
|
|
16
|
+
export function heading(title) {console.log(cyan('◆ '+title));}
|
|
17
|
+
export function info(label,value){console.log(' '+dim(label.padEnd(15))+String(value||'—'));}
|
|
18
|
+
export function success(message){console.log(green(' ✓ '+message));}
|
|
19
|
+
export function note(message){console.log(dim(' '+message));}
|
|
20
|
+
export function divider(){console.log(dim(' ───────────────────────────────────────────────────'));}
|
|
21
|
+
export function help() {
|
|
22
|
+
banner();
|
|
23
|
+
heading('Commands');
|
|
24
|
+
console.log(' remote Connect this device (default; browser approval on first run)');
|
|
25
|
+
console.log(' status Show this device, account and current relay status');
|
|
26
|
+
console.log(' logout Revoke device authorization and delete local credentials');
|
|
27
|
+
console.log(' open Open the online device dashboard in your browser');
|
|
28
|
+
console.log(' enroll Manual pairing code for headless or advanced setups');
|
|
29
|
+
console.log(' help Show these commands');
|
|
30
|
+
console.log(' --version Show CLI version');
|
|
31
|
+
console.log('');
|
|
32
|
+
heading('Useful options');
|
|
33
|
+
console.log(' remote --allow-root PATH Share directory names from an approved folder');
|
|
34
|
+
console.log(' remote --no-browser Print approval link instead of opening it');
|
|
35
|
+
console.log(' remote --server URL Use a custom vo1d server');
|
|
36
|
+
console.log(' logout --local Forget credentials without remote revocation');
|
|
37
|
+
console.log(' --name DEVICE_NAME Choose name when first pairing');
|
|
38
|
+
console.log('');
|
|
39
|
+
heading('Examples');
|
|
40
|
+
console.log(' npx -y @vo1d7bin/remote-mcp-agent@latest remote');
|
|
41
|
+
console.log(' npx -y @vo1d7bin/remote-mcp-agent@latest remote --allow-root ./mcp-share');
|
|
42
|
+
console.log(' npx -y @vo1d7bin/remote-mcp-agent@latest status');
|
|
43
|
+
console.log(' npx -y @vo1d7bin/remote-mcp-agent@latest logout');
|
|
44
|
+
console.log('');
|
|
45
|
+
note('Tip: keep the terminal open while you want this device online.');
|
|
46
|
+
note('Only system information is shared unless you specify an allowed folder.');
|
|
47
|
+
}
|
|
48
|
+
export function ready(details) {
|
|
49
|
+
console.log('');
|
|
50
|
+
divider();
|
|
51
|
+
success('Secure connection established');
|
|
52
|
+
success('Device is online');
|
|
53
|
+
console.log('');
|
|
54
|
+
info('Device',details.name);
|
|
55
|
+
info('Account',details.account||'Your vo1d workspace');
|
|
56
|
+
info('Device ID',details.id);
|
|
57
|
+
info('Server',details.server);
|
|
58
|
+
info('Status','Online');
|
|
59
|
+
info('Allowed roots',details.roots?.length?details.roots.join(' ; '):'None (system info only)');
|
|
60
|
+
console.log('');
|
|
61
|
+
heading('Next');
|
|
62
|
+
note('Return to your MCP-compatible assistant when the connection is enabled.');
|
|
63
|
+
note('Keep this terminal open, or minimize it.');
|
|
64
|
+
note('Press Ctrl+C to disconnect (not to log out).');
|
|
65
|
+
console.log('');
|
|
66
|
+
heading('Manage this device (in another terminal)');
|
|
67
|
+
note('Status: npx -y @vo1d7bin/remote-mcp-agent@latest status');
|
|
68
|
+
note('Log out: npx -y @vo1d7bin/remote-mcp-agent@latest logout');
|
|
69
|
+
console.log('');
|
|
70
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { banner, heading, info, success, note } from './cli-ux.mjs';
|
|
6
|
+
|
|
7
|
+
const profile = process.env.VO1D_CONFIG_DIR
|
|
8
|
+
? path.resolve(process.env.VO1D_CONFIG_DIR)
|
|
9
|
+
: path.join(os.homedir(), '.vo1d-remote-mcp');
|
|
10
|
+
const file=path.join(profile,'device.json');
|
|
11
|
+
const publicServer=process.env.VO1D_SERVER || 'https://mcp.thelinuxserver.cloud';
|
|
12
|
+
|
|
13
|
+
async function readProfile(){
|
|
14
|
+
let cfg;
|
|
15
|
+
try {cfg=JSON.parse(await fs.readFile(file,'utf8'));}
|
|
16
|
+
catch(error) {
|
|
17
|
+
if(error.code==='ENOENT')return null;
|
|
18
|
+
throw new Error('Unable to read device credentials: '+error.message);
|
|
19
|
+
}
|
|
20
|
+
if(typeof cfg.id!=='string' || typeof cfg.token!=='string' || cfg.token.length<30 ||
|
|
21
|
+
typeof cfg.server!=='string')throw new Error('Saved device credentials appear corrupted.');
|
|
22
|
+
const server=new URL(cfg.server);
|
|
23
|
+
if(server.protocol!=='https:' &&
|
|
24
|
+
!(server.protocol==='http:' && ['localhost','127.0.0.1'].includes(server.hostname)))
|
|
25
|
+
throw new Error('Saved profile has an invalid server address.');
|
|
26
|
+
return cfg;
|
|
27
|
+
}
|
|
28
|
+
async function call(cfg,endpoint,method='GET') {
|
|
29
|
+
const url=new URL(endpoint,cfg.server).href;
|
|
30
|
+
const response=await fetch(url,{method,headers:{Authorization:'Bearer '+cfg.token},signal:AbortSignal.timeout(12000)});
|
|
31
|
+
if(response.status===401)return {invalid:true};
|
|
32
|
+
if(!response.ok)throw new Error('Server responded HTTP '+response.status);
|
|
33
|
+
return response.json();
|
|
34
|
+
}
|
|
35
|
+
export async function showStatus(){
|
|
36
|
+
banner();
|
|
37
|
+
heading('Device status');
|
|
38
|
+
const cfg=await readProfile();
|
|
39
|
+
if(!cfg){
|
|
40
|
+
note('No saved device authorization for this profile.');
|
|
41
|
+
note('Start with: npx -y @vo1d7bin/remote-mcp-agent@latest remote');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
info('Device ID',cfg.id);
|
|
45
|
+
info('Server',cfg.server);
|
|
46
|
+
try {
|
|
47
|
+
const remote=await call(cfg,'/api/agent/me');
|
|
48
|
+
if(remote.invalid){
|
|
49
|
+
info('Status','Authorization invalid or revoked');
|
|
50
|
+
note('Use logout --local to clear this profile, then remote to reauthorize.');
|
|
51
|
+
process.exitCode=1;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
info('Device',remote.name);
|
|
55
|
+
info('Account',remote.account || 'vo1d account');
|
|
56
|
+
info('Status',remote.online?'Online':'Offline');
|
|
57
|
+
if(!remote.online)note('Run remote to bring this device online.');
|
|
58
|
+
}catch(error){
|
|
59
|
+
info('Status','Unknown (server unavailable)');
|
|
60
|
+
note(error.message);
|
|
61
|
+
process.exitCode=1;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export async function logOut(){
|
|
65
|
+
banner();
|
|
66
|
+
heading('Log out this device');
|
|
67
|
+
const cfg=await readProfile();
|
|
68
|
+
if(!cfg){note('Already logged out. No device credentials found.');return;}
|
|
69
|
+
if(process.argv.includes('--local')){
|
|
70
|
+
await fs.unlink(file);
|
|
71
|
+
note('Local credentials deleted, but server authorization was NOT revoked.');
|
|
72
|
+
note('Revoke this device in the online dashboard if it is still authorized.');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const result=await call(cfg,'/api/agent/revoke-self','POST');
|
|
77
|
+
if(!result.revoked && !result.invalid)throw new Error('Server did not confirm revocation');
|
|
78
|
+
}catch(error){
|
|
79
|
+
console.error('Could not revoke device: '+error.message);
|
|
80
|
+
note('Credentials have been kept so you can retry safely.');
|
|
81
|
+
note('Alternatively, revoke from the dashboard. --local only deletes locally.');
|
|
82
|
+
process.exitCode=1;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
await fs.unlink(file);
|
|
86
|
+
success('Server authorization revoked');
|
|
87
|
+
success('Local device credentials removed');
|
|
88
|
+
note('Run remote whenever you want to authorize this machine again.');
|
|
89
|
+
}
|
|
90
|
+
export async function openDashboard(){
|
|
91
|
+
banner();
|
|
92
|
+
const cfg=await readProfile();
|
|
93
|
+
const origin=cfg?.server || publicServer;
|
|
94
|
+
const url=new URL(origin);
|
|
95
|
+
if(url.protocol!=='https:' && !(url.protocol==='http:'&&['localhost','127.0.0.1'].includes(url.hostname)))
|
|
96
|
+
throw new Error('Dashboard URL must be HTTPS');
|
|
97
|
+
const address=url.origin+'/dashboard';
|
|
98
|
+
console.log('Dashboard:',address);
|
|
99
|
+
if(process.argv.includes('--no-browser') || process.env.VO1D_NO_BROWSER==='true')return;
|
|
100
|
+
const command=process.platform==='win32'?'explorer.exe':process.platform==='darwin'?'open':'xdg-open';
|
|
101
|
+
const child=spawn(command,[address],{stdio:'ignore',detached:true,windowsHide:true});
|
|
102
|
+
child.on('error',()=>note('Could not open browser; use the URL above.'));
|
|
103
|
+
child.unref();
|
|
104
|
+
}
|
package/bin/vo1d-agent.mjs
CHANGED
|
@@ -1,112 +1,218 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import WebSocket from 'ws';
|
|
3
|
-
import os from 'node:os';
|
|
4
|
-
import { promises as fs } from 'node:fs';
|
|
5
|
-
import path from 'node:path';
|
|
6
|
-
import readline from 'node:readline/promises';
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if
|
|
111
|
-
|
|
112
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import WebSocket from 'ws';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { promises as fs } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import readline from 'node:readline/promises';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
9
|
+
import { banner, heading, info, success, note, divider, help, ready, version } from './cli-ux.mjs';
|
|
10
|
+
import { showStatus, logOut, openDashboard } from './management.mjs';
|
|
11
|
+
|
|
12
|
+
const folder = process.env.VO1D_CONFIG_DIR
|
|
13
|
+
? path.resolve(process.env.VO1D_CONFIG_DIR)
|
|
14
|
+
: path.join(os.homedir(), '.vo1d-remote-mcp');
|
|
15
|
+
const configFile = path.join(folder, 'device.json');
|
|
16
|
+
const arg = name => {
|
|
17
|
+
const i = process.argv.indexOf(name);
|
|
18
|
+
return i < 0 ? undefined : process.argv[i + 1];
|
|
19
|
+
};
|
|
20
|
+
function normalizedServer(raw) {
|
|
21
|
+
const url = new URL(raw);
|
|
22
|
+
if (url.protocol !== 'https:' &&
|
|
23
|
+
!(url.protocol === 'http:' && ['127.0.0.1','localhost'].includes(url.hostname))) {
|
|
24
|
+
throw new Error('HTTPS is required outside localhost');
|
|
25
|
+
}
|
|
26
|
+
if (url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
|
|
27
|
+
throw new Error('Expected a server origin, not a URL with a path or credentials');
|
|
28
|
+
}
|
|
29
|
+
return url.origin;
|
|
30
|
+
}
|
|
31
|
+
function defaultServer() {
|
|
32
|
+
return normalizedServer(arg('--server') || process.env.VO1D_SERVER || 'https://mcp.thelinuxserver.cloud');
|
|
33
|
+
}
|
|
34
|
+
async function saveDevice(cfg) {
|
|
35
|
+
await fs.mkdir(folder, {recursive:true, mode:0o700});
|
|
36
|
+
try {
|
|
37
|
+
await fs.writeFile(configFile, JSON.stringify(cfg,null,2), {mode:0o600,flag:'wx'});
|
|
38
|
+
} catch(error) {
|
|
39
|
+
if(error.code==='EEXIST')throw new Error('Device already enrolled in this profile; use remote to reconnect');
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function legacyEnroll() {
|
|
44
|
+
const server=defaultServer();
|
|
45
|
+
const rl=readline.createInterface({input:process.stdin,output:process.stdout});
|
|
46
|
+
let code;
|
|
47
|
+
try {code=(await rl.question('Paste the one-time pairing code: ')).trim();}
|
|
48
|
+
finally {rl.close();}
|
|
49
|
+
const reply=await fetch(server+'/api/agent/enroll',{
|
|
50
|
+
method:'POST',headers:{'content-type':'application/json'},
|
|
51
|
+
body:JSON.stringify({code,name:arg('--name')||os.hostname(),platform:process.platform})
|
|
52
|
+
});
|
|
53
|
+
if(!reply.ok)throw new Error('Device pairing failed (HTTP '+reply.status+')');
|
|
54
|
+
const {id,token}=await reply.json();
|
|
55
|
+
await saveDevice({server,id,token});
|
|
56
|
+
console.log('Paired successfully. Device ID:',id);
|
|
57
|
+
console.log('To connect, run: vo1d-agent remote');
|
|
58
|
+
}
|
|
59
|
+
function openBrowser(address) {
|
|
60
|
+
if (process.argv.includes('--no-browser') || process.env.VO1D_NO_BROWSER==='true') return false;
|
|
61
|
+
const program=process.platform==='win32'?'explorer.exe':process.platform==='darwin'?'open':'xdg-open';
|
|
62
|
+
try {
|
|
63
|
+
const child=spawn(program,[address],{stdio:'ignore',detached:true,windowsHide:true});
|
|
64
|
+
child.on('error',()=>{}); // Link is always printed for environments without a browser.
|
|
65
|
+
child.unref();
|
|
66
|
+
return true;
|
|
67
|
+
} catch {return false;}
|
|
68
|
+
}
|
|
69
|
+
async function browserEnroll(server) {
|
|
70
|
+
const response=await fetch(server+'/api/agent/authorization/start',{
|
|
71
|
+
method:'POST',headers:{'content-type':'application/json'},
|
|
72
|
+
body:JSON.stringify({name:arg('--name')||os.hostname(),platform:process.platform})
|
|
73
|
+
});
|
|
74
|
+
if(!response.ok)throw new Error('Could not start browser authorization (HTTP '+response.status+')');
|
|
75
|
+
const data=await response.json();
|
|
76
|
+
if(typeof data.requestId!=='string' || typeof data.pollToken!=='string')throw new Error('Unexpected authorization response');
|
|
77
|
+
const link=new URL(data.verificationUrl);
|
|
78
|
+
if(link.origin!==server || link.pathname!=='/device/authorize')throw new Error('Unexpected authorization destination');
|
|
79
|
+
console.log('\nFirst-time connection: approve this device in your web browser.');
|
|
80
|
+
console.log('Verification code:',data.userCode);
|
|
81
|
+
console.log('Open:',link.href);
|
|
82
|
+
openBrowser(link.href);
|
|
83
|
+
console.log('Waiting for approval (expires in 10 minutes). Press Ctrl+C to cancel.\n');
|
|
84
|
+
const deadline=Math.min(Number(data.expiresAt),Date.now()+10*60_000);
|
|
85
|
+
let failure=0;
|
|
86
|
+
while(Date.now()<deadline) {
|
|
87
|
+
await sleep(Math.max(2000,Number(data.intervalSeconds||3)*1000));
|
|
88
|
+
let poll;
|
|
89
|
+
try {
|
|
90
|
+
poll=await fetch(server+'/api/agent/authorization/poll',{
|
|
91
|
+
method:'POST',headers:{'content-type':'application/json'},
|
|
92
|
+
body:JSON.stringify({requestId:data.requestId,pollToken:data.pollToken}),
|
|
93
|
+
signal:AbortSignal.timeout(12000)
|
|
94
|
+
});
|
|
95
|
+
} catch(error) {
|
|
96
|
+
failure++;
|
|
97
|
+
if(failure>=5)throw new Error('Unable to reach the authorization server: '+error.message);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
failure=0;
|
|
101
|
+
const info=await poll.json().catch(()=>({}));
|
|
102
|
+
if(info.status==='pending')continue;
|
|
103
|
+
if(info.status==='approved' && typeof info.id==='string' && typeof info.token==='string') {
|
|
104
|
+
const cfg={server,id:info.id,token:info.token};
|
|
105
|
+
await saveDevice(cfg);
|
|
106
|
+
console.log('Device approved and credentials stored securely for future runs.');
|
|
107
|
+
return cfg;
|
|
108
|
+
}
|
|
109
|
+
if(info.status==='denied')throw new Error('This device authorization was denied');
|
|
110
|
+
if(info.status==='expired' || poll.status===410)throw new Error('Authorization expired; rerun the command');
|
|
111
|
+
throw new Error('Authorization failed (HTTP '+poll.status+'). Rerun the command.');
|
|
112
|
+
}
|
|
113
|
+
throw new Error('Device authorization expired; rerun the command');
|
|
114
|
+
}
|
|
115
|
+
async function loadOrEnroll() {
|
|
116
|
+
try {
|
|
117
|
+
const cfg=JSON.parse(await fs.readFile(configFile,'utf8'));
|
|
118
|
+
if(typeof cfg.id!=='string' || typeof cfg.token!=='string' || cfg.token.length<30) {
|
|
119
|
+
throw new Error('Stored device credentials appear corrupted');
|
|
120
|
+
}
|
|
121
|
+
return cfg;
|
|
122
|
+
} catch(error) {
|
|
123
|
+
if(error.code!=='ENOENT')throw error;
|
|
124
|
+
return browserEnroll(defaultServer());
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function allowedRoots() {
|
|
128
|
+
const provided=process.env.VO1D_ALLOWED_ROOTS || arg('--allow-root') || '';
|
|
129
|
+
const paths=provided.split(path.delimiter).map(x=>x.trim()).filter(Boolean);
|
|
130
|
+
const roots=[];
|
|
131
|
+
for(const p of paths)roots.push(await fs.realpath(path.resolve(p)));
|
|
132
|
+
return roots;
|
|
133
|
+
}
|
|
134
|
+
function includesRoot(p,root) {
|
|
135
|
+
const value=process.platform==='win32'?p.toLowerCase():p;
|
|
136
|
+
const base=process.platform==='win32'?root.toLowerCase():root;
|
|
137
|
+
return value===base || value.startsWith(base.endsWith(path.sep)?base:base+path.sep);
|
|
138
|
+
}
|
|
139
|
+
async function directoryListing(requestPath,roots) {
|
|
140
|
+
if(!roots.length)throw new Error('No allowed roots configured; set VO1D_ALLOWED_ROOTS');
|
|
141
|
+
if(typeof requestPath!=='string' || requestPath.length>2000)throw new Error('Invalid directory path');
|
|
142
|
+
const resolved=await fs.realpath(path.resolve(requestPath));
|
|
143
|
+
if(!roots.some(root=>includesRoot(resolved,root)))throw new Error('Path outside allowed directories');
|
|
144
|
+
const entries=await fs.readdir(resolved,{withFileTypes:true});
|
|
145
|
+
return {path:resolved,truncated:entries.length>100,items:entries.slice(0,100).map(entry=>({
|
|
146
|
+
name:entry.name,type:entry.isDirectory()?'directory':entry.isFile()?'file':'other'
|
|
147
|
+
}))};
|
|
148
|
+
}
|
|
149
|
+
async function remote() {
|
|
150
|
+
banner();
|
|
151
|
+
heading('Remote connection');
|
|
152
|
+
const roots=await allowedRoots();
|
|
153
|
+
const cfg=await loadOrEnroll();
|
|
154
|
+
const server=normalizedServer(cfg.server);
|
|
155
|
+
console.log('Device:',cfg.id,'| Allowed filesystem roots:',roots.length?roots.join(' ; '):'(none)');
|
|
156
|
+
const endpoint=server.replace(/^http/,'ws')+'/agent';
|
|
157
|
+
let stopped=false,retry=1000;
|
|
158
|
+
process.on('SIGINT',()=>{stopped=true;process.exit(0);});
|
|
159
|
+
async function connect() {
|
|
160
|
+
if(stopped)return;
|
|
161
|
+
const ws=new WebSocket(endpoint,{headers:{Authorization:'Bearer '+cfg.token},handshakeTimeout:12000});
|
|
162
|
+
ws.on('open',async()=>{
|
|
163
|
+
retry=1000;
|
|
164
|
+
console.log('Connected to',server);
|
|
165
|
+
let details={id:cfg.id,name:os.hostname(),account:null,server,roots};
|
|
166
|
+
try {
|
|
167
|
+
const response=await fetch(server+'/api/agent/me',{
|
|
168
|
+
headers:{Authorization:'Bearer '+cfg.token},
|
|
169
|
+
signal:AbortSignal.timeout(9000)
|
|
170
|
+
});
|
|
171
|
+
if(response.ok){
|
|
172
|
+
const remote=await response.json();
|
|
173
|
+
details={...details,name:remote.name||details.name,account:remote.account};
|
|
174
|
+
}
|
|
175
|
+
}catch{note('Account details unavailable; relay connection is still active.');}
|
|
176
|
+
if(ws.readyState===WebSocket.OPEN)ready(details);
|
|
177
|
+
});
|
|
178
|
+
ws.on('message',async raw=>{
|
|
179
|
+
let task;
|
|
180
|
+
try{task=JSON.parse(raw.toString());}catch{return;}
|
|
181
|
+
if(typeof task.id!=='string')return;
|
|
182
|
+
try {
|
|
183
|
+
let result;
|
|
184
|
+
if(task.action==='system_info'){
|
|
185
|
+
result={hostname:os.hostname(),platform:process.platform,arch:process.arch,
|
|
186
|
+
release:os.release(),cpus:os.cpus().length,memoryBytes:os.totalmem(),allowedRoots:roots};
|
|
187
|
+
}else if(task.action==='list_directory'){
|
|
188
|
+
result=await directoryListing(task.args?.path,roots);
|
|
189
|
+
}else throw new Error('Unsupported action');
|
|
190
|
+
if(ws.readyState===WebSocket.OPEN)ws.send(JSON.stringify({id:task.id,ok:true,result}));
|
|
191
|
+
}catch(error){
|
|
192
|
+
if(ws.readyState===WebSocket.OPEN)ws.send(JSON.stringify({
|
|
193
|
+
id:task.id,ok:false,error:String(error.message).slice(0,150)
|
|
194
|
+
}));
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
ws.on('error',error=>console.error('Connection:',error.message));
|
|
198
|
+
ws.on('close',code=>{
|
|
199
|
+
if(code===4000){console.log('Another session for this device has taken over. Exiting this agent.');stopped=true;return;}
|
|
200
|
+
if(code===4001){console.error('Device revoked. Stop this process and request a new authorization.');stopped=true;return;}
|
|
201
|
+
if(stopped)return;
|
|
202
|
+
const delay=retry;retry=Math.min(retry*2,30000);
|
|
203
|
+
console.log('Disconnected. Retrying in',delay/1000,'seconds.');
|
|
204
|
+
setTimeout(connect,delay);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
await connect();
|
|
208
|
+
}
|
|
209
|
+
const command=process.argv[2];
|
|
210
|
+
const run = fn => fn().catch(error=>{console.error('Error:',error.message);process.exitCode=1;});
|
|
211
|
+
if(command==='--version' || command==='-v' || command==='version') console.log('vo1d-agent v'+version);
|
|
212
|
+
else if(command==='--help' || command==='-h' || command==='help' || command==='remote' && process.argv.includes('--help')) help();
|
|
213
|
+
else if(command==='logout' || command==='remote' && process.argv.includes('--logout')) run(logOut);
|
|
214
|
+
else if(command==='status' || command==='whoami') run(showStatus);
|
|
215
|
+
else if(command==='open' || command==='dashboard') run(openDashboard);
|
|
216
|
+
else if(command==='enroll') run(legacyEnroll);
|
|
217
|
+
else if(!command || command==='remote') run(remote);
|
|
218
|
+
else {console.error('Unknown command:',command);help();process.exitCode=1;}
|
package/package.json
CHANGED
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@vo1d7bin/remote-mcp-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Independent vo1d Remote MCP device agent for Windows, Linux and macOS",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"license": "MIT",
|
|
7
|
-
"repository": {
|
|
8
|
-
"type": "git",
|
|
9
|
-
"url": "git+https://github.com/THE-L1NUX-USER/vo1d-remote-mcp.git",
|
|
10
|
-
"directory": "packages/agent"
|
|
11
|
-
},
|
|
12
|
-
"homepage": "https://mcp.thelinuxserver.cloud",
|
|
13
|
-
"bin": {
|
|
14
|
-
"vo1d-agent": "bin/vo1d-agent.mjs"
|
|
15
|
-
},
|
|
16
|
-
"files": [
|
|
17
|
-
"bin/",
|
|
18
|
-
"README.md"
|
|
19
|
-
],
|
|
20
|
-
"engines": {
|
|
21
|
-
"node": ">=22.14.0"
|
|
22
|
-
},
|
|
23
|
-
"dependencies": {
|
|
24
|
-
"ws": "8.18.3"
|
|
25
|
-
},
|
|
26
|
-
"scripts": {
|
|
27
|
-
"prepack": "node ../../scripts/sync-agent.mjs"
|
|
28
|
-
},
|
|
29
|
-
"publishConfig": {
|
|
30
|
-
"access": "public"
|
|
31
|
-
}
|
|
32
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@vo1d7bin/remote-mcp-agent",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Independent vo1d Remote MCP device agent for Windows, Linux and macOS",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/THE-L1NUX-USER/vo1d-remote-mcp.git",
|
|
10
|
+
"directory": "packages/agent"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://mcp.thelinuxserver.cloud",
|
|
13
|
+
"bin": {
|
|
14
|
+
"vo1d-agent": "bin/vo1d-agent.mjs"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"bin/",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=22.14.0"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"ws": "8.18.3"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"prepack": "node ../../scripts/sync-agent.mjs"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
}
|
|
32
|
+
}
|