@mmmbuto/nexuscrew 0.8.51 → 0.8.52-rc.1
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 +1 -0
- package/frontend/dist/assets/{index-qLF-zV3C.js → index-fWilj5EH.js} +32 -32
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/fleet/managed.js +11 -2
- package/lib/mcp/tools.js +174 -0
- package/lib/proxy/federation.js +15 -0
- package/lib/server.js +15 -0
- package/lib/vl-nodes/broker.js +228 -0
- package/lib/vl-nodes/routes.js +195 -0
- package/lib/vl-nodes/store.js +203 -0
- package/package.json +1 -1
- package/skills/nexuscrew-agent/SKILL.md +7 -1
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const express = require('express');
|
|
4
|
+
const { bearerFrom } = require('../auth/middleware.js');
|
|
5
|
+
const store = require('./store.js');
|
|
6
|
+
const { PROTOCOL, MAX_WAIT_MS } = require('./broker.js');
|
|
7
|
+
|
|
8
|
+
const OWNER_ID_RE = /^[a-f0-9]{16,64}$/;
|
|
9
|
+
const SIMPLE_COMMANDS = new Set([
|
|
10
|
+
'status', 'health', 'start', 'stop', 'restart', 'version', 'capabilities', 'unpair',
|
|
11
|
+
]);
|
|
12
|
+
const MAX_CANDIDATE_BYTES = 2_621_440;
|
|
13
|
+
|
|
14
|
+
function commandOf(body) {
|
|
15
|
+
if (!body || typeof body.kind !== 'string') return null;
|
|
16
|
+
if (SIMPLE_COMMANDS.has(body.kind)) {
|
|
17
|
+
if (body.args !== undefined && (typeof body.args !== 'object' || body.args === null
|
|
18
|
+
|| Array.isArray(body.args) || Object.keys(body.args).length > 0)) return null;
|
|
19
|
+
return { kind: body.kind, args: {} };
|
|
20
|
+
}
|
|
21
|
+
if (body.kind === 'logs') {
|
|
22
|
+
const limit = body.args && body.args.limit;
|
|
23
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) return null;
|
|
24
|
+
return { kind: 'logs', args: { limit } };
|
|
25
|
+
}
|
|
26
|
+
if (body.kind === 'update_candidate') {
|
|
27
|
+
const args = body.args;
|
|
28
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) return null;
|
|
29
|
+
const keys = Object.keys(args).sort();
|
|
30
|
+
if (keys.join(',') !== 'sha256,size,url,version') return null;
|
|
31
|
+
if (typeof args.url !== 'string' || args.url.length > 2048) return null;
|
|
32
|
+
let parsed;
|
|
33
|
+
try { parsed = new URL(args.url); } catch (_) { return null; }
|
|
34
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) return null;
|
|
35
|
+
if (!/^[a-f0-9]{64}$/.test(String(args.sha256 || ''))) return null;
|
|
36
|
+
if (!Number.isSafeInteger(args.size) || args.size < 1 || args.size > MAX_CANDIDATE_BYTES) return null;
|
|
37
|
+
if (typeof args.version !== 'string' || !/^[A-Za-z0-9._+-]{1,64}$/.test(args.version)) return null;
|
|
38
|
+
return {
|
|
39
|
+
kind: body.kind,
|
|
40
|
+
args: { url: parsed.toString(), sha256: args.sha256, size: args.size, version: args.version },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function nodeToken(req) {
|
|
47
|
+
const token = bearerFrom(req);
|
|
48
|
+
return store.TOKEN_RE.test(String(token || '')) ? token : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function jsonBodyError(error, _req, res, _next) {
|
|
52
|
+
if (error && (error.type === 'entity.too.large' || error.status === 413)) {
|
|
53
|
+
return res.status(413).json({ error: 'body too large' });
|
|
54
|
+
}
|
|
55
|
+
if (error instanceof SyntaxError) return res.status(400).json({ error: 'invalid JSON' });
|
|
56
|
+
return res.status(400).json({ error: String(error?.message || error) });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function publicRoutes({ storePath, broker, ownerId }) {
|
|
60
|
+
const r = express.Router();
|
|
61
|
+
const body = express.json({ limit: '20kb' });
|
|
62
|
+
|
|
63
|
+
r.post('/pair', body, (req, res) => {
|
|
64
|
+
const localOwner = ownerId();
|
|
65
|
+
if (!OWNER_ID_RE.test(String(localOwner || ''))) return res.status(503).json({ error: 'owner identity unavailable' });
|
|
66
|
+
if (!req.body || req.body.protocol !== PROTOCOL) return res.status(400).json({ error: 'unsupported protocol' });
|
|
67
|
+
const invite = req.get('x-vl-invite');
|
|
68
|
+
const paired = store.pairNode(storePath, {
|
|
69
|
+
invite,
|
|
70
|
+
nodeId: req.body.nodeId,
|
|
71
|
+
label: req.body.label,
|
|
72
|
+
});
|
|
73
|
+
if (!paired.ok) {
|
|
74
|
+
const status = paired.code === 'node-exists' ? 409
|
|
75
|
+
: paired.code === 'invite-expired-or-used' ? 410 : 400;
|
|
76
|
+
return res.status(status).json({ error: paired.code });
|
|
77
|
+
}
|
|
78
|
+
return res.status(201).json({
|
|
79
|
+
protocol: PROTOCOL,
|
|
80
|
+
ownerId: localOwner,
|
|
81
|
+
nodeId: paired.node.nodeId,
|
|
82
|
+
label: paired.node.label,
|
|
83
|
+
token: paired.token,
|
|
84
|
+
pollMaxMs: MAX_WAIT_MS,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
r.post('/poll', body, async (req, res) => {
|
|
89
|
+
const token = nodeToken(req);
|
|
90
|
+
const node = token && store.authenticate(storePath, token);
|
|
91
|
+
if (!node || req.body?.nodeId !== node.nodeId) return res.status(401).json({ error: 'unauthorized-node' });
|
|
92
|
+
const abort = new AbortController();
|
|
93
|
+
req.once('aborted', () => abort.abort());
|
|
94
|
+
const result = await broker.poll(node, req.body, {
|
|
95
|
+
waitMs: Number(req.get('x-vl-wait-ms')) || MAX_WAIT_MS,
|
|
96
|
+
signal: abort.signal,
|
|
97
|
+
});
|
|
98
|
+
if (res.headersSent || res.destroyed) return undefined;
|
|
99
|
+
if (result.type === 'command') return res.json({ protocol: PROTOCOL, command: result.command });
|
|
100
|
+
if (result.type === 'idle' || result.type === 'aborted' || result.type === 'acknowledged') {
|
|
101
|
+
return res.status(204).end();
|
|
102
|
+
}
|
|
103
|
+
if (result.type === 'superseded') return res.status(409).json({ error: 'stale-session', generation: result.generation });
|
|
104
|
+
if (result.type === 'revoked') return res.status(401).json({ error: 'unpaired' });
|
|
105
|
+
return res.status(400).json({ error: result.code || 'invalid-heartbeat' });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
r.post('/unpair', body, (req, res) => {
|
|
109
|
+
const token = nodeToken(req);
|
|
110
|
+
const node = token && store.authenticate(storePath, token);
|
|
111
|
+
if (!node || req.body?.nodeId !== node.nodeId || req.body?.protocol !== PROTOCOL) {
|
|
112
|
+
return res.status(401).json({ error: 'unauthorized-node' });
|
|
113
|
+
}
|
|
114
|
+
store.removeNode(storePath, node.nodeId);
|
|
115
|
+
broker.forget(node.nodeId);
|
|
116
|
+
return res.status(204).end();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
r.use(jsonBodyError);
|
|
120
|
+
|
|
121
|
+
return r;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function apiRoutes({ storePath, broker, ownerId, readonly = () => false }) {
|
|
125
|
+
const r = express.Router();
|
|
126
|
+
|
|
127
|
+
r.get('/', (_req, res) => {
|
|
128
|
+
const localOwner = ownerId();
|
|
129
|
+
if (!OWNER_ID_RE.test(String(localOwner || ''))) return res.status(503).json({ error: 'owner identity unavailable' });
|
|
130
|
+
const nodes = broker.list(store.listNodes(storePath)).map((node) => ({
|
|
131
|
+
...node,
|
|
132
|
+
id: `${localOwner}:VL-${node.nodeId}`,
|
|
133
|
+
instanceId: localOwner,
|
|
134
|
+
cell: `VL-${node.nodeId.slice(0, 8)}`,
|
|
135
|
+
canReceive: false,
|
|
136
|
+
canManage: node.online === true,
|
|
137
|
+
}));
|
|
138
|
+
return res.json({ instanceId: localOwner, protocol: PROTOCOL, nodes });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
r.post('/invite', express.json({ limit: '4kb' }), (req, res) => {
|
|
142
|
+
if (readonly()) return res.status(403).json({ error: 'READONLY: invite blocked' });
|
|
143
|
+
const localOwner = ownerId();
|
|
144
|
+
if (!OWNER_ID_RE.test(String(localOwner || ''))) return res.status(503).json({ error: 'owner identity unavailable' });
|
|
145
|
+
const ttlSeconds = req.body?.ttlSeconds === undefined ? undefined : Number(req.body.ttlSeconds);
|
|
146
|
+
if (ttlSeconds !== undefined && (!Number.isSafeInteger(ttlSeconds) || ttlSeconds < 30 || ttlSeconds > 3600)) {
|
|
147
|
+
return res.status(400).json({ error: 'ttlSeconds must be 30..3600' });
|
|
148
|
+
}
|
|
149
|
+
const invite = store.createInvite(storePath, {
|
|
150
|
+
label: req.body?.label,
|
|
151
|
+
...(ttlSeconds === undefined ? {} : { ttlMs: ttlSeconds * 1000 }),
|
|
152
|
+
});
|
|
153
|
+
return res.status(201).json({ protocol: PROTOCOL, ownerId: localOwner, ...invite });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
r.post('/:nodeId/commands', express.json({ limit: '8kb' }), (req, res) => {
|
|
157
|
+
if (readonly()) return res.status(403).json({ error: 'READONLY: command blocked' });
|
|
158
|
+
if (!store.NODE_ID_RE.test(String(req.params.nodeId || ''))
|
|
159
|
+
|| !store.listNodes(storePath).some((node) => node.nodeId === req.params.nodeId)) {
|
|
160
|
+
return res.status(404).json({ error: 'node not paired' });
|
|
161
|
+
}
|
|
162
|
+
const command = commandOf(req.body);
|
|
163
|
+
if (!command) return res.status(400).json({ error: 'invalid bounded command' });
|
|
164
|
+
const result = broker.dispatch(req.params.nodeId, command);
|
|
165
|
+
if (!result.ok) {
|
|
166
|
+
const status = result.code === 'node-offline' || result.code === 'command-in-flight' ? 409 : 400;
|
|
167
|
+
return res.status(status).json({ error: result.code });
|
|
168
|
+
}
|
|
169
|
+
return res.status(202).json({
|
|
170
|
+
id: result.id,
|
|
171
|
+
status: result.status,
|
|
172
|
+
note: 'submitted confirms delivery to a live poll only; completion requires the node ack',
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
r.delete('/:nodeId', (req, res) => {
|
|
177
|
+
if (readonly()) return res.status(403).json({ error: 'READONLY: revoke blocked' });
|
|
178
|
+
if (!store.removeNode(storePath, req.params.nodeId)) return res.status(404).json({ error: 'node not paired' });
|
|
179
|
+
broker.forget(req.params.nodeId);
|
|
180
|
+
return res.status(204).end();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
r.use(jsonBodyError);
|
|
184
|
+
|
|
185
|
+
return r;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = {
|
|
189
|
+
OWNER_ID_RE,
|
|
190
|
+
SIMPLE_COMMANDS,
|
|
191
|
+
MAX_CANDIDATE_BYTES,
|
|
192
|
+
commandOf,
|
|
193
|
+
publicRoutes,
|
|
194
|
+
apiRoutes,
|
|
195
|
+
};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const NODE_ID_RE = /^[a-f0-9]{32}$/;
|
|
8
|
+
const TOKEN_RE = /^[a-f0-9]{64}$/;
|
|
9
|
+
const STORE_VERSION = 1;
|
|
10
|
+
const DEFAULT_INVITE_TTL_MS = 10 * 60 * 1000;
|
|
11
|
+
const MAX_INVITE_TTL_MS = 60 * 60 * 1000;
|
|
12
|
+
const MAX_STORE_BYTES = 256 * 1024;
|
|
13
|
+
|
|
14
|
+
function emptyStore() {
|
|
15
|
+
return { version: STORE_VERSION, invites: [], nodes: [] };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function defaultPath(home) {
|
|
19
|
+
return path.join(home, '.nexuscrew', 'vl-nodes.json');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function cleanLabel(value, fallback = 'VL') {
|
|
23
|
+
if (typeof value !== 'string') return fallback;
|
|
24
|
+
const clean = value.normalize('NFC').replace(/[\p{Cc}\p{Cf}]/gu, '').trim();
|
|
25
|
+
return clean ? clean.slice(0, 48) : fallback;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function validRecord(record) {
|
|
29
|
+
return record && NODE_ID_RE.test(String(record.nodeId || ''))
|
|
30
|
+
&& TOKEN_RE.test(String(record.tokenHash || ''))
|
|
31
|
+
&& Number.isSafeInteger(record.pairedAt) && record.pairedAt > 0
|
|
32
|
+
&& typeof record.label === 'string' && record.label.length > 0 && record.label.length <= 48;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeStore(raw, now = Date.now()) {
|
|
36
|
+
if (!raw || raw.version !== STORE_VERSION) return emptyStore();
|
|
37
|
+
const invites = Array.isArray(raw.invites) ? raw.invites.filter((invite) => (
|
|
38
|
+
invite && TOKEN_RE.test(String(invite.tokenHash || ''))
|
|
39
|
+
&& Number.isSafeInteger(invite.expiresAt) && invite.expiresAt > now
|
|
40
|
+
&& typeof invite.label === 'string' && invite.label.length > 0 && invite.label.length <= 48
|
|
41
|
+
)).slice(-64) : [];
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
const nodes = [];
|
|
44
|
+
for (const record of Array.isArray(raw.nodes) ? raw.nodes : []) {
|
|
45
|
+
if (!validRecord(record) || seen.has(record.nodeId)) continue;
|
|
46
|
+
seen.add(record.nodeId);
|
|
47
|
+
nodes.push({
|
|
48
|
+
nodeId: record.nodeId,
|
|
49
|
+
label: cleanLabel(record.label),
|
|
50
|
+
tokenHash: record.tokenHash,
|
|
51
|
+
pairedAt: record.pairedAt,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return { version: STORE_VERSION, invites, nodes };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function ownedByCurrentUser(stat) {
|
|
58
|
+
return typeof process.getuid !== 'function' || stat.uid === process.getuid();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function assertSafeFile(stat) {
|
|
62
|
+
if (stat.isSymbolicLink() || !stat.isFile() || !ownedByCurrentUser(stat)
|
|
63
|
+
|| (stat.mode & 0o077) !== 0 || stat.size > MAX_STORE_BYTES) {
|
|
64
|
+
throw new Error('unsafe VL node store');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function loadStore(file, now = Date.now()) {
|
|
69
|
+
let fd;
|
|
70
|
+
try {
|
|
71
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
72
|
+
assertSafeFile(fs.fstatSync(fd));
|
|
73
|
+
return normalizeStore(JSON.parse(fs.readFileSync(fd, 'utf8')), now);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error && error.code === 'ENOENT') return emptyStore();
|
|
76
|
+
throw error;
|
|
77
|
+
} finally {
|
|
78
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function atomicWriteStore(file, store, now = Date.now()) {
|
|
83
|
+
const dir = path.dirname(file);
|
|
84
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
85
|
+
const dirStat = fs.lstatSync(dir);
|
|
86
|
+
if (dirStat.isSymbolicLink() || !dirStat.isDirectory() || !ownedByCurrentUser(dirStat)) {
|
|
87
|
+
throw new Error('unsafe VL node store directory');
|
|
88
|
+
}
|
|
89
|
+
fs.chmodSync(dir, 0o700);
|
|
90
|
+
try {
|
|
91
|
+
assertSafeFile(fs.lstatSync(file));
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (!error || error.code !== 'ENOENT') throw error;
|
|
94
|
+
}
|
|
95
|
+
const tmp = `${file}.tmp.${process.pid}.${crypto.randomBytes(8).toString('hex')}`;
|
|
96
|
+
const fd = fs.openSync(tmp, 'wx', 0o600);
|
|
97
|
+
try {
|
|
98
|
+
const payload = `${JSON.stringify(normalizeStore(store, now), null, 2)}\n`;
|
|
99
|
+
fs.writeFileSync(fd, payload, { encoding: 'utf8' });
|
|
100
|
+
fs.fsyncSync(fd);
|
|
101
|
+
} finally {
|
|
102
|
+
fs.closeSync(fd);
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
fs.renameSync(tmp, file);
|
|
106
|
+
fs.chmodSync(file, 0o600);
|
|
107
|
+
try {
|
|
108
|
+
const dirFd = fs.openSync(dir, 'r');
|
|
109
|
+
try { fs.fsyncSync(dirFd); } finally { fs.closeSync(dirFd); }
|
|
110
|
+
} catch (_) {}
|
|
111
|
+
} catch (error) {
|
|
112
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function digest(token) {
|
|
118
|
+
return crypto.createHash('sha256').update(String(token), 'utf8').digest('hex');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function timingSafeHex(left, right) {
|
|
122
|
+
if (!TOKEN_RE.test(String(left || '')) || !TOKEN_RE.test(String(right || ''))) return false;
|
|
123
|
+
return crypto.timingSafeEqual(Buffer.from(left, 'hex'), Buffer.from(right, 'hex'));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function randomToken(randomBytes = crypto.randomBytes) {
|
|
127
|
+
return randomBytes(32).toString('hex');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function createInvite(file, { label = 'VL', ttlMs = DEFAULT_INVITE_TTL_MS } = {}, seams = {}) {
|
|
131
|
+
const now = typeof seams.now === 'function' ? seams.now() : Date.now();
|
|
132
|
+
const boundedTtl = Math.max(30_000, Math.min(MAX_INVITE_TTL_MS, Number(ttlMs) || DEFAULT_INVITE_TTL_MS));
|
|
133
|
+
const token = randomToken(seams.randomBytes);
|
|
134
|
+
const store = loadStore(file, now);
|
|
135
|
+
store.invites.push({ tokenHash: digest(token), expiresAt: now + boundedTtl, label: cleanLabel(label) });
|
|
136
|
+
store.invites = store.invites.filter((invite) => invite.expiresAt > now).slice(-64);
|
|
137
|
+
atomicWriteStore(file, store, now);
|
|
138
|
+
return { invite: token, expiresAt: now + boundedTtl, label: cleanLabel(label) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function pairNode(file, { invite, nodeId, label }, seams = {}) {
|
|
142
|
+
if (!TOKEN_RE.test(String(invite || ''))) return { ok: false, code: 'invalid-invite' };
|
|
143
|
+
if (!NODE_ID_RE.test(String(nodeId || ''))) return { ok: false, code: 'invalid-node-id' };
|
|
144
|
+
const now = typeof seams.now === 'function' ? seams.now() : Date.now();
|
|
145
|
+
const store = loadStore(file, now);
|
|
146
|
+
if (store.nodes.some((node) => node.nodeId === nodeId)) return { ok: false, code: 'node-exists' };
|
|
147
|
+
const inviteHash = digest(invite);
|
|
148
|
+
const index = store.invites.findIndex((row) => timingSafeHex(row.tokenHash, inviteHash));
|
|
149
|
+
if (index < 0) return { ok: false, code: 'invite-expired-or-used' };
|
|
150
|
+
const [accepted] = store.invites.splice(index, 1);
|
|
151
|
+
const token = randomToken(seams.randomBytes);
|
|
152
|
+
const record = {
|
|
153
|
+
nodeId,
|
|
154
|
+
label: cleanLabel(label, accepted.label),
|
|
155
|
+
tokenHash: digest(token),
|
|
156
|
+
pairedAt: now,
|
|
157
|
+
};
|
|
158
|
+
store.nodes.push(record);
|
|
159
|
+
atomicWriteStore(file, store, now);
|
|
160
|
+
return { ok: true, token, node: { nodeId, label: record.label, pairedAt: now } };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function authenticate(file, token, now = Date.now()) {
|
|
164
|
+
if (!TOKEN_RE.test(String(token || ''))) return null;
|
|
165
|
+
const tokenHash = digest(token);
|
|
166
|
+
const record = loadStore(file, now).nodes.find((node) => timingSafeHex(node.tokenHash, tokenHash));
|
|
167
|
+
return record ? { nodeId: record.nodeId, label: record.label, pairedAt: record.pairedAt } : null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function removeNode(file, nodeId, now = Date.now()) {
|
|
171
|
+
if (!NODE_ID_RE.test(String(nodeId || ''))) return false;
|
|
172
|
+
const store = loadStore(file, now);
|
|
173
|
+
const kept = store.nodes.filter((node) => node.nodeId !== nodeId);
|
|
174
|
+
if (kept.length === store.nodes.length) return false;
|
|
175
|
+
store.nodes = kept;
|
|
176
|
+
atomicWriteStore(file, store, now);
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function listNodes(file, now = Date.now()) {
|
|
181
|
+
return loadStore(file, now).nodes.map(({ nodeId, label, pairedAt }) => ({ nodeId, label, pairedAt }));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = {
|
|
185
|
+
NODE_ID_RE,
|
|
186
|
+
TOKEN_RE,
|
|
187
|
+
STORE_VERSION,
|
|
188
|
+
DEFAULT_INVITE_TTL_MS,
|
|
189
|
+
MAX_INVITE_TTL_MS,
|
|
190
|
+
emptyStore,
|
|
191
|
+
defaultPath,
|
|
192
|
+
cleanLabel,
|
|
193
|
+
normalizeStore,
|
|
194
|
+
loadStore,
|
|
195
|
+
atomicWriteStore,
|
|
196
|
+
digest,
|
|
197
|
+
timingSafeHex,
|
|
198
|
+
createInvite,
|
|
199
|
+
pairNode,
|
|
200
|
+
authenticate,
|
|
201
|
+
removeNode,
|
|
202
|
+
listNodes,
|
|
203
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: nexuscrew-agent
|
|
3
|
-
description: Use when an AI agent connected to NexusCrew must notify or ask the human, inspect runtime or deck context, list authorized Fleet cells, message an exact active cell, read its inbox, deliver a file, recover from local tmux messages that remain unsubmitted or garbled, or when a drag in the web terminal does not scroll a full-screen TUI. Prefer nc_notify, nc_ask, nc_status, nc_deck, nc_cells, nc_send_cell, nc_inbox, and nc_send_file; use bundled tmux/file helpers only as a declared compatibility fallback.
|
|
3
|
+
description: Use when an AI agent connected to NexusCrew must notify or ask the human, inspect runtime or deck context, manage exact authorized VL micro-device nodes, list authorized Fleet cells, message an exact active cell, read its inbox, deliver a file, recover from local tmux messages that remain unsubmitted or garbled, or when a drag in the web terminal does not scroll a full-screen TUI. Prefer nc_notify, nc_ask, nc_status, nc_deck, nc_cells, nc_vl_nodes, nc_vl_command, nc_send_cell, nc_inbox, and nc_send_file; use bundled tmux/file helpers only as a declared compatibility fallback.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# NexusCrew Agent I/O
|
|
@@ -19,6 +19,10 @@ When the client exposes the NexusCrew MCP server, use these tools directly:
|
|
|
19
19
|
| Read the caller's deck name(s) and member cells/tmux sessions | `nc_deck` |
|
|
20
20
|
| List every authorized owner-qualified Fleet cell | `nc_cells` |
|
|
21
21
|
| Submit a bounded message to an exact active Fleet cell | `nc_send_cell` |
|
|
22
|
+
| List authorized VL micro-device owners/nodes | `nc_vl_nodes` |
|
|
23
|
+
| Create a one-time VL pairing invite | `nc_vl_invite` |
|
|
24
|
+
| Deliver a bounded VL command | `nc_vl_command` |
|
|
25
|
+
| Revoke an exact VL pairing | `nc_vl_revoke` |
|
|
22
26
|
| List files received for the current session | `nc_inbox` |
|
|
23
27
|
| Deliver an absolute file path under the user's home | `nc_send_file` |
|
|
24
28
|
|
|
@@ -30,6 +34,7 @@ Apply these rules:
|
|
|
30
34
|
- Use `nc_status` instead of scraping NexusCrew state files. Use `nc_deck` instead of reading `decks.json`: it returns every local or authorized shared-owner deck containing the caller, preserves visual member order, identifies each deck and member by stable owner ID, includes viewer-valid Hydra routes, and reports `cell: null` when no managed Fleet match is available.
|
|
31
35
|
- Use `nc_cells` immediately before cross-cell delivery. Select its exact owner-qualified `id`; require `canReceive: true`; never guess a duplicate name or stale route.
|
|
32
36
|
- Use `nc_send_cell {target, message}` for actual Fleet-cell delivery. `submitted` confirms bracketed paste plus Enter only, never task acceptance or completion. Inactive targets are not queued.
|
|
37
|
+
- Use `nc_vl_nodes` immediately before VL mutation. Mutation tools require an active local Fleet caller and the full owner-qualified VL ID. `nc_vl_command` `submitted` means live-poll delivery only; require the same ID in `lastAck`. No offline queue exists.
|
|
33
38
|
- Use `nc_inbox` instead of guessing an inbox path when the tool is available.
|
|
34
39
|
- Pass `nc_send_file` an existing absolute regular-file path below the user's home. Let NexusCrew choose and sanitize the outbox name.
|
|
35
40
|
- Do not treat an MCP notification as a substitute for the final response required by the active client.
|
|
@@ -128,6 +133,7 @@ work. The setting is also applied to windows created later in that session.
|
|
|
128
133
|
| Discover this session's deck neighbours | `nc_deck` |
|
|
129
134
|
| Discover authorized cells across nodes | `nc_cells` |
|
|
130
135
|
| Submit to an exact active Fleet cell | `nc_send_cell` |
|
|
136
|
+
| Discover/manage a paired VL micro-device | `nc_vl_nodes`, then `nc_vl_command` |
|
|
131
137
|
| Give the human a file | `nc_send_file` or fallback `nc-deliver <file>...` |
|
|
132
138
|
| Read a file the human sent | `nc_inbox` or fallback to the path in the prompt |
|
|
133
139
|
| Send a prompt/command to a session | `nc-send <session> "text"` |
|