@buda-ai/connector 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # Buda Connector
2
+
3
+ Standalone connector daemon for Buda Agent Connector runtimes.
4
+
5
+ The cloud server defaults to `https://buda.im`. Pass `--server-url` or set
6
+ `BUDA_CONNECTOR_SERVER_URL` only for local development or private deployments.
7
+
8
+ ## Role
9
+
10
+ - Registers the local or remote host with `apps/buda`.
11
+ - Starts an embedded `@bunny-agent/daemon` HTTP runtime in the same Node process.
12
+ - Sends heartbeats and local log batches to the cloud registry.
13
+ - Maintains a WebSocket relay so Buda Cloud can actively reach the connector.
14
+ - Stores device identity and local logs under `~/.buda`.
15
+
16
+ ## Local Development
17
+
18
+ Start Buda first:
19
+
20
+ ```bash
21
+ pnpm --filter buda dev
22
+ ```
23
+
24
+ Create/register a connector identity:
25
+
26
+ ```bash
27
+ BUDA_CONNECTOR_SERVER_URL=http://localhost:3000 pnpm --filter @buda-ai/connector new
28
+ ```
29
+
30
+ Run the daemon loop:
31
+
32
+ ```bash
33
+ BUDA_CONNECTOR_SERVER_URL=http://localhost:3000 pnpm --filter @buda-ai/connector dev
34
+ ```
35
+
36
+ The connector starts Bunny Agent daemon on `127.0.0.1` with an ephemeral port
37
+ by default and reports the local daemon URL to Buda in connector metadata.
38
+
39
+ The localhost API path bypasses auth for development. Desktop sidecars should pass
40
+ an OAuth token with `--launch-config-stdin`.
41
+
42
+ ## Launch Config
43
+
44
+ Desktop can start the sidecar with:
45
+
46
+ ```bash
47
+ printf '{"serverUrl":"https://buda.im","oauthToken":"..."}\n' \
48
+ | buda-connector daemon --launch-config-stdin
49
+ ```
50
+
51
+ Supported launch config fields:
52
+
53
+ - `serverUrl`
54
+ - `spaceId`
55
+ - `workdirRoot`
56
+ - `oauthToken`
57
+ - `bunnyDaemonHost`
58
+ - `bunnyDaemonPort`
59
+
60
+ ## One-line install
61
+
62
+ Buda Space Settings can show a simple Space ID command for the current space.
63
+ This mode identifies the Space, not a specific user:
64
+
65
+ ```bash
66
+ npx -y @buda-ai/connector@latest daemon --space-id "org_..."
67
+ ```
68
+
69
+ Desktop and other authenticated clients should use OAuth token launch config
70
+ instead:
71
+
72
+ ```bash
73
+ printf '%s\n' '{"serverUrl":"https://buda.im","oauthToken":"..."}' \
74
+ | npx -y @buda-ai/connector@latest daemon --launch-config-stdin
75
+ ```
76
+
77
+ You can also pass launch options directly:
78
+
79
+ ```bash
80
+ buda-connector daemon --space-id "org_..." --workdir "$HOME/.buda/agents"
81
+ ```
82
+
83
+ ## Local Files
84
+
85
+ - `~/.buda/device.json`
86
+ - `~/.buda/connector.log`
87
+ - `~/.buda/agents/`
package/dist/cli.js ADDED
@@ -0,0 +1,532 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/config.ts
4
+ import { randomUUID } from "crypto";
5
+ import { mkdir, readFile, writeFile } from "fs/promises";
6
+ import { homedir, hostname, platform } from "os";
7
+ import path from "path";
8
+ var CONFIG_DIR = path.join(homedir(), ".buda");
9
+ var CONFIG_PATH = path.join(CONFIG_DIR, "device.json");
10
+ var LOG_PATH = path.join(CONFIG_DIR, "connector.log");
11
+ var DEFAULT_SERVER_URL = "https://buda.im";
12
+ var DEFAULT_WORKDIR_ROOT = path.join(CONFIG_DIR, "agents");
13
+ var DEFAULT_BUNNY_DAEMON_HOST = "127.0.0.1";
14
+ var HEARTBEAT_INTERVAL_MS = 5e3;
15
+ var RELAY_RECONNECT_INTERVAL_MS = 3e3;
16
+ var LAUNCH_CONFIG_STDIN_FLAG = "--launch-config-stdin";
17
+ function getServerUrl(launchConfig) {
18
+ return (launchConfig?.serverUrl || process.env.BUDA_CONNECTOR_SERVER_URL || process.env.BUDA_SERVER_URL || process.env.NEXT_PUBLIC_APP_URL || DEFAULT_SERVER_URL).replace(/\/$/, "");
19
+ }
20
+ function getConnectorRelayUrl(serverUrl, connectorId) {
21
+ const url = new URL("/api/connectors/ws", serverUrl);
22
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
23
+ url.searchParams.set("connectorId", connectorId);
24
+ return url.toString();
25
+ }
26
+ function getWorkdirRoot(launchConfig) {
27
+ return launchConfig?.workdirRoot || process.env.BUDA_CONNECTOR_WORKDIR || DEFAULT_WORKDIR_ROOT;
28
+ }
29
+ function getBunnyDaemonHost(launchConfig) {
30
+ return launchConfig?.bunnyDaemonHost || process.env.BUDA_CONNECTOR_BUNNY_DAEMON_HOST || DEFAULT_BUNNY_DAEMON_HOST;
31
+ }
32
+ function getBunnyDaemonPort(launchConfig) {
33
+ if (launchConfig?.bunnyDaemonPort !== void 0) return launchConfig.bunnyDaemonPort;
34
+ const raw = process.env.BUDA_CONNECTOR_BUNNY_DAEMON_PORT;
35
+ if (!raw) return 0;
36
+ const parsed = Number(raw);
37
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
38
+ }
39
+ function getSpaceId(launchConfig) {
40
+ return launchConfig?.spaceId || process.env.BUDA_CONNECTOR_SPACE_ID;
41
+ }
42
+ function buildDeviceConfig(launchConfig) {
43
+ const host = hostname();
44
+ return {
45
+ connectorId: `cnr_${randomUUID()}`,
46
+ deviceId: `dev_${randomUUID()}`,
47
+ deviceName: host,
48
+ hostLabel: `${host} (${platform()})`,
49
+ serverUrl: getServerUrl(launchConfig),
50
+ workdirRoot: getWorkdirRoot(launchConfig),
51
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
52
+ };
53
+ }
54
+ async function readDeviceConfig() {
55
+ try {
56
+ const raw = await readFile(CONFIG_PATH, "utf8");
57
+ return JSON.parse(raw);
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+ async function writeDeviceConfig(config) {
63
+ await mkdir(CONFIG_DIR, { recursive: true });
64
+ await mkdir(config.workdirRoot, { recursive: true });
65
+ await writeFile(CONFIG_PATH, `${JSON.stringify(config, null, 2)}
66
+ `, { mode: 384 });
67
+ }
68
+ async function ensureDeviceConfig(launchConfig) {
69
+ const existing = await readDeviceConfig();
70
+ if (existing) {
71
+ return {
72
+ ...existing,
73
+ serverUrl: getServerUrl(launchConfig),
74
+ workdirRoot: launchConfig?.workdirRoot || process.env.BUDA_CONNECTOR_WORKDIR || existing.workdirRoot || getWorkdirRoot(launchConfig)
75
+ };
76
+ }
77
+ const config = buildDeviceConfig(launchConfig);
78
+ await writeDeviceConfig(config);
79
+ return config;
80
+ }
81
+ function parseLaunchConfig(raw) {
82
+ if (!raw.trim()) return {};
83
+ const parsed = JSON.parse(raw);
84
+ const bunnyDaemonPort = typeof parsed.bunnyDaemonPort === "number" && Number.isInteger(parsed.bunnyDaemonPort) ? parsed.bunnyDaemonPort : void 0;
85
+ return {
86
+ serverUrl: typeof parsed.serverUrl === "string" ? parsed.serverUrl : void 0,
87
+ workdirRoot: typeof parsed.workdirRoot === "string" ? parsed.workdirRoot : void 0,
88
+ oauthToken: typeof parsed.oauthToken === "string" ? parsed.oauthToken : void 0,
89
+ spaceId: typeof parsed.spaceId === "string" ? parsed.spaceId : void 0,
90
+ bunnyDaemonHost: typeof parsed.bunnyDaemonHost === "string" ? parsed.bunnyDaemonHost : void 0,
91
+ bunnyDaemonPort
92
+ };
93
+ }
94
+
95
+ // src/bunny-daemon.ts
96
+ import { mkdir as mkdir3 } from "fs/promises";
97
+ import { createDaemon } from "@bunny-agent/daemon";
98
+
99
+ // src/logger.ts
100
+ import { appendFile, mkdir as mkdir2 } from "fs/promises";
101
+ var pendingLogs = [];
102
+ async function writeLog(level, message, metadata) {
103
+ const entry = {
104
+ level,
105
+ message,
106
+ metadata,
107
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
108
+ };
109
+ pendingLogs.push(entry);
110
+ if (pendingLogs.length > 100) pendingLogs.splice(0, pendingLogs.length - 100);
111
+ await mkdir2(CONFIG_DIR, { recursive: true }).catch(() => void 0);
112
+ await appendFile(LOG_PATH, `${JSON.stringify(entry)}
113
+ `).catch(() => void 0);
114
+ const line = `[buda-connector] ${message}`;
115
+ if (level === "error") console.error(line);
116
+ else if (level === "warn") console.warn(line);
117
+ else console.log(line);
118
+ }
119
+ function takePendingLogs() {
120
+ return pendingLogs.splice(0, pendingLogs.length);
121
+ }
122
+ function restorePendingLogs(logs) {
123
+ pendingLogs.unshift(...logs);
124
+ }
125
+
126
+ // src/bunny-daemon.ts
127
+ async function startEmbeddedBunnyDaemon(config, launchConfig) {
128
+ const host = getBunnyDaemonHost(launchConfig);
129
+ const port = getBunnyDaemonPort(launchConfig);
130
+ const root = config.workdirRoot;
131
+ await mkdir3(root, { recursive: true });
132
+ const server = createDaemon({ host, port, root });
133
+ await new Promise((resolve, reject) => {
134
+ const onError = (error) => {
135
+ server.off("listening", onListening);
136
+ reject(error);
137
+ };
138
+ const onListening = () => {
139
+ server.off("error", onError);
140
+ resolve();
141
+ };
142
+ server.once("error", onError);
143
+ server.once("listening", onListening);
144
+ server.listen(port, host);
145
+ });
146
+ const address = server.address();
147
+ const actualPort = typeof address === "object" && address ? address.port : port;
148
+ const baseUrl = `http://${host}:${actualPort}`;
149
+ await writeLog("info", `bunny daemon listening ${baseUrl}`, {
150
+ root
151
+ });
152
+ return {
153
+ server,
154
+ host,
155
+ port: actualPort,
156
+ baseUrl,
157
+ root
158
+ };
159
+ }
160
+ async function stopEmbeddedBunnyDaemon(daemon) {
161
+ await new Promise((resolve) => {
162
+ daemon.server.close(() => resolve());
163
+ });
164
+ await writeLog("info", `bunny daemon stopped ${daemon.baseUrl}`);
165
+ }
166
+
167
+ // src/cloud-client.ts
168
+ import { platform as platform2, release } from "os";
169
+ function buildPayload(config, extra) {
170
+ return {
171
+ connectorId: config.connectorId,
172
+ deviceId: config.deviceId,
173
+ deviceName: config.deviceName,
174
+ hostLabel: config.hostLabel,
175
+ platform: `${platform2()} ${release()}`,
176
+ version: "dev",
177
+ pid: process.pid,
178
+ workdirRoot: config.workdirRoot,
179
+ baseUrl: extra?.bunnyDaemon?.baseUrl,
180
+ bunnyDaemon: extra?.bunnyDaemon ? {
181
+ baseUrl: extra.bunnyDaemon.baseUrl,
182
+ host: extra.bunnyDaemon.host,
183
+ port: extra.bunnyDaemon.port,
184
+ root: extra.bunnyDaemon.root,
185
+ status: "online"
186
+ } : void 0,
187
+ capabilities: ["connector", "bunny-daemon", "coding", "filesystem", "shell", "git"],
188
+ ...extra?.logs?.length ? { logs: extra.logs } : {}
189
+ };
190
+ }
191
+ async function postJson(serverUrl, pathname, body, launchConfig) {
192
+ const token = launchConfig?.oauthToken?.trim();
193
+ const spaceId = getSpaceId(launchConfig)?.trim();
194
+ const response = await fetch(`${serverUrl}${pathname}`, {
195
+ method: "POST",
196
+ headers: {
197
+ "Content-Type": "application/json",
198
+ ...token ? { Authorization: `Bearer ${token}` } : {},
199
+ ...!token && spaceId ? { "X-Buda-Space-Id": spaceId } : {}
200
+ },
201
+ body: JSON.stringify(body)
202
+ });
203
+ if (!response.ok) {
204
+ const text = await response.text().catch(() => "");
205
+ throw new Error(`${pathname} failed: ${response.status} ${text}`);
206
+ }
207
+ return await response.json();
208
+ }
209
+ function formatConnectorOwner(connector) {
210
+ return connector.userEmail ?? connector.userId ?? "anonymous";
211
+ }
212
+ async function registerConnector(config, launchConfig, bunnyDaemon) {
213
+ const result = await postJson(
214
+ config.serverUrl,
215
+ "/api/connectors/register",
216
+ buildPayload(config, { bunnyDaemon }),
217
+ launchConfig
218
+ );
219
+ await writeLog(
220
+ "info",
221
+ `registered connector=${result.connector.connectorId} status=${result.connector.status} owner=${formatConnectorOwner(result.connector)}`
222
+ );
223
+ }
224
+ async function heartbeatConnector(config, launchConfig, bunnyDaemon) {
225
+ const logs = takePendingLogs();
226
+ const result = await postJson(
227
+ config.serverUrl,
228
+ "/api/connectors/heartbeat",
229
+ buildPayload(config, {
230
+ bunnyDaemon,
231
+ logs
232
+ }),
233
+ launchConfig
234
+ ).catch((error) => {
235
+ restorePendingLogs(logs);
236
+ throw error;
237
+ });
238
+ await writeLog(
239
+ "info",
240
+ `heartbeat connector=${result.connector.connectorId} status=${result.connector.status} owner=${formatConnectorOwner(result.connector)}`
241
+ );
242
+ }
243
+ async function unregisterConnector(config, launchConfig) {
244
+ await postJson(
245
+ config.serverUrl,
246
+ "/api/connectors/unregister",
247
+ {
248
+ connectorId: config.connectorId
249
+ },
250
+ launchConfig
251
+ ).catch((error) => {
252
+ void writeLog("warn", `unregister failed: ${error instanceof Error ? error.message : error}`);
253
+ });
254
+ }
255
+
256
+ // ../../packages/relaylib/src/protocol.ts
257
+ var utf8Encoder = new TextEncoder();
258
+ function bytesToBase64(bytes) {
259
+ if (typeof Buffer !== "undefined") {
260
+ return Buffer.from(bytes).toString("base64");
261
+ }
262
+ let binary = "";
263
+ for (const byte of bytes) {
264
+ binary += String.fromCharCode(byte);
265
+ }
266
+ return btoa(binary);
267
+ }
268
+
269
+ // ../../packages/relaylib/src/relay-client.ts
270
+ function attachRelayClient(socket, handler, options = {}) {
271
+ const chunkEncoding = options.chunkEncoding ?? "base64";
272
+ socket.on("message", (raw) => {
273
+ let message;
274
+ try {
275
+ message = JSON.parse(raw.toString());
276
+ } catch {
277
+ return;
278
+ }
279
+ if (message.type !== "request") return;
280
+ void handleRequest(socket, message.requestId, message.request, handler, chunkEncoding);
281
+ });
282
+ }
283
+ async function handleRequest(socket, requestId, request, handler, chunkEncoding) {
284
+ const send = (message) => {
285
+ if (socket.readyState === 1) socket.send(JSON.stringify(message));
286
+ };
287
+ let response;
288
+ try {
289
+ response = await handler(request);
290
+ } catch (error) {
291
+ send({ type: "error", requestId, error: errorMessage(error) });
292
+ return;
293
+ }
294
+ send({
295
+ type: "start",
296
+ requestId,
297
+ status: response.status,
298
+ headers: headersToRecord(response.headers)
299
+ });
300
+ try {
301
+ const body = response.body;
302
+ if (body) {
303
+ const reader = body.getReader();
304
+ if (chunkEncoding === "utf8") {
305
+ const decoder = new TextDecoder();
306
+ while (true) {
307
+ const { done, value } = await reader.read();
308
+ if (done) break;
309
+ const text = decoder.decode(value, { stream: true });
310
+ if (text) send({ type: "chunk", requestId, data: text });
311
+ }
312
+ const tail = decoder.decode();
313
+ if (tail) send({ type: "chunk", requestId, data: tail });
314
+ } else {
315
+ while (true) {
316
+ const { done, value } = await reader.read();
317
+ if (done) break;
318
+ if (value && value.length > 0) {
319
+ send({
320
+ type: "chunk",
321
+ requestId,
322
+ encoding: "base64",
323
+ data: bytesToBase64(value)
324
+ });
325
+ }
326
+ }
327
+ }
328
+ }
329
+ send({ type: "end", requestId });
330
+ } catch (error) {
331
+ send({ type: "error", requestId, error: errorMessage(error) });
332
+ }
333
+ }
334
+ function headersToRecord(headers) {
335
+ const record = {};
336
+ headers.forEach((value, key) => {
337
+ record[key] = value;
338
+ });
339
+ return record;
340
+ }
341
+ function errorMessage(error) {
342
+ return error instanceof Error ? error.message : String(error);
343
+ }
344
+
345
+ // src/relay.ts
346
+ import WebSocket from "ws";
347
+ function startConnectorRelay(config, bunnyDaemon, launchConfig) {
348
+ let socket = null;
349
+ let reconnectTimer = null;
350
+ let stopped = false;
351
+ const handler = createRelayHandler(bunnyDaemon);
352
+ const connect = () => {
353
+ if (stopped) return;
354
+ const relayUrl = getConnectorRelayUrl(config.serverUrl, config.connectorId);
355
+ const token = launchConfig?.oauthToken?.trim();
356
+ socket = new WebSocket(relayUrl, {
357
+ headers: token ? { Authorization: `Bearer ${token}` } : void 0
358
+ });
359
+ socket.on("open", () => {
360
+ void writeLog("info", `relay connected ${relayUrl}`);
361
+ });
362
+ attachRelayClient(socket, handler, { chunkEncoding: "utf8" });
363
+ socket.on("close", () => {
364
+ void writeLog("warn", "relay disconnected");
365
+ if (stopped) return;
366
+ reconnectTimer = setTimeout(connect, RELAY_RECONNECT_INTERVAL_MS);
367
+ });
368
+ socket.on("error", (error) => {
369
+ void writeLog("warn", `relay error: ${error instanceof Error ? error.message : error}`);
370
+ });
371
+ };
372
+ connect();
373
+ return {
374
+ stop() {
375
+ stopped = true;
376
+ if (reconnectTimer) clearTimeout(reconnectTimer);
377
+ socket?.close();
378
+ }
379
+ };
380
+ }
381
+ function createRelayHandler(bunnyDaemon) {
382
+ const allowedOrigin = new URL(bunnyDaemon.baseUrl).origin;
383
+ return async (request) => {
384
+ const requestUrl = new URL(request.path, bunnyDaemon.baseUrl);
385
+ if (requestUrl.origin !== allowedOrigin) {
386
+ throw new Error("Relay request must target the embedded bunny daemon.");
387
+ }
388
+ await writeLog("info", `relay request ${request.method} ${requestUrl.pathname}`, {
389
+ actorEmail: getHeaderValue(request.headers, "x-buda-relay-actor-email") || null,
390
+ actorId: getHeaderValue(request.headers, "x-buda-relay-actor-id") || null,
391
+ source: getHeaderValue(request.headers, "x-buda-relay-source") || "unknown",
392
+ action: getHeaderValue(request.headers, "x-buda-relay-action") || "unknown",
393
+ target: requestUrl.toString()
394
+ });
395
+ return fetch(requestUrl, {
396
+ method: request.method,
397
+ headers: {
398
+ "Content-Type": "application/json",
399
+ ...request.headers ?? {}
400
+ },
401
+ body: request.body === void 0 ? void 0 : JSON.stringify(request.body)
402
+ });
403
+ };
404
+ }
405
+ function getHeaderValue(headers, name) {
406
+ if (!headers) return void 0;
407
+ const normalized = name.toLowerCase();
408
+ for (const [key, value] of Object.entries(headers)) {
409
+ if (key.toLowerCase() === normalized) return value;
410
+ }
411
+ return void 0;
412
+ }
413
+
414
+ // src/runtime.ts
415
+ async function runNewConnector(launchConfig) {
416
+ const config = buildDeviceConfig(launchConfig);
417
+ await writeDeviceConfig(config);
418
+ await registerConnector(config, launchConfig);
419
+ await writeLog("info", `wrote ${CONFIG_PATH}`);
420
+ await writeLog("info", `workdir root ${config.workdirRoot}`);
421
+ await writeLog("info", `log file ${LOG_PATH}`);
422
+ }
423
+ async function runConnectorDaemon(launchConfig) {
424
+ const config = await ensureDeviceConfig(launchConfig);
425
+ const bunnyDaemon = await startEmbeddedBunnyDaemon(config, launchConfig);
426
+ try {
427
+ await registerConnector(config, launchConfig, bunnyDaemon);
428
+ } catch (error) {
429
+ await stopEmbeddedBunnyDaemon(bunnyDaemon);
430
+ throw error;
431
+ }
432
+ const relay = startConnectorRelay(config, bunnyDaemon, launchConfig);
433
+ await writeLog("info", `server ${config.serverUrl}`);
434
+ await writeLog("info", `config ${CONFIG_PATH}`);
435
+ await writeLog("info", `workdir root ${config.workdirRoot}`);
436
+ await writeLog("info", `log file ${LOG_PATH}`);
437
+ await writeLog("info", `bunny daemon ${bunnyDaemon.baseUrl}`);
438
+ const interval = setInterval(() => {
439
+ heartbeatConnector(config, launchConfig, bunnyDaemon).catch((error) => {
440
+ void writeLog(
441
+ "warn",
442
+ `heartbeat failed: ${error instanceof Error ? error.message : String(error)}`
443
+ );
444
+ });
445
+ }, HEARTBEAT_INTERVAL_MS);
446
+ const shutdown = async () => {
447
+ clearInterval(interval);
448
+ relay.stop();
449
+ await unregisterConnector(config, launchConfig);
450
+ await stopEmbeddedBunnyDaemon(bunnyDaemon);
451
+ process.exit(0);
452
+ };
453
+ process.on("SIGINT", shutdown);
454
+ process.on("SIGTERM", shutdown);
455
+ }
456
+
457
+ // src/cli.ts
458
+ function printUsage() {
459
+ console.log(
460
+ "Usage: buda-connector new | dev | daemon [--space-id <space>] [--server-url <url>] [--token <token>] [--workdir <path>] [--launch-config-stdin]\n pnpm --filter @buda-ai/connector dev\n\nCommands:\n new Create/register a new connector identity\n dev Run the connector daemon in development\n daemon Run the connector daemon for sidecar/service usage"
461
+ );
462
+ }
463
+ function readFlagValue(name) {
464
+ const index = process.argv.indexOf(name);
465
+ if (index < 0) return void 0;
466
+ const value = process.argv[index + 1];
467
+ return value && !value.startsWith("--") ? value : void 0;
468
+ }
469
+ function readCliLaunchConfig() {
470
+ const bunnyDaemonPort = readFlagValue("--bunny-daemon-port");
471
+ return {
472
+ serverUrl: readFlagValue("--server-url"),
473
+ workdirRoot: readFlagValue("--workdir"),
474
+ oauthToken: readFlagValue("--token"),
475
+ spaceId: readFlagValue("--space-id"),
476
+ bunnyDaemonHost: readFlagValue("--bunny-daemon-host"),
477
+ bunnyDaemonPort: bunnyDaemonPort && Number.isInteger(Number(bunnyDaemonPort)) ? Number(bunnyDaemonPort) : void 0
478
+ };
479
+ }
480
+ async function readLaunchConfig() {
481
+ const cliConfig = readCliLaunchConfig();
482
+ if (!process.argv.includes(LAUNCH_CONFIG_STDIN_FLAG)) return cliConfig;
483
+ process.stdin.setEncoding("utf8");
484
+ return new Promise((resolve, reject) => {
485
+ let raw = "";
486
+ const cleanup = () => {
487
+ process.stdin.off("data", onData);
488
+ process.stdin.off("error", onError);
489
+ process.stdin.off("end", onEnd);
490
+ };
491
+ const onError = (error) => {
492
+ cleanup();
493
+ reject(error);
494
+ };
495
+ const onData = (chunk) => {
496
+ raw += chunk;
497
+ if (!raw.includes("\n")) return;
498
+ cleanup();
499
+ resolve({ ...cliConfig, ...parseLaunchConfig(raw.split("\n")[0] ?? "") });
500
+ };
501
+ const onEnd = () => {
502
+ cleanup();
503
+ resolve({ ...cliConfig, ...parseLaunchConfig(raw) });
504
+ };
505
+ process.stdin.on("data", onData);
506
+ process.stdin.on("error", onError);
507
+ process.stdin.on("end", onEnd);
508
+ process.stdin.resume();
509
+ });
510
+ }
511
+ async function main() {
512
+ const command = process.argv[2];
513
+ if (!command || process.argv.includes("--help") || process.argv.includes("-h")) {
514
+ printUsage();
515
+ return;
516
+ }
517
+ const launchConfig = await readLaunchConfig();
518
+ if (command === "new") {
519
+ await runNewConnector(launchConfig);
520
+ return;
521
+ }
522
+ if (command === "dev" || command === "daemon") {
523
+ await runConnectorDaemon(launchConfig);
524
+ return;
525
+ }
526
+ printUsage();
527
+ process.exit(1);
528
+ }
529
+ main().catch((error) => {
530
+ console.error("[buda-connector] failed:", error);
531
+ process.exit(1);
532
+ });
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@buda-ai/connector",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "bin": {
10
+ "buda-connector": "./dist/cli.js",
11
+ "connector": "./dist/cli.js"
12
+ },
13
+ "scripts": {
14
+ "dev": "tsx src/cli.ts dev",
15
+ "daemon": "tsx src/cli.ts daemon",
16
+ "new": "tsx src/cli.ts new",
17
+ "start": "node dist/cli.js daemon",
18
+ "build": "tsup",
19
+ "typecheck": "tsc --noEmit",
20
+ "lint": "biome format . --write && biome check .",
21
+ "lint:err": "biome format . --write && biome check . --write --diagnostic-level=error"
22
+ },
23
+ "dependencies": {
24
+ "@bunny-agent/daemon": "0.9.43",
25
+ "relaylib": "workspace:*",
26
+ "ws": "^8.21.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^24.10.0",
30
+ "@types/ws": "^8.18.1",
31
+ "tsup": "^8.5.0",
32
+ "tsx": "^4.20.5",
33
+ "typescript": "^5.9.3"
34
+ },
35
+ "engines": {
36
+ "node": ">=20.0.0"
37
+ }
38
+ }
@@ -0,0 +1,52 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { createDaemon } from "@bunny-agent/daemon";
3
+ import { getBunnyDaemonHost, getBunnyDaemonPort } from "./config.js";
4
+ import { writeLog } from "./logger.js";
5
+ import type { ConnectorLaunchConfig, DeviceConfig, EmbeddedBunnyDaemon } from "./types.js";
6
+
7
+ export async function startEmbeddedBunnyDaemon(
8
+ config: DeviceConfig,
9
+ launchConfig?: ConnectorLaunchConfig,
10
+ ): Promise<EmbeddedBunnyDaemon> {
11
+ const host = getBunnyDaemonHost(launchConfig);
12
+ const port = getBunnyDaemonPort(launchConfig);
13
+ const root = config.workdirRoot;
14
+ await mkdir(root, { recursive: true });
15
+
16
+ const server = createDaemon({ host, port, root });
17
+ await new Promise<void>((resolve, reject) => {
18
+ const onError = (error: Error) => {
19
+ server.off("listening", onListening);
20
+ reject(error);
21
+ };
22
+ const onListening = () => {
23
+ server.off("error", onError);
24
+ resolve();
25
+ };
26
+ server.once("error", onError);
27
+ server.once("listening", onListening);
28
+ server.listen(port, host);
29
+ });
30
+
31
+ const address = server.address();
32
+ const actualPort = typeof address === "object" && address ? address.port : port;
33
+ const baseUrl = `http://${host}:${actualPort}`;
34
+ await writeLog("info", `bunny daemon listening ${baseUrl}`, {
35
+ root,
36
+ });
37
+
38
+ return {
39
+ server,
40
+ host,
41
+ port: actualPort,
42
+ baseUrl,
43
+ root,
44
+ };
45
+ }
46
+
47
+ export async function stopEmbeddedBunnyDaemon(daemon: EmbeddedBunnyDaemon) {
48
+ await new Promise<void>((resolve) => {
49
+ daemon.server.close(() => resolve());
50
+ });
51
+ await writeLog("info", `bunny daemon stopped ${daemon.baseUrl}`);
52
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { LAUNCH_CONFIG_STDIN_FLAG, parseLaunchConfig } from "./config.js";
4
+ import { runConnectorDaemon, runNewConnector } from "./runtime.js";
5
+ import type { ConnectorCliCommand, ConnectorLaunchConfig } from "./types.js";
6
+
7
+ function printUsage() {
8
+ console.log(
9
+ "Usage: buda-connector new | dev | daemon [--space-id <space>] [--server-url <url>] [--token <token>] [--workdir <path>] [--launch-config-stdin]\n" +
10
+ " pnpm --filter @buda-ai/connector dev\n\n" +
11
+ "Commands:\n" +
12
+ " new Create/register a new connector identity\n" +
13
+ " dev Run the connector daemon in development\n" +
14
+ " daemon Run the connector daemon for sidecar/service usage",
15
+ );
16
+ }
17
+
18
+ function readFlagValue(name: string): string | undefined {
19
+ const index = process.argv.indexOf(name);
20
+ if (index < 0) return undefined;
21
+ const value = process.argv[index + 1];
22
+ return value && !value.startsWith("--") ? value : undefined;
23
+ }
24
+
25
+ function readCliLaunchConfig(): ConnectorLaunchConfig {
26
+ const bunnyDaemonPort = readFlagValue("--bunny-daemon-port");
27
+ return {
28
+ serverUrl: readFlagValue("--server-url"),
29
+ workdirRoot: readFlagValue("--workdir"),
30
+ oauthToken: readFlagValue("--token"),
31
+ spaceId: readFlagValue("--space-id"),
32
+ bunnyDaemonHost: readFlagValue("--bunny-daemon-host"),
33
+ bunnyDaemonPort:
34
+ bunnyDaemonPort && Number.isInteger(Number(bunnyDaemonPort))
35
+ ? Number(bunnyDaemonPort)
36
+ : undefined,
37
+ };
38
+ }
39
+
40
+ async function readLaunchConfig(): Promise<ConnectorLaunchConfig> {
41
+ const cliConfig = readCliLaunchConfig();
42
+ if (!process.argv.includes(LAUNCH_CONFIG_STDIN_FLAG)) return cliConfig;
43
+
44
+ process.stdin.setEncoding("utf8");
45
+ return new Promise((resolve, reject) => {
46
+ let raw = "";
47
+ const cleanup = () => {
48
+ process.stdin.off("data", onData);
49
+ process.stdin.off("error", onError);
50
+ process.stdin.off("end", onEnd);
51
+ };
52
+ const onError = (error: Error) => {
53
+ cleanup();
54
+ reject(error);
55
+ };
56
+ const onData = (chunk: string) => {
57
+ raw += chunk;
58
+ if (!raw.includes("\n")) return;
59
+ cleanup();
60
+ resolve({ ...cliConfig, ...parseLaunchConfig(raw.split("\n")[0] ?? "") });
61
+ };
62
+ const onEnd = () => {
63
+ cleanup();
64
+ resolve({ ...cliConfig, ...parseLaunchConfig(raw) });
65
+ };
66
+ process.stdin.on("data", onData);
67
+ process.stdin.on("error", onError);
68
+ process.stdin.on("end", onEnd);
69
+ process.stdin.resume();
70
+ });
71
+ }
72
+
73
+ async function main() {
74
+ const command = process.argv[2] as ConnectorCliCommand | undefined;
75
+ if (!command || process.argv.includes("--help") || process.argv.includes("-h")) {
76
+ printUsage();
77
+ return;
78
+ }
79
+
80
+ const launchConfig = await readLaunchConfig();
81
+ if (command === "new") {
82
+ await runNewConnector(launchConfig);
83
+ return;
84
+ }
85
+ if (command === "dev" || command === "daemon") {
86
+ await runConnectorDaemon(launchConfig);
87
+ return;
88
+ }
89
+
90
+ printUsage();
91
+ process.exit(1);
92
+ }
93
+
94
+ main().catch((error) => {
95
+ console.error("[buda-connector] failed:", error);
96
+ process.exit(1);
97
+ });
@@ -0,0 +1,134 @@
1
+ import { platform, release } from "node:os";
2
+ import { getSpaceId } from "./config.js";
3
+ import { restorePendingLogs, takePendingLogs, writeLog } from "./logger.js";
4
+ import type {
5
+ ConnectorLaunchConfig,
6
+ ConnectorLogEntry,
7
+ DeviceConfig,
8
+ EmbeddedBunnyDaemon,
9
+ } from "./types.js";
10
+
11
+ function buildPayload(
12
+ config: DeviceConfig,
13
+ extra?: {
14
+ bunnyDaemon?: EmbeddedBunnyDaemon;
15
+ logs?: ConnectorLogEntry[];
16
+ },
17
+ ) {
18
+ return {
19
+ connectorId: config.connectorId,
20
+ deviceId: config.deviceId,
21
+ deviceName: config.deviceName,
22
+ hostLabel: config.hostLabel,
23
+ platform: `${platform()} ${release()}`,
24
+ version: "dev",
25
+ pid: process.pid,
26
+ workdirRoot: config.workdirRoot,
27
+ baseUrl: extra?.bunnyDaemon?.baseUrl,
28
+ bunnyDaemon: extra?.bunnyDaemon
29
+ ? {
30
+ baseUrl: extra.bunnyDaemon.baseUrl,
31
+ host: extra.bunnyDaemon.host,
32
+ port: extra.bunnyDaemon.port,
33
+ root: extra.bunnyDaemon.root,
34
+ status: "online",
35
+ }
36
+ : undefined,
37
+ capabilities: ["connector", "bunny-daemon", "coding", "filesystem", "shell", "git"],
38
+ ...(extra?.logs?.length ? { logs: extra.logs } : {}),
39
+ };
40
+ }
41
+
42
+ async function postJson<T>(
43
+ serverUrl: string,
44
+ pathname: string,
45
+ body: unknown,
46
+ launchConfig?: ConnectorLaunchConfig,
47
+ ): Promise<T> {
48
+ const token = launchConfig?.oauthToken?.trim();
49
+ const spaceId = getSpaceId(launchConfig)?.trim();
50
+ const response = await fetch(`${serverUrl}${pathname}`, {
51
+ method: "POST",
52
+ headers: {
53
+ "Content-Type": "application/json",
54
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
55
+ ...(!token && spaceId ? { "X-Buda-Space-Id": spaceId } : {}),
56
+ },
57
+ body: JSON.stringify(body),
58
+ });
59
+ if (!response.ok) {
60
+ const text = await response.text().catch(() => "");
61
+ throw new Error(`${pathname} failed: ${response.status} ${text}`);
62
+ }
63
+ return (await response.json()) as T;
64
+ }
65
+
66
+ interface RegisteredConnectorResponse {
67
+ connector: {
68
+ connectorId: string;
69
+ status: string;
70
+ userId?: string | null;
71
+ userEmail?: string | null;
72
+ };
73
+ }
74
+
75
+ function formatConnectorOwner(connector: RegisteredConnectorResponse["connector"]): string {
76
+ return connector.userEmail ?? connector.userId ?? "anonymous";
77
+ }
78
+
79
+ export async function registerConnector(
80
+ config: DeviceConfig,
81
+ launchConfig?: ConnectorLaunchConfig,
82
+ bunnyDaemon?: EmbeddedBunnyDaemon,
83
+ ) {
84
+ const result = await postJson<RegisteredConnectorResponse>(
85
+ config.serverUrl,
86
+ "/api/connectors/register",
87
+ buildPayload(config, { bunnyDaemon }),
88
+ launchConfig,
89
+ );
90
+ await writeLog(
91
+ "info",
92
+ `registered connector=${result.connector.connectorId} status=${result.connector.status} owner=${formatConnectorOwner(result.connector)}`,
93
+ );
94
+ }
95
+
96
+ export async function heartbeatConnector(
97
+ config: DeviceConfig,
98
+ launchConfig?: ConnectorLaunchConfig,
99
+ bunnyDaemon?: EmbeddedBunnyDaemon,
100
+ ) {
101
+ const logs = takePendingLogs();
102
+ const result = await postJson<RegisteredConnectorResponse>(
103
+ config.serverUrl,
104
+ "/api/connectors/heartbeat",
105
+ buildPayload(config, {
106
+ bunnyDaemon,
107
+ logs,
108
+ }),
109
+ launchConfig,
110
+ ).catch((error) => {
111
+ restorePendingLogs(logs);
112
+ throw error;
113
+ });
114
+ await writeLog(
115
+ "info",
116
+ `heartbeat connector=${result.connector.connectorId} status=${result.connector.status} owner=${formatConnectorOwner(result.connector)}`,
117
+ );
118
+ }
119
+
120
+ export async function unregisterConnector(
121
+ config: DeviceConfig,
122
+ launchConfig?: ConnectorLaunchConfig,
123
+ ) {
124
+ await postJson(
125
+ config.serverUrl,
126
+ "/api/connectors/unregister",
127
+ {
128
+ connectorId: config.connectorId,
129
+ },
130
+ launchConfig,
131
+ ).catch((error) => {
132
+ void writeLog("warn", `unregister failed: ${error instanceof Error ? error.message : error}`);
133
+ });
134
+ }
package/src/config.ts ADDED
@@ -0,0 +1,122 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { homedir, hostname, platform } from "node:os";
4
+ import path from "node:path";
5
+ import type { ConnectorLaunchConfig, DeviceConfig } from "./types.js";
6
+
7
+ export const CONFIG_DIR = path.join(homedir(), ".buda");
8
+ export const CONFIG_PATH = path.join(CONFIG_DIR, "device.json");
9
+ export const LOG_PATH = path.join(CONFIG_DIR, "connector.log");
10
+ export const DEFAULT_SERVER_URL = "https://buda.im";
11
+ export const DEFAULT_WORKDIR_ROOT = path.join(CONFIG_DIR, "agents");
12
+ export const DEFAULT_BUNNY_DAEMON_HOST = "127.0.0.1";
13
+ export const HEARTBEAT_INTERVAL_MS = 5_000;
14
+ export const RELAY_RECONNECT_INTERVAL_MS = 3_000;
15
+ export const LAUNCH_CONFIG_STDIN_FLAG = "--launch-config-stdin";
16
+
17
+ export function getServerUrl(launchConfig?: ConnectorLaunchConfig) {
18
+ return (
19
+ launchConfig?.serverUrl ||
20
+ process.env.BUDA_CONNECTOR_SERVER_URL ||
21
+ process.env.BUDA_SERVER_URL ||
22
+ process.env.NEXT_PUBLIC_APP_URL ||
23
+ DEFAULT_SERVER_URL
24
+ ).replace(/\/$/, "");
25
+ }
26
+
27
+ export function getConnectorRelayUrl(serverUrl: string, connectorId: string) {
28
+ const url = new URL("/api/connectors/ws", serverUrl);
29
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
30
+ url.searchParams.set("connectorId", connectorId);
31
+ return url.toString();
32
+ }
33
+
34
+ export function getWorkdirRoot(launchConfig?: ConnectorLaunchConfig) {
35
+ return launchConfig?.workdirRoot || process.env.BUDA_CONNECTOR_WORKDIR || DEFAULT_WORKDIR_ROOT;
36
+ }
37
+
38
+ export function getBunnyDaemonHost(launchConfig?: ConnectorLaunchConfig) {
39
+ return (
40
+ launchConfig?.bunnyDaemonHost ||
41
+ process.env.BUDA_CONNECTOR_BUNNY_DAEMON_HOST ||
42
+ DEFAULT_BUNNY_DAEMON_HOST
43
+ );
44
+ }
45
+
46
+ export function getBunnyDaemonPort(launchConfig?: ConnectorLaunchConfig) {
47
+ if (launchConfig?.bunnyDaemonPort !== undefined) return launchConfig.bunnyDaemonPort;
48
+ const raw = process.env.BUDA_CONNECTOR_BUNNY_DAEMON_PORT;
49
+ if (!raw) return 0;
50
+ const parsed = Number(raw);
51
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
52
+ }
53
+
54
+ export function getSpaceId(launchConfig?: ConnectorLaunchConfig) {
55
+ return launchConfig?.spaceId || process.env.BUDA_CONNECTOR_SPACE_ID;
56
+ }
57
+
58
+ export function buildDeviceConfig(launchConfig?: ConnectorLaunchConfig): DeviceConfig {
59
+ const host = hostname();
60
+ return {
61
+ connectorId: `cnr_${randomUUID()}`,
62
+ deviceId: `dev_${randomUUID()}`,
63
+ deviceName: host,
64
+ hostLabel: `${host} (${platform()})`,
65
+ serverUrl: getServerUrl(launchConfig),
66
+ workdirRoot: getWorkdirRoot(launchConfig),
67
+ createdAt: new Date().toISOString(),
68
+ };
69
+ }
70
+
71
+ export async function readDeviceConfig(): Promise<DeviceConfig | null> {
72
+ try {
73
+ const raw = await readFile(CONFIG_PATH, "utf8");
74
+ return JSON.parse(raw) as DeviceConfig;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ export async function writeDeviceConfig(config: DeviceConfig) {
81
+ await mkdir(CONFIG_DIR, { recursive: true });
82
+ await mkdir(config.workdirRoot, { recursive: true });
83
+ await writeFile(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
84
+ }
85
+
86
+ export async function ensureDeviceConfig(
87
+ launchConfig?: ConnectorLaunchConfig,
88
+ ): Promise<DeviceConfig> {
89
+ const existing = await readDeviceConfig();
90
+ if (existing) {
91
+ return {
92
+ ...existing,
93
+ serverUrl: getServerUrl(launchConfig),
94
+ workdirRoot:
95
+ launchConfig?.workdirRoot ||
96
+ process.env.BUDA_CONNECTOR_WORKDIR ||
97
+ existing.workdirRoot ||
98
+ getWorkdirRoot(launchConfig),
99
+ };
100
+ }
101
+ const config = buildDeviceConfig(launchConfig);
102
+ await writeDeviceConfig(config);
103
+ return config;
104
+ }
105
+
106
+ export function parseLaunchConfig(raw: string): ConnectorLaunchConfig {
107
+ if (!raw.trim()) return {};
108
+ const parsed = JSON.parse(raw) as Partial<ConnectorLaunchConfig>;
109
+ const bunnyDaemonPort =
110
+ typeof parsed.bunnyDaemonPort === "number" && Number.isInteger(parsed.bunnyDaemonPort)
111
+ ? parsed.bunnyDaemonPort
112
+ : undefined;
113
+ return {
114
+ serverUrl: typeof parsed.serverUrl === "string" ? parsed.serverUrl : undefined,
115
+ workdirRoot: typeof parsed.workdirRoot === "string" ? parsed.workdirRoot : undefined,
116
+ oauthToken: typeof parsed.oauthToken === "string" ? parsed.oauthToken : undefined,
117
+ spaceId: typeof parsed.spaceId === "string" ? parsed.spaceId : undefined,
118
+ bunnyDaemonHost:
119
+ typeof parsed.bunnyDaemonHost === "string" ? parsed.bunnyDaemonHost : undefined,
120
+ bunnyDaemonPort,
121
+ };
122
+ }
package/src/logger.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { appendFile, mkdir } from "node:fs/promises";
2
+ import { CONFIG_DIR, LOG_PATH } from "./config.js";
3
+ import type { ConnectorLogEntry } from "./types.js";
4
+
5
+ const pendingLogs: ConnectorLogEntry[] = [];
6
+
7
+ export async function writeLog(
8
+ level: ConnectorLogEntry["level"],
9
+ message: string,
10
+ metadata?: Record<string, unknown>,
11
+ ) {
12
+ const entry: ConnectorLogEntry = {
13
+ level,
14
+ message,
15
+ metadata,
16
+ createdAt: new Date().toISOString(),
17
+ };
18
+ pendingLogs.push(entry);
19
+ if (pendingLogs.length > 100) pendingLogs.splice(0, pendingLogs.length - 100);
20
+
21
+ await mkdir(CONFIG_DIR, { recursive: true }).catch(() => undefined);
22
+ await appendFile(LOG_PATH, `${JSON.stringify(entry)}\n`).catch(() => undefined);
23
+
24
+ const line = `[buda-connector] ${message}`;
25
+ if (level === "error") console.error(line);
26
+ else if (level === "warn") console.warn(line);
27
+ else console.log(line);
28
+ }
29
+
30
+ export function takePendingLogs(): ConnectorLogEntry[] {
31
+ return pendingLogs.splice(0, pendingLogs.length);
32
+ }
33
+
34
+ export function restorePendingLogs(logs: ConnectorLogEntry[]) {
35
+ pendingLogs.unshift(...logs);
36
+ }
package/src/relay.ts ADDED
@@ -0,0 +1,96 @@
1
+ import { attachRelayClient, type RelayRequestHandler } from "relaylib";
2
+ import WebSocket from "ws";
3
+ import { getConnectorRelayUrl, RELAY_RECONNECT_INTERVAL_MS } from "./config.js";
4
+ import { writeLog } from "./logger.js";
5
+ import type { ConnectorLaunchConfig, DeviceConfig, EmbeddedBunnyDaemon } from "./types.js";
6
+
7
+ export function startConnectorRelay(
8
+ config: DeviceConfig,
9
+ bunnyDaemon: EmbeddedBunnyDaemon,
10
+ launchConfig?: ConnectorLaunchConfig,
11
+ ) {
12
+ let socket: WebSocket | null = null;
13
+ let reconnectTimer: NodeJS.Timeout | null = null;
14
+ let stopped = false;
15
+ const handler = createRelayHandler(bunnyDaemon);
16
+
17
+ const connect = () => {
18
+ if (stopped) return;
19
+ const relayUrl = getConnectorRelayUrl(config.serverUrl, config.connectorId);
20
+ const token = launchConfig?.oauthToken?.trim();
21
+ socket = new WebSocket(relayUrl, {
22
+ headers: token ? { Authorization: `Bearer ${token}` } : undefined,
23
+ });
24
+
25
+ socket.on("open", () => {
26
+ void writeLog("info", `relay connected ${relayUrl}`);
27
+ });
28
+
29
+ // The protocol loop (request -> handler -> streamed start/chunk/end) lives in
30
+ // relaylib. `chunkEncoding: "utf8"` keeps the legacy text wire format, since a
31
+ // base64-aware hub is not guaranteed to be deployed yet (the connector ships
32
+ // independently of the cloud). Switch to base64 once that hub is rolled out.
33
+ attachRelayClient(socket, handler, { chunkEncoding: "utf8" });
34
+
35
+ socket.on("close", () => {
36
+ void writeLog("warn", "relay disconnected");
37
+ if (stopped) return;
38
+ reconnectTimer = setTimeout(connect, RELAY_RECONNECT_INTERVAL_MS);
39
+ });
40
+
41
+ socket.on("error", (error) => {
42
+ void writeLog("warn", `relay error: ${error instanceof Error ? error.message : error}`);
43
+ });
44
+ };
45
+
46
+ connect();
47
+
48
+ return {
49
+ stop() {
50
+ stopped = true;
51
+ if (reconnectTimer) clearTimeout(reconnectTimer);
52
+ socket?.close();
53
+ },
54
+ };
55
+ }
56
+
57
+ /**
58
+ * App-specific relay handler: forwards each request to the embedded bunny daemon
59
+ * only (origin guard), records the relay audit headers, then proxies via fetch.
60
+ */
61
+ function createRelayHandler(bunnyDaemon: EmbeddedBunnyDaemon): RelayRequestHandler {
62
+ const allowedOrigin = new URL(bunnyDaemon.baseUrl).origin;
63
+
64
+ return async (request) => {
65
+ const requestUrl = new URL(request.path, bunnyDaemon.baseUrl);
66
+ if (requestUrl.origin !== allowedOrigin) {
67
+ throw new Error("Relay request must target the embedded bunny daemon.");
68
+ }
69
+
70
+ await writeLog("info", `relay request ${request.method} ${requestUrl.pathname}`, {
71
+ actorEmail: getHeaderValue(request.headers, "x-buda-relay-actor-email") || null,
72
+ actorId: getHeaderValue(request.headers, "x-buda-relay-actor-id") || null,
73
+ source: getHeaderValue(request.headers, "x-buda-relay-source") || "unknown",
74
+ action: getHeaderValue(request.headers, "x-buda-relay-action") || "unknown",
75
+ target: requestUrl.toString(),
76
+ });
77
+
78
+ return fetch(requestUrl, {
79
+ method: request.method,
80
+ headers: {
81
+ "Content-Type": "application/json",
82
+ ...(request.headers ?? {}),
83
+ },
84
+ body: request.body === undefined ? undefined : JSON.stringify(request.body),
85
+ });
86
+ };
87
+ }
88
+
89
+ function getHeaderValue(headers: Record<string, string> | undefined, name: string) {
90
+ if (!headers) return undefined;
91
+ const normalized = name.toLowerCase();
92
+ for (const [key, value] of Object.entries(headers)) {
93
+ if (key.toLowerCase() === normalized) return value;
94
+ }
95
+ return undefined;
96
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { startEmbeddedBunnyDaemon, stopEmbeddedBunnyDaemon } from "./bunny-daemon.js";
2
+ import { heartbeatConnector, registerConnector, unregisterConnector } from "./cloud-client.js";
3
+ import {
4
+ buildDeviceConfig,
5
+ CONFIG_PATH,
6
+ ensureDeviceConfig,
7
+ HEARTBEAT_INTERVAL_MS,
8
+ LOG_PATH,
9
+ writeDeviceConfig,
10
+ } from "./config.js";
11
+ import { writeLog } from "./logger.js";
12
+ import { startConnectorRelay } from "./relay.js";
13
+ import type { ConnectorLaunchConfig } from "./types.js";
14
+
15
+ export async function runNewConnector(launchConfig?: ConnectorLaunchConfig) {
16
+ const config = buildDeviceConfig(launchConfig);
17
+ await writeDeviceConfig(config);
18
+ await registerConnector(config, launchConfig);
19
+ await writeLog("info", `wrote ${CONFIG_PATH}`);
20
+ await writeLog("info", `workdir root ${config.workdirRoot}`);
21
+ await writeLog("info", `log file ${LOG_PATH}`);
22
+ }
23
+
24
+ export async function runConnectorDaemon(launchConfig?: ConnectorLaunchConfig) {
25
+ const config = await ensureDeviceConfig(launchConfig);
26
+ const bunnyDaemon = await startEmbeddedBunnyDaemon(config, launchConfig);
27
+ try {
28
+ await registerConnector(config, launchConfig, bunnyDaemon);
29
+ } catch (error) {
30
+ await stopEmbeddedBunnyDaemon(bunnyDaemon);
31
+ throw error;
32
+ }
33
+ const relay = startConnectorRelay(config, bunnyDaemon, launchConfig);
34
+ await writeLog("info", `server ${config.serverUrl}`);
35
+ await writeLog("info", `config ${CONFIG_PATH}`);
36
+ await writeLog("info", `workdir root ${config.workdirRoot}`);
37
+ await writeLog("info", `log file ${LOG_PATH}`);
38
+ await writeLog("info", `bunny daemon ${bunnyDaemon.baseUrl}`);
39
+
40
+ const interval = setInterval(() => {
41
+ heartbeatConnector(config, launchConfig, bunnyDaemon).catch((error) => {
42
+ void writeLog(
43
+ "warn",
44
+ `heartbeat failed: ${error instanceof Error ? error.message : String(error)}`,
45
+ );
46
+ });
47
+ }, HEARTBEAT_INTERVAL_MS);
48
+
49
+ const shutdown = async () => {
50
+ clearInterval(interval);
51
+ relay.stop();
52
+ await unregisterConnector(config, launchConfig);
53
+ await stopEmbeddedBunnyDaemon(bunnyDaemon);
54
+ process.exit(0);
55
+ };
56
+
57
+ process.on("SIGINT", shutdown);
58
+ process.on("SIGTERM", shutdown);
59
+ }
package/src/types.ts ADDED
@@ -0,0 +1,37 @@
1
+ import type { Server } from "node:http";
2
+
3
+ export type ConnectorCliCommand = "new" | "dev" | "daemon";
4
+
5
+ export interface DeviceConfig {
6
+ connectorId: string;
7
+ deviceId: string;
8
+ deviceName: string;
9
+ hostLabel: string;
10
+ serverUrl: string;
11
+ workdirRoot: string;
12
+ createdAt: string;
13
+ }
14
+
15
+ export interface ConnectorLaunchConfig {
16
+ serverUrl?: string;
17
+ workdirRoot?: string;
18
+ oauthToken?: string;
19
+ spaceId?: string;
20
+ bunnyDaemonHost?: string;
21
+ bunnyDaemonPort?: number;
22
+ }
23
+
24
+ export interface EmbeddedBunnyDaemon {
25
+ server: Server;
26
+ host: string;
27
+ port: number;
28
+ baseUrl: string;
29
+ root: string;
30
+ }
31
+
32
+ export interface ConnectorLogEntry {
33
+ level: "info" | "warn" | "error";
34
+ message: string;
35
+ metadata?: Record<string, unknown>;
36
+ createdAt: string;
37
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2022", "DOM"],
5
+ "module": "NodeNext",
6
+ "moduleResolution": "NodeNext",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "noEmit": true,
12
+ "types": ["node"],
13
+ "paths": {
14
+ "kui/ai-elements/*": ["../../packages/kui/src/components/ai-elements/*"],
15
+ "kui/hooks/*": ["../../packages/kui/src/hooks/*"],
16
+ "kui/tour": ["../../packages/kui/src/components/tour.tsx"],
17
+ "kui/utils": ["../../packages/kui/src/lib/utils.ts"],
18
+ "kui/*": ["../../packages/kui/src/components/ui/*"],
19
+ "sharelib": ["../../packages/sharelib/index.ts"],
20
+ "sharelib/onboarding-types": ["../../packages/sharelib/src/onboarding-types.ts"],
21
+ "sharelib/scheduler/cache": ["../../packages/sharelib/scheduler/scheduler-cache.ts"],
22
+ "sharelib/scheduler/cron": ["../../packages/sharelib/scheduler/cron-parser.ts"],
23
+ "sharelib/ui/ProductScreenshot": ["../../packages/sharelib/ui/common/ProductScreenshot.tsx"],
24
+ "sharelib/ui/ProductShowcase": ["../../packages/sharelib/ui/common/ProductShowcase.tsx"],
25
+ "sharelib/ui/LanguageSwitcher": ["../../packages/sharelib/ui/common/LanguageSwitcher.tsx"],
26
+ "sharelib/ui/dashboard": ["../../packages/sharelib/ui/dashboard/index.ts"],
27
+ "sharelib/*": ["../../packages/sharelib/*"],
28
+ "share-domains/*": ["../../packages/share-domains/*"],
29
+ "billing": ["../../packages/billing/index.ts"],
30
+ "billing/*": ["../../packages/billing/*"],
31
+ "emaillib": ["../../packages/emaillib/index.ts"],
32
+ "emaillib/types": ["../../packages/emaillib/types/index.ts"],
33
+ "transactional/components": ["../../packages/transactional/emails/_components/index.ts"],
34
+ "transactional/emails/*": ["../../packages/transactional/emails/*.tsx"],
35
+ "transactional/*": ["../../packages/transactional/emails/*.tsx"],
36
+ "@mcpsdk/sdk-ts": ["../../packages/@mcpsdk/sdk-ts/src/index.ts"],
37
+ "@mcpsdk/sdk-ts/*": ["../../packages/@mcpsdk/sdk-ts/src/*"],
38
+ "@mcpsdk/plugin-core": ["../../packages/@mcpsdk/plugin-core/src/index.ts"],
39
+ "@mcpsdk/plugin-core/*": ["../../packages/@mcpsdk/plugin-core/src/*"],
40
+ "@mcpsdk/mcp-server": ["../../packages/@mcpsdk/mcp-server/index.js"]
41
+ }
42
+ },
43
+ "include": ["src/**/*.ts"]
44
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { resolve } from "node:path";
2
+ import { defineConfig } from "tsup";
3
+
4
+ // relaylib ships TypeScript source (its package exports point at `./src/*.ts`),
5
+ // so it cannot be a runtime dependency of this published npm CLI. Bundle it
6
+ // straight into dist instead — the connector stays standalone. `ws` and
7
+ // @bunny-agent/* stay external (real runtime deps).
8
+ export default defineConfig({
9
+ entry: { cli: "src/cli.ts" },
10
+ format: ["esm"],
11
+ target: "node20",
12
+ platform: "node",
13
+ outDir: "dist",
14
+ clean: true,
15
+ dts: false,
16
+ noExternal: [/^relaylib/],
17
+ esbuildOptions(options) {
18
+ options.nodePaths = [resolve(process.cwd(), "node_modules")];
19
+ },
20
+ });