@honeybee-ai/waggle-cli 1.0.21 → 1.0.23
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/dist/brood.d.ts +38 -0
- package/dist/brood.js +96 -0
- package/dist/brood.js.map +1 -1
- package/dist/cli.js +9 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/funnel.d.ts +58 -0
- package/dist/commands/funnel.js +1031 -0
- package/dist/commands/funnel.js.map +1 -0
- package/dist/commands/up.js +88 -0
- package/dist/commands/up.js.map +1 -1
- package/dist/lib/dance-loader.d.ts +32 -0
- package/dist/lib/dance-loader.js +66 -0
- package/dist/lib/dance-loader.js.map +1 -0
- package/dist/lib/graphql-loader.d.ts +57 -0
- package/dist/lib/graphql-loader.js +283 -0
- package/dist/lib/graphql-loader.js.map +1 -0
- package/dist/lib/header-resolver.d.ts +9 -0
- package/dist/lib/header-resolver.js +32 -0
- package/dist/lib/header-resolver.js.map +1 -0
- package/dist/lib/openapi-loader.d.ts +17 -0
- package/dist/lib/openapi-loader.js +164 -0
- package/dist/lib/openapi-loader.js.map +1 -0
- package/dist/wgl.js +735 -445
- package/dist/wgl.js.map +4 -4
- package/package.json +1 -1
|
@@ -0,0 +1,1031 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wgl funnel — connect local tools to a Colony hive.
|
|
3
|
+
*
|
|
4
|
+
* Opens an outbound WebSocket to Colony, registers available tools,
|
|
5
|
+
* and dispatches incoming tool_call messages to local handlers.
|
|
6
|
+
*
|
|
7
|
+
* Uses Node 22 native WebSocket — no npm dependency.
|
|
8
|
+
*/
|
|
9
|
+
import { c, error, success, heading, label } from '../lib/format.js';
|
|
10
|
+
import { hostname, loadavg, homedir } from 'node:os';
|
|
11
|
+
import { resolve, relative, join } from 'node:path';
|
|
12
|
+
// --- HMAC verification (mirrors colony/src/durable-objects/funnel-hmac.ts) ---
|
|
13
|
+
const HMAC_ALGO = { name: 'HMAC', hash: 'SHA-256' };
|
|
14
|
+
async function importSigningKey(rawBase64) {
|
|
15
|
+
const binaryStr = atob(rawBase64);
|
|
16
|
+
const bytes = new Uint8Array(binaryStr.length);
|
|
17
|
+
for (let i = 0; i < binaryStr.length; i++) {
|
|
18
|
+
bytes[i] = binaryStr.charCodeAt(i);
|
|
19
|
+
}
|
|
20
|
+
return crypto.subtle.importKey('raw', bytes, HMAC_ALGO, false, ['verify']);
|
|
21
|
+
}
|
|
22
|
+
async function verifyHmac(key, payload, sig) {
|
|
23
|
+
const enc = new TextEncoder();
|
|
24
|
+
const sigBytes = new Uint8Array(sig.length / 2);
|
|
25
|
+
for (let i = 0; i < sig.length; i += 2) {
|
|
26
|
+
sigBytes[i / 2] = parseInt(sig.slice(i, i + 2), 16);
|
|
27
|
+
}
|
|
28
|
+
return crypto.subtle.verify('HMAC', key, sigBytes, enc.encode(payload));
|
|
29
|
+
}
|
|
30
|
+
function buildSignPayload(callId, tool, args, nonce) {
|
|
31
|
+
return JSON.stringify({ callId, tool, args, nonce });
|
|
32
|
+
}
|
|
33
|
+
// --- Main ---
|
|
34
|
+
export async function funnel(args) {
|
|
35
|
+
const sub = args[0];
|
|
36
|
+
if (sub === '--help' || sub === '-h') {
|
|
37
|
+
showHelp();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (sub === 'status') {
|
|
41
|
+
await showStatus();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (sub === 'install') {
|
|
45
|
+
await installDaemon(args.slice(1));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (sub === 'uninstall') {
|
|
49
|
+
await uninstallDaemon();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
// Default: start funnel
|
|
53
|
+
await startFunnel(args);
|
|
54
|
+
}
|
|
55
|
+
function showHelp() {
|
|
56
|
+
console.log(`
|
|
57
|
+
${c('bold', 'wgl funnel')} — connect local tools to a Colony hive
|
|
58
|
+
|
|
59
|
+
${c('cyan', 'USAGE:')}
|
|
60
|
+
wgl funnel [options] Start funnel (connects to Colony)
|
|
61
|
+
wgl funnel status Show connection state
|
|
62
|
+
wgl funnel install [options] Install as system daemon (systemd/launchd)
|
|
63
|
+
wgl funnel uninstall Remove daemon and stop service
|
|
64
|
+
|
|
65
|
+
${c('cyan', 'OPTIONS:')}
|
|
66
|
+
--hive <id> Hive ID to connect to
|
|
67
|
+
--key <key> Colony API key (or set COLONY_KEY env var)
|
|
68
|
+
--name <name> Funnel name (default: hostname)
|
|
69
|
+
--verbose Detailed logging
|
|
70
|
+
--colony <url> Colony URL (default: colony.honeyb.dev)
|
|
71
|
+
--dances <path> Load custom tools from dance file (repeatable)
|
|
72
|
+
Whitelist: --dances=file.js:tool1,tool2
|
|
73
|
+
--tool <name=cmd> One-off shell tool (repeatable)
|
|
74
|
+
--scope <path> Restrict file/shell ops to this directory (default: cwd)
|
|
75
|
+
--no-scope Disable scope restriction (dangerous!)
|
|
76
|
+
--acl <tool:roles> Restrict tool to roles (repeatable)
|
|
77
|
+
Example: --acl="deploy:deployer,admin"
|
|
78
|
+
--label <key:value> Tag this funnel with a label (repeatable)
|
|
79
|
+
Agents can route to funnels by label.
|
|
80
|
+
Example: --label=region:us-east --label=gpu:a100
|
|
81
|
+
|
|
82
|
+
${c('cyan', 'API LOADERS:')}
|
|
83
|
+
--api <path> Load OpenAPI 3.x spec as tools (repeatable)
|
|
84
|
+
--api-url <url> Base URL for OpenAPI requests (overrides spec servers)
|
|
85
|
+
--graphql <src> Load GraphQL tools — URL (introspect) or file (repeatable)
|
|
86
|
+
--graphql-url <url> Query endpoint (required when --graphql is a file)
|
|
87
|
+
--api-header <h> Header for API requests (repeatable)
|
|
88
|
+
Supports \${VAR} env interpolation:
|
|
89
|
+
--api-header="Authorization: Bearer \${API_KEY}"
|
|
90
|
+
|
|
91
|
+
${c('cyan', 'SECURITY:')}
|
|
92
|
+
Tool calls from Colony are HMAC-SHA256 signed. The signing key is
|
|
93
|
+
exchanged per session over the TLS+API-key-authenticated channel.
|
|
94
|
+
Calls with invalid or missing signatures are rejected.
|
|
95
|
+
|
|
96
|
+
${c('cyan', 'DESCRIPTION:')}
|
|
97
|
+
Connects your machine as a tool endpoint for a Colony hive. Agents
|
|
98
|
+
running in Colony can call tools that execute on your local machine.
|
|
99
|
+
|
|
100
|
+
By default, registers propolis tools (shell, git, file ops) if
|
|
101
|
+
@honeybee-ai/propolis is installed. Falls back to a built-in shell
|
|
102
|
+
tool if propolis is not available.
|
|
103
|
+
|
|
104
|
+
API loaders let you expose REST (OpenAPI) and GraphQL endpoints as
|
|
105
|
+
funnel tools. Each operation becomes a tool that proxies requests.
|
|
106
|
+
Headers with \${VAR} syntax resolve from environment variables, so
|
|
107
|
+
secrets never appear in CLI args or process lists.
|
|
108
|
+
|
|
109
|
+
All tools are scope-restricted to the current directory by default.
|
|
110
|
+
Use --scope to change the restriction path, or --no-scope to disable
|
|
111
|
+
(not recommended for production).
|
|
112
|
+
|
|
113
|
+
${c('cyan', 'EXAMPLES:')}
|
|
114
|
+
wgl funnel --hive abc123 --key col_...
|
|
115
|
+
wgl funnel --hive abc123 --dances=game.js --tool="deploy=./deploy.sh"
|
|
116
|
+
wgl funnel --hive abc123 --dances="game.js:roll,attack"
|
|
117
|
+
wgl funnel --hive abc123 --scope=/home/user/project
|
|
118
|
+
wgl funnel --hive abc123 --label=region:us-east --label=gpu:a100
|
|
119
|
+
wgl funnel --hive abc123 --acl="shell:developer" --acl="deploy:deployer"
|
|
120
|
+
wgl funnel install --hive abc123 Install as daemon (auto-start on boot)
|
|
121
|
+
|
|
122
|
+
${c('dim', '# OpenAPI: expose a REST API to Colony agents')}
|
|
123
|
+
wgl funnel --hive abc123 --api=petstore.yaml --api-header="Authorization: Bearer \${PET_KEY}"
|
|
124
|
+
|
|
125
|
+
${c('dim', '# GraphQL: expose endpoint via introspection')}
|
|
126
|
+
wgl funnel --hive abc123 --graphql=https://api.example.com/graphql
|
|
127
|
+
|
|
128
|
+
${c('dim', '# GraphQL from local schema file')}
|
|
129
|
+
wgl funnel --hive abc123 --graphql=schema.graphql --graphql-url=http://localhost:4000/graphql
|
|
130
|
+
|
|
131
|
+
${c('dim', '# Combine all sources')}
|
|
132
|
+
wgl funnel --hive abc123 --api=api.yaml --graphql=https://hasura.example.com/v1/graphql --dances=tools.js
|
|
133
|
+
`);
|
|
134
|
+
}
|
|
135
|
+
function parseArgs(args) {
|
|
136
|
+
let hiveId = '';
|
|
137
|
+
let apiKey = '';
|
|
138
|
+
let name = '';
|
|
139
|
+
let verbose = false;
|
|
140
|
+
let colonyUrl = '';
|
|
141
|
+
const dancePaths = [];
|
|
142
|
+
const toolDefs = [];
|
|
143
|
+
let scope = process.cwd(); // default: cwd
|
|
144
|
+
let noScope = false;
|
|
145
|
+
const acls = new Map();
|
|
146
|
+
const apiSpecs = [];
|
|
147
|
+
let apiUrl = '';
|
|
148
|
+
const graphqlSources = [];
|
|
149
|
+
let graphqlUrl = '';
|
|
150
|
+
const apiHeaders = [];
|
|
151
|
+
const labels = {};
|
|
152
|
+
for (let i = 0; i < args.length; i++) {
|
|
153
|
+
const arg = args[i];
|
|
154
|
+
if (arg === '--hive' && args[i + 1]) {
|
|
155
|
+
hiveId = args[++i];
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (arg.startsWith('--hive=')) {
|
|
159
|
+
hiveId = arg.slice(7);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (arg === '--key' && args[i + 1]) {
|
|
163
|
+
apiKey = args[++i];
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (arg.startsWith('--key=')) {
|
|
167
|
+
apiKey = arg.slice(6);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (arg === '--name' && args[i + 1]) {
|
|
171
|
+
name = args[++i];
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (arg.startsWith('--name=')) {
|
|
175
|
+
name = arg.slice(7);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (arg === '--colony' && args[i + 1]) {
|
|
179
|
+
colonyUrl = args[++i];
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (arg.startsWith('--colony=')) {
|
|
183
|
+
colonyUrl = arg.slice(9);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (arg === '--verbose' || arg === '-v') {
|
|
187
|
+
verbose = true;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
// Dance files: --dances=file.js or --dances="file.js:tool1,tool2"
|
|
191
|
+
if (arg === '--dances' && args[i + 1]) {
|
|
192
|
+
parseDancePath(args[++i], dancePaths);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (arg.startsWith('--dances=')) {
|
|
196
|
+
parseDancePath(arg.slice(9), dancePaths);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
// Custom shell tools: --tool="name=command"
|
|
200
|
+
if (arg === '--tool' && args[i + 1]) {
|
|
201
|
+
toolDefs.push(args[++i]);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (arg.startsWith('--tool=')) {
|
|
205
|
+
toolDefs.push(arg.slice(7));
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
// Scope: --scope=<path> or --no-scope
|
|
209
|
+
if (arg === '--scope' && args[i + 1]) {
|
|
210
|
+
scope = resolve(args[++i]);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (arg.startsWith('--scope=')) {
|
|
214
|
+
scope = resolve(arg.slice(8));
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (arg === '--no-scope') {
|
|
218
|
+
noScope = true;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
// ACLs: --acl="tool:role1,role2"
|
|
222
|
+
if (arg === '--acl' && args[i + 1]) {
|
|
223
|
+
parseAcl(args[++i], acls);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (arg.startsWith('--acl=')) {
|
|
227
|
+
parseAcl(arg.slice(6), acls);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
// OpenAPI specs: --api=<path> (repeatable)
|
|
231
|
+
if (arg === '--api' && args[i + 1]) {
|
|
232
|
+
apiSpecs.push(args[++i]);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (arg.startsWith('--api=')) {
|
|
236
|
+
apiSpecs.push(arg.slice(6));
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
// OpenAPI base URL: --api-url=<url>
|
|
240
|
+
if (arg === '--api-url' && args[i + 1]) {
|
|
241
|
+
apiUrl = args[++i];
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (arg.startsWith('--api-url=')) {
|
|
245
|
+
apiUrl = arg.slice(10);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
// GraphQL sources: --graphql=<url-or-file> (repeatable)
|
|
249
|
+
if (arg === '--graphql' && args[i + 1]) {
|
|
250
|
+
graphqlSources.push(args[++i]);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (arg.startsWith('--graphql=')) {
|
|
254
|
+
graphqlSources.push(arg.slice(10));
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
// GraphQL endpoint URL: --graphql-url=<url>
|
|
258
|
+
if (arg === '--graphql-url' && args[i + 1]) {
|
|
259
|
+
graphqlUrl = args[++i];
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (arg.startsWith('--graphql-url=')) {
|
|
263
|
+
graphqlUrl = arg.slice(14);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
// API headers: --api-header="Name: ${VAR}" (repeatable)
|
|
267
|
+
if (arg === '--api-header' && args[i + 1]) {
|
|
268
|
+
apiHeaders.push(args[++i]);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (arg.startsWith('--api-header=')) {
|
|
272
|
+
apiHeaders.push(arg.slice(13));
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
// Labels: --label=key:value (repeatable)
|
|
276
|
+
if (arg === '--label' && args[i + 1]) {
|
|
277
|
+
parseLabel(args[++i], labels);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (arg.startsWith('--label=')) {
|
|
281
|
+
parseLabel(arg.slice(8), labels);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (noScope)
|
|
286
|
+
scope = null;
|
|
287
|
+
// Resolve from env / config
|
|
288
|
+
if (!apiKey)
|
|
289
|
+
apiKey = process.env.COLONY_KEY || '';
|
|
290
|
+
if (!colonyUrl)
|
|
291
|
+
colonyUrl = process.env.COLONY_URL || 'https://colony.honeyb.dev';
|
|
292
|
+
if (!name)
|
|
293
|
+
name = hostname() || 'funnel';
|
|
294
|
+
// Normalize to wss:// for WebSocket
|
|
295
|
+
if (colonyUrl.startsWith('http://'))
|
|
296
|
+
colonyUrl = colonyUrl.replace('http://', 'ws://');
|
|
297
|
+
else if (colonyUrl.startsWith('https://'))
|
|
298
|
+
colonyUrl = colonyUrl.replace('https://', 'wss://');
|
|
299
|
+
else if (!colonyUrl.startsWith('ws://') && !colonyUrl.startsWith('wss://'))
|
|
300
|
+
colonyUrl = `wss://${colonyUrl}`;
|
|
301
|
+
return { hiveId, apiKey, name, verbose, colonyUrl, dancePaths, toolDefs, scope, acls, apiSpecs, apiUrl, graphqlSources, graphqlUrl, apiHeaders, labels };
|
|
302
|
+
}
|
|
303
|
+
function parseDancePath(value, out) {
|
|
304
|
+
const colonIdx = value.indexOf(':');
|
|
305
|
+
// Check if the colon is part of a Windows drive letter (e.g. C:\) or not present
|
|
306
|
+
const isWindowsDrive = colonIdx === 1 && /^[a-zA-Z]$/.test(value[0]);
|
|
307
|
+
if (colonIdx > 0 && !isWindowsDrive) {
|
|
308
|
+
const path = value.slice(0, colonIdx);
|
|
309
|
+
const names = value.slice(colonIdx + 1).split(',').map(s => s.trim()).filter(Boolean);
|
|
310
|
+
out.push({ path, whitelist: new Set(names) });
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
out.push({ path: value });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
function parseAcl(value, out) {
|
|
317
|
+
const colonIdx = value.indexOf(':');
|
|
318
|
+
if (colonIdx <= 0)
|
|
319
|
+
return;
|
|
320
|
+
const tool = value.slice(0, colonIdx).trim();
|
|
321
|
+
const roles = value.slice(colonIdx + 1).split(',').map(s => s.trim()).filter(Boolean);
|
|
322
|
+
if (tool && roles.length > 0) {
|
|
323
|
+
out.set(tool, roles);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
function parseLabel(value, out) {
|
|
327
|
+
const colonIdx = value.indexOf(':');
|
|
328
|
+
if (colonIdx <= 0)
|
|
329
|
+
return;
|
|
330
|
+
const key = value.slice(0, colonIdx).trim();
|
|
331
|
+
const val = value.slice(colonIdx + 1).trim();
|
|
332
|
+
if (key && val)
|
|
333
|
+
out[key] = val;
|
|
334
|
+
}
|
|
335
|
+
// --- Scope validation ---
|
|
336
|
+
/**
|
|
337
|
+
* Validate that a path or command doesn't escape the scope directory.
|
|
338
|
+
* Returns null if ok, error string if violation detected.
|
|
339
|
+
*/
|
|
340
|
+
function validateScope(scope, tool, args) {
|
|
341
|
+
// File operations: check path arg
|
|
342
|
+
if (tool === 'read_file' || tool === 'write_file' || tool === 'patch_file') {
|
|
343
|
+
const filePath = args.path;
|
|
344
|
+
if (typeof filePath !== 'string')
|
|
345
|
+
return null;
|
|
346
|
+
const resolved = resolve(filePath);
|
|
347
|
+
if (!resolved.startsWith(scope + '/') && resolved !== scope) {
|
|
348
|
+
return `Path ${relative(scope, resolved) || resolved} escapes scope ${scope}`;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// List/glob: check path/dir arg
|
|
352
|
+
if (tool === 'list_files' || tool === 'glob') {
|
|
353
|
+
const dir = args.path || args.dir || args.directory;
|
|
354
|
+
if (typeof dir === 'string') {
|
|
355
|
+
const resolved = resolve(dir);
|
|
356
|
+
if (!resolved.startsWith(scope + '/') && resolved !== scope) {
|
|
357
|
+
return `Directory ${relative(scope, resolved) || resolved} escapes scope ${scope}`;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
// Shell: check cwd arg
|
|
362
|
+
if (tool === 'shell') {
|
|
363
|
+
const cwd = args.cwd;
|
|
364
|
+
if (typeof cwd === 'string') {
|
|
365
|
+
const resolved = resolve(cwd);
|
|
366
|
+
if (!resolved.startsWith(scope + '/') && resolved !== scope) {
|
|
367
|
+
return `Working directory ${relative(scope, resolved) || resolved} escapes scope ${scope}`;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// Git operations: check cwd
|
|
372
|
+
if (tool.startsWith('git_')) {
|
|
373
|
+
const cwd = args.cwd;
|
|
374
|
+
if (typeof cwd === 'string') {
|
|
375
|
+
const resolved = resolve(cwd);
|
|
376
|
+
if (!resolved.startsWith(scope + '/') && resolved !== scope) {
|
|
377
|
+
return `Git cwd ${relative(scope, resolved) || resolved} escapes scope ${scope}`;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
// Grep: check path arg
|
|
382
|
+
if (tool === 'grep') {
|
|
383
|
+
const grepPath = args.path || args.dir;
|
|
384
|
+
if (typeof grepPath === 'string') {
|
|
385
|
+
const resolved = resolve(grepPath);
|
|
386
|
+
if (!resolved.startsWith(scope + '/') && resolved !== scope) {
|
|
387
|
+
return `Search path ${relative(scope, resolved) || resolved} escapes scope ${scope}`;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
// --- Custom shell tool execution ---
|
|
394
|
+
async function execShellTool(command, args) {
|
|
395
|
+
const { execFileSync } = await import('node:child_process');
|
|
396
|
+
try {
|
|
397
|
+
const stdout = execFileSync(command, [JSON.stringify(args)], {
|
|
398
|
+
encoding: 'utf8',
|
|
399
|
+
timeout: 30_000,
|
|
400
|
+
maxBuffer: 1_048_576,
|
|
401
|
+
});
|
|
402
|
+
return { ok: true, data: { stdout: stdout.trim() } };
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
return { ok: false, error: err.stderr || err.message || 'Shell tool failed' };
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// --- Tool discovery ---
|
|
409
|
+
async function discoverTools(opts, danceHandlers, customHandlers) {
|
|
410
|
+
const tools = [];
|
|
411
|
+
// 1. Dance files
|
|
412
|
+
if (opts.dancePaths.length > 0) {
|
|
413
|
+
const { loadDanceTools } = await import('../lib/dance-loader.js');
|
|
414
|
+
for (const { path, whitelist } of opts.dancePaths) {
|
|
415
|
+
try {
|
|
416
|
+
const danceTools = await loadDanceTools(path, whitelist);
|
|
417
|
+
for (const dt of danceTools) {
|
|
418
|
+
const acl = opts.acls.get(dt.name);
|
|
419
|
+
tools.push({
|
|
420
|
+
name: dt.name,
|
|
421
|
+
description: dt.description,
|
|
422
|
+
parameters: dt.params,
|
|
423
|
+
source: 'dance',
|
|
424
|
+
allowedRoles: acl,
|
|
425
|
+
});
|
|
426
|
+
danceHandlers.set(dt.name, dt.handler);
|
|
427
|
+
}
|
|
428
|
+
if (opts.verbose)
|
|
429
|
+
console.log(c('dim', ` Loaded ${danceTools.length} tools from ${path}`));
|
|
430
|
+
}
|
|
431
|
+
catch (err) {
|
|
432
|
+
console.log(c('red', ` Failed to load dance file ${path}: ${err instanceof Error ? err.message : String(err)}`));
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
// 2. OpenAPI specs
|
|
437
|
+
if (opts.apiSpecs.length > 0) {
|
|
438
|
+
const { resolveHeaders } = await import('../lib/header-resolver.js');
|
|
439
|
+
const { loadOpenAPITools } = await import('../lib/openapi-loader.js');
|
|
440
|
+
const headers = opts.apiHeaders.length > 0 ? resolveHeaders(opts.apiHeaders) : undefined;
|
|
441
|
+
for (const specPath of opts.apiSpecs) {
|
|
442
|
+
try {
|
|
443
|
+
const apiTools = await loadOpenAPITools(specPath, opts.apiUrl || undefined, headers);
|
|
444
|
+
for (const at of apiTools) {
|
|
445
|
+
const acl = opts.acls.get(at.name);
|
|
446
|
+
tools.push({
|
|
447
|
+
name: at.name,
|
|
448
|
+
description: at.description,
|
|
449
|
+
parameters: at.params,
|
|
450
|
+
source: 'openapi',
|
|
451
|
+
allowedRoles: acl,
|
|
452
|
+
});
|
|
453
|
+
customHandlers.set(at.name, at.handler);
|
|
454
|
+
}
|
|
455
|
+
if (opts.verbose)
|
|
456
|
+
console.log(c('dim', ` Loaded ${apiTools.length} tools from OpenAPI: ${specPath}`));
|
|
457
|
+
}
|
|
458
|
+
catch (err) {
|
|
459
|
+
console.log(c('red', ` Failed to load OpenAPI spec ${specPath}: ${err instanceof Error ? err.message : String(err)}`));
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
// 3. GraphQL sources
|
|
464
|
+
if (opts.graphqlSources.length > 0) {
|
|
465
|
+
const { resolveHeaders } = await import('../lib/header-resolver.js');
|
|
466
|
+
const { loadGraphQLTools } = await import('../lib/graphql-loader.js');
|
|
467
|
+
const headers = opts.apiHeaders.length > 0 ? resolveHeaders(opts.apiHeaders) : undefined;
|
|
468
|
+
for (const source of opts.graphqlSources) {
|
|
469
|
+
try {
|
|
470
|
+
const gqlTools = await loadGraphQLTools(source, opts.graphqlUrl || undefined, headers);
|
|
471
|
+
for (const gt of gqlTools) {
|
|
472
|
+
const acl = opts.acls.get(gt.name);
|
|
473
|
+
tools.push({
|
|
474
|
+
name: gt.name,
|
|
475
|
+
description: gt.description,
|
|
476
|
+
parameters: gt.params,
|
|
477
|
+
source: 'graphql',
|
|
478
|
+
allowedRoles: acl,
|
|
479
|
+
});
|
|
480
|
+
customHandlers.set(gt.name, gt.handler);
|
|
481
|
+
}
|
|
482
|
+
if (opts.verbose)
|
|
483
|
+
console.log(c('dim', ` Loaded ${gqlTools.length} tools from GraphQL: ${source}`));
|
|
484
|
+
}
|
|
485
|
+
catch (err) {
|
|
486
|
+
console.log(c('red', ` Failed to load GraphQL source ${source}: ${err instanceof Error ? err.message : String(err)}`));
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
// 4. Custom --tool definitions
|
|
491
|
+
for (const def of opts.toolDefs) {
|
|
492
|
+
const eqIdx = def.indexOf('=');
|
|
493
|
+
if (eqIdx === -1) {
|
|
494
|
+
console.log(c('yellow', ` Skipping malformed --tool (expected name=command): ${def}`));
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
const toolName = def.slice(0, eqIdx);
|
|
498
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(toolName)) {
|
|
499
|
+
console.log(c('yellow', ` Skipping invalid tool name: ${toolName}`));
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
const command = resolve(def.slice(eqIdx + 1));
|
|
503
|
+
const acl = opts.acls.get(toolName);
|
|
504
|
+
tools.push({
|
|
505
|
+
name: toolName,
|
|
506
|
+
description: `Custom: ${command}`,
|
|
507
|
+
parameters: { args: { type: 'object', description: 'Arguments passed as JSON' } },
|
|
508
|
+
source: 'shell',
|
|
509
|
+
allowedRoles: acl,
|
|
510
|
+
});
|
|
511
|
+
customHandlers.set(toolName, (args) => execShellTool(command, args));
|
|
512
|
+
if (opts.verbose)
|
|
513
|
+
console.log(c('dim', ` Registered custom tool: ${toolName} -> ${command}`));
|
|
514
|
+
}
|
|
515
|
+
// 5. Propolis tools (auto-discovered)
|
|
516
|
+
try {
|
|
517
|
+
// @ts-expect-error — optional dependency, may not be installed
|
|
518
|
+
const propolis = await import('@honeybee-ai/propolis');
|
|
519
|
+
if (propolis.createPlugin) {
|
|
520
|
+
const plugin = propolis.createPlugin();
|
|
521
|
+
if (plugin.getToolEntries) {
|
|
522
|
+
const entries = plugin.getToolEntries({});
|
|
523
|
+
for (const [toolName, entry] of Object.entries(entries)) {
|
|
524
|
+
const acl = opts.acls.get(toolName);
|
|
525
|
+
tools.push({
|
|
526
|
+
name: toolName,
|
|
527
|
+
description: entry.description || `Propolis: ${toolName}`,
|
|
528
|
+
source: 'propolis',
|
|
529
|
+
allowedRoles: acl,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (opts.verbose)
|
|
535
|
+
console.log(c('dim', ` Discovered propolis tools`));
|
|
536
|
+
}
|
|
537
|
+
catch {
|
|
538
|
+
// Propolis not installed — provide basic shell tool if no other tools found
|
|
539
|
+
if (tools.length === 0) {
|
|
540
|
+
if (opts.verbose)
|
|
541
|
+
console.log(c('dim', ' Propolis not installed, using built-in shell tool'));
|
|
542
|
+
const acl = opts.acls.get('shell');
|
|
543
|
+
tools.push({
|
|
544
|
+
name: 'shell',
|
|
545
|
+
description: 'Execute a shell command on this machine',
|
|
546
|
+
parameters: { command: { type: 'string', description: 'Command to execute' } },
|
|
547
|
+
source: 'custom',
|
|
548
|
+
allowedRoles: acl,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return tools;
|
|
553
|
+
}
|
|
554
|
+
// --- Built-in tool handler ---
|
|
555
|
+
async function handleToolCall(tool, args, scope, danceHandlers, customHandlers) {
|
|
556
|
+
// Scope check (applies to all tools)
|
|
557
|
+
if (scope) {
|
|
558
|
+
const violation = validateScope(scope, tool, args);
|
|
559
|
+
if (violation) {
|
|
560
|
+
return { ok: false, error: `Scope violation: ${violation}` };
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
// Check dance handlers first
|
|
564
|
+
const danceHandler = danceHandlers.get(tool);
|
|
565
|
+
if (danceHandler)
|
|
566
|
+
return danceHandler(args);
|
|
567
|
+
// Check custom shell tool handlers
|
|
568
|
+
const customHandler = customHandlers.get(tool);
|
|
569
|
+
if (customHandler)
|
|
570
|
+
return customHandler(args);
|
|
571
|
+
// Try propolis
|
|
572
|
+
try {
|
|
573
|
+
// @ts-expect-error — optional dependency, may not be installed
|
|
574
|
+
const propolis = await import('@honeybee-ai/propolis');
|
|
575
|
+
if (propolis.createPlugin) {
|
|
576
|
+
const plugin = propolis.createPlugin();
|
|
577
|
+
const entries = plugin.getToolEntries ? plugin.getToolEntries({}) : {};
|
|
578
|
+
const handler = entries[tool];
|
|
579
|
+
if (handler && handler.handler) {
|
|
580
|
+
const result = await handler.handler(args);
|
|
581
|
+
return { ok: true, data: result };
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
catch {
|
|
586
|
+
// Fall through to built-in handlers
|
|
587
|
+
}
|
|
588
|
+
// Built-in shell handler
|
|
589
|
+
if (tool === 'shell' && typeof args.command === 'string') {
|
|
590
|
+
const { execFileSync } = await import('node:child_process');
|
|
591
|
+
try {
|
|
592
|
+
const cwd = typeof args.cwd === 'string' ? args.cwd : (scope || process.cwd());
|
|
593
|
+
const stdout = execFileSync('/bin/sh', ['-c', args.command], {
|
|
594
|
+
encoding: 'utf8',
|
|
595
|
+
timeout: 30_000,
|
|
596
|
+
maxBuffer: 1_048_576,
|
|
597
|
+
cwd,
|
|
598
|
+
});
|
|
599
|
+
return { ok: true, data: { stdout } };
|
|
600
|
+
}
|
|
601
|
+
catch (err) {
|
|
602
|
+
return {
|
|
603
|
+
ok: false,
|
|
604
|
+
error: err.stderr || err.message || 'Shell command failed',
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return { ok: false, error: `Unknown tool: ${tool}` };
|
|
609
|
+
}
|
|
610
|
+
// --- WebSocket connection ---
|
|
611
|
+
async function startFunnel(args) {
|
|
612
|
+
const opts = parseArgs(args);
|
|
613
|
+
if (!opts.hiveId) {
|
|
614
|
+
console.log(error('Missing --hive <id>. Specify the hive to connect to.'));
|
|
615
|
+
process.exit(1);
|
|
616
|
+
}
|
|
617
|
+
if (!opts.apiKey) {
|
|
618
|
+
console.log(error('Missing API key. Use --key <key> or set COLONY_KEY env var.'));
|
|
619
|
+
process.exit(1);
|
|
620
|
+
}
|
|
621
|
+
console.log(heading('Funnel'));
|
|
622
|
+
console.log(label('Hive', opts.hiveId));
|
|
623
|
+
console.log(label('Name', opts.name));
|
|
624
|
+
console.log(label('Colony', opts.colonyUrl));
|
|
625
|
+
if (opts.scope) {
|
|
626
|
+
console.log(label('Scope', opts.scope));
|
|
627
|
+
}
|
|
628
|
+
else {
|
|
629
|
+
console.log(label('Scope', c('yellow', 'DISABLED (--no-scope)')));
|
|
630
|
+
console.log(c('yellow', ' WARNING: All tools have unrestricted filesystem access'));
|
|
631
|
+
}
|
|
632
|
+
// Handler registries for dance + custom tools
|
|
633
|
+
const danceHandlers = new Map();
|
|
634
|
+
const customHandlers = new Map();
|
|
635
|
+
// Discover tools
|
|
636
|
+
console.log(c('dim', '\nDiscovering tools...'));
|
|
637
|
+
const tools = await discoverTools(opts, danceHandlers, customHandlers);
|
|
638
|
+
if (tools.length === 0) {
|
|
639
|
+
console.log(error('No tools discovered. Install @honeybee-ai/propolis, use --dances, or --tool.'));
|
|
640
|
+
process.exit(1);
|
|
641
|
+
}
|
|
642
|
+
console.log(success(`Found ${tools.length} tools: ${tools.map(t => t.name).join(', ')}`));
|
|
643
|
+
if (opts.acls.size > 0) {
|
|
644
|
+
for (const [toolName, roles] of opts.acls) {
|
|
645
|
+
console.log(c('dim', ` ACL: ${toolName} -> ${roles.join(', ')}`));
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
// Connect with reconnection
|
|
649
|
+
let reconnectDelay = 1000;
|
|
650
|
+
let funnelId = '';
|
|
651
|
+
let signingKey = null;
|
|
652
|
+
let callCount = 0;
|
|
653
|
+
let shuttingDown = false;
|
|
654
|
+
function connect() {
|
|
655
|
+
// Native WebSocket can't set custom headers — pass API key as query param
|
|
656
|
+
const wsUrl = `${opts.colonyUrl}/f/${encodeURIComponent(opts.hiveId)}/ws?key=${encodeURIComponent(opts.apiKey)}`;
|
|
657
|
+
if (opts.verbose)
|
|
658
|
+
console.log(c('dim', `\nConnecting to ${opts.colonyUrl}/f/${opts.hiveId}/ws ...`));
|
|
659
|
+
let ws;
|
|
660
|
+
try {
|
|
661
|
+
ws = new WebSocket(wsUrl);
|
|
662
|
+
}
|
|
663
|
+
catch {
|
|
664
|
+
console.log(c('yellow', 'Failed to create WebSocket connection'));
|
|
665
|
+
scheduleReconnect();
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
ws.addEventListener('open', () => {
|
|
669
|
+
reconnectDelay = 1000; // Reset backoff
|
|
670
|
+
// Send registration with ACL info and labels
|
|
671
|
+
ws.send(JSON.stringify({
|
|
672
|
+
type: 'funnel_register',
|
|
673
|
+
name: opts.name,
|
|
674
|
+
tools: tools.map(t => ({
|
|
675
|
+
name: t.name,
|
|
676
|
+
description: t.description,
|
|
677
|
+
parameters: t.parameters,
|
|
678
|
+
source: t.source,
|
|
679
|
+
...(t.allowedRoles ? { allowedRoles: t.allowedRoles } : {}),
|
|
680
|
+
})),
|
|
681
|
+
...(Object.keys(opts.labels).length > 0 ? { labels: opts.labels } : {}),
|
|
682
|
+
}));
|
|
683
|
+
});
|
|
684
|
+
ws.addEventListener('message', (ev) => {
|
|
685
|
+
const text = typeof ev.data === 'string' ? ev.data : String(ev.data);
|
|
686
|
+
let msg;
|
|
687
|
+
try {
|
|
688
|
+
msg = JSON.parse(text);
|
|
689
|
+
}
|
|
690
|
+
catch {
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
switch (msg.type) {
|
|
694
|
+
case 'funnel_welcome': {
|
|
695
|
+
funnelId = msg.funnelId;
|
|
696
|
+
// Import HMAC signing key if provided
|
|
697
|
+
if (typeof msg.signingKey === 'string') {
|
|
698
|
+
importSigningKey(msg.signingKey).then(key => {
|
|
699
|
+
signingKey = key;
|
|
700
|
+
if (opts.verbose)
|
|
701
|
+
console.log(c('dim', ' HMAC signing key imported'));
|
|
702
|
+
}).catch(() => {
|
|
703
|
+
console.log(c('yellow', ' Warning: failed to import signing key'));
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
console.log(success(`Connected as ${funnelId}`));
|
|
707
|
+
console.log(c('dim', 'Waiting for tool calls... (Ctrl+C to disconnect)\n'));
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
case 'tool_ack': {
|
|
711
|
+
if (opts.verbose) {
|
|
712
|
+
const accepted = msg.accepted || [];
|
|
713
|
+
const rejected = msg.rejected || [];
|
|
714
|
+
if (accepted.length > 0)
|
|
715
|
+
console.log(c('dim', ` Accepted: ${accepted.join(', ')}`));
|
|
716
|
+
if (rejected.length > 0)
|
|
717
|
+
console.log(c('yellow', ` Rejected: ${rejected.join(', ')}`));
|
|
718
|
+
}
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
case 'tool_call': {
|
|
722
|
+
const call = msg;
|
|
723
|
+
callCount++;
|
|
724
|
+
const ts = new Date().toLocaleTimeString();
|
|
725
|
+
console.log(`${c('dim', ts)} ${c('cyan', call.tool)} from ${c('bold', call.agentId)} ${c('dim', `(${call.role})`)}`);
|
|
726
|
+
if (opts.verbose) {
|
|
727
|
+
console.log(c('dim', ` args: ${JSON.stringify(call.args)}`));
|
|
728
|
+
}
|
|
729
|
+
// Verify HMAC signature if signing key is available
|
|
730
|
+
const verifySig = (signingKey && call.sig)
|
|
731
|
+
? verifyHmac(signingKey, buildSignPayload(call.callId, call.tool, call.args, call.nonce), call.sig)
|
|
732
|
+
: Promise.resolve(true);
|
|
733
|
+
verifySig.then(valid => {
|
|
734
|
+
if (!valid) {
|
|
735
|
+
console.log(c('red', ' -> REJECTED: invalid HMAC signature'));
|
|
736
|
+
ws.send(JSON.stringify({
|
|
737
|
+
type: 'tool_result',
|
|
738
|
+
callId: call.callId,
|
|
739
|
+
result: { ok: false, error: 'HMAC signature verification failed' },
|
|
740
|
+
nonce: call.nonce,
|
|
741
|
+
}));
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
return handleToolCall(call.tool, call.args, opts.scope, danceHandlers, customHandlers);
|
|
745
|
+
}).then(result => {
|
|
746
|
+
if (!result)
|
|
747
|
+
return; // Already handled (sig rejection)
|
|
748
|
+
ws.send(JSON.stringify({
|
|
749
|
+
type: 'tool_result',
|
|
750
|
+
callId: call.callId,
|
|
751
|
+
result,
|
|
752
|
+
nonce: call.nonce,
|
|
753
|
+
}));
|
|
754
|
+
if (result.ok) {
|
|
755
|
+
console.log(c('green', ' -> ok'));
|
|
756
|
+
}
|
|
757
|
+
else {
|
|
758
|
+
console.log(c('red', ` -> error: ${result.error}`));
|
|
759
|
+
}
|
|
760
|
+
}).catch((err) => {
|
|
761
|
+
ws.send(JSON.stringify({
|
|
762
|
+
type: 'tool_result',
|
|
763
|
+
callId: call.callId,
|
|
764
|
+
result: { ok: false, error: err.message || 'Handler threw' },
|
|
765
|
+
nonce: call.nonce,
|
|
766
|
+
}));
|
|
767
|
+
console.log(c('red', ` -> exception: ${err.message}`));
|
|
768
|
+
});
|
|
769
|
+
break;
|
|
770
|
+
}
|
|
771
|
+
case 'ping': {
|
|
772
|
+
const mem = process.memoryUsage?.();
|
|
773
|
+
ws.send(JSON.stringify({
|
|
774
|
+
type: 'pong',
|
|
775
|
+
ts: Date.now(),
|
|
776
|
+
cpuLoad: loadavg()[0],
|
|
777
|
+
memoryMb: mem ? Math.round(mem.rss / 1048576) : 0,
|
|
778
|
+
queueDepth: 0,
|
|
779
|
+
uptime: Math.round(process.uptime()),
|
|
780
|
+
}));
|
|
781
|
+
break;
|
|
782
|
+
}
|
|
783
|
+
case 'error': {
|
|
784
|
+
console.log(c('red', `Server error: ${msg.message || 'Unknown'}`));
|
|
785
|
+
break;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
ws.addEventListener('close', () => {
|
|
790
|
+
if (shuttingDown)
|
|
791
|
+
return;
|
|
792
|
+
console.log(c('yellow', `\nDisconnected. Reconnecting in ${reconnectDelay / 1000}s...`));
|
|
793
|
+
scheduleReconnect();
|
|
794
|
+
});
|
|
795
|
+
ws.addEventListener('error', () => {
|
|
796
|
+
// error fires before close — close handler will reconnect
|
|
797
|
+
});
|
|
798
|
+
// Graceful shutdown
|
|
799
|
+
const shutdown = () => {
|
|
800
|
+
if (shuttingDown)
|
|
801
|
+
return;
|
|
802
|
+
shuttingDown = true;
|
|
803
|
+
console.log(c('dim', `\nDisconnecting... (${callCount} calls handled)`));
|
|
804
|
+
try {
|
|
805
|
+
ws.close(1000);
|
|
806
|
+
}
|
|
807
|
+
catch {
|
|
808
|
+
// Already closed
|
|
809
|
+
}
|
|
810
|
+
setTimeout(() => process.exit(0), 500);
|
|
811
|
+
};
|
|
812
|
+
process.on('SIGINT', shutdown);
|
|
813
|
+
process.on('SIGTERM', shutdown);
|
|
814
|
+
function scheduleReconnect() {
|
|
815
|
+
setTimeout(() => {
|
|
816
|
+
reconnectDelay = Math.min(reconnectDelay * 2, 30_000);
|
|
817
|
+
connect();
|
|
818
|
+
}, reconnectDelay);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
connect();
|
|
822
|
+
// Keep process alive
|
|
823
|
+
await new Promise(() => { });
|
|
824
|
+
}
|
|
825
|
+
// --- Status ---
|
|
826
|
+
async function showStatus() {
|
|
827
|
+
console.log(heading('Funnel Status'));
|
|
828
|
+
// Check for propolis
|
|
829
|
+
let propolisAvailable = false;
|
|
830
|
+
try {
|
|
831
|
+
// @ts-expect-error — optional dependency, may not be installed
|
|
832
|
+
await import('@honeybee-ai/propolis');
|
|
833
|
+
propolisAvailable = true;
|
|
834
|
+
}
|
|
835
|
+
catch {
|
|
836
|
+
// Not installed
|
|
837
|
+
}
|
|
838
|
+
console.log(label('Propolis', propolisAvailable ? c('green', 'installed') : c('yellow', 'not installed')));
|
|
839
|
+
const dummyHandlers = new Map();
|
|
840
|
+
const dummyOpts = {
|
|
841
|
+
hiveId: '', apiKey: '', name: '', verbose: false, colonyUrl: '',
|
|
842
|
+
dancePaths: [], toolDefs: [], scope: process.cwd(), acls: new Map(),
|
|
843
|
+
apiSpecs: [], apiUrl: '', graphqlSources: [], graphqlUrl: '', apiHeaders: [],
|
|
844
|
+
labels: {},
|
|
845
|
+
};
|
|
846
|
+
const tools = await discoverTools(dummyOpts, dummyHandlers, dummyHandlers);
|
|
847
|
+
console.log(label('Available tools', tools.length > 0 ? tools.map(t => t.name).join(', ') : c('yellow', 'none')));
|
|
848
|
+
const colonyKey = process.env.COLONY_KEY;
|
|
849
|
+
console.log(label('COLONY_KEY', colonyKey ? c('green', 'set') : c('yellow', 'not set')));
|
|
850
|
+
console.log(c('dim', '\nUse `wgl funnel --hive <id> --key <key>` to connect'));
|
|
851
|
+
}
|
|
852
|
+
// --- Daemon install/uninstall ---
|
|
853
|
+
const FUNNEL_CONFIG_DIR = join(homedir(), '.honeyb');
|
|
854
|
+
const FUNNEL_CONFIG_FILE = join(FUNNEL_CONFIG_DIR, 'funnel.json');
|
|
855
|
+
async function installDaemon(args) {
|
|
856
|
+
const opts = parseArgs(args);
|
|
857
|
+
if (!opts.hiveId) {
|
|
858
|
+
console.log(error('Missing --hive <id>. Specify the hive to connect to.'));
|
|
859
|
+
process.exit(1);
|
|
860
|
+
}
|
|
861
|
+
if (!opts.apiKey) {
|
|
862
|
+
console.log(error('Missing API key. Use --key <key> or set COLONY_KEY env var.'));
|
|
863
|
+
process.exit(1);
|
|
864
|
+
}
|
|
865
|
+
const { existsSync, mkdirSync, writeFileSync } = await import('node:fs');
|
|
866
|
+
const { execFileSync } = await import('node:child_process');
|
|
867
|
+
const nodePath = process.execPath;
|
|
868
|
+
const wglPath = process.argv[1];
|
|
869
|
+
const isMac = process.platform === 'darwin';
|
|
870
|
+
// Build the funnel args string
|
|
871
|
+
const funnelArgs = ['funnel', '--hive', opts.hiveId];
|
|
872
|
+
if (opts.colonyUrl && opts.colonyUrl !== 'wss://colony.honeyb.dev') {
|
|
873
|
+
funnelArgs.push('--colony', opts.colonyUrl);
|
|
874
|
+
}
|
|
875
|
+
if (opts.scope === null) {
|
|
876
|
+
funnelArgs.push('--no-scope');
|
|
877
|
+
}
|
|
878
|
+
else if (opts.scope !== process.cwd()) {
|
|
879
|
+
funnelArgs.push('--scope', opts.scope);
|
|
880
|
+
}
|
|
881
|
+
for (const dp of opts.dancePaths) {
|
|
882
|
+
const danceArg = dp.whitelist ? `${dp.path}:${[...dp.whitelist].join(',')}` : dp.path;
|
|
883
|
+
funnelArgs.push(`--dances=${danceArg}`);
|
|
884
|
+
}
|
|
885
|
+
for (const td of opts.toolDefs)
|
|
886
|
+
funnelArgs.push(`--tool=${td}`);
|
|
887
|
+
for (const [tool, roles] of opts.acls)
|
|
888
|
+
funnelArgs.push(`--acl=${tool}:${roles.join(',')}`);
|
|
889
|
+
for (const [k, v] of Object.entries(opts.labels))
|
|
890
|
+
funnelArgs.push(`--label=${k}:${v}`);
|
|
891
|
+
if (opts.verbose)
|
|
892
|
+
funnelArgs.push('--verbose');
|
|
893
|
+
if (opts.name && opts.name !== (hostname() || 'funnel'))
|
|
894
|
+
funnelArgs.push('--name', opts.name);
|
|
895
|
+
const argsString = funnelArgs.map(a => a.includes(' ') ? `"${a}"` : a).join(' ');
|
|
896
|
+
console.log(heading('Funnel Daemon Install'));
|
|
897
|
+
if (isMac) {
|
|
898
|
+
// --- launchd ---
|
|
899
|
+
const plistDir = join(homedir(), 'Library', 'LaunchAgents');
|
|
900
|
+
const plistPath = join(plistDir, 'dev.honeyb.wgl-funnel.plist');
|
|
901
|
+
const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
|
|
902
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
903
|
+
<plist version="1.0">
|
|
904
|
+
<dict>
|
|
905
|
+
<key>Label</key>
|
|
906
|
+
<string>dev.honeyb.wgl-funnel</string>
|
|
907
|
+
<key>ProgramArguments</key>
|
|
908
|
+
<array>
|
|
909
|
+
<string>${nodePath}</string>
|
|
910
|
+
<string>${wglPath}</string>
|
|
911
|
+
${funnelArgs.map(a => ` <string>${a}</string>`).join('\n')}
|
|
912
|
+
</array>
|
|
913
|
+
<key>EnvironmentVariables</key>
|
|
914
|
+
<dict>
|
|
915
|
+
<key>COLONY_KEY</key>
|
|
916
|
+
<string>${opts.apiKey}</string>
|
|
917
|
+
</dict>
|
|
918
|
+
<key>RunAtLoad</key>
|
|
919
|
+
<true/>
|
|
920
|
+
<key>KeepAlive</key>
|
|
921
|
+
<true/>
|
|
922
|
+
<key>StandardOutPath</key>
|
|
923
|
+
<string>${join(FUNNEL_CONFIG_DIR, 'funnel.log')}</string>
|
|
924
|
+
<key>StandardErrorPath</key>
|
|
925
|
+
<string>${join(FUNNEL_CONFIG_DIR, 'funnel.err')}</string>
|
|
926
|
+
</dict>
|
|
927
|
+
</plist>`;
|
|
928
|
+
if (!existsSync(plistDir))
|
|
929
|
+
mkdirSync(plistDir, { recursive: true });
|
|
930
|
+
writeFileSync(plistPath, plistContent);
|
|
931
|
+
console.log(label('Plist', plistPath));
|
|
932
|
+
try {
|
|
933
|
+
execFileSync('launchctl', ['load', plistPath], { encoding: 'utf8' });
|
|
934
|
+
console.log(success('Daemon installed and started'));
|
|
935
|
+
}
|
|
936
|
+
catch (err) {
|
|
937
|
+
console.log(error(`launchctl load failed: ${err.message}`));
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
else {
|
|
941
|
+
// --- systemd (user-level) ---
|
|
942
|
+
const unitDir = join(homedir(), '.config', 'systemd', 'user');
|
|
943
|
+
const unitPath = join(unitDir, 'wgl-funnel.service');
|
|
944
|
+
const unitContent = `[Unit]
|
|
945
|
+
Description=Honeybee Funnel (${opts.name})
|
|
946
|
+
After=network.target
|
|
947
|
+
|
|
948
|
+
[Service]
|
|
949
|
+
Type=simple
|
|
950
|
+
ExecStart=${nodePath} ${wglPath} ${argsString}
|
|
951
|
+
Restart=always
|
|
952
|
+
RestartSec=5
|
|
953
|
+
Environment=COLONY_KEY=${opts.apiKey}
|
|
954
|
+
|
|
955
|
+
[Install]
|
|
956
|
+
WantedBy=default.target
|
|
957
|
+
`;
|
|
958
|
+
if (!existsSync(unitDir))
|
|
959
|
+
mkdirSync(unitDir, { recursive: true });
|
|
960
|
+
writeFileSync(unitPath, unitContent);
|
|
961
|
+
console.log(label('Unit file', unitPath));
|
|
962
|
+
try {
|
|
963
|
+
execFileSync('systemctl', ['--user', 'daemon-reload'], { encoding: 'utf8' });
|
|
964
|
+
execFileSync('systemctl', ['--user', 'enable', '--now', 'wgl-funnel'], { encoding: 'utf8' });
|
|
965
|
+
console.log(success('Daemon installed and started'));
|
|
966
|
+
console.log(c('dim', ' Check status: systemctl --user status wgl-funnel'));
|
|
967
|
+
console.log(c('dim', ' View logs: journalctl --user -u wgl-funnel -f'));
|
|
968
|
+
}
|
|
969
|
+
catch (err) {
|
|
970
|
+
console.log(error(`systemctl failed: ${err.message}`));
|
|
971
|
+
console.log(c('dim', ' You may need to enable lingering: loginctl enable-linger $USER'));
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
// Save config for reference / uninstall
|
|
975
|
+
if (!existsSync(FUNNEL_CONFIG_DIR))
|
|
976
|
+
mkdirSync(FUNNEL_CONFIG_DIR, { recursive: true });
|
|
977
|
+
writeFileSync(FUNNEL_CONFIG_FILE, JSON.stringify({
|
|
978
|
+
hiveId: opts.hiveId,
|
|
979
|
+
name: opts.name,
|
|
980
|
+
args: funnelArgs,
|
|
981
|
+
installedAt: new Date().toISOString(),
|
|
982
|
+
platform: isMac ? 'launchd' : 'systemd',
|
|
983
|
+
}, null, 2));
|
|
984
|
+
console.log(label('Config', FUNNEL_CONFIG_FILE));
|
|
985
|
+
}
|
|
986
|
+
async function uninstallDaemon() {
|
|
987
|
+
const { existsSync, unlinkSync } = await import('node:fs');
|
|
988
|
+
const { execFileSync } = await import('node:child_process');
|
|
989
|
+
const isMac = process.platform === 'darwin';
|
|
990
|
+
console.log(heading('Funnel Daemon Uninstall'));
|
|
991
|
+
if (isMac) {
|
|
992
|
+
const plistPath = join(homedir(), 'Library', 'LaunchAgents', 'dev.honeyb.wgl-funnel.plist');
|
|
993
|
+
if (existsSync(plistPath)) {
|
|
994
|
+
try {
|
|
995
|
+
execFileSync('launchctl', ['unload', plistPath], { encoding: 'utf8' });
|
|
996
|
+
}
|
|
997
|
+
catch { /* already unloaded */ }
|
|
998
|
+
unlinkSync(plistPath);
|
|
999
|
+
console.log(success('Daemon stopped and plist removed'));
|
|
1000
|
+
}
|
|
1001
|
+
else {
|
|
1002
|
+
console.log(c('yellow', 'No funnel daemon installed (plist not found)'));
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
else {
|
|
1006
|
+
const unitPath = join(homedir(), '.config', 'systemd', 'user', 'wgl-funnel.service');
|
|
1007
|
+
if (existsSync(unitPath)) {
|
|
1008
|
+
try {
|
|
1009
|
+
execFileSync('systemctl', ['--user', 'disable', '--now', 'wgl-funnel'], { encoding: 'utf8' });
|
|
1010
|
+
}
|
|
1011
|
+
catch { /* already stopped */ }
|
|
1012
|
+
try {
|
|
1013
|
+
execFileSync('systemctl', ['--user', 'daemon-reload'], { encoding: 'utf8' });
|
|
1014
|
+
}
|
|
1015
|
+
catch { /* non-fatal */ }
|
|
1016
|
+
unlinkSync(unitPath);
|
|
1017
|
+
console.log(success('Daemon stopped and unit file removed'));
|
|
1018
|
+
}
|
|
1019
|
+
else {
|
|
1020
|
+
console.log(c('yellow', 'No funnel daemon installed (unit file not found)'));
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
// Remove config file
|
|
1024
|
+
if (existsSync(FUNNEL_CONFIG_FILE)) {
|
|
1025
|
+
unlinkSync(FUNNEL_CONFIG_FILE);
|
|
1026
|
+
console.log(c('dim', ' Removed config: ' + FUNNEL_CONFIG_FILE));
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
// Exported for testing
|
|
1030
|
+
export { parseArgs, discoverTools, validateScope, parseDancePath, parseAcl, parseLabel, importSigningKey, verifyHmac, buildSignPayload };
|
|
1031
|
+
//# sourceMappingURL=funnel.js.map
|