@buda-ai/connector 0.1.2 → 0.1.5

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.
Files changed (3) hide show
  1. package/README.md +9 -7
  2. package/dist/cli.js +138 -70
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -57,7 +57,7 @@ printf '{"serverUrl":"https://buda.im","oauthToken":"..."}\n' \
57
57
  Supported launch config fields:
58
58
 
59
59
  - `serverUrl`
60
- - `spaceId`
60
+ - `token`
61
61
  - `workdirRoot`
62
62
  - `oauthToken`
63
63
  - `bunnyDaemonHost`
@@ -65,11 +65,11 @@ Supported launch config fields:
65
65
 
66
66
  ## One-line install
67
67
 
68
- Buda Space Settings can show a simple Space ID command for the current space.
69
- This mode identifies the Space, not a specific user:
68
+ Buda Space Settings can show a connector token command for the current space.
69
+ The token selects that Space's independent local connector profile:
70
70
 
71
71
  ```bash
72
- npx -y @buda-ai/connector@latest daemon --space-id "org_..."
72
+ npx -y @buda-ai/connector@latest daemon --token "cet_..."
73
73
  ```
74
74
 
75
75
  Desktop and other authenticated clients should use OAuth token launch config
@@ -83,17 +83,19 @@ printf '%s\n' '{"serverUrl":"https://buda.im","oauthToken":"..."}' \
83
83
  You can also pass launch options directly:
84
84
 
85
85
  ```bash
86
- buda-connector daemon --space-id "org_..." --workdir "$HOME/.buda/agents"
86
+ buda-connector daemon --token "cet_..." --workdir "$HOME/.buda/agents"
87
87
  ```
88
88
 
89
89
  ## Local Files
90
90
 
91
- - `~/.buda/device.json` (versioned device identities keyed by normalized server origin)
91
+ - `~/.buda/device.json` (versioned device profiles keyed by server origin and token fingerprint)
92
92
  - `~/.buda/connector.log`
93
93
  - `~/.buda/agents/`
94
94
 
95
95
  Server URL paths and query strings do not create a new identity. Scheme and non-default port
96
- changes do. Existing single-device config files are upgraded in place on first use.
96
+ changes do. Connector tokens for different Spaces on the same server select independent profiles,
97
+ so their daemons can run concurrently. Raw tokens are never used in profile keys or lock paths.
98
+ The installation-level physical device ID remains shared across profiles.
97
99
 
98
100
  New server profiles share a privacy-preserving device ID derived by hashing the operating
99
101
  system's stable machine identifier. The raw machine identifier is never stored or sent. If the
package/dist/cli.js CHANGED
@@ -147,6 +147,13 @@ function getServerOriginKey(serverUrl) {
147
147
  const digest = createHash2("sha256").update(origin).digest("hex").slice(0, 12);
148
148
  return `${hostnameKey}-${digest}`;
149
149
  }
150
+ function getConnectorTokenFingerprint(token) {
151
+ return createHash2("sha256").update(`buda:connector-profile:v1:${token.trim()}`, "utf8").digest("hex");
152
+ }
153
+ function getConnectorProfileKey(serverUrl, token) {
154
+ const discriminator = token?.trim() ? getConnectorTokenFingerprint(token) : "legacy-no-token";
155
+ return `${getServerOriginKey(serverUrl)}--${discriminator}`;
156
+ }
150
157
  function getConnectorPaths(serverUrl, configRoot = path.join(homedir(), ".buda")) {
151
158
  const scopedLegacyDir = path.join(configRoot, "servers", getServerOriginKey(serverUrl));
152
159
  return {
@@ -158,6 +165,16 @@ function getConnectorPaths(serverUrl, configRoot = path.join(homedir(), ".buda")
158
165
  scopedLegacyWorkdirRoot: path.join(scopedLegacyDir, "agents")
159
166
  };
160
167
  }
168
+ function getConnectorDaemonLockTarget(serverUrl, connectorId, configRoot = path.join(homedir(), ".buda")) {
169
+ const connectorKey = connectorId.replace(/[^a-z0-9_-]+/gi, "-").toLowerCase();
170
+ return path.join(
171
+ configRoot,
172
+ "daemons",
173
+ getServerOriginKey(serverUrl),
174
+ connectorKey,
175
+ "connector-daemon"
176
+ );
177
+ }
161
178
  function getServerUrl(launchConfig) {
162
179
  return (launchConfig?.serverUrl || process.env.BUDA_CONNECTOR_SERVER_URL || process.env.BUDA_SERVER_URL || process.env.NEXT_PUBLIC_APP_URL || DEFAULT_SERVER_URL).replace(/\/$/, "");
163
180
  }
@@ -211,29 +228,50 @@ async function readJsonFile(configPath) {
211
228
  return null;
212
229
  }
213
230
  }
