@remcp/remcp 0.1.3 → 0.1.4
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 +54 -105
- package/package.json +12 -52
- package/src/agent.mjs +19 -24
- package/src/cli.mjs +42 -19
- package/src/runtime.mjs +15 -0
- package/src/version.mjs +0 -2
- package/THIRD_PARTY_NOTICES.md +0 -7
- package/assets/remcp-icon.png +0 -0
- package/mcp.json +0 -9
- package/plugin.json +0 -37
- package/public/android-chrome-192x192.png +0 -0
- package/public/android-chrome-512x512.png +0 -0
- package/public/app.js +0 -96
- package/public/apple-touch-icon.png +0 -0
- package/public/assets/art/hero-relay.webp +0 -0
- package/public/assets/art/relay-detail.webp +0 -0
- package/public/assets/icon-120.png +0 -0
- package/public/assets/icon-16.png +0 -0
- package/public/assets/icon-180.png +0 -0
- package/public/assets/icon-192.png +0 -0
- package/public/assets/icon-256.png +0 -0
- package/public/assets/icon-32.png +0 -0
- package/public/assets/icon-48.png +0 -0
- package/public/assets/icon-512.png +0 -0
- package/public/assets/og-remcp.png +0 -0
- package/public/authorize.html +0 -59
- package/public/copy.js +0 -40
- package/public/docs.html +0 -93
- package/public/favicon-16x16.png +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/favicon-48x48.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/index.html +0 -134
- package/public/oauth-app.js +0 -63
- package/public/privacy.html +0 -80
- package/public/remcp-darkmode-16x16.png +0 -0
- package/public/remcp-darkmode-192x192.png +0 -0
- package/public/remcp-darkmode-32x32.png +0 -0
- package/public/remcp-darkmode-48x48.png +0 -0
- package/public/remcp-darkmode-512x512.png +0 -0
- package/public/security.html +0 -70
- package/public/site.webmanifest +0 -23
- package/public/style.css +0 -261
- package/public/support.html +0 -75
- package/public/terms.html +0 -77
- package/skills/remcp-operator/SKILL.md +0 -36
- package/src/auth.mjs +0 -71
- package/src/config.mjs +0 -51
- package/src/db.mjs +0 -102
- package/src/mcp.mjs +0 -95
- package/src/oauth.mjs +0 -164
- package/src/relay.mjs +0 -97
- package/src/review-sandbox.mjs +0 -96
- package/src/server.mjs +0 -159
- package/src/tool-catalog.json +0 -796
- package/src/util.mjs +0 -24
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: remcp-operator
|
|
3
|
-
description: Safely operate computers paired through ReMCP. Use when the user asks to inspect or change files, directories, local processes, terminal sessions, or ReMCP device state on one of their paired computers.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# ReMCP Operator
|
|
7
|
-
|
|
8
|
-
Use ReMCP only when the request actually needs a paired computer. Do not invoke ReMCP for general knowledge, writing, weather, web research, or conceptual questions that can be answered without the user's device.
|
|
9
|
-
|
|
10
|
-
## Device selection
|
|
11
|
-
|
|
12
|
-
1. Call `list_devices` before the first device operation unless a current ReMCP device id is already unambiguous in the conversation.
|
|
13
|
-
2. Prefer an online device whose user-facing name or hostname matches the request.
|
|
14
|
-
3. If more than one online device plausibly matches and choosing the wrong machine could change state, ask the user which device to use.
|
|
15
|
-
|
|
16
|
-
## Read before write
|
|
17
|
-
|
|
18
|
-
Inspect the smallest amount of device state needed to understand the request before changing it. Prefer `list_directory`, `get_file_info`, `read_file`, `list_processes`, or existing process output before a write or terminal mutation.
|
|
19
|
-
## Changes
|
|
20
|
-
|
|
21
|
-
- Use the narrowest tool that performs the requested change. Prefer `edit_block`, `write_file`, or `move_file` over a shell command when the dedicated tool expresses the action clearly.
|
|
22
|
-
- Do not broaden a requested path, command, or target without a reason tied to the user's task.
|
|
23
|
-
- Treat `set_config_value`, file replacement, process termination, and generic terminal commands as state-changing actions.
|
|
24
|
-
- Generic terminal commands may also affect external services. Keep commands scoped to the user's stated goal and surface consequential effects before execution when they are not already clear from the request.
|
|
25
|
-
|
|
26
|
-
## Verification
|
|
27
|
-
|
|
28
|
-
After a change, use the cheapest relevant read to verify the result. Examples: re-read the edited range, list the destination directory after a move, or read process output after starting a command.
|
|
29
|
-
|
|
30
|
-
## Long-running processes
|
|
31
|
-
|
|
32
|
-
Use `start_process` once and then `read_process_output` or `interact_with_process` for the existing session. Avoid starting duplicate long-running processes just to obtain new output.
|
|
33
|
-
|
|
34
|
-
## Out-of-scope requests
|
|
35
|
-
|
|
36
|
-
Do not use ReMCP to access a computer the user has not paired or is not authorized to control. Do not ask for or process passwords, MFA codes, private keys, API keys, payment-card data, protected health information, government identifiers, or other restricted credentials/data through ReMCP tools. If a requested file or command would expose those categories, ask the user to use a safer local workflow instead.
|
package/src/auth.mjs
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
import { createRemoteJWKSet, jwtVerify, SignJWT } from 'jose';
|
|
2
|
-
import { config } from './config.mjs';
|
|
3
|
-
import { bearer } from './util.mjs';
|
|
4
|
-
import { upsertUser } from './db.mjs';
|
|
5
|
-
|
|
6
|
-
const firebaseJwks = createRemoteJWKSet(new URL('https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com'));
|
|
7
|
-
const signingKey = new TextEncoder().encode(config.tokenSecret);
|
|
8
|
-
|
|
9
|
-
export async function verifyFirebaseToken(token) {
|
|
10
|
-
const { payload } = await jwtVerify(token, firebaseJwks, {
|
|
11
|
-
issuer: `https://securetoken.google.com/${config.firebaseProjectId}`,
|
|
12
|
-
audience: config.firebaseProjectId,
|
|
13
|
-
});
|
|
14
|
-
if (!payload.sub) throw new Error('Firebase token has no subject');
|
|
15
|
-
const email = typeof payload.email === 'string' ? payload.email.toLowerCase() : '';
|
|
16
|
-
const uid = config.ownerEmail && email === config.ownerEmail ? `owner:${config.ownerEmail}` : payload.sub;
|
|
17
|
-
return {
|
|
18
|
-
uid,
|
|
19
|
-
firebaseUid: payload.sub,
|
|
20
|
-
email,
|
|
21
|
-
emailVerified: payload.email_verified === true,
|
|
22
|
-
name: typeof payload.name === 'string' ? payload.name : '',
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export async function requireFirebase(req, res, next) {
|
|
27
|
-
try {
|
|
28
|
-
const token = bearer(req);
|
|
29
|
-
if (!token) return res.status(401).json({ error: 'firebase_auth_required' });
|
|
30
|
-
const user = await verifyFirebaseToken(token);
|
|
31
|
-
upsertUser(user);
|
|
32
|
-
req.user = user;
|
|
33
|
-
next();
|
|
34
|
-
} catch (error) {
|
|
35
|
-
res.status(401).json({ error: 'invalid_firebase_token', message: error.message });
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export async function signMcpAccessToken({ uid, clientId, scope }) {
|
|
40
|
-
return new SignJWT({ scope, client_id: clientId, typ: 'remcp_access' })
|
|
41
|
-
.setProtectedHeader({ alg: 'HS256' })
|
|
42
|
-
.setIssuer(config.publicUrl)
|
|
43
|
-
.setAudience(`${config.publicUrl}/mcp`)
|
|
44
|
-
.setSubject(uid)
|
|
45
|
-
.setIssuedAt()
|
|
46
|
-
.setExpirationTime(`${config.accessTtlSeconds}s`)
|
|
47
|
-
.sign(signingKey);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export async function verifyMcpAccessToken(token) {
|
|
51
|
-
const { payload } = await jwtVerify(token, signingKey, {
|
|
52
|
-
issuer: config.publicUrl,
|
|
53
|
-
audience: `${config.publicUrl}/mcp`,
|
|
54
|
-
});
|
|
55
|
-
if (payload.typ !== 'remcp_access' || !payload.sub) throw new Error('Invalid ReMCP access token');
|
|
56
|
-
return { uid: payload.sub, scope: String(payload.scope || ''), clientId: String(payload.client_id || '') };
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export async function requireMcpAuth(req, res, next) {
|
|
60
|
-
try {
|
|
61
|
-
const token = bearer(req);
|
|
62
|
-
if (!token) throw new Error('missing_token');
|
|
63
|
-
const auth = await verifyMcpAccessToken(token);
|
|
64
|
-
if (!auth.scope.split(/\s+/).includes('remcp:control')) throw new Error('missing_scope');
|
|
65
|
-
req.mcpAuth = auth;
|
|
66
|
-
next();
|
|
67
|
-
} catch {
|
|
68
|
-
res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${config.publicUrl}/.well-known/oauth-protected-resource/mcp"`);
|
|
69
|
-
res.status(401).json({ error: 'unauthorized' });
|
|
70
|
-
}
|
|
71
|
-
}
|
package/src/config.mjs
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import process from 'node:process';
|
|
2
|
-
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { randomBytes } from 'node:crypto';
|
|
5
|
-
|
|
6
|
-
function required(name, fallback = '') {
|
|
7
|
-
const value = process.env[name] || fallback;
|
|
8
|
-
if (!value) throw new Error(`${name} is required`);
|
|
9
|
-
return value;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const publicUrl = required('PUBLIC_URL', 'http://127.0.0.1:8787').replace(/\/$/, '');
|
|
13
|
-
const dataDir = process.env.DATA_DIR || './data';
|
|
14
|
-
function loadSigningSecret() {
|
|
15
|
-
const configured = process.env.TOKEN_SECRET || '';
|
|
16
|
-
if (configured) { if (configured.length < 32) throw new Error('TOKEN_SECRET must be at least 32 characters'); return configured; }
|
|
17
|
-
mkdirSync(dataDir, { recursive: true });
|
|
18
|
-
const file = join(dataDir, 'token-secret');
|
|
19
|
-
try { return readFileSync(file, 'utf8').trim(); } catch {}
|
|
20
|
-
const generated = randomBytes(48).toString('base64url');
|
|
21
|
-
writeFileSync(file, generated + '\n', { mode: 0o600 });
|
|
22
|
-
return generated;
|
|
23
|
-
}
|
|
24
|
-
const tokenSecret = loadSigningSecret();
|
|
25
|
-
const installSpec = (process.env.REMCP_INSTALL_SPEC || '@remcp/remcp@latest').trim();
|
|
26
|
-
if (!/^[A-Za-z0-9@._:/#-]+$/.test(installSpec)) throw new Error('REMCP_INSTALL_SPEC contains unsupported characters');
|
|
27
|
-
|
|
28
|
-
export const config = Object.freeze({
|
|
29
|
-
publicUrl,
|
|
30
|
-
port: Number(process.env.PORT || 8787),
|
|
31
|
-
dataDir,
|
|
32
|
-
tokenSecret,
|
|
33
|
-
installSpec,
|
|
34
|
-
firebaseProjectId: required('FIREBASE_PROJECT_ID', 'remcp-33037'),
|
|
35
|
-
ownerEmail: (process.env.OWNER_EMAIL || '').trim().toLowerCase(),
|
|
36
|
-
pairTtlSeconds: Number(process.env.PAIR_TTL_SECONDS || 600),
|
|
37
|
-
accessTtlSeconds: Number(process.env.ACCESS_TTL_SECONDS || 3600),
|
|
38
|
-
refreshTtlSeconds: Number(process.env.REFRESH_TTL_SECONDS || 2592000),
|
|
39
|
-
rpcTimeoutMs: Number(process.env.RPC_TIMEOUT_MS || 120000),
|
|
40
|
-
domainVerificationToken: (process.env.OPENAI_DOMAIN_VERIFICATION_TOKEN || '').trim(),
|
|
41
|
-
domainVerificationPath: (process.env.OPENAI_DOMAIN_VERIFICATION_PATH || '/.well-known/openai-apps-challenge').trim(),
|
|
42
|
-
firebase: {
|
|
43
|
-
apiKey: required('FIREBASE_API_KEY'),
|
|
44
|
-
authDomain: required('FIREBASE_AUTH_DOMAIN'),
|
|
45
|
-
projectId: required('FIREBASE_PROJECT_ID', 'remcp-33037'),
|
|
46
|
-
storageBucket: process.env.FIREBASE_STORAGE_BUCKET || '',
|
|
47
|
-
messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID || '',
|
|
48
|
-
appId: required('FIREBASE_APP_ID'),
|
|
49
|
-
measurementId: process.env.FIREBASE_MEASUREMENT_ID || '',
|
|
50
|
-
},
|
|
51
|
-
});
|
package/src/db.mjs
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
import { mkdirSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
-
import { config } from './config.mjs';
|
|
5
|
-
import { now } from './util.mjs';
|
|
6
|
-
|
|
7
|
-
mkdirSync(config.dataDir, { recursive: true });
|
|
8
|
-
export const db = new DatabaseSync(join(config.dataDir, 'remcp.sqlite'));
|
|
9
|
-
db.exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;`);
|
|
10
|
-
db.exec(`
|
|
11
|
-
CREATE TABLE IF NOT EXISTS users (
|
|
12
|
-
uid TEXT PRIMARY KEY, email TEXT, name TEXT, email_verified INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, last_login_at INTEGER NOT NULL
|
|
13
|
-
);
|
|
14
|
-
CREATE TABLE IF NOT EXISTS pair_codes (
|
|
15
|
-
code_hash TEXT PRIMARY KEY, uid TEXT NOT NULL REFERENCES users(uid) ON DELETE CASCADE,
|
|
16
|
-
expires_at INTEGER NOT NULL, used_at INTEGER
|
|
17
|
-
);
|
|
18
|
-
CREATE TABLE IF NOT EXISTS devices (
|
|
19
|
-
id TEXT PRIMARY KEY, uid TEXT NOT NULL REFERENCES users(uid) ON DELETE CASCADE,
|
|
20
|
-
name TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, hostname TEXT, platform TEXT, arch TEXT,
|
|
21
|
-
created_at INTEGER NOT NULL, last_seen_at INTEGER, revoked_at INTEGER
|
|
22
|
-
);
|
|
23
|
-
CREATE INDEX IF NOT EXISTS devices_uid_idx ON devices(uid);
|
|
24
|
-
CREATE TABLE IF NOT EXISTS oauth_clients (
|
|
25
|
-
client_id TEXT PRIMARY KEY, client_name TEXT, redirect_uris TEXT NOT NULL, created_at INTEGER NOT NULL
|
|
26
|
-
);
|
|
27
|
-
CREATE TABLE IF NOT EXISTS oauth_codes (
|
|
28
|
-
code_hash TEXT PRIMARY KEY, client_id TEXT NOT NULL, uid TEXT NOT NULL,
|
|
29
|
-
redirect_uri TEXT NOT NULL, code_challenge TEXT NOT NULL, scope TEXT NOT NULL,
|
|
30
|
-
expires_at INTEGER NOT NULL, used_at INTEGER
|
|
31
|
-
);
|
|
32
|
-
CREATE TABLE IF NOT EXISTS oauth_refresh_tokens (
|
|
33
|
-
token_hash TEXT PRIMARY KEY, client_id TEXT NOT NULL, uid TEXT NOT NULL,
|
|
34
|
-
scope TEXT NOT NULL, expires_at INTEGER NOT NULL, revoked_at INTEGER
|
|
35
|
-
);
|
|
36
|
-
`);
|
|
37
|
-
try { db.exec('ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0'); } catch {}
|
|
38
|
-
|
|
39
|
-
export const getUser = uid => db.prepare('SELECT * FROM users WHERE uid=?').get(uid);
|
|
40
|
-
|
|
41
|
-
export function upsertUser({ uid, email = '', name = '', emailVerified = false }) {
|
|
42
|
-
const t = now();
|
|
43
|
-
db.prepare(`INSERT INTO users(uid,email,name,email_verified,created_at,last_login_at) VALUES(?,?,?,?,?,?)
|
|
44
|
-
ON CONFLICT(uid) DO UPDATE SET email=excluded.email,name=excluded.name,email_verified=excluded.email_verified,last_login_at=excluded.last_login_at`)
|
|
45
|
-
.run(uid, email, name, emailVerified ? 1 : 0, t, t);
|
|
46
|
-
return db.prepare('SELECT * FROM users WHERE uid=?').get(uid);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function createPairCode(uid, codeHash, expiresAt) {
|
|
50
|
-
db.prepare('DELETE FROM pair_codes WHERE expires_at < ? OR used_at IS NOT NULL').run(now());
|
|
51
|
-
db.prepare('INSERT INTO pair_codes(code_hash,uid,expires_at) VALUES(?,?,?)').run(codeHash, uid, expiresAt);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function consumePairCode(codeHash) {
|
|
55
|
-
db.exec('BEGIN IMMEDIATE');
|
|
56
|
-
try {
|
|
57
|
-
const row = db.prepare('SELECT * FROM pair_codes WHERE code_hash=? AND used_at IS NULL AND expires_at>?').get(codeHash, now());
|
|
58
|
-
if (row) db.prepare('UPDATE pair_codes SET used_at=? WHERE code_hash=?').run(now(), codeHash);
|
|
59
|
-
db.exec('COMMIT');
|
|
60
|
-
return row;
|
|
61
|
-
} catch (error) { db.exec('ROLLBACK'); throw error; }
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function createDevice(device) {
|
|
65
|
-
db.prepare(`INSERT INTO devices(id,uid,name,token_hash,hostname,platform,arch,created_at,last_seen_at)
|
|
66
|
-
VALUES(?,?,?,?,?,?,?,?,?)`).run(device.id, device.uid, device.name, device.tokenHash, device.hostname || '', device.platform || '', device.arch || '', now(), now());
|
|
67
|
-
return getDevice(device.id);
|
|
68
|
-
}
|
|
69
|
-
export const getDevice = id => db.prepare('SELECT * FROM devices WHERE id=?').get(id);
|
|
70
|
-
export const getDeviceByTokenHash = hash => db.prepare('SELECT * FROM devices WHERE token_hash=? AND revoked_at IS NULL').get(hash);
|
|
71
|
-
export const listDevices = uid => db.prepare('SELECT id,name,hostname,platform,arch,created_at,last_seen_at,revoked_at FROM devices WHERE uid=? ORDER BY created_at DESC').all(uid);
|
|
72
|
-
export function touchDevice(id, meta = {}) {
|
|
73
|
-
db.prepare(`UPDATE devices SET name=COALESCE(?,name),hostname=COALESCE(?,hostname),platform=COALESCE(?,platform),arch=COALESCE(?,arch),last_seen_at=? WHERE id=?`)
|
|
74
|
-
.run(meta.name ?? null, meta.hostname ?? null, meta.platform ?? null, meta.arch ?? null, now(), id);
|
|
75
|
-
}
|
|
76
|
-
export function revokeDevice(uid, id) { return db.prepare('UPDATE devices SET revoked_at=? WHERE uid=? AND id=?').run(now(), uid, id); }
|
|
77
|
-
|
|
78
|
-
export function saveOauthClient({ clientId, clientName = 'MCP client', redirectUris }) {
|
|
79
|
-
db.prepare('INSERT OR REPLACE INTO oauth_clients(client_id,client_name,redirect_uris,created_at) VALUES(?,?,?,?)')
|
|
80
|
-
.run(clientId, clientName, JSON.stringify(redirectUris), now());
|
|
81
|
-
}
|
|
82
|
-
export const getOauthClient = clientId => db.prepare('SELECT * FROM oauth_clients WHERE client_id=?').get(clientId);
|
|
83
|
-
|
|
84
|
-
export function createOauthCode(row) {
|
|
85
|
-
db.prepare(`INSERT INTO oauth_codes(code_hash,client_id,uid,redirect_uri,code_challenge,scope,expires_at) VALUES(?,?,?,?,?,?,?)`)
|
|
86
|
-
.run(row.codeHash, row.clientId, row.uid, row.redirectUri, row.codeChallenge, row.scope, row.expiresAt);
|
|
87
|
-
}
|
|
88
|
-
export function consumeOauthCode(codeHash) {
|
|
89
|
-
db.exec('BEGIN IMMEDIATE');
|
|
90
|
-
try {
|
|
91
|
-
const row = db.prepare('SELECT * FROM oauth_codes WHERE code_hash=? AND used_at IS NULL AND expires_at>?').get(codeHash, now());
|
|
92
|
-
if (row) db.prepare('UPDATE oauth_codes SET used_at=? WHERE code_hash=?').run(now(), codeHash);
|
|
93
|
-
db.exec('COMMIT');
|
|
94
|
-
return row;
|
|
95
|
-
} catch (error) { db.exec('ROLLBACK'); throw error; }
|
|
96
|
-
}
|
|
97
|
-
export function saveRefreshToken(row) {
|
|
98
|
-
db.prepare('INSERT INTO oauth_refresh_tokens(token_hash,client_id,uid,scope,expires_at) VALUES(?,?,?,?,?)')
|
|
99
|
-
.run(row.tokenHash, row.clientId, row.uid, row.scope, row.expiresAt);
|
|
100
|
-
}
|
|
101
|
-
export const getRefreshToken = hash => db.prepare('SELECT * FROM oauth_refresh_tokens WHERE token_hash=? AND revoked_at IS NULL AND expires_at>?').get(hash, now());
|
|
102
|
-
export const revokeRefreshToken = hash => db.prepare('UPDATE oauth_refresh_tokens SET revoked_at=? WHERE token_hash=?').run(now(), hash);
|
package/src/mcp.mjs
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
-
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
|
-
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
-
import { VERSION } from './version.mjs';
|
|
5
|
-
|
|
6
|
-
const rawCatalog = JSON.parse(readFileSync(new URL('./tool-catalog.json', import.meta.url), 'utf8'));
|
|
7
|
-
const destructive = new Set(['set_config_value','write_file','write_pdf','move_file','edit_block','start_process','interact_with_process','force_terminate','kill_process']);
|
|
8
|
-
const openWorld = new Set(['start_process','interact_with_process']);
|
|
9
|
-
const descriptions = {
|
|
10
|
-
get_config: 'Read the local device runtime configuration for the selected paired device.',
|
|
11
|
-
set_config_value: 'Change one supported local device runtime configuration value on the selected paired device.',
|
|
12
|
-
read_file: 'Read a local file or a user-supplied URL from the selected paired device.',
|
|
13
|
-
read_multiple_files: 'Read multiple local files from the selected paired device in one call.',
|
|
14
|
-
write_file: 'Create, replace, or append to a file on the selected paired device.',
|
|
15
|
-
write_pdf: 'Create a PDF or apply supported page edits to a PDF on the selected paired device.',
|
|
16
|
-
create_directory: 'Create a directory, including missing parent directories, on the selected paired device.',
|
|
17
|
-
list_directory: 'List files and directories at a path on the selected paired device.',
|
|
18
|
-
move_file: 'Move or rename a file or directory on the selected paired device.',
|
|
19
|
-
start_search: 'Start a local filename or content search session on the selected paired device.',
|
|
20
|
-
get_more_search_results: 'Read additional results from an existing local search session on the selected paired device.',
|
|
21
|
-
stop_search: 'Stop an active local search session on the selected paired device.',
|
|
22
|
-
list_searches: 'List active local search sessions on the selected paired device.',
|
|
23
|
-
get_file_info: 'Read file or directory metadata from the selected paired device.',
|
|
24
|
-
edit_block: 'Apply an exact targeted text or supported document edit on the selected paired device.',
|
|
25
|
-
start_process: 'Start a command or interactive process on the selected paired device. The command may change local or external state.',
|
|
26
|
-
read_process_output: 'Read buffered output from a running process on the selected paired device.',
|
|
27
|
-
interact_with_process: 'Send input to a running process on the selected paired device. Input may change local or external state.',
|
|
28
|
-
force_terminate: 'Force-stop a terminal session on the selected paired device.',
|
|
29
|
-
list_sessions: 'List active terminal sessions on the selected paired device.',
|
|
30
|
-
list_processes: 'List running operating-system processes on the selected paired device.',
|
|
31
|
-
kill_process: 'Terminate a process by PID on the selected paired device.',
|
|
32
|
-
get_usage_stats: 'Read local device runtime usage statistics from the selected paired device.',
|
|
33
|
-
get_recent_tool_calls: 'Read recent local device runtime tool-call history from the selected paired device.',
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
function publicDescription(tool) {
|
|
37
|
-
return String(descriptions[tool.name] || tool.description || '').replace(/\s+/g, ' ').trim();
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function publicTool(tool) {
|
|
41
|
-
const schema = structuredClone(tool.inputSchema || { type: 'object', properties: {} });
|
|
42
|
-
schema.type = 'object';
|
|
43
|
-
schema.properties ||= {};
|
|
44
|
-
schema.properties.device = {
|
|
45
|
-
type: 'string',
|
|
46
|
-
description: 'ReMCP device id returned by list_devices. Required for every device operation.',
|
|
47
|
-
};
|
|
48
|
-
schema.required = [...new Set([...(schema.required || []), 'device'])];
|
|
49
|
-
const upstream = tool.annotations || {};
|
|
50
|
-
return {
|
|
51
|
-
name: tool.name,
|
|
52
|
-
title: upstream.title || tool.name,
|
|
53
|
-
description: publicDescription(tool),
|
|
54
|
-
inputSchema: schema,
|
|
55
|
-
annotations: {
|
|
56
|
-
title: upstream.title || tool.name,
|
|
57
|
-
readOnlyHint: tool.name === 'start_search' ? false : upstream.readOnlyHint === true,
|
|
58
|
-
destructiveHint: destructive.has(tool.name),
|
|
59
|
-
idempotentHint: upstream.idempotentHint === true,
|
|
60
|
-
openWorldHint: openWorld.has(tool.name) || tool.name === 'read_file' || upstream.openWorldHint === true,
|
|
61
|
-
},
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const excludedPublicTools = new Set(['get_usage_stats','get_recent_tool_calls']);
|
|
66
|
-
const catalog = rawCatalog.filter(tool => !excludedPublicTools.has(tool.name)).map(publicTool);
|
|
67
|
-
const listDevicesTool = {
|
|
68
|
-
name: 'list_devices',
|
|
69
|
-
title: 'List ReMCP devices',
|
|
70
|
-
description: 'List computers paired to the signed-in ReMCP account and show whether each one is online.',
|
|
71
|
-
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
72
|
-
annotations: { title: 'List ReMCP devices', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
export function createMcpServer(uid, relay) {
|
|
76
|
-
const server = new Server({ name: 'remcp', version: VERSION }, { capabilities: { tools: {} } });
|
|
77
|
-
|
|
78
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [listDevicesTool, ...catalog] }));
|
|
79
|
-
server.setRequestHandler(CallToolRequestSchema, async request => {
|
|
80
|
-
const name = request.params.name;
|
|
81
|
-
if (name === 'list_devices') {
|
|
82
|
-
return { content: [{ type: 'text', text: JSON.stringify({ devices: relay.devicesFor(uid) }, null, 2) }] };
|
|
83
|
-
}
|
|
84
|
-
if (!catalog.some(tool => tool.name === name)) throw new Error(`Unknown tool: ${name}`);
|
|
85
|
-
const args = { ...(request.params.arguments || {}) };
|
|
86
|
-
const device = String(args.device || '');
|
|
87
|
-
if (!device) throw new Error('device is required');
|
|
88
|
-
delete args.device;
|
|
89
|
-
return relay.rpc(uid, device, 'tools/call', { name, arguments: args });
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
return server;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export const publicToolCatalog = [listDevicesTool, ...catalog];
|
package/src/oauth.mjs
DELETED
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
import crypto from 'node:crypto';
|
|
2
|
-
import express from 'express';
|
|
3
|
-
import { rateLimit } from 'express-rate-limit';
|
|
4
|
-
import { config } from './config.mjs';
|
|
5
|
-
import { verifyFirebaseToken, signMcpAccessToken, verifyMcpAccessToken } from './auth.mjs';
|
|
6
|
-
import { createOauthCode, consumeOauthCode, getOauthClient, getRefreshToken, getUser, revokeRefreshToken, saveOauthClient, saveRefreshToken, upsertUser } from './db.mjs';
|
|
7
|
-
import { bearer, normalizeScope, now, randomId, randomToken, sha256, validRedirectUri } from './util.mjs';
|
|
8
|
-
|
|
9
|
-
const CODE_TTL_MS = 5 * 60 * 1000;
|
|
10
|
-
const router = express.Router();
|
|
11
|
-
const oauthLimiter = rateLimit({ windowMs: 60_000, limit: 60, standardHeaders: 'draft-8', legacyHeaders: false });
|
|
12
|
-
const tokenLimiter = rateLimit({ windowMs: 60_000, limit: 30, standardHeaders: 'draft-8', legacyHeaders: false });
|
|
13
|
-
const endpoint = path => `${config.publicUrl}${path}`;
|
|
14
|
-
|
|
15
|
-
function metadata() {
|
|
16
|
-
return {
|
|
17
|
-
issuer: config.publicUrl,
|
|
18
|
-
authorization_endpoint: endpoint('/oauth/authorize'),
|
|
19
|
-
token_endpoint: endpoint('/oauth/token'),
|
|
20
|
-
registration_endpoint: endpoint('/oauth/register'),
|
|
21
|
-
userinfo_endpoint: endpoint('/oauth/userinfo'),
|
|
22
|
-
revocation_endpoint: endpoint('/oauth/revoke'),
|
|
23
|
-
response_types_supported: ['code'],
|
|
24
|
-
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
25
|
-
token_endpoint_auth_methods_supported: ['none'],
|
|
26
|
-
code_challenge_methods_supported: ['S256'],
|
|
27
|
-
scopes_supported: ['openid', 'email', 'remcp:control', 'offline_access'],
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function protectedResource() {
|
|
32
|
-
return {
|
|
33
|
-
resource: endpoint('/mcp'),
|
|
34
|
-
authorization_servers: [config.publicUrl],
|
|
35
|
-
bearer_methods_supported: ['header'],
|
|
36
|
-
scopes_supported: ['openid', 'email', 'remcp:control', 'offline_access'],
|
|
37
|
-
resource_documentation: config.publicUrl,
|
|
38
|
-
resource_policy_uri: endpoint('/privacy'),
|
|
39
|
-
resource_tos_uri: endpoint('/terms'),
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
router.get('/.well-known/oauth-authorization-server', (_req, res) => res.json(metadata()));
|
|
44
|
-
router.get('/oauth/.well-known/oauth-authorization-server', (_req, res) => res.json(metadata()));
|
|
45
|
-
router.get('/.well-known/oauth-protected-resource', (_req, res) => res.json(protectedResource()));
|
|
46
|
-
router.get('/.well-known/oauth-protected-resource/mcp', (_req, res) => res.json(protectedResource()));
|
|
47
|
-
|
|
48
|
-
router.post('/oauth/register', oauthLimiter, (req, res) => {
|
|
49
|
-
const redirectUris = Array.isArray(req.body?.redirect_uris) ? req.body.redirect_uris.map(String) : [];
|
|
50
|
-
if (!redirectUris.length || redirectUris.some(uri => !validRedirectUri(uri))) {
|
|
51
|
-
return res.status(400).json({ error: 'invalid_redirect_uri' });
|
|
52
|
-
}
|
|
53
|
-
if (req.body?.token_endpoint_auth_method && req.body.token_endpoint_auth_method !== 'none') {
|
|
54
|
-
return res.status(400).json({ error: 'invalid_client_metadata', error_description: 'ReMCP requires PKCE public clients (token_endpoint_auth_method=none).' });
|
|
55
|
-
}
|
|
56
|
-
const clientId = `remcp_${randomToken(18)}`;
|
|
57
|
-
saveOauthClient({ clientId, clientName: String(req.body?.client_name || 'MCP client').slice(0, 160), redirectUris });
|
|
58
|
-
res.status(201).json({
|
|
59
|
-
client_id: clientId,
|
|
60
|
-
client_name: String(req.body?.client_name || 'MCP client').slice(0, 160),
|
|
61
|
-
redirect_uris: redirectUris,
|
|
62
|
-
token_endpoint_auth_method: 'none',
|
|
63
|
-
grant_types: ['authorization_code', 'refresh_token'],
|
|
64
|
-
response_types: ['code'],
|
|
65
|
-
});
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
function validateAuthorize(values) {
|
|
69
|
-
const client = getOauthClient(String(values.client_id || ''));
|
|
70
|
-
if (!client) throw new Error('unknown_client');
|
|
71
|
-
const redirectUri = String(values.redirect_uri || '');
|
|
72
|
-
const allowed = JSON.parse(client.redirect_uris);
|
|
73
|
-
if (!allowed.includes(redirectUri)) throw new Error('invalid_redirect_uri');
|
|
74
|
-
if (values.response_type !== 'code') throw new Error('unsupported_response_type');
|
|
75
|
-
if (values.code_challenge_method !== 'S256' || !values.code_challenge) throw new Error('pkce_s256_required');
|
|
76
|
-
if (values.resource && values.resource !== endpoint('/mcp')) throw new Error('invalid_resource');
|
|
77
|
-
return { client, redirectUri };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
router.get('/oauth/authorize', (req, res, next) => {
|
|
81
|
-
try { validateAuthorize(req.query); res.sendFile('authorize.html', { root: new URL('../public', import.meta.url).pathname }); }
|
|
82
|
-
catch (error) { res.status(400).send(`OAuth request rejected: ${error.message}`); }
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
router.post('/oauth/authorize/complete', oauthLimiter, async (req, res) => {
|
|
86
|
-
try {
|
|
87
|
-
const { redirectUri } = validateAuthorize(req.body || {});
|
|
88
|
-
const user = await verifyFirebaseToken(String(req.body?.id_token || ''));
|
|
89
|
-
upsertUser(user);
|
|
90
|
-
const rawCode = randomToken(32);
|
|
91
|
-
createOauthCode({
|
|
92
|
-
codeHash: sha256(rawCode), clientId: String(req.body.client_id), uid: user.uid,
|
|
93
|
-
redirectUri, codeChallenge: String(req.body.code_challenge),
|
|
94
|
-
scope: normalizeScope(req.body.scope), expiresAt: now() + CODE_TTL_MS,
|
|
95
|
-
});
|
|
96
|
-
const callback = new URL(redirectUri);
|
|
97
|
-
callback.searchParams.set('code', rawCode);
|
|
98
|
-
if (req.body.state) callback.searchParams.set('state', String(req.body.state));
|
|
99
|
-
callback.searchParams.set('iss', config.publicUrl);
|
|
100
|
-
res.json({ redirect_to: callback.toString() });
|
|
101
|
-
} catch (error) {
|
|
102
|
-
res.status(400).json({ error: 'access_denied', error_description: error.message });
|
|
103
|
-
}
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
function noStore(res) { res.setHeader('Cache-Control', 'no-store'); res.setHeader('Pragma', 'no-cache'); }
|
|
107
|
-
function pkce(verifier) { return crypto.createHash('sha256').update(verifier).digest('base64url'); }
|
|
108
|
-
|
|
109
|
-
router.post('/oauth/token', tokenLimiter, async (req, res) => {
|
|
110
|
-
noStore(res);
|
|
111
|
-
try {
|
|
112
|
-
const grant = String(req.body?.grant_type || '');
|
|
113
|
-
if (grant === 'authorization_code') {
|
|
114
|
-
const row = consumeOauthCode(sha256(String(req.body.code || '')));
|
|
115
|
-
if (!row) throw new Error('invalid_or_expired_code');
|
|
116
|
-
if (row.client_id !== String(req.body.client_id || '') || row.redirect_uri !== String(req.body.redirect_uri || '')) throw new Error('client_or_redirect_mismatch');
|
|
117
|
-
if (pkce(String(req.body.code_verifier || '')) !== row.code_challenge) throw new Error('pkce_verification_failed');
|
|
118
|
-
const accessToken = await signMcpAccessToken({ uid: row.uid, clientId: row.client_id, scope: row.scope });
|
|
119
|
-
const refreshToken = randomToken(48);
|
|
120
|
-
saveRefreshToken({ tokenHash: sha256(refreshToken), clientId: row.client_id, uid: row.uid, scope: row.scope, expiresAt: now() + config.refreshTtlSeconds * 1000 });
|
|
121
|
-
return res.json({ access_token: accessToken, token_type: 'Bearer', expires_in: config.accessTtlSeconds, refresh_token: refreshToken, scope: row.scope });
|
|
122
|
-
}
|
|
123
|
-
if (grant === 'refresh_token') {
|
|
124
|
-
const oldHash = sha256(String(req.body.refresh_token || ''));
|
|
125
|
-
const row = getRefreshToken(oldHash);
|
|
126
|
-
if (!row || row.client_id !== String(req.body.client_id || '')) throw new Error('invalid_refresh_token');
|
|
127
|
-
revokeRefreshToken(oldHash);
|
|
128
|
-
const scope = normalizeScope(req.body.scope || row.scope);
|
|
129
|
-
const accessToken = await signMcpAccessToken({ uid: row.uid, clientId: row.client_id, scope });
|
|
130
|
-
const refreshToken = randomToken(48);
|
|
131
|
-
saveRefreshToken({ tokenHash: sha256(refreshToken), clientId: row.client_id, uid: row.uid, scope, expiresAt: now() + config.refreshTtlSeconds * 1000 });
|
|
132
|
-
return res.json({ access_token: accessToken, token_type: 'Bearer', expires_in: config.accessTtlSeconds, refresh_token: refreshToken, scope });
|
|
133
|
-
}
|
|
134
|
-
res.status(400).json({ error: 'unsupported_grant_type' });
|
|
135
|
-
} catch (error) {
|
|
136
|
-
res.status(400).json({ error: 'invalid_grant', error_description: error.message });
|
|
137
|
-
}
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
router.get('/oauth/userinfo', oauthLimiter, async (req, res) => {
|
|
142
|
-
noStore(res);
|
|
143
|
-
try {
|
|
144
|
-
const token = bearer(req);
|
|
145
|
-
if (!token) throw new Error('missing_token');
|
|
146
|
-
const auth = await verifyMcpAccessToken(token);
|
|
147
|
-
const scopes = new Set(auth.scope.split(/\s+/).filter(Boolean));
|
|
148
|
-
if (!scopes.has('openid') || !scopes.has('email')) throw new Error('insufficient_scope');
|
|
149
|
-
const user = getUser(auth.uid);
|
|
150
|
-
if (!user?.email || user.email_verified !== 1) throw new Error('verified_email_required');
|
|
151
|
-
res.json({ sub: auth.uid, email: user.email, email_verified: true, ...(user.name ? { name: user.name } : {}) });
|
|
152
|
-
} catch (error) {
|
|
153
|
-
res.setHeader('WWW-Authenticate', 'Bearer error="invalid_token"');
|
|
154
|
-
res.status(401).json({ error: 'invalid_token', error_description: error.message });
|
|
155
|
-
}
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
router.post('/oauth/revoke', oauthLimiter, (req, res) => {
|
|
159
|
-
const token = String(req.body?.token || '');
|
|
160
|
-
if (token) revokeRefreshToken(sha256(token));
|
|
161
|
-
res.status(200).end();
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
export const oauthRouter = router;
|
package/src/relay.mjs
DELETED
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
import { WebSocketServer } from 'ws';
|
|
2
|
-
import { config } from './config.mjs';
|
|
3
|
-
import { getDeviceByTokenHash, listDevices, touchDevice } from './db.mjs';
|
|
4
|
-
import { sha256 } from './util.mjs';
|
|
5
|
-
import { REVIEW_SANDBOX_ID, reviewSandboxCall, reviewSandboxDevice } from './review-sandbox.mjs';
|
|
6
|
-
|
|
7
|
-
export function createRelay(httpServer) {
|
|
8
|
-
const wss = new WebSocketServer({ noServer: true });
|
|
9
|
-
const agents = new Map();
|
|
10
|
-
const pending = new Map();
|
|
11
|
-
let seq = 0;
|
|
12
|
-
|
|
13
|
-
httpServer.on('upgrade', (req, socket, head) => {
|
|
14
|
-
const url = new URL(req.url || '/', 'http://localhost');
|
|
15
|
-
if (url.pathname !== '/agent') return socket.destroy();
|
|
16
|
-
const raw = req.headers.authorization || '';
|
|
17
|
-
const token = raw.startsWith('Bearer ') ? raw.slice(7).trim() : url.searchParams.get('token') || '';
|
|
18
|
-
const device = token ? getDeviceByTokenHash(sha256(token)) : undefined;
|
|
19
|
-
if (!device) {
|
|
20
|
-
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
|
21
|
-
return socket.destroy();
|
|
22
|
-
}
|
|
23
|
-
req.remcpDevice = device;
|
|
24
|
-
wss.handleUpgrade(req, socket, head, ws => wss.emit('connection', ws, req));
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
wss.on('connection', (ws, req) => {
|
|
28
|
-
const device = req.remcpDevice;
|
|
29
|
-
const previous = agents.get(device.id);
|
|
30
|
-
if (previous && previous.ws !== ws) previous.ws.close(1012, 'replaced');
|
|
31
|
-
const agent = { ws, id: device.id, uid: device.uid, name: device.name, hostname: device.hostname, platform: device.platform, arch: device.arch, connectedAt: new Date().toISOString(), alive: true };
|
|
32
|
-
agents.set(device.id, agent);
|
|
33
|
-
touchDevice(device.id);
|
|
34
|
-
|
|
35
|
-
ws.on('pong', () => { agent.alive = true; touchDevice(device.id); });
|
|
36
|
-
ws.on('message', raw => {
|
|
37
|
-
let message;
|
|
38
|
-
try { message = JSON.parse(raw.toString()); } catch { return; }
|
|
39
|
-
if (message?.type === 'hello') {
|
|
40
|
-
agent.name = String(message.deviceName || agent.name);
|
|
41
|
-
agent.hostname = String(message.hostname || agent.hostname || '');
|
|
42
|
-
agent.platform = String(message.platform || agent.platform || '');
|
|
43
|
-
agent.arch = String(message.arch || agent.arch || '');
|
|
44
|
-
touchDevice(device.id, { name: agent.name, hostname: agent.hostname, platform: agent.platform, arch: agent.arch });
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
if (message?.type === 'response' && message.id) {
|
|
48
|
-
const waiter = pending.get(message.id);
|
|
49
|
-
if (!waiter) return;
|
|
50
|
-
clearTimeout(waiter.timer); pending.delete(message.id);
|
|
51
|
-
if (message.error) waiter.reject(new Error(message.error.message || 'Agent error'));
|
|
52
|
-
else waiter.resolve(message.result);
|
|
53
|
-
}
|
|
54
|
-
});
|
|
55
|
-
ws.on('close', () => {
|
|
56
|
-
if (agents.get(device.id)?.ws === ws) agents.delete(device.id);
|
|
57
|
-
for (const [id, waiter] of pending) if (waiter.deviceId === device.id) {
|
|
58
|
-
clearTimeout(waiter.timer); pending.delete(id); waiter.reject(new Error(`Device disconnected: ${device.id}`));
|
|
59
|
-
}
|
|
60
|
-
});
|
|
61
|
-
ws.on('error', () => {});
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
const heartbeat = setInterval(() => {
|
|
65
|
-
for (const agent of agents.values()) {
|
|
66
|
-
if (!agent.alive) { agent.ws.terminate(); continue; }
|
|
67
|
-
agent.alive = false; agent.ws.ping();
|
|
68
|
-
}
|
|
69
|
-
}, 30000);
|
|
70
|
-
heartbeat.unref();
|
|
71
|
-
|
|
72
|
-
function rpc(uid, deviceId, method, params = {}) {
|
|
73
|
-
if (deviceId === REVIEW_SANDBOX_ID) {
|
|
74
|
-
if (method !== 'tools/call') throw new Error(`Unsupported Review Sandbox relay method: ${method}`);
|
|
75
|
-
return Promise.resolve(reviewSandboxCall(uid, params.name, params.arguments || {}));
|
|
76
|
-
}
|
|
77
|
-
const agent = agents.get(deviceId);
|
|
78
|
-
if (!agent || agent.uid !== uid || agent.ws.readyState !== 1) throw new Error(`Device is offline or unavailable: ${deviceId}`);
|
|
79
|
-
return new Promise((resolve, reject) => {
|
|
80
|
-
const id = `${Date.now()}-${++seq}`;
|
|
81
|
-
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Device request timed out: ${deviceId}`)); }, config.rpcTimeoutMs);
|
|
82
|
-
pending.set(id, { resolve, reject, timer, deviceId });
|
|
83
|
-
agent.ws.send(JSON.stringify({ type: 'request', id, method, params }));
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function devicesFor(uid) {
|
|
88
|
-
const paired = listDevices(uid).map(device => ({
|
|
89
|
-
...device,
|
|
90
|
-
online: agents.get(device.id)?.uid === uid,
|
|
91
|
-
connectedAt: agents.get(device.id)?.connectedAt || null,
|
|
92
|
-
}));
|
|
93
|
-
return [reviewSandboxDevice(), ...paired];
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
return { rpc, devicesFor, onlineCount: () => agents.size };
|
|
97
|
-
}
|