231
+ function migrateLegacyProfiles(values) {
232
+ const profiles = {};
233
+ for (const value of values) {
234
+ if (!isDeviceConfig(value)) continue;
235
+ try {
236
+ profiles[getConnectorProfileKey(value.serverUrl, value.token)] = value;
237
+ } catch {
238
+ }
239
+ }
240
+ return profiles;
241
+ }
214
242
  async function loadDeviceConfigStore(configPath) {
215
243
  const parsed = await readJsonFile(configPath);
216
244
  const candidate = parsed;
245
+ if (candidate?.version === 3 && candidate.profiles && typeof candidate.profiles === "object") {
246
+ return {
247
+ store: {
248
+ version: 3,
249
+ ...typeof candidate.systemDeviceId === "string" ? { systemDeviceId: candidate.systemDeviceId } : {},
250
+ profiles: candidate.profiles
251
+ },
252
+ needsWrite: false
253
+ };
254
+ }
217
255
  if (candidate && (candidate.version === 1 || candidate.version === 2) && candidate.servers && typeof candidate.servers === "object") {
218
256
  return {
219
257
  store: {
220
- version: 2,
258
+ version: 3,
221
259
  ...typeof candidate.systemDeviceId === "string" ? { systemDeviceId: candidate.systemDeviceId } : {},
222
- servers: candidate.servers
260
+ profiles: migrateLegacyProfiles(Object.values(candidate.servers))
223
261
  },
224
- needsWrite: candidate.version !== 2
262
+ needsWrite: true
225
263
  };
226
264
  }
227
265
  if (isDeviceConfig(parsed)) {
228
266
  return {
229
267
  store: {
230
- version: 2,
231
- servers: { [normalizeServerOrigin(parsed.serverUrl)]: parsed }
268
+ version: 3,
269
+ profiles: migrateLegacyProfiles([parsed])
232
270
  },
233
271
  needsWrite: true
234
272
  };
235
273
  }
236
- return { store: { version: 2, servers: {} }, needsWrite: false };
274
+ return { store: { version: 3, profiles: {} }, needsWrite: false };
237
275
  }
238
276
  function normalizeStoredWorkdirRoot(config, paths) {
239
277
  const configuredRoot = config.workdirRoot?.trim();
@@ -264,33 +302,21 @@ async function writeDeviceConfigStore(configPath, store) {
264
302
  async function readDeviceConfig(launchConfig, configRoot) {
265
303
  const serverUrl = getServerUrl(launchConfig);
266
304
  const paths = getConnectorPaths(serverUrl, configRoot);
267
- const origin = normalizeServerOrigin(serverUrl);
305
+ const launchToken = getConnectorToken(launchConfig);
268
306
  await mkdir(paths.configDir, { recursive: true });
269
307
  return withExclusiveFileLock(paths.configPath, async () => {
270
308
  const { store, needsWrite } = await loadDeviceConfigStore(paths.configPath);
271
- const storedConfig = store.servers[origin];
309
+ const profileKey = getConnectorProfileKey(serverUrl, launchToken);
310
+ const storedConfig = store.profiles[profileKey];
272
311
  let config = isDeviceConfig(storedConfig) ? storedConfig : null;
273
- let migratedScopedConfig = false;
274
312
  let normalizedWorkdirRoot = false;
275
- if (!config) {
276
- const scopedLegacy = await readJsonFile(paths.scopedLegacyConfigPath);
277
- if (isDeviceConfig(scopedLegacy)) {
278
- try {
279
- if (normalizeServerOrigin(scopedLegacy.serverUrl) === origin) {
280
- config = scopedLegacy;
281
- migratedScopedConfig = true;
282
- }
283
- } catch {
284
- }
285
- }
286
- }
287
313
  if (config) {
288
314
  const normalized = normalizeStoredWorkdirRoot(config, paths);
289
315
  config = normalized.config;
290
316
  normalizedWorkdirRoot = normalized.changed;
291
- store.servers[origin] = config;
317
+ store.profiles[getConnectorProfileKey(config.serverUrl, config.token)] = config;
292
318
  }
293
- if (needsWrite || migratedScopedConfig || normalizedWorkdirRoot) {
319
+ if (needsWrite || normalizedWorkdirRoot) {
294
320
  await writeDeviceConfigStore(paths.configPath, store);
295
321
  }
296
322
  return config;
@@ -302,7 +328,7 @@ async function writeDeviceConfig(config, configRoot) {
302
328
  await mkdir(paths.configDir, { recursive: true });
303
329
  await withExclusiveFileLock(paths.configPath, async () => {
304
330
  const { store } = await loadDeviceConfigStore(paths.configPath);
305
- store.servers[normalizeServerOrigin(config.serverUrl)] = config;
331
+ store.profiles[getConnectorProfileKey(config.serverUrl, config.token)] = config;
306
332
  await writeDeviceConfigStore(paths.configPath, store);
307
333
  });
308
334
  }
@@ -315,7 +341,7 @@ async function getOrCreateSystemDeviceId(configRoot, resolver = resolveSystemDev
315
341
  if (needsWrite) await writeDeviceConfigStore(paths.configPath, store);
316
342
  return store.systemDeviceId;
317
343
  }
318
- const existingDeviceId = Object.values(store.servers).find(isDeviceConfig)?.deviceId;
344
+ const existingDeviceId = Object.values(store.profiles).find(isDeviceConfig)?.deviceId;
319
345
  store.systemDeviceId = resolver() || existingDeviceId || `dvc_${randomUUID()}`;
320
346
  await writeDeviceConfigStore(paths.configPath, store);
321
347
  return store.systemDeviceId;
@@ -325,19 +351,40 @@ async function createDeviceConfig(launchConfig, configRoot, resolver) {
325
351
  const deviceId = await getOrCreateSystemDeviceId(configRoot, resolver);
326
352
  return buildDeviceConfig(launchConfig, deviceId);
327
353
  }
328
- async function ensureDeviceConfig(launchConfig) {
329
- const existing = await readDeviceConfig(launchConfig);
330
- if (existing) {
331
- const overrideToken = getConnectorToken(launchConfig);
332
- return {
333
- ...existing,
334
- serverUrl: getServerUrl(launchConfig),
335
- workdirRoot: launchConfig?.workdirRoot || process.env.BUDA_CONNECTOR_WORKDIR || existing.workdirRoot || getWorkdirRoot(launchConfig),
336
- ...overrideToken ? { token: overrideToken } : {}
337
- };
338
- }
339
- const config = await createDeviceConfig(launchConfig);
340
- await writeDeviceConfig(config);
354
+ async function ensureDeviceConfig(launchConfig, configRoot, resolver = resolveSystemDeviceId) {
355
+ const serverUrl = getServerUrl(launchConfig);
356
+ const paths = getConnectorPaths(serverUrl, configRoot);
357
+ const token = getConnectorToken(launchConfig);
358
+ const profileKey = getConnectorProfileKey(serverUrl, token);
359
+ await mkdir(paths.configDir, { recursive: true });
360
+ const config = await withExclusiveFileLock(paths.configPath, async () => {
361
+ const { store, needsWrite } = await loadDeviceConfigStore(paths.configPath);
362
+ const storedConfig = store.profiles[profileKey];
363
+ if (isDeviceConfig(storedConfig)) {
364
+ const normalized = normalizeStoredWorkdirRoot(storedConfig, paths);
365
+ if (needsWrite || normalized.changed) {
366
+ store.profiles[profileKey] = normalized.config;
367
+ await writeDeviceConfigStore(paths.configPath, store);
368
+ }
369
+ const existing = normalized.config;
370
+ const overrideToken = getConnectorToken(launchConfig);
371
+ return {
372
+ ...existing,
373
+ serverUrl,
374
+ workdirRoot: launchConfig?.workdirRoot || process.env.BUDA_CONNECTOR_WORKDIR || existing.workdirRoot || getWorkdirRoot(launchConfig),
375
+ ...overrideToken ? { token: overrideToken } : {}
376
+ };
377
+ }
378
+ if (!store.systemDeviceId) {
379
+ const existingDeviceId = Object.values(store.profiles).find(isDeviceConfig)?.deviceId;
380
+ store.systemDeviceId = resolver() || existingDeviceId || `dvc_${randomUUID()}`;
381
+ }
382
+ const created = buildDeviceConfig(launchConfig, store.systemDeviceId);
383
+ store.profiles[profileKey] = created;
384
+ await writeDeviceConfigStore(paths.configPath, store);
385
+ return created;
386
+ });
387
+ await mkdir(config.workdirRoot, { recursive: true });
341
388
  return config;
342
389
  }
343
390
  function parseLaunchConfig(raw) {
@@ -1034,6 +1081,7 @@ var BASE_CONNECTOR_CAPABILITIES = [
1034
1081
  "git"
1035
1082
  ];
1036
1083
  var browserAvailabilityWarningLogged = false;
1084
+ var getBrowserAvailabilityWarning = (browserAvailability) => browserAvailability.available ? null : `${browserAvailability.error} Browser automation is disabled, but shell, filesystem, Git, and coding capabilities remain available.`;
1037
1085
  var getAdvertisedCapabilities = (browserAvailability = resolveBrowserAvailability(), deviceToken) => [
1038
1086
  ...BASE_CONNECTOR_CAPABILITIES,
1039
1087
  ...browserAvailability.available ? ["browser"] : [],
@@ -1042,9 +1090,10 @@ var getAdvertisedCapabilities = (browserAvailability = resolveBrowserAvailabilit
1042
1090
  async function buildPayload(config, extra) {
1043
1091
  const bundle = await readBundledManifest();
1044
1092
  const browserAvailability = resolveBrowserAvailability();
1045
- if (!browserAvailability.available && !browserAvailabilityWarningLogged) {
1093
+ const browserWarning = getBrowserAvailabilityWarning(browserAvailability);
1094
+ if (browserWarning && !browserAvailabilityWarningLogged) {
1046
1095
  browserAvailabilityWarningLogged = true;
1047
- await writeLog("warn", browserAvailability.error);
1096
+ await writeLog("warn", browserWarning);
1048
1097
  }
1049
1098
  return {
1050
1099
  connectorId: config.connectorId,
@@ -2066,6 +2115,7 @@ var isAlreadyExists3 = (error) => error instanceof Error && "code" in error && e
2066
2115
  // src/relay.ts
2067
2116
  var RELAY_PING_INTERVAL_MS = 2e4;
2068
2117
  var RELAY_PONG_TIMEOUT_MS = 1e4;
2118
+ var RELAY_HANDSHAKE_TIMEOUT_MS = 1e4;
2069
2119
  var RELAY_RECONNECT_MAX_MS = 3e4;
2070
2120
  var RELAY_RECONNECT_JITTER = 0.2;
2071
2121
  var RELAY_FAST_RECONNECT_MS = 500;
@@ -2073,6 +2123,22 @@ var CONNECTOR_SOCKET_REPLACED_CLOSE_CODE = 4001;
2073
2123
  var CONNECTOR_SOCKET_REVOKED_CLOSE_CODE = 4003;
2074
2124
  var shouldReconnectConnectorRelay = (closeCode) => closeCode !== CONNECTOR_SOCKET_REPLACED_CLOSE_CODE && closeCode !== CONNECTOR_SOCKET_REVOKED_CLOSE_CODE;
2075
2125
  var getConnectorRelayReconnectBaseDelay = (hadOpenConnection, backoffMs) => hadOpenConnection ? RELAY_FAST_RECONNECT_MS : backoffMs;
2126
+ var formatConnectorRelayError = (error, depth = 0) => {
2127
+ if (depth >= 3) return String(error);
2128
+ const details = [String(error)];
2129
+ if (error && typeof error === "object") {
2130
+ const candidate = error;
2131
+ if (candidate.cause !== void 0) {
2132
+ details.push(`cause=${formatConnectorRelayError(candidate.cause, depth + 1)}`);
2133
+ }
2134
+ if (Array.isArray(candidate.errors) && candidate.errors.length > 0) {
2135
+ details.push(
2136
+ `errors=[${candidate.errors.map((nestedError) => formatConnectorRelayError(nestedError, depth + 1)).join(", ")}]`
2137
+ );
2138
+ }
2139
+ }
2140
+ return [...new Set(details.filter(Boolean))].join(" ") || "Unknown relay error";
2141
+ };
2076
2142
  function startConnectorRelay(config, bunnyDaemon, relayProtocol, workspaceRegistry, launchConfig) {
2077
2143
  let socket = null;
2078
2144
  let reconnectTimer = null;
@@ -2114,10 +2180,7 @@ function startConnectorRelay(config, bunnyDaemon, relayProtocol, workspaceRegist
2114
2180
  try {
2115
2181
  connect();
2116
2182
  } catch (error) {
2117
- void writeLog(
2118
- "warn",
2119
- `relay reconnect threw: ${error instanceof Error ? error.message : error}`
2120
- );
2183
+ void writeLog("warn", `relay reconnect threw: ${formatConnectorRelayError(error)}`);
2121
2184
  scheduleReconnect("connect-throw");
2122
2185
  }
2123
2186
  }, delay);
@@ -2133,7 +2196,8 @@ function startConnectorRelay(config, bunnyDaemon, relayProtocol, workspaceRegist
2133
2196
  headers["X-Buda-Connector-Id"] = config.connectorId;
2134
2197
  if (config.deviceToken) headers["X-Buda-Connector-Device-Token"] = config.deviceToken;
2135
2198
  const ws = new WebSocket(relayUrl, {
2136
- headers: Object.keys(headers).length > 0 ? headers : void 0
2199
+ headers: Object.keys(headers).length > 0 ? headers : void 0,
2200
+ handshakeTimeout: RELAY_HANDSHAKE_TIMEOUT_MS
2137
2201
  });
2138
2202
  socket = ws;
2139
2203
  const inflight = /* @__PURE__ */ new Map();
@@ -2154,10 +2218,7 @@ function startConnectorRelay(config, bunnyDaemon, relayProtocol, workspaceRegist
2154
2218
  try {
2155
2219
  ws.ping();
2156
2220
  } catch (error) {
2157
- void writeLog(
2158
- "warn",
2159
- `relay ping failed: ${error instanceof Error ? error.message : error}`
2160
- );
2221
+ void writeLog("warn", `relay ping failed: ${formatConnectorRelayError(error)}`);
2161
2222
  ws.terminate();
2162
2223
  return;
2163
2224
  }
@@ -2200,7 +2261,7 @@ function startConnectorRelay(config, bunnyDaemon, relayProtocol, workspaceRegist
2200
2261
  scheduleReconnect("close");
2201
2262
  });
2202
2263
  ws.on("error", (error) => {
2203
- void writeLog("warn", `relay error: ${error instanceof Error ? error.message : error}`);
2264
+ void writeLog("warn", `relay error: ${formatConnectorRelayError(error)}`);
2204
2265
  detach();
2205
2266
  scheduleReconnect("error");
2206
2267
  });
@@ -2208,10 +2269,7 @@ function startConnectorRelay(config, bunnyDaemon, relayProtocol, workspaceRegist
2208
2269
  try {
2209
2270
  connect();
2210
2271
  } catch (error) {
2211
- void writeLog(
2212
- "warn",
2213
- `relay initial connect threw: ${error instanceof Error ? error.message : error}`
2214
- );
2272
+ void writeLog("warn", `relay initial connect threw: ${formatConnectorRelayError(error)}`);
2215
2273
  scheduleReconnect("connect-throw");
2216
2274
  }
2217
2275
  return {
@@ -2916,7 +2974,7 @@ import { mkdir as mkdir9 } from "fs/promises";
2916
2974
  import path9 from "path";
2917
2975
  async function runNewConnector(launchConfig) {
2918
2976
  const existing = await readDeviceConfig(launchConfig);
2919
- const config = existing ? await ensureDeviceConfig(launchConfig) : await createDeviceConfig(launchConfig);
2977
+ const config = await ensureDeviceConfig(launchConfig);
2920
2978
  const paths = getConnectorPaths(config.serverUrl);
2921
2979
  await migrateLegacyWorkspaceRoot(config.workdirRoot);
2922
2980
  await provisionWorkdirLayout(config.workdirRoot);
@@ -2932,22 +2990,15 @@ async function runNewConnector(launchConfig) {
2932
2990
  }
2933
2991
  async function runConnectorDaemon(launchConfig) {
2934
2992
  const existing = await readDeviceConfig(launchConfig);
2935
- const config = existing ? await ensureDeviceConfig(launchConfig) : await createDeviceConfig(launchConfig);
2993
+ const config = await ensureDeviceConfig(launchConfig);
2936
2994
  const paths = getConnectorPaths(config.serverUrl);
2937
- const daemonLockTarget = path9.join(
2938
- paths.configDir,
2939
- "servers",
2940
- getServerOriginKey(config.serverUrl),
2941
- "connector-daemon"
2942
- );
2995
+ const daemonLockTarget = getConnectorDaemonLockTarget(config.serverUrl, config.connectorId);
2943
2996
  await mkdir9(path9.dirname(daemonLockTarget), { recursive: true, mode: 448 });
2944
- const daemonLock = await acquireExclusiveFileLock(daemonLockTarget, { timeoutMs: 0 }).catch(
2945
- () => {
2946
- throw new Error(
2947
- `Another Buda Connector daemon is already running for ${config.serverUrl}. Stop it before starting another instance.`
2948
- );
2949
- }
2950
- );
2997
+ let daemonLock = await acquireExclusiveFileLock(daemonLockTarget, { timeoutMs: 0 }).catch(() => {
2998
+ throw new Error(
2999
+ `Another Buda Connector daemon is already running for this profile on ${config.serverUrl}. Stop it before starting another instance.`
3000
+ );
3001
+ });
2951
3002
  let bunnyDaemon;
2952
3003
  let relayProtocol;
2953
3004
  try {
@@ -2959,9 +3010,26 @@ async function runConnectorDaemon(launchConfig) {
2959
3010
  const registration = await registerConnector(config, launchConfig, activeBunnyDaemon, {
2960
3011
  recoverMissingProfile: !existing
2961
3012
  });
3013
+ const connectorIdChanged = config.connectorId !== registration.connectorId;
3014
+ if (connectorIdChanged) {
3015
+ const registeredLockTarget = getConnectorDaemonLockTarget(
3016
+ config.serverUrl,
3017
+ registration.connectorId
3018
+ );
3019
+ await mkdir9(path9.dirname(registeredLockTarget), { recursive: true, mode: 448 });
3020
+ const registeredLock = await acquireExclusiveFileLock(registeredLockTarget, {
3021
+ timeoutMs: 0
3022
+ }).catch(() => {
3023
+ throw new Error(
3024
+ `Another Buda Connector daemon is already running for this profile on ${config.serverUrl}. Stop it before starting another instance.`
3025
+ );
3026
+ });
3027
+ await daemonLock.release();
3028
+ daemonLock = registeredLock;
3029
+ }
2962
3030
  config.connectorId = registration.connectorId;
2963
3031
  relayProtocol = registration.relayProtocol;
2964
- if (!existing) await writeDeviceConfig(config);
3032
+ if (!existing || connectorIdChanged) await writeDeviceConfig(config);
2965
3033
  const relay = startConnectorRelay(
2966
3034
  config,
2967
3035
  activeBunnyDaemon,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buda-ai/connector",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "publishConfig": {