@buda-ai/connector 0.1.1 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -6
- package/agent-templates/scripts/skill-scanner.sh +125 -0
- package/agent-templates/shared/AGENTS.md +220 -0
- package/agent-templates/shared/BOOTSTRAP.md +15 -0
- package/agent-templates/shared/HEARTBEAT.md +5 -0
- package/agent-templates/shared/IDENTITY.md +29 -0
- package/agent-templates/shared/MEMORY.md +19 -0
- package/agent-templates/shared/SOUL.md +43 -0
- package/agent-templates/shared/TOOLS.md +85 -0
- package/agent-templates/shared/USER.md +23 -0
- package/agent-templates/system-skills/app-builder/README.md +8 -0
- package/agent-templates/system-skills/app-builder/SKILL.md +678 -0
- package/agent-templates/system-skills/app-builder/buda.app.schema.json +165 -0
- package/agent-templates/system-skills/app-builder/evals/evals.json +23 -0
- package/agent-templates/system-skills/app-builder/scripts/check-preview-url.mjs +184 -0
- package/agent-templates/system-skills/buda-automations/SKILL.md +175 -0
- package/agent-templates/system-skills/buda-automations/scripts/create-automation.sh +226 -0
- package/agent-templates/system-skills/buda-automations/scripts/delete-automation.sh +73 -0
- package/agent-templates/system-skills/buda-automations/scripts/list-automations.sh +77 -0
- package/agent-templates/system-skills/buda-far/SKILL.md +221 -0
- package/agent-templates/system-skills/buda-far/far_gen.py +1499 -0
- package/agent-templates/system-skills/buda-machine-info/SKILL.md +67 -0
- package/agent-templates/system-skills/buda-machine-info/scripts/get-info.sh +25 -0
- package/agent-templates/system-skills/buda-machine-info/scripts/send-file.mjs +62 -0
- package/agent-templates/system-skills/buda-web-preview/SKILL.md +462 -0
- package/agent-templates/system-skills/find-skills/SKILL.md +142 -0
- package/agent-templates/system-skills/skill-creator/LICENSE.txt +202 -0
- package/agent-templates/system-skills/skill-creator/SKILL.md +479 -0
- package/agent-templates/system-skills/skill-creator/agents/analyzer.md +274 -0
- package/agent-templates/system-skills/skill-creator/agents/comparator.md +202 -0
- package/agent-templates/system-skills/skill-creator/agents/grader.md +223 -0
- package/agent-templates/system-skills/skill-creator/assets/eval_review.html +146 -0
- package/agent-templates/system-skills/skill-creator/eval-viewer/generate_review.py +471 -0
- package/agent-templates/system-skills/skill-creator/eval-viewer/viewer.html +1325 -0
- package/agent-templates/system-skills/skill-creator/references/schemas.md +430 -0
- package/agent-templates/system-skills/skill-creator/scripts/__init__.py +0 -0
- package/agent-templates/system-skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- package/agent-templates/system-skills/skill-creator/scripts/generate_report.py +326 -0
- package/agent-templates/system-skills/skill-creator/scripts/improve_description.py +248 -0
- package/agent-templates/system-skills/skill-creator/scripts/package_skill.py +136 -0
- package/agent-templates/system-skills/skill-creator/scripts/quick_validate.py +103 -0
- package/agent-templates/system-skills/skill-creator/scripts/run_eval.py +310 -0
- package/agent-templates/system-skills/skill-creator/scripts/run_loop.py +332 -0
- package/agent-templates/system-skills/skill-creator/scripts/utils.py +47 -0
- package/dist/cli.js +2807 -183
- package/package.json +10 -6
package/dist/cli.js
CHANGED
|
@@ -1,19 +1,180 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/config.ts
|
|
4
|
-
import { randomUUID } from "crypto";
|
|
5
|
-
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
6
|
-
import { homedir, hostname, platform } from "os";
|
|
4
|
+
import { createHash as createHash2, randomBytes, randomUUID } from "crypto";
|
|
5
|
+
import { chmod, mkdir, readFile as readFile2, rename, unlink as unlink2, writeFile } from "fs/promises";
|
|
6
|
+
import { homedir, hostname, platform as platform2 } from "os";
|
|
7
7
|
import path from "path";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
|
|
9
|
+
// src/exclusive-file-lock.ts
|
|
10
|
+
import { open, readFile, stat, unlink } from "fs/promises";
|
|
11
|
+
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
12
|
+
var DEFAULT_STALE_MS = 12e4;
|
|
13
|
+
var DEFAULT_POLL_MS = 25;
|
|
14
|
+
async function acquireExclusiveFileLock(targetPath, options = {}) {
|
|
15
|
+
const lockPath = `${targetPath}.lock`;
|
|
16
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
17
|
+
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
18
|
+
const pollMs = options.pollMs ?? DEFAULT_POLL_MS;
|
|
19
|
+
const deadline = Date.now() + timeoutMs;
|
|
20
|
+
while (true) {
|
|
21
|
+
let handle;
|
|
22
|
+
try {
|
|
23
|
+
handle = await open(lockPath, "wx", 384);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (!isAlreadyExists(error)) throw error;
|
|
26
|
+
if (await shouldRecoverLock(lockPath, staleMs)) {
|
|
27
|
+
await unlink(lockPath).catch(() => void 0);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (Date.now() >= deadline) {
|
|
31
|
+
throw new Error(`Timed out waiting for exclusive access to ${targetPath}`);
|
|
32
|
+
}
|
|
33
|
+
await wait(pollMs);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
await handle.writeFile(`${JSON.stringify({ pid: process.pid, createdAt: Date.now() })}
|
|
37
|
+
`);
|
|
38
|
+
await handle.sync();
|
|
39
|
+
let released = false;
|
|
40
|
+
return {
|
|
41
|
+
release: async () => {
|
|
42
|
+
if (released) return;
|
|
43
|
+
released = true;
|
|
44
|
+
await handle.close();
|
|
45
|
+
await unlink(lockPath).catch(() => void 0);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async function withExclusiveFileLock(targetPath, operation, options = {}) {
|
|
51
|
+
const lock = await acquireExclusiveFileLock(targetPath, options);
|
|
52
|
+
try {
|
|
53
|
+
return await operation();
|
|
54
|
+
} finally {
|
|
55
|
+
await lock.release();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
var wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
59
|
+
var isAlreadyExists = (error) => typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST";
|
|
60
|
+
var shouldRecoverLock = async (lockPath, staleMs) => {
|
|
61
|
+
const [raw, lockStat] = await Promise.all([
|
|
62
|
+
readFile(lockPath, "utf8").catch(() => ""),
|
|
63
|
+
stat(lockPath).catch(() => null)
|
|
64
|
+
]);
|
|
65
|
+
if (!lockStat) return false;
|
|
66
|
+
try {
|
|
67
|
+
const metadata = JSON.parse(raw);
|
|
68
|
+
if (typeof metadata.pid === "number" && Number.isInteger(metadata.pid) && metadata.pid > 0) {
|
|
69
|
+
return !isProcessAlive(metadata.pid);
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
}
|
|
73
|
+
return Date.now() - lockStat.mtimeMs >= staleMs;
|
|
74
|
+
};
|
|
75
|
+
var isProcessAlive = (pid) => {
|
|
76
|
+
try {
|
|
77
|
+
process.kill(pid, 0);
|
|
78
|
+
return true;
|
|
79
|
+
} catch (error) {
|
|
80
|
+
return !(typeof error === "object" && error !== null && "code" in error && error.code === "ESRCH");
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// src/system-device-id.ts
|
|
85
|
+
import { execFileSync } from "child_process";
|
|
86
|
+
import { createHash } from "crypto";
|
|
87
|
+
import { readFileSync } from "fs";
|
|
88
|
+
import { platform } from "os";
|
|
89
|
+
var defaultDependencies = {
|
|
90
|
+
platform: platform(),
|
|
91
|
+
readFile: (filePath) => readFileSync(filePath, "utf8"),
|
|
92
|
+
execFile: (file, args) => execFileSync(file, args, { encoding: "utf8" })
|
|
93
|
+
};
|
|
94
|
+
var readRawSystemId = (dependencies) => {
|
|
95
|
+
if (dependencies.platform === "darwin") {
|
|
96
|
+
const output = dependencies.execFile("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"]);
|
|
97
|
+
return output.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/)?.[1];
|
|
98
|
+
}
|
|
99
|
+
if (dependencies.platform === "linux") {
|
|
100
|
+
for (const filePath of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
|
|
101
|
+
try {
|
|
102
|
+
const value = dependencies.readFile(filePath).trim();
|
|
103
|
+
if (value) return value;
|
|
104
|
+
} catch {
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return void 0;
|
|
108
|
+
}
|
|
109
|
+
if (dependencies.platform === "win32") {
|
|
110
|
+
const output = dependencies.execFile("reg", [
|
|
111
|
+
"query",
|
|
112
|
+
String.raw`HKLM\SOFTWARE\Microsoft\Cryptography`,
|
|
113
|
+
"/v",
|
|
114
|
+
"MachineGuid"
|
|
115
|
+
]);
|
|
116
|
+
return output.match(/MachineGuid\s+REG_SZ\s+([^\r\n]+)/i)?.[1];
|
|
117
|
+
}
|
|
118
|
+
return void 0;
|
|
119
|
+
};
|
|
120
|
+
var hashSystemDeviceId = (rawSystemId) => {
|
|
121
|
+
const normalized = rawSystemId.trim().toLowerCase();
|
|
122
|
+
if (!normalized) return void 0;
|
|
123
|
+
const digest = createHash("sha256").update(`buda:system-device:v1:${normalized}`, "utf8").digest("hex");
|
|
124
|
+
return `dvc_${digest}`;
|
|
125
|
+
};
|
|
126
|
+
var resolveSystemDeviceId = (dependencies = defaultDependencies) => {
|
|
127
|
+
try {
|
|
128
|
+
const rawSystemId = readRawSystemId(dependencies);
|
|
129
|
+
return rawSystemId ? hashSystemDeviceId(rawSystemId) : void 0;
|
|
130
|
+
} catch {
|
|
131
|
+
return void 0;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// src/config.ts
|
|
11
136
|
var DEFAULT_SERVER_URL = "https://buda.im";
|
|
12
|
-
var DEFAULT_WORKDIR_ROOT = path.join(CONFIG_DIR, "agents");
|
|
13
137
|
var DEFAULT_BUNNY_DAEMON_HOST = "127.0.0.1";
|
|
14
138
|
var HEARTBEAT_INTERVAL_MS = 5e3;
|
|
15
139
|
var RELAY_RECONNECT_INTERVAL_MS = 3e3;
|
|
16
140
|
var LAUNCH_CONFIG_STDIN_FLAG = "--launch-config-stdin";
|
|
141
|
+
function normalizeServerOrigin(serverUrl) {
|
|
142
|
+
return new URL(serverUrl).origin;
|
|
143
|
+
}
|
|
144
|
+
function getServerOriginKey(serverUrl) {
|
|
145
|
+
const origin = normalizeServerOrigin(serverUrl);
|
|
146
|
+
const hostnameKey = new URL(origin).hostname.replace(/[^a-z0-9.-]+/gi, "-").toLowerCase();
|
|
147
|
+
const digest = createHash2("sha256").update(origin).digest("hex").slice(0, 12);
|
|
148
|
+
return `${hostnameKey}-${digest}`;
|
|
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
|
+
}
|
|
157
|
+
function getConnectorPaths(serverUrl, configRoot = path.join(homedir(), ".buda")) {
|
|
158
|
+
const scopedLegacyDir = path.join(configRoot, "servers", getServerOriginKey(serverUrl));
|
|
159
|
+
return {
|
|
160
|
+
configDir: configRoot,
|
|
161
|
+
configPath: path.join(configRoot, "device.json"),
|
|
162
|
+
logPath: path.join(configRoot, "connector.log"),
|
|
163
|
+
workdirRoot: path.join(configRoot, "agents"),
|
|
164
|
+
scopedLegacyConfigPath: path.join(scopedLegacyDir, "device.json"),
|
|
165
|
+
scopedLegacyWorkdirRoot: path.join(scopedLegacyDir, "agents")
|
|
166
|
+
};
|
|
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
|
+
}
|
|
17
178
|
function getServerUrl(launchConfig) {
|
|
18
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(/\/$/, "");
|
|
19
180
|
}
|
|
@@ -24,7 +185,7 @@ function getConnectorRelayUrl(serverUrl, connectorId) {
|
|
|
24
185
|
return url.toString();
|
|
25
186
|
}
|
|
26
187
|
function getWorkdirRoot(launchConfig) {
|
|
27
|
-
return launchConfig?.workdirRoot || process.env.BUDA_CONNECTOR_WORKDIR ||
|
|
188
|
+
return launchConfig?.workdirRoot || process.env.BUDA_CONNECTOR_WORKDIR || getConnectorPaths(getServerUrl(launchConfig)).workdirRoot;
|
|
28
189
|
}
|
|
29
190
|
function getBunnyDaemonHost(launchConfig) {
|
|
30
191
|
return launchConfig?.bunnyDaemonHost || process.env.BUDA_CONNECTOR_BUNNY_DAEMON_HOST || DEFAULT_BUNNY_DAEMON_HOST;
|
|
@@ -36,46 +197,194 @@ function getBunnyDaemonPort(launchConfig) {
|
|
|
36
197
|
const parsed = Number(raw);
|
|
37
198
|
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
|
|
38
199
|
}
|
|
39
|
-
function
|
|
40
|
-
return launchConfig?.
|
|
200
|
+
function getConnectorToken(launchConfig) {
|
|
201
|
+
return launchConfig?.token?.trim() || process.env.BUDA_CONNECTOR_TOKEN?.trim() || void 0;
|
|
41
202
|
}
|
|
42
|
-
function buildDeviceConfig(launchConfig) {
|
|
203
|
+
function buildDeviceConfig(launchConfig, deviceId = `dvc_${randomUUID()}`) {
|
|
43
204
|
const host = hostname();
|
|
205
|
+
const token = getConnectorToken(launchConfig);
|
|
44
206
|
return {
|
|
45
207
|
connectorId: `cnr_${randomUUID()}`,
|
|
46
|
-
deviceId
|
|
208
|
+
deviceId,
|
|
47
209
|
deviceName: host,
|
|
48
|
-
hostLabel: `${host} (${
|
|
210
|
+
hostLabel: `${host} (${platform2()})`,
|
|
49
211
|
serverUrl: getServerUrl(launchConfig),
|
|
50
212
|
workdirRoot: getWorkdirRoot(launchConfig),
|
|
51
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
213
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
214
|
+
deviceToken: randomBytes(32).toString("base64url"),
|
|
215
|
+
...token ? { token } : {}
|
|
52
216
|
};
|
|
53
217
|
}
|
|
54
|
-
|
|
218
|
+
function isDeviceConfig(value) {
|
|
219
|
+
if (!value || typeof value !== "object") return false;
|
|
220
|
+
const config = value;
|
|
221
|
+
return typeof config.connectorId === "string" && typeof config.deviceId === "string" && typeof config.serverUrl === "string";
|
|
222
|
+
}
|
|
223
|
+
async function readJsonFile(configPath) {
|
|
55
224
|
try {
|
|
56
|
-
const raw = await
|
|
225
|
+
const raw = await readFile2(configPath, "utf8");
|
|
57
226
|
return JSON.parse(raw);
|
|
58
227
|
} catch {
|
|
59
228
|
return null;
|
|
60
229
|
}
|
|
61
230
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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;
|
|
67
241
|
}
|
|
68
|
-
async function
|
|
69
|
-
const
|
|
70
|
-
|
|
242
|
+
async function loadDeviceConfigStore(configPath) {
|
|
243
|
+
const parsed = await readJsonFile(configPath);
|
|
244
|
+
const candidate = parsed;
|
|
245
|
+
if (candidate?.version === 3 && candidate.profiles && typeof candidate.profiles === "object") {
|
|
71
246
|
return {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
247
|
+
store: {
|
|
248
|
+
version: 3,
|
|
249
|
+
...typeof candidate.systemDeviceId === "string" ? { systemDeviceId: candidate.systemDeviceId } : {},
|
|
250
|
+
profiles: candidate.profiles
|
|
251
|
+
},
|
|
252
|
+
needsWrite: false
|
|
75
253
|
};
|
|
76
254
|
}
|
|
77
|
-
|
|
78
|
-
|
|
255
|
+
if (candidate && (candidate.version === 1 || candidate.version === 2) && candidate.servers && typeof candidate.servers === "object") {
|
|
256
|
+
return {
|
|
257
|
+
store: {
|
|
258
|
+
version: 3,
|
|
259
|
+
...typeof candidate.systemDeviceId === "string" ? { systemDeviceId: candidate.systemDeviceId } : {},
|
|
260
|
+
profiles: migrateLegacyProfiles(Object.values(candidate.servers))
|
|
261
|
+
},
|
|
262
|
+
needsWrite: true
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
if (isDeviceConfig(parsed)) {
|
|
266
|
+
return {
|
|
267
|
+
store: {
|
|
268
|
+
version: 3,
|
|
269
|
+
profiles: migrateLegacyProfiles([parsed])
|
|
270
|
+
},
|
|
271
|
+
needsWrite: true
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
return { store: { version: 3, profiles: {} }, needsWrite: false };
|
|
275
|
+
}
|
|
276
|
+
function normalizeStoredWorkdirRoot(config, paths) {
|
|
277
|
+
const configuredRoot = config.workdirRoot?.trim();
|
|
278
|
+
if (!configuredRoot) {
|
|
279
|
+
return { config: { ...config, workdirRoot: paths.workdirRoot }, changed: true };
|
|
280
|
+
}
|
|
281
|
+
const relativeToLegacyServers = path.relative(
|
|
282
|
+
path.join(paths.configDir, "servers"),
|
|
283
|
+
path.resolve(configuredRoot)
|
|
284
|
+
);
|
|
285
|
+
const segments = relativeToLegacyServers.split(path.sep);
|
|
286
|
+
const isServerScopedDefault = !path.isAbsolute(relativeToLegacyServers) && segments.length === 2 && segments[0] !== ".." && segments[1] === "agents";
|
|
287
|
+
if (!isServerScopedDefault) return { config, changed: false };
|
|
288
|
+
return { config: { ...config, workdirRoot: paths.workdirRoot }, changed: true };
|
|
289
|
+
}
|
|
290
|
+
async function writeDeviceConfigStore(configPath, store) {
|
|
291
|
+
await mkdir(path.dirname(configPath), { recursive: true });
|
|
292
|
+
const temporaryPath = `${configPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
293
|
+
try {
|
|
294
|
+
await writeFile(temporaryPath, `${JSON.stringify(store, null, 2)}
|
|
295
|
+
`, { mode: 384 });
|
|
296
|
+
await rename(temporaryPath, configPath);
|
|
297
|
+
await chmod(configPath, 384);
|
|
298
|
+
} finally {
|
|
299
|
+
await unlink2(temporaryPath).catch(() => void 0);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
async function readDeviceConfig(launchConfig, configRoot) {
|
|
303
|
+
const serverUrl = getServerUrl(launchConfig);
|
|
304
|
+
const paths = getConnectorPaths(serverUrl, configRoot);
|
|
305
|
+
const launchToken = getConnectorToken(launchConfig);
|
|
306
|
+
await mkdir(paths.configDir, { recursive: true });
|
|
307
|
+
return withExclusiveFileLock(paths.configPath, async () => {
|
|
308
|
+
const { store, needsWrite } = await loadDeviceConfigStore(paths.configPath);
|
|
309
|
+
const profileKey = getConnectorProfileKey(serverUrl, launchToken);
|
|
310
|
+
const storedConfig = store.profiles[profileKey];
|
|
311
|
+
let config = isDeviceConfig(storedConfig) ? storedConfig : null;
|
|
312
|
+
let normalizedWorkdirRoot = false;
|
|
313
|
+
if (config) {
|
|
314
|
+
const normalized = normalizeStoredWorkdirRoot(config, paths);
|
|
315
|
+
config = normalized.config;
|
|
316
|
+
normalizedWorkdirRoot = normalized.changed;
|
|
317
|
+
store.profiles[getConnectorProfileKey(config.serverUrl, config.token)] = config;
|
|
318
|
+
}
|
|
319
|
+
if (needsWrite || normalizedWorkdirRoot) {
|
|
320
|
+
await writeDeviceConfigStore(paths.configPath, store);
|
|
321
|
+
}
|
|
322
|
+
return config;
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
async function writeDeviceConfig(config, configRoot) {
|
|
326
|
+
const paths = getConnectorPaths(config.serverUrl, configRoot);
|
|
327
|
+
await mkdir(config.workdirRoot, { recursive: true });
|
|
328
|
+
await mkdir(paths.configDir, { recursive: true });
|
|
329
|
+
await withExclusiveFileLock(paths.configPath, async () => {
|
|
330
|
+
const { store } = await loadDeviceConfigStore(paths.configPath);
|
|
331
|
+
store.profiles[getConnectorProfileKey(config.serverUrl, config.token)] = config;
|
|
332
|
+
await writeDeviceConfigStore(paths.configPath, store);
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
async function getOrCreateSystemDeviceId(configRoot, resolver = resolveSystemDeviceId) {
|
|
336
|
+
const paths = getConnectorPaths(DEFAULT_SERVER_URL, configRoot);
|
|
337
|
+
await mkdir(paths.configDir, { recursive: true });
|
|
338
|
+
return withExclusiveFileLock(paths.configPath, async () => {
|
|
339
|
+
const { store, needsWrite } = await loadDeviceConfigStore(paths.configPath);
|
|
340
|
+
if (store.systemDeviceId) {
|
|
341
|
+
if (needsWrite) await writeDeviceConfigStore(paths.configPath, store);
|
|
342
|
+
return store.systemDeviceId;
|
|
343
|
+
}
|
|
344
|
+
const existingDeviceId = Object.values(store.profiles).find(isDeviceConfig)?.deviceId;
|
|
345
|
+
store.systemDeviceId = resolver() || existingDeviceId || `dvc_${randomUUID()}`;
|
|
346
|
+
await writeDeviceConfigStore(paths.configPath, store);
|
|
347
|
+
return store.systemDeviceId;
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
async function createDeviceConfig(launchConfig, configRoot, resolver) {
|
|
351
|
+
const deviceId = await getOrCreateSystemDeviceId(configRoot, resolver);
|
|
352
|
+
return buildDeviceConfig(launchConfig, deviceId);
|
|
353
|
+
}
|
|
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 });
|
|
79
388
|
return config;
|
|
80
389
|
}
|
|
81
390
|
function parseLaunchConfig(raw) {
|
|
@@ -86,12 +395,15 @@ function parseLaunchConfig(raw) {
|
|
|
86
395
|
serverUrl: typeof parsed.serverUrl === "string" ? parsed.serverUrl : void 0,
|
|
87
396
|
workdirRoot: typeof parsed.workdirRoot === "string" ? parsed.workdirRoot : void 0,
|
|
88
397
|
oauthToken: typeof parsed.oauthToken === "string" ? parsed.oauthToken : void 0,
|
|
89
|
-
|
|
398
|
+
token: typeof parsed.token === "string" ? parsed.token : void 0,
|
|
90
399
|
bunnyDaemonHost: typeof parsed.bunnyDaemonHost === "string" ? parsed.bunnyDaemonHost : void 0,
|
|
91
400
|
bunnyDaemonPort
|
|
92
401
|
};
|
|
93
402
|
}
|
|
94
403
|
|
|
404
|
+
// src/onboard.ts
|
|
405
|
+
import { cancel, intro, isCancel, log, note, outro, spinner, text } from "@clack/prompts";
|
|
406
|
+
|
|
95
407
|
// src/bunny-daemon.ts
|
|
96
408
|
import { mkdir as mkdir3 } from "fs/promises";
|
|
97
409
|
import { createDaemon } from "@bunny-agent/daemon";
|
|
@@ -108,8 +420,9 @@ async function writeLog(level, message, metadata) {
|
|
|
108
420
|
};
|
|
109
421
|
pendingLogs.push(entry);
|
|
110
422
|
if (pendingLogs.length > 100) pendingLogs.splice(0, pendingLogs.length - 100);
|
|
111
|
-
|
|
112
|
-
await
|
|
423
|
+
const paths = getConnectorPaths(getServerUrl());
|
|
424
|
+
await mkdir2(paths.configDir, { recursive: true }).catch(() => void 0);
|
|
425
|
+
await appendFile(paths.logPath, `${JSON.stringify(entry)}
|
|
113
426
|
`).catch(() => void 0);
|
|
114
427
|
const line = `[buda-connector] ${message}`;
|
|
115
428
|
if (level === "error") console.error(line);
|
|
@@ -165,14 +478,627 @@ async function stopEmbeddedBunnyDaemon(daemon) {
|
|
|
165
478
|
}
|
|
166
479
|
|
|
167
480
|
// src/cloud-client.ts
|
|
168
|
-
import {
|
|
169
|
-
|
|
481
|
+
import { createHash as createHash4 } from "crypto";
|
|
482
|
+
import { platform as platform3, release } from "os";
|
|
483
|
+
|
|
484
|
+
// src/browser.ts
|
|
485
|
+
import { accessSync, constants, statSync } from "fs";
|
|
486
|
+
import { mkdir as mkdir4 } from "fs/promises";
|
|
487
|
+
import { platform as getPlatform, homedir as homedir2 } from "os";
|
|
488
|
+
import path2 from "path";
|
|
489
|
+
var DEFAULT_VIEWPORT = { width: 1280, height: 800 };
|
|
490
|
+
var MAX_SCREENCAST_DIMENSION = 2560;
|
|
491
|
+
var SESSION_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,320}$/;
|
|
492
|
+
var sessions = /* @__PURE__ */ new Map();
|
|
493
|
+
var sessionPromises = /* @__PURE__ */ new Map();
|
|
494
|
+
var getBrowserMode = () => {
|
|
495
|
+
const configured = process.env.BUDA_CONNECTOR_BROWSER_MODE?.trim().toLowerCase();
|
|
496
|
+
return configured === "auto" || configured === "headed" || configured === "headless" ? configured : "headless";
|
|
497
|
+
};
|
|
498
|
+
var getSystemBrowserCandidates = (platform4, homeDirectory, env) => {
|
|
499
|
+
if (platform4 === "darwin") {
|
|
500
|
+
return [
|
|
501
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
502
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
503
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
504
|
+
`${homeDirectory}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`,
|
|
505
|
+
`${homeDirectory}/Applications/Chromium.app/Contents/MacOS/Chromium`,
|
|
506
|
+
`${homeDirectory}/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge`
|
|
507
|
+
];
|
|
508
|
+
}
|
|
509
|
+
if (platform4 === "win32") {
|
|
510
|
+
const localAppData = env.LOCALAPPDATA;
|
|
511
|
+
const programFiles = env.PROGRAMFILES;
|
|
512
|
+
const programFilesX86 = env["PROGRAMFILES(X86)"];
|
|
513
|
+
return [
|
|
514
|
+
localAppData ? `${localAppData}\\Google\\Chrome\\Application\\chrome.exe` : "",
|
|
515
|
+
localAppData ? `${localAppData}\\Chromium\\Application\\chrome.exe` : "",
|
|
516
|
+
programFiles ? `${programFiles}\\Google\\Chrome\\Application\\chrome.exe` : "",
|
|
517
|
+
programFilesX86 ? `${programFilesX86}\\Google\\Chrome\\Application\\chrome.exe` : "",
|
|
518
|
+
programFiles ? `${programFiles}\\Microsoft\\Edge\\Application\\msedge.exe` : "",
|
|
519
|
+
programFilesX86 ? `${programFilesX86}\\Microsoft\\Edge\\Application\\msedge.exe` : ""
|
|
520
|
+
];
|
|
521
|
+
}
|
|
522
|
+
return [
|
|
523
|
+
"/usr/bin/google-chrome",
|
|
524
|
+
"/usr/bin/google-chrome-stable",
|
|
525
|
+
"/usr/bin/chromium",
|
|
526
|
+
"/usr/bin/chromium-browser",
|
|
527
|
+
"/snap/bin/chromium",
|
|
528
|
+
"/usr/bin/microsoft-edge",
|
|
529
|
+
"/usr/bin/microsoft-edge-stable"
|
|
530
|
+
];
|
|
531
|
+
};
|
|
532
|
+
var resolveBrowserAvailability = (options = {}) => {
|
|
533
|
+
const env = options.env ?? process.env;
|
|
534
|
+
const platform4 = options.platform ?? getPlatform();
|
|
535
|
+
const homeDirectory = options.homeDirectory ?? homedir2();
|
|
536
|
+
const pathIsExecutable = options.pathIsExecutable ?? ((candidate) => {
|
|
537
|
+
try {
|
|
538
|
+
if (!statSync(candidate).isFile()) return false;
|
|
539
|
+
accessSync(candidate, constants.X_OK);
|
|
540
|
+
return true;
|
|
541
|
+
} catch {
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
const configuredPath = env.BUDA_CONNECTOR_BROWSER_PATH?.trim();
|
|
546
|
+
if (configuredPath) {
|
|
547
|
+
if (pathIsExecutable(configuredPath)) {
|
|
548
|
+
return { available: true, executablePath: configuredPath };
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
available: false,
|
|
552
|
+
error: `BUDA_CONNECTOR_BROWSER_PATH is missing or not executable: ${configuredPath}. Set it to a Chrome, Chromium, or Edge executable.`
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
const executablePath = getSystemBrowserCandidates(platform4, homeDirectory, env).find(
|
|
556
|
+
(candidate) => candidate && pathIsExecutable(candidate)
|
|
557
|
+
);
|
|
558
|
+
if (executablePath) {
|
|
559
|
+
return { available: true, executablePath };
|
|
560
|
+
}
|
|
561
|
+
return {
|
|
562
|
+
available: false,
|
|
563
|
+
error: "No supported system browser was found. Install Google Chrome, Chromium, or Microsoft Edge, or set BUDA_CONNECTOR_BROWSER_PATH to its executable. The connector browser capability will not be advertised."
|
|
564
|
+
};
|
|
565
|
+
};
|
|
566
|
+
async function launchPlaywright(viewport = DEFAULT_VIEWPORT) {
|
|
567
|
+
const availability = resolveBrowserAvailability();
|
|
568
|
+
if (!availability.available) throw new Error(availability.error);
|
|
569
|
+
const { chromium } = await import("playwright-core");
|
|
570
|
+
const mode = getBrowserMode();
|
|
571
|
+
const executablePath = availability.executablePath;
|
|
572
|
+
let browser = null;
|
|
573
|
+
if (mode !== "headless") {
|
|
574
|
+
try {
|
|
575
|
+
await writeLog("info", `launching headed system browser ${executablePath}`);
|
|
576
|
+
browser = await chromium.launch({
|
|
577
|
+
executablePath,
|
|
578
|
+
headless: false,
|
|
579
|
+
args: ["--no-sandbox", "--disable-setuid-sandbox"]
|
|
580
|
+
});
|
|
581
|
+
} catch (error) {
|
|
582
|
+
if (mode === "headed") throw error;
|
|
583
|
+
await writeLog(
|
|
584
|
+
"warn",
|
|
585
|
+
`headed browser launch failed, falling back to headless: ${error instanceof Error ? error.message : String(error)}`
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (!browser) {
|
|
590
|
+
await writeLog("info", `launching headless system browser ${executablePath}`);
|
|
591
|
+
browser = await chromium.launch({
|
|
592
|
+
executablePath,
|
|
593
|
+
headless: true,
|
|
594
|
+
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu"]
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
const context = await browser.newContext({ viewport, deviceScaleFactor: 1 });
|
|
598
|
+
const page = await context.newPage();
|
|
599
|
+
const cdpSession = await context.newCDPSession(page);
|
|
600
|
+
return { browser, page, cdpSession };
|
|
601
|
+
}
|
|
602
|
+
var normalizeSessionId = (value) => {
|
|
603
|
+
if (typeof value !== "string") return null;
|
|
604
|
+
const normalized = value.trim();
|
|
605
|
+
return SESSION_ID_PATTERN.test(normalized) ? normalized : null;
|
|
606
|
+
};
|
|
607
|
+
async function ensureSession(sessionId, initialViewport = DEFAULT_VIEWPORT) {
|
|
608
|
+
const activeSession = sessions.get(sessionId);
|
|
609
|
+
if (activeSession) return activeSession;
|
|
610
|
+
let sessionPromise = sessionPromises.get(sessionId);
|
|
611
|
+
if (!sessionPromise) {
|
|
612
|
+
sessionPromise = (async () => {
|
|
613
|
+
await writeLog("info", `launching local browser session ${sessionId}`);
|
|
614
|
+
const { browser, page, cdpSession } = await launchPlaywright(initialViewport);
|
|
615
|
+
const nextSession = {
|
|
616
|
+
page,
|
|
617
|
+
browser,
|
|
618
|
+
cdpSession,
|
|
619
|
+
screencastClients: /* @__PURE__ */ new Set(),
|
|
620
|
+
isScreencasting: false,
|
|
621
|
+
screencastListenerAttached: false,
|
|
622
|
+
resizeQueue: Promise.resolve(),
|
|
623
|
+
inputQueue: Promise.resolve(),
|
|
624
|
+
navigationQueue: Promise.resolve(),
|
|
625
|
+
clientIds: /* @__PURE__ */ new Set()
|
|
626
|
+
};
|
|
627
|
+
sessions.set(sessionId, nextSession);
|
|
628
|
+
browser.on("disconnected", () => {
|
|
629
|
+
if (sessions.get(sessionId) === nextSession) sessions.delete(sessionId);
|
|
630
|
+
});
|
|
631
|
+
return nextSession;
|
|
632
|
+
})();
|
|
633
|
+
sessionPromises.set(sessionId, sessionPromise);
|
|
634
|
+
}
|
|
635
|
+
try {
|
|
636
|
+
return await sessionPromise;
|
|
637
|
+
} finally {
|
|
638
|
+
if (sessionPromises.get(sessionId) === sessionPromise) sessionPromises.delete(sessionId);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
async function startScreencast(s) {
|
|
642
|
+
if (s.isScreencasting) return;
|
|
643
|
+
if (!s.screencastListenerAttached) {
|
|
644
|
+
s.screencastListenerAttached = true;
|
|
645
|
+
s.cdpSession.on("Page.screencastFrame", (params) => {
|
|
646
|
+
const metadata = params.metadata;
|
|
647
|
+
const viewport = s.page.viewportSize() ?? DEFAULT_VIEWPORT;
|
|
648
|
+
const frame = {
|
|
649
|
+
data: params.data,
|
|
650
|
+
viewportWidth: metadata.deviceWidth ?? viewport.width,
|
|
651
|
+
viewportHeight: metadata.deviceHeight ?? viewport.height
|
|
652
|
+
};
|
|
653
|
+
for (const listener of s.screencastClients) listener(frame);
|
|
654
|
+
void s.cdpSession.send("Page.screencastFrameAck", { sessionId: params.sessionId }).catch(() => {
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
await s.cdpSession.send("Page.startScreencast", {
|
|
659
|
+
format: "jpeg",
|
|
660
|
+
quality: 90,
|
|
661
|
+
maxWidth: MAX_SCREENCAST_DIMENSION,
|
|
662
|
+
maxHeight: MAX_SCREENCAST_DIMENSION,
|
|
663
|
+
everyNthFrame: 2
|
|
664
|
+
});
|
|
665
|
+
s.isScreencasting = true;
|
|
666
|
+
}
|
|
667
|
+
async function stopScreencast(s) {
|
|
668
|
+
if (!s.isScreencasting) return;
|
|
669
|
+
await s.cdpSession.send("Page.stopScreencast").catch(() => {
|
|
670
|
+
});
|
|
671
|
+
s.isScreencasting = false;
|
|
672
|
+
}
|
|
673
|
+
async function closeSession(sessionId) {
|
|
674
|
+
const activeSession = sessions.get(sessionId) ?? await sessionPromises.get(sessionId)?.catch(() => null);
|
|
675
|
+
if (!activeSession) return;
|
|
676
|
+
if (sessions.get(sessionId) === activeSession) sessions.delete(sessionId);
|
|
677
|
+
await stopScreencast(activeSession);
|
|
678
|
+
await activeSession.cdpSession.detach().catch(() => {
|
|
679
|
+
});
|
|
680
|
+
await activeSession.browser.close().catch(() => {
|
|
681
|
+
});
|
|
682
|
+
await writeLog("info", `browser session stopped ${sessionId}`);
|
|
683
|
+
}
|
|
684
|
+
async function closeAllBrowserSessions() {
|
|
685
|
+
const sessionIds = /* @__PURE__ */ new Set([...sessions.keys(), ...sessionPromises.keys()]);
|
|
686
|
+
await Promise.all([...sessionIds].map((sessionId) => closeSession(sessionId)));
|
|
687
|
+
}
|
|
688
|
+
async function resizeBrowser(s, viewport) {
|
|
689
|
+
const resize = s.resizeQueue.catch(() => {
|
|
690
|
+
}).then(async () => {
|
|
691
|
+
const current = s.page.viewportSize();
|
|
692
|
+
if (current?.width === viewport.width && current.height === viewport.height) return;
|
|
693
|
+
const wasScreencasting = s.isScreencasting;
|
|
694
|
+
if (wasScreencasting) await stopScreencast(s);
|
|
695
|
+
await s.page.setViewportSize(viewport);
|
|
696
|
+
if (wasScreencasting) await startScreencast(s);
|
|
697
|
+
await writeLog("info", `browser resized to ${viewport.width}x${viewport.height}`);
|
|
698
|
+
});
|
|
699
|
+
s.resizeQueue = resize;
|
|
700
|
+
await resize;
|
|
701
|
+
}
|
|
702
|
+
async function navigateBrowser(s, url, options) {
|
|
703
|
+
const navigation = s.navigationQueue.catch(() => {
|
|
704
|
+
}).then(async () => {
|
|
705
|
+
if (s.page.url() === url) return null;
|
|
706
|
+
return s.page.goto(url, options);
|
|
707
|
+
});
|
|
708
|
+
s.navigationQueue = navigation.then(() => {
|
|
709
|
+
});
|
|
710
|
+
return navigation;
|
|
711
|
+
}
|
|
712
|
+
var normalizeViewportDimension = (value, min, max, fallback) => {
|
|
713
|
+
const dimension = typeof value === "number" && Number.isFinite(value) ? Math.round(value) : fallback;
|
|
714
|
+
return Math.min(max, Math.max(min, dimension));
|
|
715
|
+
};
|
|
716
|
+
var normalizeTimeout = (value, fallback, max) => typeof value === "number" && Number.isFinite(value) ? Math.min(max, Math.max(1e3, Math.round(value))) : fallback;
|
|
717
|
+
function jsonResponse(status, body) {
|
|
718
|
+
return new Response(JSON.stringify(body), {
|
|
719
|
+
status,
|
|
720
|
+
headers: { "content-type": "application/json" }
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
var getRequestSessionId = (context) => normalizeSessionId(context.sessionId);
|
|
724
|
+
async function handleBrowserInput(sessionIdValue, body) {
|
|
725
|
+
const sessionId = normalizeSessionId(sessionIdValue);
|
|
726
|
+
if (!sessionId) throw new Error("A valid browser session id is required.");
|
|
727
|
+
const ev = body ?? {};
|
|
728
|
+
const s = await ensureSession(sessionId);
|
|
729
|
+
const input = s.inputQueue.catch(() => {
|
|
730
|
+
}).then(async () => {
|
|
731
|
+
switch (ev.type) {
|
|
732
|
+
case "mouseMove":
|
|
733
|
+
await s.cdpSession.send("Input.dispatchMouseEvent", {
|
|
734
|
+
type: "mouseMoved",
|
|
735
|
+
x: ev.x,
|
|
736
|
+
y: ev.y
|
|
737
|
+
});
|
|
738
|
+
return;
|
|
739
|
+
case "mouseDown":
|
|
740
|
+
await s.cdpSession.send("Input.dispatchMouseEvent", {
|
|
741
|
+
type: "mousePressed",
|
|
742
|
+
x: ev.x,
|
|
743
|
+
y: ev.y,
|
|
744
|
+
button: "left",
|
|
745
|
+
clickCount: 1
|
|
746
|
+
});
|
|
747
|
+
return;
|
|
748
|
+
case "mouseUp":
|
|
749
|
+
await s.cdpSession.send("Input.dispatchMouseEvent", {
|
|
750
|
+
type: "mouseReleased",
|
|
751
|
+
x: ev.x,
|
|
752
|
+
y: ev.y,
|
|
753
|
+
button: "left",
|
|
754
|
+
clickCount: 1
|
|
755
|
+
});
|
|
756
|
+
return;
|
|
757
|
+
case "keyDown":
|
|
758
|
+
await s.cdpSession.send("Input.dispatchKeyEvent", {
|
|
759
|
+
type: "keyDown",
|
|
760
|
+
key: ev.key,
|
|
761
|
+
text: ev.text ?? ""
|
|
762
|
+
});
|
|
763
|
+
return;
|
|
764
|
+
case "keyUp":
|
|
765
|
+
await s.cdpSession.send("Input.dispatchKeyEvent", {
|
|
766
|
+
type: "keyUp",
|
|
767
|
+
key: ev.key
|
|
768
|
+
});
|
|
769
|
+
return;
|
|
770
|
+
case "scroll":
|
|
771
|
+
await s.cdpSession.send("Input.dispatchMouseEvent", {
|
|
772
|
+
type: "mouseWheel",
|
|
773
|
+
x: ev.x,
|
|
774
|
+
y: ev.y,
|
|
775
|
+
deltaX: 0,
|
|
776
|
+
deltaY: ev.deltaY
|
|
777
|
+
});
|
|
778
|
+
return;
|
|
779
|
+
default:
|
|
780
|
+
throw new Error(`Unknown browser input event: ${String(ev.type)}`);
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
s.inputQueue = input;
|
|
784
|
+
await input;
|
|
785
|
+
}
|
|
786
|
+
var getPageResult = async (s) => ({
|
|
787
|
+
url: s.page.url(),
|
|
788
|
+
title: await s.page.title().catch(() => "")
|
|
789
|
+
});
|
|
790
|
+
var resolveScreenshotPath = (workdirRoot, filePath) => {
|
|
791
|
+
if (!workdirRoot) throw new Error("Connector workdir is unavailable for screenshots.");
|
|
792
|
+
const raw = typeof filePath === "string" ? filePath.trim() : "";
|
|
793
|
+
if (!raw) throw new Error("file_path is required");
|
|
794
|
+
const agentRoot = path2.resolve(workdirRoot, "agent");
|
|
795
|
+
const relativeInput = raw === "/agent" ? "" : raw.startsWith("/agent/") ? raw.slice(7) : raw;
|
|
796
|
+
const absolutePath = path2.resolve(agentRoot, relativeInput);
|
|
797
|
+
if (absolutePath === agentRoot || !absolutePath.startsWith(`${agentRoot}${path2.sep}`)) {
|
|
798
|
+
throw new Error("file_path must stay under the connector agent drive");
|
|
799
|
+
}
|
|
800
|
+
return {
|
|
801
|
+
absolutePath,
|
|
802
|
+
relativePath: path2.relative(agentRoot, absolutePath).split(path2.sep).join("/")
|
|
803
|
+
};
|
|
804
|
+
};
|
|
805
|
+
async function handleBrowserRequest(requestPath, method, body, context = {}) {
|
|
806
|
+
const route = new URL(requestPath, "http://connector.local").pathname.replace(
|
|
807
|
+
/^\/browser\/?/,
|
|
808
|
+
""
|
|
809
|
+
);
|
|
810
|
+
const sessionId = getRequestSessionId(context);
|
|
811
|
+
if (!sessionId)
|
|
812
|
+
return jsonResponse(400, { ok: false, error: "valid browser session id required" });
|
|
813
|
+
if (route === "start" && method === "POST") {
|
|
814
|
+
const input = body ?? {};
|
|
815
|
+
const viewport = {
|
|
816
|
+
width: normalizeViewportDimension(input.width, 320, 2560, DEFAULT_VIEWPORT.width),
|
|
817
|
+
height: normalizeViewportDimension(input.height, 240, 2560, DEFAULT_VIEWPORT.height)
|
|
818
|
+
};
|
|
819
|
+
const s = await ensureSession(sessionId, viewport);
|
|
820
|
+
const clientId = normalizeSessionId(input.clientId);
|
|
821
|
+
if (clientId) s.clientIds.add(clientId);
|
|
822
|
+
await resizeBrowser(s, viewport);
|
|
823
|
+
const url = input.url?.trim() || "about:blank";
|
|
824
|
+
await writeLog("info", `browser viewport ${viewport.width}x${viewport.height}`);
|
|
825
|
+
await navigateBrowser(s, url);
|
|
826
|
+
await writeLog("info", `browser ready at ${s.page.url()}`);
|
|
827
|
+
return jsonResponse(200, { ok: true, ...await getPageResult(s) });
|
|
828
|
+
}
|
|
829
|
+
if (route === "stop" && method === "POST") {
|
|
830
|
+
await closeSession(sessionId);
|
|
831
|
+
return jsonResponse(200, { ok: true });
|
|
832
|
+
}
|
|
833
|
+
if (route === "release" && method === "POST") {
|
|
834
|
+
const input = body ?? {};
|
|
835
|
+
const clientId = normalizeSessionId(input.clientId);
|
|
836
|
+
if (!clientId) return jsonResponse(400, { ok: false, error: "valid client id required" });
|
|
837
|
+
const s = sessions.get(sessionId);
|
|
838
|
+
if (!s) return jsonResponse(200, { ok: true, closed: false });
|
|
839
|
+
s.clientIds.delete(clientId);
|
|
840
|
+
if (s.clientIds.size > 0) return jsonResponse(200, { ok: true, closed: false });
|
|
841
|
+
await closeSession(sessionId);
|
|
842
|
+
return jsonResponse(200, { ok: true, closed: true });
|
|
843
|
+
}
|
|
844
|
+
if (route === "navigate" && method === "POST") {
|
|
845
|
+
const input = body ?? {};
|
|
846
|
+
const url = input.url?.trim();
|
|
847
|
+
if (!url) return jsonResponse(400, { ok: false, error: "url required" });
|
|
848
|
+
const s = await ensureSession(sessionId);
|
|
849
|
+
const response = await navigateBrowser(s, url, {
|
|
850
|
+
waitUntil: input.waitUntil ?? "domcontentloaded",
|
|
851
|
+
timeout: normalizeTimeout(input.timeoutMs, 3e4, 6e4)
|
|
852
|
+
});
|
|
853
|
+
return jsonResponse(200, {
|
|
854
|
+
ok: true,
|
|
855
|
+
action: "navigate",
|
|
856
|
+
...await getPageResult(s),
|
|
857
|
+
status: response?.status() ?? null
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
if (route === "click" && method === "POST") {
|
|
861
|
+
const input = body ?? {};
|
|
862
|
+
const selector = input.selector?.trim();
|
|
863
|
+
if (!selector) return jsonResponse(400, { ok: false, error: "selector required" });
|
|
864
|
+
const s = await ensureSession(sessionId);
|
|
865
|
+
const button = input.button === "right" || input.button === "middle" ? input.button : "left";
|
|
866
|
+
await s.page.locator(selector).click({
|
|
867
|
+
button,
|
|
868
|
+
timeout: normalizeTimeout(input.timeoutMs, 1e4, 3e4)
|
|
869
|
+
});
|
|
870
|
+
await s.page.waitForLoadState("domcontentloaded", { timeout: 5e3 }).catch(() => {
|
|
871
|
+
});
|
|
872
|
+
return jsonResponse(200, {
|
|
873
|
+
ok: true,
|
|
874
|
+
action: "click",
|
|
875
|
+
...await getPageResult(s),
|
|
876
|
+
message: `Clicked ${selector}`
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
if (route === "type" && method === "POST") {
|
|
880
|
+
const input = body ?? {};
|
|
881
|
+
const selector = input.selector?.trim();
|
|
882
|
+
if (!selector) return jsonResponse(400, { ok: false, error: "selector required" });
|
|
883
|
+
const s = await ensureSession(sessionId);
|
|
884
|
+
const locator = s.page.locator(selector);
|
|
885
|
+
const text2 = typeof input.text === "string" ? input.text : String(input.text ?? "");
|
|
886
|
+
const timeout = normalizeTimeout(input.timeoutMs, 1e4, 3e4);
|
|
887
|
+
if (input.clear !== false) {
|
|
888
|
+
await locator.fill("", { timeout });
|
|
889
|
+
await locator.fill(text2, { timeout });
|
|
890
|
+
} else {
|
|
891
|
+
await locator.type(text2, { timeout });
|
|
892
|
+
}
|
|
893
|
+
return jsonResponse(200, {
|
|
894
|
+
ok: true,
|
|
895
|
+
action: "type",
|
|
896
|
+
...await getPageResult(s),
|
|
897
|
+
message: `Typed into ${selector}`
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
if (route === "extract" && method === "POST") {
|
|
901
|
+
const input = body ?? {};
|
|
902
|
+
const selector = input.selector?.trim() || "body";
|
|
903
|
+
const s = await ensureSession(sessionId);
|
|
904
|
+
const locator = s.page.locator(selector).first();
|
|
905
|
+
const text2 = (await locator.innerText({ timeout: 1e4 }).catch(async () => s.page.locator("body").innerText({ timeout: 1e4 }))).trim();
|
|
906
|
+
const maxChars = typeof input.maxChars === "number" && Number.isFinite(input.maxChars) ? Math.min(2e4, Math.max(500, Math.round(input.maxChars))) : 8e3;
|
|
907
|
+
return jsonResponse(200, {
|
|
908
|
+
ok: true,
|
|
909
|
+
action: "extract",
|
|
910
|
+
...await getPageResult(s),
|
|
911
|
+
text: text2.slice(0, maxChars)
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
if (route === "screenshot" && method === "POST") {
|
|
915
|
+
const input = body ?? {};
|
|
916
|
+
const s = await ensureSession(sessionId);
|
|
917
|
+
const screenshotPath = resolveScreenshotPath(context.workdirRoot, input.file_path);
|
|
918
|
+
await mkdir4(path2.dirname(screenshotPath.absolutePath), { recursive: true });
|
|
919
|
+
await s.page.screenshot({
|
|
920
|
+
path: screenshotPath.absolutePath,
|
|
921
|
+
fullPage: input.fullPage !== false
|
|
922
|
+
});
|
|
923
|
+
return jsonResponse(200, {
|
|
924
|
+
ok: true,
|
|
925
|
+
action: "screenshot",
|
|
926
|
+
...await getPageResult(s),
|
|
927
|
+
filePath: screenshotPath.relativePath,
|
|
928
|
+
message: "Screenshot saved"
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
if (route === "resize" && method === "POST") {
|
|
932
|
+
const input = body ?? {};
|
|
933
|
+
const viewport = {
|
|
934
|
+
width: normalizeViewportDimension(input.width, 320, 2560, DEFAULT_VIEWPORT.width),
|
|
935
|
+
height: normalizeViewportDimension(input.height, 240, 2560, DEFAULT_VIEWPORT.height)
|
|
936
|
+
};
|
|
937
|
+
const s = await ensureSession(sessionId);
|
|
938
|
+
await resizeBrowser(s, viewport);
|
|
939
|
+
return jsonResponse(200, { ok: true, ...viewport });
|
|
940
|
+
}
|
|
941
|
+
if (route === "input" && method === "POST") {
|
|
942
|
+
await handleBrowserInput(sessionId, body);
|
|
943
|
+
return jsonResponse(200, { ok: true });
|
|
944
|
+
}
|
|
945
|
+
if (route === "location" && method === "GET") {
|
|
946
|
+
const s = sessions.get(sessionId);
|
|
947
|
+
if (!s) return jsonResponse(200, { ok: true, url: "", title: "" });
|
|
948
|
+
return jsonResponse(200, { ok: true, ...await getPageResult(s) });
|
|
949
|
+
}
|
|
950
|
+
if (route === "screencast" && method === "GET") {
|
|
951
|
+
const s = await ensureSession(sessionId);
|
|
952
|
+
let cleanup = () => {
|
|
953
|
+
};
|
|
954
|
+
const stream = new ReadableStream({
|
|
955
|
+
start(controller) {
|
|
956
|
+
const encoder = new TextEncoder();
|
|
957
|
+
const listener = (frame) => {
|
|
958
|
+
const line = `data:${JSON.stringify({
|
|
959
|
+
type: "frame",
|
|
960
|
+
...frame,
|
|
961
|
+
mimeType: "image/jpeg"
|
|
962
|
+
})}
|
|
963
|
+
|
|
964
|
+
`;
|
|
965
|
+
controller.enqueue(encoder.encode(line));
|
|
966
|
+
};
|
|
967
|
+
s.screencastClients.add(listener);
|
|
968
|
+
void startScreencast(s).catch((error) => controller.error(error));
|
|
969
|
+
let checkInterval = null;
|
|
970
|
+
cleanup = () => {
|
|
971
|
+
s.screencastClients.delete(listener);
|
|
972
|
+
if (checkInterval) clearInterval(checkInterval);
|
|
973
|
+
checkInterval = null;
|
|
974
|
+
if (s.screencastClients.size === 0) void stopScreencast(s);
|
|
975
|
+
};
|
|
976
|
+
checkInterval = setInterval(() => {
|
|
977
|
+
if (sessions.get(sessionId) !== s) {
|
|
978
|
+
cleanup();
|
|
979
|
+
controller.close();
|
|
980
|
+
}
|
|
981
|
+
}, 1e3);
|
|
982
|
+
},
|
|
983
|
+
cancel() {
|
|
984
|
+
cleanup();
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
return new Response(stream, {
|
|
988
|
+
status: 200,
|
|
989
|
+
headers: {
|
|
990
|
+
"content-type": "text/event-stream",
|
|
991
|
+
"cache-control": "no-cache",
|
|
992
|
+
connection: "keep-alive"
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
return jsonResponse(404, { ok: false, error: "not found" });
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// src/bundled.ts
|
|
1000
|
+
import { createHash as createHash3 } from "crypto";
|
|
1001
|
+
import { cpSync, existsSync, rmSync } from "fs";
|
|
1002
|
+
import { readdir, readFile as readFile3 } from "fs/promises";
|
|
1003
|
+
import path3 from "path";
|
|
1004
|
+
import { fileURLToPath } from "url";
|
|
1005
|
+
var PKG_ROOT = path3.resolve(fileURLToPath(import.meta.url), "../..");
|
|
1006
|
+
var BUNDLED_DIR = path3.join(PKG_ROOT, "agent-templates");
|
|
1007
|
+
var SYSTEM_SKILLS_DIR = path3.join(BUNDLED_DIR, "system-skills");
|
|
1008
|
+
var SCRIPTS_DIR = path3.join(BUNDLED_DIR, "scripts");
|
|
1009
|
+
var SHARED_DIR = path3.join(BUNDLED_DIR, "shared");
|
|
1010
|
+
var MONOREPO_SRC = path3.resolve(PKG_ROOT, "../buda/agent-templates");
|
|
1011
|
+
function syncTemplatesFromMonorepo() {
|
|
1012
|
+
if (!existsSync(MONOREPO_SRC)) {
|
|
1013
|
+
throw new Error(`Cannot sync agent-templates: source missing at ${MONOREPO_SRC}`);
|
|
1014
|
+
}
|
|
1015
|
+
rmSync(BUNDLED_DIR, { recursive: true, force: true });
|
|
1016
|
+
cpSync(MONOREPO_SRC, BUNDLED_DIR, {
|
|
1017
|
+
recursive: true,
|
|
1018
|
+
dereference: true,
|
|
1019
|
+
filter: (src) => !src.split(path3.sep).includes("extensions")
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
ensureBundledDir();
|
|
1023
|
+
function ensureBundledDir() {
|
|
1024
|
+
const missingRequiredTemplate = !existsSync(path3.join(SHARED_DIR, "BOOTSTRAP.md"));
|
|
1025
|
+
if (existsSync(BUNDLED_DIR) && !missingRequiredTemplate) return;
|
|
1026
|
+
if (!existsSync(MONOREPO_SRC)) {
|
|
1027
|
+
console.warn(
|
|
1028
|
+
`[buda-connector] agent-templates not found at ${BUNDLED_DIR} \u2014 system skills will be empty. Reinstall @buda-ai/connector to restore.`
|
|
1029
|
+
);
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
syncTemplatesFromMonorepo();
|
|
1033
|
+
console.log(`[buda-connector] synced agent-templates from ${MONOREPO_SRC}`);
|
|
1034
|
+
}
|
|
1035
|
+
var cached = null;
|
|
1036
|
+
async function readBundledManifest() {
|
|
1037
|
+
if (cached) return cached;
|
|
1038
|
+
const systemSkillNames = (await readdir(SYSTEM_SKILLS_DIR, { withFileTypes: true }).catch(() => [])).filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
1039
|
+
const version = await hashDir(BUNDLED_DIR);
|
|
1040
|
+
cached = {
|
|
1041
|
+
version,
|
|
1042
|
+
bundledDir: BUNDLED_DIR,
|
|
1043
|
+
systemSkillsDir: SYSTEM_SKILLS_DIR,
|
|
1044
|
+
systemSkillNames,
|
|
1045
|
+
scriptsDir: SCRIPTS_DIR,
|
|
1046
|
+
sharedDir: SHARED_DIR
|
|
1047
|
+
};
|
|
1048
|
+
return cached;
|
|
1049
|
+
}
|
|
1050
|
+
async function hashDir(dir) {
|
|
1051
|
+
const hash = createHash3("sha256");
|
|
1052
|
+
await walk(dir, async (relPath, abs) => {
|
|
1053
|
+
hash.update(`${relPath}:`).update(await readFile3(abs));
|
|
1054
|
+
});
|
|
1055
|
+
return hash.digest("hex");
|
|
1056
|
+
}
|
|
1057
|
+
async function walk(dir, visit, base = dir) {
|
|
1058
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
1059
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
1060
|
+
for (const entry of entries) {
|
|
1061
|
+
const abs = path3.join(dir, entry.name);
|
|
1062
|
+
if (entry.isDirectory()) {
|
|
1063
|
+
await walk(abs, visit, base);
|
|
1064
|
+
} else if (entry.isFile()) {
|
|
1065
|
+
await visit(path3.relative(base, abs), abs);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// src/types.ts
|
|
1071
|
+
var CONNECTOR_RELAY_PROTOCOL_VERSION = 1;
|
|
1072
|
+
var CONNECTOR_EXECUTION_IDENTITY_CAPABILITY = "execution-identity:ed25519-v1";
|
|
1073
|
+
|
|
1074
|
+
// src/cloud-client.ts
|
|
1075
|
+
var BASE_CONNECTOR_CAPABILITIES = [
|
|
1076
|
+
"connector",
|
|
1077
|
+
"bunny-daemon",
|
|
1078
|
+
"coding",
|
|
1079
|
+
"filesystem",
|
|
1080
|
+
"shell",
|
|
1081
|
+
"git"
|
|
1082
|
+
];
|
|
1083
|
+
var browserAvailabilityWarningLogged = false;
|
|
1084
|
+
var getAdvertisedCapabilities = (browserAvailability = resolveBrowserAvailability(), deviceToken) => [
|
|
1085
|
+
...BASE_CONNECTOR_CAPABILITIES,
|
|
1086
|
+
...browserAvailability.available ? ["browser"] : [],
|
|
1087
|
+
...deviceToken ? [CONNECTOR_EXECUTION_IDENTITY_CAPABILITY] : []
|
|
1088
|
+
];
|
|
1089
|
+
async function buildPayload(config, extra) {
|
|
1090
|
+
const bundle = await readBundledManifest();
|
|
1091
|
+
const browserAvailability = resolveBrowserAvailability();
|
|
1092
|
+
if (!browserAvailability.available && !browserAvailabilityWarningLogged) {
|
|
1093
|
+
browserAvailabilityWarningLogged = true;
|
|
1094
|
+
await writeLog("warn", browserAvailability.error);
|
|
1095
|
+
}
|
|
170
1096
|
return {
|
|
171
1097
|
connectorId: config.connectorId,
|
|
172
1098
|
deviceId: config.deviceId,
|
|
173
1099
|
deviceName: config.deviceName,
|
|
174
1100
|
hostLabel: config.hostLabel,
|
|
175
|
-
platform: `${
|
|
1101
|
+
platform: `${platform3()} ${release()}`,
|
|
176
1102
|
version: "dev",
|
|
177
1103
|
pid: process.pid,
|
|
178
1104
|
workdirRoot: config.workdirRoot,
|
|
@@ -184,61 +1110,109 @@ function buildPayload(config, extra) {
|
|
|
184
1110
|
root: extra.bunnyDaemon.root,
|
|
185
1111
|
status: "online"
|
|
186
1112
|
} : void 0,
|
|
187
|
-
|
|
188
|
-
|
|
1113
|
+
paths: {
|
|
1114
|
+
workdir: config.workdirRoot,
|
|
1115
|
+
systemSkillsDir: bundle.systemSkillsDir,
|
|
1116
|
+
scriptsDir: bundle.scriptsDir,
|
|
1117
|
+
sharedDir: bundle.sharedDir
|
|
1118
|
+
},
|
|
1119
|
+
bundle: {
|
|
1120
|
+
version: bundle.version,
|
|
1121
|
+
systemSkillNames: bundle.systemSkillNames
|
|
1122
|
+
},
|
|
1123
|
+
capabilities: getAdvertisedCapabilities(browserAvailability, config.deviceToken),
|
|
1124
|
+
deviceCredential: config.deviceToken ? {
|
|
1125
|
+
version: 1,
|
|
1126
|
+
tokenHash: createHash4("sha256").update(config.deviceToken, "utf8").digest("base64url")
|
|
1127
|
+
} : void 0,
|
|
1128
|
+
...extra?.logs?.length ? { logs: extra.logs } : {},
|
|
1129
|
+
...extra?.recovery ? { recovery: extra.recovery } : {}
|
|
189
1130
|
};
|
|
190
1131
|
}
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
|
|
1132
|
+
function buildAuthHeaders(config, launchConfig) {
|
|
1133
|
+
const headers = {
|
|
1134
|
+
"X-Buda-Connector-Id": config.connectorId
|
|
1135
|
+
};
|
|
1136
|
+
const token = config.token?.trim() || getConnectorToken(launchConfig);
|
|
1137
|
+
if (token) headers["X-Buda-Connector-Token"] = token;
|
|
1138
|
+
if (config.deviceToken) headers["X-Buda-Connector-Device-Token"] = config.deviceToken;
|
|
1139
|
+
return headers;
|
|
1140
|
+
}
|
|
1141
|
+
async function postJson(serverUrl, pathname, body, config, launchConfig) {
|
|
194
1142
|
const response = await fetch(`${serverUrl}${pathname}`, {
|
|
195
1143
|
method: "POST",
|
|
196
1144
|
headers: {
|
|
197
1145
|
"Content-Type": "application/json",
|
|
198
|
-
...
|
|
199
|
-
...!token && spaceId ? { "X-Buda-Space-Id": spaceId } : {}
|
|
1146
|
+
...buildAuthHeaders(config, launchConfig)
|
|
200
1147
|
},
|
|
201
1148
|
body: JSON.stringify(body)
|
|
202
1149
|
});
|
|
203
1150
|
if (!response.ok) {
|
|
204
|
-
const
|
|
205
|
-
|
|
1151
|
+
const text2 = await response.text().catch(() => "");
|
|
1152
|
+
let errorCode;
|
|
1153
|
+
let errorMessage3 = text2;
|
|
1154
|
+
try {
|
|
1155
|
+
const payload = JSON.parse(text2);
|
|
1156
|
+
errorCode = typeof payload.code === "string" ? payload.code : void 0;
|
|
1157
|
+
errorMessage3 = typeof payload.error === "string" ? payload.error : text2;
|
|
1158
|
+
} catch {
|
|
1159
|
+
}
|
|
1160
|
+
if (errorCode === "device_revoked") {
|
|
1161
|
+
throw new Error(
|
|
1162
|
+
`This device is prohibited/revoked and cannot reconnect. Contact support. (${errorMessage3})`
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
throw new Error(`${pathname} failed: ${response.status} ${errorMessage3}`);
|
|
206
1166
|
}
|
|
207
1167
|
return await response.json();
|
|
208
1168
|
}
|
|
209
1169
|
function formatConnectorOwner(connector) {
|
|
210
1170
|
return connector.userEmail ?? connector.userId ?? "anonymous";
|
|
211
1171
|
}
|
|
212
|
-
async function
|
|
1172
|
+
async function warnIfStaleBundle(result) {
|
|
1173
|
+
if (!result.bundle?.staleClient) return;
|
|
1174
|
+
await writeLog(
|
|
1175
|
+
"warn",
|
|
1176
|
+
`connector bundle out of date \u2014 server expects ${result.bundle.expectedVersion ?? "newer"}; run \`pnpm up @buda-ai/connector\``
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
async function registerConnector(config, launchConfig, bunnyDaemon, options) {
|
|
213
1180
|
const result = await postJson(
|
|
214
1181
|
config.serverUrl,
|
|
215
1182
|
"/api/connectors/register",
|
|
216
|
-
buildPayload(config, {
|
|
1183
|
+
await buildPayload(config, {
|
|
1184
|
+
bunnyDaemon,
|
|
1185
|
+
...options?.recoverMissingProfile ? { recovery: "missing_local_profile" } : {}
|
|
1186
|
+
}),
|
|
1187
|
+
config,
|
|
217
1188
|
launchConfig
|
|
218
1189
|
);
|
|
219
1190
|
await writeLog(
|
|
220
1191
|
"info",
|
|
221
1192
|
`registered connector=${result.connector.connectorId} status=${result.connector.status} owner=${formatConnectorOwner(result.connector)}`
|
|
222
1193
|
);
|
|
1194
|
+
await warnIfStaleBundle(result);
|
|
1195
|
+
return {
|
|
1196
|
+
connectorId: result.connector.connectorId,
|
|
1197
|
+
relayProtocol: result.relayProtocol
|
|
1198
|
+
};
|
|
223
1199
|
}
|
|
224
1200
|
async function heartbeatConnector(config, launchConfig, bunnyDaemon) {
|
|
225
1201
|
const logs = takePendingLogs();
|
|
226
1202
|
const result = await postJson(
|
|
227
1203
|
config.serverUrl,
|
|
228
1204
|
"/api/connectors/heartbeat",
|
|
229
|
-
buildPayload(config, {
|
|
1205
|
+
await buildPayload(config, {
|
|
230
1206
|
bunnyDaemon,
|
|
231
1207
|
logs
|
|
232
1208
|
}),
|
|
1209
|
+
config,
|
|
233
1210
|
launchConfig
|
|
234
1211
|
).catch((error) => {
|
|
235
1212
|
restorePendingLogs(logs);
|
|
236
1213
|
throw error;
|
|
237
1214
|
});
|
|
238
|
-
|
|
239
|
-
"info",
|
|
240
|
-
`heartbeat connector=${result.connector.connectorId} status=${result.connector.status} owner=${formatConnectorOwner(result.connector)}`
|
|
241
|
-
);
|
|
1215
|
+
return { relayProtocol: result.relayProtocol };
|
|
242
1216
|
}
|
|
243
1217
|
async function unregisterConnector(config, launchConfig) {
|
|
244
1218
|
await postJson(
|
|
@@ -247,160 +1221,1260 @@ async function unregisterConnector(config, launchConfig) {
|
|
|
247
1221
|
{
|
|
248
1222
|
connectorId: config.connectorId
|
|
249
1223
|
},
|
|
1224
|
+
config,
|
|
250
1225
|
launchConfig
|
|
251
1226
|
).catch((error) => {
|
|
252
1227
|
void writeLog("warn", `unregister failed: ${error instanceof Error ? error.message : error}`);
|
|
253
1228
|
});
|
|
254
1229
|
}
|
|
255
1230
|
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
1231
|
+
// src/legacy-workspace-migration.ts
|
|
1232
|
+
import { createHash as createHash5, randomUUID as randomUUID2 } from "crypto";
|
|
1233
|
+
import { constants as constants2, createReadStream } from "fs";
|
|
1234
|
+
import {
|
|
1235
|
+
chmod as chmod2,
|
|
1236
|
+
copyFile,
|
|
1237
|
+
lstat,
|
|
1238
|
+
mkdir as mkdir5,
|
|
1239
|
+
open as open2,
|
|
1240
|
+
readdir as readdir2,
|
|
1241
|
+
readFile as readFile4,
|
|
1242
|
+
readlink,
|
|
1243
|
+
realpath,
|
|
1244
|
+
rename as rename2,
|
|
1245
|
+
rm,
|
|
1246
|
+
symlink,
|
|
1247
|
+
unlink as unlink3
|
|
1248
|
+
} from "fs/promises";
|
|
1249
|
+
import path4 from "path";
|
|
1250
|
+
var MIGRATION_VERSION = 1;
|
|
1251
|
+
var MIGRATION_DIRECTORY = path4.join(".buda", "migrations", "legacy-workspace-v1");
|
|
1252
|
+
var JOURNAL_FILENAME = "journal.json";
|
|
1253
|
+
var IMMUTABLE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
1254
|
+
var LEGACY_TEMPLATE_FILES = [
|
|
1255
|
+
"AGENTS.md",
|
|
1256
|
+
"HEARTBEAT.md",
|
|
1257
|
+
"IDENTITY.md",
|
|
1258
|
+
"MEMORY.md",
|
|
1259
|
+
"SOUL.md",
|
|
1260
|
+
"TOOLS.md",
|
|
1261
|
+
"USER.md"
|
|
1262
|
+
];
|
|
1263
|
+
var JOURNAL_STATUSES = /* @__PURE__ */ new Set([
|
|
1264
|
+
"planned",
|
|
1265
|
+
"migrating",
|
|
1266
|
+
"complete",
|
|
1267
|
+
"rolling-back",
|
|
1268
|
+
"rolled-back"
|
|
1269
|
+
]);
|
|
1270
|
+
var ITEM_PHASES = /* @__PURE__ */ new Set([
|
|
1271
|
+
"pending",
|
|
1272
|
+
"copying",
|
|
1273
|
+
"copied",
|
|
1274
|
+
"verified",
|
|
1275
|
+
"committed",
|
|
1276
|
+
"rolled-back"
|
|
1277
|
+
]);
|
|
1278
|
+
var LegacyWorkspaceMigrationError = class extends Error {
|
|
1279
|
+
constructor(code, message) {
|
|
1280
|
+
super(message);
|
|
1281
|
+
this.code = code;
|
|
1282
|
+
this.name = "LegacyWorkspaceMigrationError";
|
|
261
1283
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
1284
|
+
recoverable = true;
|
|
1285
|
+
};
|
|
1286
|
+
var migrateLegacyWorkspaceRoot = async (storageRoot, options = {}) => {
|
|
1287
|
+
await mkdir5(storageRoot, { recursive: true, mode: 448 });
|
|
1288
|
+
const rootInfo = await lstat(storageRoot);
|
|
1289
|
+
if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) {
|
|
1290
|
+
throw new LegacyWorkspaceMigrationError(
|
|
1291
|
+
"MIGRATION_SOURCE_UNSAFE",
|
|
1292
|
+
"Legacy migration storage root must be a real directory"
|
|
1293
|
+
);
|
|
265
1294
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
1295
|
+
const canonicalRoot = await realpath(storageRoot);
|
|
1296
|
+
const migrationDirectory = path4.join(canonicalRoot, MIGRATION_DIRECTORY);
|
|
1297
|
+
await ensurePrivateDirectoryWithin(canonicalRoot, migrationDirectory);
|
|
1298
|
+
const journalPath = path4.join(migrationDirectory, JOURNAL_FILENAME);
|
|
1299
|
+
let journal = await readJournal(journalPath);
|
|
1300
|
+
if (journal) validateJournalPaths(journal, canonicalRoot, migrationDirectory);
|
|
1301
|
+
if (journal?.status === "complete") {
|
|
1302
|
+
await verifyCommittedItems(journal);
|
|
1303
|
+
return toResult(journal);
|
|
1304
|
+
}
|
|
1305
|
+
if (journal) await checkpoint(options, "restart", null);
|
|
1306
|
+
if (journal?.status === "rolling-back") {
|
|
1307
|
+
await resumeRollback(journalPath, journal, options);
|
|
1308
|
+
journal = void 0;
|
|
1309
|
+
}
|
|
1310
|
+
if (journal?.status === "rolled-back") journal = void 0;
|
|
1311
|
+
if (!journal) {
|
|
1312
|
+
journal = await createJournal(canonicalRoot, migrationDirectory, options.owner);
|
|
1313
|
+
await writeJournal(journalPath, journal);
|
|
1314
|
+
} else {
|
|
1315
|
+
assertOwnerMatches(journal, options.owner);
|
|
1316
|
+
}
|
|
1317
|
+
if (journal.status === "planned") {
|
|
1318
|
+
journal.status = "migrating";
|
|
1319
|
+
await writeJournal(journalPath, journal);
|
|
1320
|
+
}
|
|
1321
|
+
for (const item of journal.items) {
|
|
1322
|
+
await migrateItem(canonicalRoot, journalPath, journal, item, options);
|
|
1323
|
+
}
|
|
1324
|
+
journal.status = "complete";
|
|
1325
|
+
journal.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1326
|
+
await writeJournal(journalPath, journal);
|
|
1327
|
+
return toResult(journal);
|
|
1328
|
+
};
|
|
1329
|
+
var migrateItem = async (storageRoot, journalPath, journal, item, options) => {
|
|
1330
|
+
if (item.phase === "committed") return;
|
|
1331
|
+
if (await exists(item.target)) {
|
|
1332
|
+
if (!await exists(item.staging) && ["verified", "copied", "copying"].includes(item.phase)) {
|
|
1333
|
+
await verifyTree(item.source, item.target);
|
|
1334
|
+
item.phase = "committed";
|
|
1335
|
+
await writeJournal(journalPath, journal);
|
|
277
1336
|
return;
|
|
278
1337
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
1338
|
+
throw new LegacyWorkspaceMigrationError(
|
|
1339
|
+
"MIGRATION_TARGET_CONFLICT",
|
|
1340
|
+
`Legacy migration target already exists for ${item.id}`
|
|
1341
|
+
);
|
|
1342
|
+
}
|
|
1343
|
+
if (item.phase === "pending" || item.phase === "copying") {
|
|
1344
|
+
item.phase = "copying";
|
|
1345
|
+
await writeJournal(journalPath, journal);
|
|
1346
|
+
await checkpoint(options, "before-copy", item.id);
|
|
1347
|
+
await rm(item.staging, { recursive: true, force: true });
|
|
1348
|
+
await ensurePrivateDirectoryWithin(storageRoot, path4.dirname(item.staging));
|
|
1349
|
+
await copyTree(item.source, item.staging, () => checkpoint(options, "during-copy", item.id));
|
|
1350
|
+
await checkpoint(options, "after-copy", item.id);
|
|
1351
|
+
item.phase = "copied";
|
|
1352
|
+
await writeJournal(journalPath, journal);
|
|
1353
|
+
}
|
|
1354
|
+
if (item.phase === "copied") {
|
|
1355
|
+
await checkpoint(options, "before-verify", item.id);
|
|
1356
|
+
try {
|
|
1357
|
+
await verifyTree(item.source, item.staging);
|
|
1358
|
+
} catch (error) {
|
|
1359
|
+
await rm(item.staging, { recursive: true, force: true });
|
|
1360
|
+
item.phase = "copying";
|
|
1361
|
+
await writeJournal(journalPath, journal);
|
|
1362
|
+
throw error;
|
|
1363
|
+
}
|
|
1364
|
+
await checkpoint(options, "after-verify", item.id);
|
|
1365
|
+
item.phase = "verified";
|
|
1366
|
+
await writeJournal(journalPath, journal);
|
|
1367
|
+
}
|
|
1368
|
+
if (item.phase === "verified") {
|
|
1369
|
+
await checkpoint(options, "before-rename", item.id);
|
|
1370
|
+
await ensurePrivateDirectoryWithin(storageRoot, path4.dirname(item.target));
|
|
1371
|
+
await rename2(item.staging, item.target);
|
|
1372
|
+
await syncDirectory(path4.dirname(item.target));
|
|
1373
|
+
await checkpoint(options, "after-rename", item.id);
|
|
1374
|
+
item.phase = "committed";
|
|
1375
|
+
await writeJournal(journalPath, journal);
|
|
1376
|
+
}
|
|
1377
|
+
};
|
|
1378
|
+
var resumeRollback = async (journalPath, journal, options) => {
|
|
1379
|
+
for (const item of [...journal.items].reverse()) {
|
|
1380
|
+
if (item.phase === "rolled-back") continue;
|
|
1381
|
+
await checkpoint(options, "before-rollback", item.id);
|
|
1382
|
+
if (!await exists(item.source)) {
|
|
1383
|
+
throw new LegacyWorkspaceMigrationError(
|
|
1384
|
+
"MIGRATION_VERIFY_FAILED",
|
|
1385
|
+
`Cannot roll back ${item.id}: the preserved legacy source is missing`
|
|
1386
|
+
);
|
|
1387
|
+
}
|
|
1388
|
+
await rm(item.staging, { recursive: true, force: true });
|
|
1389
|
+
await rm(item.target, { recursive: true, force: true });
|
|
1390
|
+
await checkpoint(options, "after-rollback", item.id);
|
|
1391
|
+
item.phase = "rolled-back";
|
|
1392
|
+
await writeJournal(journalPath, journal);
|
|
1393
|
+
}
|
|
1394
|
+
journal.status = "rolled-back";
|
|
1395
|
+
delete journal.completedAt;
|
|
1396
|
+
await writeJournal(journalPath, journal);
|
|
1397
|
+
};
|
|
1398
|
+
var createJournal = async (storageRoot, migrationDirectory, owner) => {
|
|
1399
|
+
if (owner) validateOwner(owner);
|
|
1400
|
+
const ownership = owner ? "explicit-owner" : "shared-legacy";
|
|
1401
|
+
const label = owner ? `Legacy Connector root assigned to ${owner.spaceId}/${owner.agentId}` : "Shared legacy Connector root (ownership unassigned)";
|
|
1402
|
+
const sources = await inventoryLegacySources(storageRoot);
|
|
1403
|
+
const stagingRoot = path4.join(migrationDirectory, "staging", randomUUID2());
|
|
1404
|
+
const items = sources.map(({ id, source }) => ({
|
|
1405
|
+
id,
|
|
1406
|
+
source,
|
|
1407
|
+
target: deriveTarget(storageRoot, id, owner),
|
|
1408
|
+
staging: path4.join(stagingRoot, id),
|
|
1409
|
+
phase: "pending"
|
|
1410
|
+
}));
|
|
1411
|
+
assertDistinctTargets(items);
|
|
1412
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1413
|
+
return {
|
|
1414
|
+
version: MIGRATION_VERSION,
|
|
1415
|
+
status: "planned",
|
|
1416
|
+
ownership,
|
|
1417
|
+
label,
|
|
1418
|
+
...owner ? { owner } : {},
|
|
1419
|
+
createdAt: now,
|
|
1420
|
+
updatedAt: now,
|
|
1421
|
+
items
|
|
286
1422
|
};
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
1423
|
+
};
|
|
1424
|
+
var inventoryLegacySources = async (storageRoot) => {
|
|
1425
|
+
const rootEntries = await readdir2(storageRoot, { withFileTypes: true });
|
|
1426
|
+
const templateNames = new Set(LEGACY_TEMPLATE_FILES);
|
|
1427
|
+
for (const entry of rootEntries) {
|
|
1428
|
+
if (entry.name.endsWith(".md")) templateNames.add(entry.name);
|
|
1429
|
+
}
|
|
1430
|
+
const candidates = [
|
|
1431
|
+
{ id: "agent", source: path4.join(storageRoot, "agent") },
|
|
1432
|
+
{ id: "space", source: path4.join(storageRoot, "space") },
|
|
1433
|
+
{ id: "logs", source: path4.join(storageRoot, "logs") },
|
|
1434
|
+
{ id: "connector-log", source: path4.join(storageRoot, "connector.log") },
|
|
1435
|
+
{ id: "runtime-config", source: path4.join(storageRoot, "runtime-config") },
|
|
1436
|
+
{ id: "runtime-config-hidden", source: path4.join(storageRoot, ".runtime-config") },
|
|
1437
|
+
{ id: "runtime-config-file", source: path4.join(storageRoot, "runtime-config.json") },
|
|
1438
|
+
{ id: "runtime-config-hidden-file", source: path4.join(storageRoot, ".runtime-config.json") },
|
|
1439
|
+
{ id: "bunny-runtime", source: path4.join(storageRoot, ".bunny") },
|
|
1440
|
+
...[...templateNames].sort().map((name) => ({
|
|
1441
|
+
id: `template-${name}`,
|
|
1442
|
+
source: path4.join(storageRoot, name)
|
|
1443
|
+
}))
|
|
1444
|
+
];
|
|
1445
|
+
const found = [];
|
|
1446
|
+
for (const candidate of candidates) {
|
|
1447
|
+
if (!await exists(candidate.source)) continue;
|
|
1448
|
+
const info = await lstat(candidate.source);
|
|
1449
|
+
if (info.isSymbolicLink()) {
|
|
1450
|
+
throw new LegacyWorkspaceMigrationError(
|
|
1451
|
+
"MIGRATION_SOURCE_UNSAFE",
|
|
1452
|
+
`Legacy migration source ${candidate.id} must not be a symbolic link`
|
|
1453
|
+
);
|
|
1454
|
+
}
|
|
1455
|
+
if (info.isDirectory() && (await readdir2(candidate.source)).length === 0) continue;
|
|
1456
|
+
found.push(candidate);
|
|
1457
|
+
}
|
|
1458
|
+
return found;
|
|
1459
|
+
};
|
|
1460
|
+
var deriveTarget = (storageRoot, id, owner) => {
|
|
1461
|
+
if (!owner) return path4.join(storageRoot, ".buda", "legacy", "shared", id);
|
|
1462
|
+
const spaceRoot = path4.join(storageRoot, ".buda", "spaces", owner.spaceId);
|
|
1463
|
+
const agentRoot = path4.join(spaceRoot, "agents", owner.agentId);
|
|
1464
|
+
if (id === "agent") return path4.join(agentRoot, "workspace", "legacy-connector-root");
|
|
1465
|
+
if (id === "space") return path4.join(spaceRoot, "shared", "legacy-connector-root");
|
|
1466
|
+
if (id.startsWith("template-")) {
|
|
1467
|
+
return path4.join(spaceRoot, "shared", "legacy-templates", id.slice("template-".length));
|
|
1468
|
+
}
|
|
1469
|
+
return path4.join(agentRoot, "state", "legacy-connector-root", id);
|
|
1470
|
+
};
|
|
1471
|
+
var copyTree = async (source, destination, onEntryCopied) => {
|
|
1472
|
+
const info = await lstat(source);
|
|
1473
|
+
if (info.isDirectory()) {
|
|
1474
|
+
await mkdir5(destination, { mode: info.mode & 4095 });
|
|
1475
|
+
const entries = (await readdir2(source)).sort();
|
|
1476
|
+
for (const entry of entries) {
|
|
1477
|
+
await copyTree(path4.join(source, entry), path4.join(destination, entry), onEntryCopied);
|
|
1478
|
+
}
|
|
1479
|
+
await chmod2(destination, info.mode & 4095);
|
|
292
1480
|
return;
|
|
293
1481
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
1482
|
+
if (info.isFile()) {
|
|
1483
|
+
await copyFile(source, destination, constants2.COPYFILE_EXCL);
|
|
1484
|
+
await chmod2(destination, info.mode & 4095);
|
|
1485
|
+
await onEntryCopied();
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
if (info.isSymbolicLink()) {
|
|
1489
|
+
await symlink(await readlink(source), destination);
|
|
1490
|
+
await onEntryCopied();
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
throw new LegacyWorkspaceMigrationError(
|
|
1494
|
+
"MIGRATION_SOURCE_UNSAFE",
|
|
1495
|
+
"Legacy migration supports only files, directories, and symbolic links"
|
|
1496
|
+
);
|
|
1497
|
+
};
|
|
1498
|
+
var verifyTree = async (source, destination) => {
|
|
1499
|
+
const [sourceInfo, destinationInfo] = await Promise.all([lstat(source), lstat(destination)]);
|
|
1500
|
+
if (sourceInfo.isDirectory() !== destinationInfo.isDirectory()) {
|
|
1501
|
+
throw verifyFailure("entry types differ");
|
|
1502
|
+
}
|
|
1503
|
+
if (sourceInfo.isFile() !== destinationInfo.isFile()) throw verifyFailure("entry types differ");
|
|
1504
|
+
if (sourceInfo.isSymbolicLink() !== destinationInfo.isSymbolicLink()) {
|
|
1505
|
+
throw verifyFailure("entry types differ");
|
|
1506
|
+
}
|
|
1507
|
+
if (!sourceInfo.isSymbolicLink() && (sourceInfo.mode & 4095) !== (destinationInfo.mode & 4095)) {
|
|
1508
|
+
throw verifyFailure("permission bits differ");
|
|
1509
|
+
}
|
|
1510
|
+
if (sourceInfo.isDirectory()) {
|
|
1511
|
+
const [sourceEntries, destinationEntries] = await Promise.all([
|
|
1512
|
+
readdir2(source),
|
|
1513
|
+
readdir2(destination)
|
|
1514
|
+
]);
|
|
1515
|
+
sourceEntries.sort();
|
|
1516
|
+
destinationEntries.sort();
|
|
1517
|
+
if (JSON.stringify(sourceEntries) !== JSON.stringify(destinationEntries)) {
|
|
1518
|
+
throw verifyFailure("directory entries differ");
|
|
1519
|
+
}
|
|
1520
|
+
for (const entry of sourceEntries) {
|
|
1521
|
+
await verifyTree(path4.join(source, entry), path4.join(destination, entry));
|
|
1522
|
+
}
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
if (sourceInfo.isFile()) {
|
|
1526
|
+
if (sourceInfo.size !== destinationInfo.size) throw verifyFailure("file sizes differ");
|
|
1527
|
+
const [sourceHash, destinationHash] = await Promise.all([
|
|
1528
|
+
hashFile(source),
|
|
1529
|
+
hashFile(destination)
|
|
1530
|
+
]);
|
|
1531
|
+
if (sourceHash !== destinationHash) throw verifyFailure("file bytes differ");
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
if (await readlink(source) !== await readlink(destination)) {
|
|
1535
|
+
throw verifyFailure("symbolic link targets differ");
|
|
1536
|
+
}
|
|
1537
|
+
};
|
|
1538
|
+
var hashFile = async (filePath) => {
|
|
1539
|
+
const hash = createHash5("sha256");
|
|
1540
|
+
await new Promise((resolve, reject) => {
|
|
1541
|
+
const stream = createReadStream(filePath);
|
|
1542
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
1543
|
+
stream.once("error", reject);
|
|
1544
|
+
stream.once("end", resolve);
|
|
299
1545
|
});
|
|
1546
|
+
return hash.digest("hex");
|
|
1547
|
+
};
|
|
1548
|
+
var readJournal = async (journalPath) => {
|
|
1549
|
+
let raw;
|
|
300
1550
|
try {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
1551
|
+
raw = await readFile4(journalPath, "utf8");
|
|
1552
|
+
} catch (error) {
|
|
1553
|
+
if (isMissing(error)) return void 0;
|
|
1554
|
+
throw error;
|
|
1555
|
+
}
|
|
1556
|
+
let value;
|
|
1557
|
+
try {
|
|
1558
|
+
value = JSON.parse(raw);
|
|
1559
|
+
} catch {
|
|
1560
|
+
throw corrupt("Legacy migration journal contains invalid JSON");
|
|
1561
|
+
}
|
|
1562
|
+
if (!isJournal(value)) throw corrupt("Legacy migration journal is malformed");
|
|
1563
|
+
await chmod2(journalPath, 384);
|
|
1564
|
+
return value;
|
|
1565
|
+
};
|
|
1566
|
+
var writeJournal = async (journalPath, journal) => {
|
|
1567
|
+
journal.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1568
|
+
await ensurePrivateDirectory(path4.dirname(journalPath));
|
|
1569
|
+
const temporaryPath = `${journalPath}.${process.pid}.${randomUUID2()}.tmp`;
|
|
1570
|
+
const handle = await open2(
|
|
1571
|
+
temporaryPath,
|
|
1572
|
+
constants2.O_CREAT | constants2.O_EXCL | constants2.O_WRONLY,
|
|
1573
|
+
384
|
|
1574
|
+
);
|
|
1575
|
+
try {
|
|
1576
|
+
await handle.writeFile(`${JSON.stringify(journal, null, 2)}
|
|
1577
|
+
`, "utf8");
|
|
1578
|
+
await handle.sync();
|
|
1579
|
+
} finally {
|
|
1580
|
+
await handle.close();
|
|
1581
|
+
}
|
|
1582
|
+
try {
|
|
1583
|
+
await rename2(temporaryPath, journalPath);
|
|
1584
|
+
await chmod2(journalPath, 384);
|
|
1585
|
+
await syncDirectory(path4.dirname(journalPath));
|
|
1586
|
+
} finally {
|
|
1587
|
+
await unlink3(temporaryPath).catch(() => void 0);
|
|
1588
|
+
}
|
|
1589
|
+
};
|
|
1590
|
+
var syncDirectory = async (directory) => {
|
|
1591
|
+
const handle = await open2(directory, constants2.O_RDONLY);
|
|
1592
|
+
try {
|
|
1593
|
+
await handle.sync();
|
|
1594
|
+
} finally {
|
|
1595
|
+
await handle.close();
|
|
1596
|
+
}
|
|
1597
|
+
};
|
|
1598
|
+
var ensurePrivateDirectory = async (directory) => {
|
|
1599
|
+
await mkdir5(directory, { mode: 448 }).catch((error) => {
|
|
1600
|
+
if (!isAlreadyExists2(error)) throw error;
|
|
1601
|
+
});
|
|
1602
|
+
const info = await lstat(directory);
|
|
1603
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
1604
|
+
throw new LegacyWorkspaceMigrationError(
|
|
1605
|
+
"MIGRATION_SOURCE_UNSAFE",
|
|
1606
|
+
"Legacy migration control path must be a real directory"
|
|
1607
|
+
);
|
|
1608
|
+
}
|
|
1609
|
+
await chmod2(directory, 448);
|
|
1610
|
+
};
|
|
1611
|
+
var ensurePrivateDirectoryWithin = async (storageRoot, directory) => {
|
|
1612
|
+
if (directory !== storageRoot && !isPathInside(storageRoot, directory)) {
|
|
1613
|
+
throw corrupt("Legacy migration control path escapes protected storage");
|
|
1614
|
+
}
|
|
1615
|
+
const relative = path4.relative(storageRoot, directory);
|
|
1616
|
+
let current = storageRoot;
|
|
1617
|
+
for (const segment of relative.split(path4.sep).filter(Boolean)) {
|
|
1618
|
+
current = path4.join(current, segment);
|
|
1619
|
+
await ensurePrivateDirectory(current);
|
|
1620
|
+
}
|
|
1621
|
+
};
|
|
1622
|
+
var checkpoint = async (options, stage, itemId) => options.faultInjector?.({ stage, itemId });
|
|
1623
|
+
var assertOwnerMatches = (journal, owner) => {
|
|
1624
|
+
if (!owner) return;
|
|
1625
|
+
const expected = owner ? `${owner.spaceId}:${owner.agentId}` : void 0;
|
|
1626
|
+
const actual = journal.owner ? `${journal.owner.spaceId}:${journal.owner.agentId}` : void 0;
|
|
1627
|
+
if (expected !== actual) {
|
|
1628
|
+
throw new LegacyWorkspaceMigrationError(
|
|
1629
|
+
"MIGRATION_TARGET_CONFLICT",
|
|
1630
|
+
"Legacy migration ownership cannot change while a journal is active; roll back first"
|
|
1631
|
+
);
|
|
1632
|
+
}
|
|
1633
|
+
};
|
|
1634
|
+
var validateJournalPaths = (journal, storageRoot, migrationDirectory) => {
|
|
1635
|
+
validateJournalOwnership(journal);
|
|
1636
|
+
const stagingParents = /* @__PURE__ */ new Set();
|
|
1637
|
+
const itemIds = /* @__PURE__ */ new Set();
|
|
1638
|
+
const sources = /* @__PURE__ */ new Set();
|
|
1639
|
+
const targets = /* @__PURE__ */ new Set();
|
|
1640
|
+
const stagingPaths = /* @__PURE__ */ new Set();
|
|
1641
|
+
for (const item of journal.items) {
|
|
1642
|
+
const sourceRelative = sourceRelativePath(item.id);
|
|
1643
|
+
if (!sourceRelative || item.source !== path4.join(storageRoot, sourceRelative)) {
|
|
1644
|
+
throw corrupt("Legacy migration journal contains an unexpected source path");
|
|
1645
|
+
}
|
|
1646
|
+
if (item.target !== deriveTarget(storageRoot, item.id, journal.owner)) {
|
|
1647
|
+
throw corrupt("Legacy migration journal target is not canonical");
|
|
328
1648
|
}
|
|
329
|
-
|
|
1649
|
+
if (!isPathInside(migrationDirectory, item.staging)) {
|
|
1650
|
+
throw corrupt("Legacy migration journal staging path escapes the migration directory");
|
|
1651
|
+
}
|
|
1652
|
+
const stagingParent = path4.dirname(item.staging);
|
|
1653
|
+
if (path4.dirname(stagingParent) !== path4.join(migrationDirectory, "staging") || item.staging !== path4.join(stagingParent, item.id)) {
|
|
1654
|
+
throw corrupt("Legacy migration journal staging path is not canonical");
|
|
1655
|
+
}
|
|
1656
|
+
stagingParents.add(stagingParent);
|
|
1657
|
+
if (itemIds.has(item.id) || sources.has(item.source) || targets.has(item.target) || stagingPaths.has(item.staging)) {
|
|
1658
|
+
throw corrupt("Legacy migration journal contains duplicate items");
|
|
1659
|
+
}
|
|
1660
|
+
itemIds.add(item.id);
|
|
1661
|
+
sources.add(item.source);
|
|
1662
|
+
targets.add(item.target);
|
|
1663
|
+
stagingPaths.add(item.staging);
|
|
1664
|
+
}
|
|
1665
|
+
if (stagingParents.size > 1) throw corrupt("Legacy migration journal has multiple staging roots");
|
|
1666
|
+
validateJournalState(journal);
|
|
1667
|
+
};
|
|
1668
|
+
var sourceRelativePath = (id) => {
|
|
1669
|
+
const known = {
|
|
1670
|
+
agent: "agent",
|
|
1671
|
+
space: "space",
|
|
1672
|
+
logs: "logs",
|
|
1673
|
+
"connector-log": "connector.log",
|
|
1674
|
+
"runtime-config": "runtime-config",
|
|
1675
|
+
"runtime-config-hidden": ".runtime-config",
|
|
1676
|
+
"runtime-config-file": "runtime-config.json",
|
|
1677
|
+
"runtime-config-hidden-file": ".runtime-config.json",
|
|
1678
|
+
"bunny-runtime": ".bunny"
|
|
1679
|
+
};
|
|
1680
|
+
if (known[id]) return known[id];
|
|
1681
|
+
if (!id.startsWith("template-")) return null;
|
|
1682
|
+
const name = id.slice("template-".length);
|
|
1683
|
+
return name.endsWith(".md") && path4.basename(name) === name ? name : null;
|
|
1684
|
+
};
|
|
1685
|
+
var validateJournalOwnership = (journal) => {
|
|
1686
|
+
if (journal.ownership === "explicit-owner") {
|
|
1687
|
+
if (!journal.owner) throw corrupt("Explicit legacy migration ownership is missing");
|
|
1688
|
+
validateOwner(journal.owner);
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
if (journal.ownership !== "shared-legacy" || journal.owner) {
|
|
1692
|
+
throw corrupt("Legacy migration ownership is inconsistent");
|
|
1693
|
+
}
|
|
1694
|
+
};
|
|
1695
|
+
var validateJournalState = (journal) => {
|
|
1696
|
+
if (journal.status === "planned" && journal.items.some((item) => item.phase !== "pending")) {
|
|
1697
|
+
throw corrupt("Planned legacy migration contains advanced items");
|
|
1698
|
+
}
|
|
1699
|
+
if (journal.status === "migrating" && journal.items.some((item) => item.phase === "rolled-back")) {
|
|
1700
|
+
throw corrupt("Active legacy migration contains rolled-back items");
|
|
1701
|
+
}
|
|
1702
|
+
if (journal.status === "complete" && journal.items.some((item) => item.phase !== "committed")) {
|
|
1703
|
+
throw corrupt("Completed legacy migration contains unfinished items");
|
|
1704
|
+
}
|
|
1705
|
+
if (journal.status === "rolled-back" && journal.items.some((item) => item.phase !== "rolled-back")) {
|
|
1706
|
+
throw corrupt("Rolled-back legacy migration contains active items");
|
|
1707
|
+
}
|
|
1708
|
+
};
|
|
1709
|
+
var verifyCommittedItems = async (journal) => {
|
|
1710
|
+
try {
|
|
1711
|
+
for (const item of journal.items) await verifyTree(item.source, item.target);
|
|
1712
|
+
} catch (error) {
|
|
1713
|
+
if (error instanceof LegacyWorkspaceMigrationError) throw error;
|
|
1714
|
+
throw verifyFailure(`committed data is unavailable: ${errorMessage(error)}`);
|
|
1715
|
+
}
|
|
1716
|
+
};
|
|
1717
|
+
var validateOwner = (owner) => {
|
|
1718
|
+
if (!IMMUTABLE_ID_PATTERN.test(owner.spaceId) || !IMMUTABLE_ID_PATTERN.test(owner.agentId)) {
|
|
1719
|
+
throw new LegacyWorkspaceMigrationError(
|
|
1720
|
+
"MIGRATION_SOURCE_UNSAFE",
|
|
1721
|
+
"Legacy migration owner must use immutable Space and Agent IDs"
|
|
1722
|
+
);
|
|
1723
|
+
}
|
|
1724
|
+
};
|
|
1725
|
+
var assertDistinctTargets = (items) => {
|
|
1726
|
+
const targets = /* @__PURE__ */ new Set();
|
|
1727
|
+
for (const item of items) {
|
|
1728
|
+
if (targets.has(item.target)) throw corrupt("Legacy migration targets collide");
|
|
1729
|
+
targets.add(item.target);
|
|
1730
|
+
}
|
|
1731
|
+
};
|
|
1732
|
+
var toResult = (journal) => ({
|
|
1733
|
+
status: "complete",
|
|
1734
|
+
ownership: journal.ownership,
|
|
1735
|
+
label: journal.label,
|
|
1736
|
+
migratedItems: journal.items.map((item) => item.id),
|
|
1737
|
+
rollbackAvailable: true
|
|
1738
|
+
});
|
|
1739
|
+
var isJournal = (value) => {
|
|
1740
|
+
if (!value || typeof value !== "object") return false;
|
|
1741
|
+
const journal = value;
|
|
1742
|
+
return journal.version === MIGRATION_VERSION && typeof journal.status === "string" && JOURNAL_STATUSES.has(journal.status) && (journal.ownership === "explicit-owner" || journal.ownership === "shared-legacy") && typeof journal.label === "string" && typeof journal.createdAt === "string" && typeof journal.updatedAt === "string" && Array.isArray(journal.items) && journal.items.every(
|
|
1743
|
+
(item) => item && typeof item === "object" && typeof item.id === "string" && typeof item.source === "string" && typeof item.target === "string" && typeof item.staging === "string" && typeof item.phase === "string" && ITEM_PHASES.has(item.phase)
|
|
1744
|
+
);
|
|
1745
|
+
};
|
|
1746
|
+
var exists = async (filePath) => {
|
|
1747
|
+
try {
|
|
1748
|
+
await lstat(filePath);
|
|
1749
|
+
return true;
|
|
330
1750
|
} catch (error) {
|
|
331
|
-
|
|
1751
|
+
if (isMissing(error)) return false;
|
|
1752
|
+
throw error;
|
|
1753
|
+
}
|
|
1754
|
+
};
|
|
1755
|
+
var isPathInside = (parent, child) => {
|
|
1756
|
+
const relative = path4.relative(parent, child);
|
|
1757
|
+
return relative !== "" && relative !== ".." && !relative.startsWith(`..${path4.sep}`) && !path4.isAbsolute(relative);
|
|
1758
|
+
};
|
|
1759
|
+
var isMissing = (error) => error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
1760
|
+
var isAlreadyExists2 = (error) => error instanceof Error && "code" in error && error.code === "EEXIST";
|
|
1761
|
+
var verifyFailure = (detail) => new LegacyWorkspaceMigrationError(
|
|
1762
|
+
"MIGRATION_VERIFY_FAILED",
|
|
1763
|
+
`Legacy migration verification failed: ${detail}`
|
|
1764
|
+
);
|
|
1765
|
+
var corrupt = (message) => new LegacyWorkspaceMigrationError("MIGRATION_CORRUPT", message);
|
|
1766
|
+
var errorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
1767
|
+
|
|
1768
|
+
// src/relay.ts
|
|
1769
|
+
import path6 from "path";
|
|
1770
|
+
import { createNextHandler } from "@bunny-agent/daemon/nextjs";
|
|
1771
|
+
import WebSocket from "ws";
|
|
1772
|
+
|
|
1773
|
+
// src/execution-identity.ts
|
|
1774
|
+
import {
|
|
1775
|
+
createHash as createHash6,
|
|
1776
|
+
createPublicKey,
|
|
1777
|
+
timingSafeEqual,
|
|
1778
|
+
verify as verifySignature
|
|
1779
|
+
} from "crypto";
|
|
1780
|
+
var MAX_ISSUED_AT_FUTURE_SKEW_MS = 3e4;
|
|
1781
|
+
var ERROR_MESSAGES = {
|
|
1782
|
+
IDENTITY_INVALID: "The Connector execution identity is invalid.",
|
|
1783
|
+
IDENTITY_EXPIRED: "The Connector execution identity has expired or is not yet valid.",
|
|
1784
|
+
WORKSPACE_MISMATCH: "The Connector execution identity targets a different workspace.",
|
|
1785
|
+
REPLAY_REJECTED: "The Connector execution identity has already been used.",
|
|
1786
|
+
PROTOCOL_UNSUPPORTED: "The Connector execution identity protocol is unsupported."
|
|
1787
|
+
};
|
|
1788
|
+
var ConnectorRelayProtocolError = class extends Error {
|
|
1789
|
+
code;
|
|
1790
|
+
constructor(code) {
|
|
1791
|
+
super(ERROR_MESSAGES[code]);
|
|
1792
|
+
this.name = "ConnectorRelayProtocolError";
|
|
1793
|
+
this.code = code;
|
|
332
1794
|
}
|
|
1795
|
+
};
|
|
1796
|
+
var ConnectorExecutionReplayGuard = class {
|
|
1797
|
+
nonces = /* @__PURE__ */ new Map();
|
|
1798
|
+
runIds = /* @__PURE__ */ new Map();
|
|
1799
|
+
assertUnusedAndRemember(identity, expiresAt, now) {
|
|
1800
|
+
this.prune(now);
|
|
1801
|
+
if (this.nonces.has(identity.nonce) || this.runIds.has(identity.runId)) {
|
|
1802
|
+
throw new ConnectorRelayProtocolError("REPLAY_REJECTED");
|
|
1803
|
+
}
|
|
1804
|
+
this.nonces.set(identity.nonce, expiresAt);
|
|
1805
|
+
this.runIds.set(identity.runId, expiresAt);
|
|
1806
|
+
}
|
|
1807
|
+
prune(now) {
|
|
1808
|
+
for (const [nonce, expiresAt] of this.nonces) {
|
|
1809
|
+
if (expiresAt <= now) this.nonces.delete(nonce);
|
|
1810
|
+
}
|
|
1811
|
+
for (const [runId, expiresAt] of this.runIds) {
|
|
1812
|
+
if (expiresAt <= now) this.runIds.delete(runId);
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
};
|
|
1816
|
+
function canonicalizeConnectorExecutionIdentity(identity) {
|
|
1817
|
+
return Buffer.from(
|
|
1818
|
+
JSON.stringify([
|
|
1819
|
+
identity.version,
|
|
1820
|
+
identity.keyId,
|
|
1821
|
+
identity.connectorId,
|
|
1822
|
+
identity.spaceId,
|
|
1823
|
+
identity.agentId,
|
|
1824
|
+
identity.runId,
|
|
1825
|
+
identity.issuedAt,
|
|
1826
|
+
identity.expiresAt,
|
|
1827
|
+
identity.nonce,
|
|
1828
|
+
identity.requestDigest
|
|
1829
|
+
]),
|
|
1830
|
+
"utf8"
|
|
1831
|
+
);
|
|
333
1832
|
}
|
|
334
|
-
function
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
1833
|
+
function verifyConnectorExecutionIdentity(options) {
|
|
1834
|
+
const now = options.now ?? Date.now();
|
|
1835
|
+
const protocol = assertSupportedProtocol(options.protocolVersion, options.protocol);
|
|
1836
|
+
const identity = parseExecutionIdentity(options.executionIdentity);
|
|
1837
|
+
if (identity.version !== CONNECTOR_RELAY_PROTOCOL_VERSION) {
|
|
1838
|
+
throw new ConnectorRelayProtocolError("PROTOCOL_UNSUPPORTED");
|
|
1839
|
+
}
|
|
1840
|
+
if (identity.keyId !== protocol.keyId) {
|
|
1841
|
+
throw new ConnectorRelayProtocolError("IDENTITY_INVALID");
|
|
1842
|
+
}
|
|
1843
|
+
if (identity.connectorId !== options.expectedConnectorId) {
|
|
1844
|
+
throw new ConnectorRelayProtocolError("WORKSPACE_MISMATCH");
|
|
1845
|
+
}
|
|
1846
|
+
const issuedAt = Date.parse(identity.issuedAt);
|
|
1847
|
+
const expiresAt = Date.parse(identity.expiresAt);
|
|
1848
|
+
if (!Number.isFinite(issuedAt) || !Number.isFinite(expiresAt) || expiresAt <= issuedAt) {
|
|
1849
|
+
throw new ConnectorRelayProtocolError("IDENTITY_INVALID");
|
|
1850
|
+
}
|
|
1851
|
+
if (issuedAt > now + MAX_ISSUED_AT_FUTURE_SKEW_MS || expiresAt <= now) {
|
|
1852
|
+
throw new ConnectorRelayProtocolError("IDENTITY_EXPIRED");
|
|
1853
|
+
}
|
|
1854
|
+
const signature = decodeSignature(identity.signature);
|
|
1855
|
+
let verified = false;
|
|
1856
|
+
try {
|
|
1857
|
+
const publicKey = createPublicKey({ key: protocol.publicKeyJwk, format: "jwk" });
|
|
1858
|
+
if (publicKey.asymmetricKeyType !== "ed25519") {
|
|
1859
|
+
throw new ConnectorRelayProtocolError("PROTOCOL_UNSUPPORTED");
|
|
1860
|
+
}
|
|
1861
|
+
verified = verifySignature(
|
|
1862
|
+
null,
|
|
1863
|
+
canonicalizeConnectorExecutionIdentity(identity),
|
|
1864
|
+
publicKey,
|
|
1865
|
+
signature
|
|
1866
|
+
);
|
|
1867
|
+
} catch (error) {
|
|
1868
|
+
if (error instanceof ConnectorRelayProtocolError) throw error;
|
|
1869
|
+
throw new ConnectorRelayProtocolError("PROTOCOL_UNSUPPORTED");
|
|
1870
|
+
}
|
|
1871
|
+
if (!verified) throw new ConnectorRelayProtocolError("IDENTITY_INVALID");
|
|
1872
|
+
const actualRequestDigest = digestConnectorRelayRequest(options.request);
|
|
1873
|
+
if (!safeDigestEqual(identity.requestDigest, actualRequestDigest)) {
|
|
1874
|
+
throw new ConnectorRelayProtocolError("IDENTITY_INVALID");
|
|
1875
|
+
}
|
|
1876
|
+
options.replayGuard.assertUnusedAndRemember(identity, expiresAt, now);
|
|
1877
|
+
return identity;
|
|
1878
|
+
}
|
|
1879
|
+
function assertSupportedProtocol(protocolVersion, protocol) {
|
|
1880
|
+
if (protocolVersion !== CONNECTOR_RELAY_PROTOCOL_VERSION || !protocol || protocol.version !== CONNECTOR_RELAY_PROTOCOL_VERSION || protocol.capability !== CONNECTOR_EXECUTION_IDENTITY_CAPABILITY || protocol.algorithm !== "Ed25519" || !isNonEmptyString(protocol.keyId) || !protocol.publicKeyJwk) {
|
|
1881
|
+
throw new ConnectorRelayProtocolError("PROTOCOL_UNSUPPORTED");
|
|
1882
|
+
}
|
|
1883
|
+
return protocol;
|
|
1884
|
+
}
|
|
1885
|
+
function parseExecutionIdentity(value) {
|
|
1886
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1887
|
+
throw new ConnectorRelayProtocolError("PROTOCOL_UNSUPPORTED");
|
|
1888
|
+
}
|
|
1889
|
+
const identity = value;
|
|
1890
|
+
const requiredStringFields = [
|
|
1891
|
+
"keyId",
|
|
1892
|
+
"connectorId",
|
|
1893
|
+
"spaceId",
|
|
1894
|
+
"agentId",
|
|
1895
|
+
"runId",
|
|
1896
|
+
"issuedAt",
|
|
1897
|
+
"expiresAt",
|
|
1898
|
+
"nonce",
|
|
1899
|
+
"requestDigest",
|
|
1900
|
+
"signature"
|
|
1901
|
+
];
|
|
1902
|
+
if (requiredStringFields.some((field) => !isNonEmptyString(identity[field]))) {
|
|
1903
|
+
throw new ConnectorRelayProtocolError("IDENTITY_INVALID");
|
|
1904
|
+
}
|
|
1905
|
+
if (typeof identity.version !== "number") {
|
|
1906
|
+
throw new ConnectorRelayProtocolError("IDENTITY_INVALID");
|
|
1907
|
+
}
|
|
1908
|
+
return identity;
|
|
1909
|
+
}
|
|
1910
|
+
function digestConnectorRelayRequest(request) {
|
|
1911
|
+
const serialized = JSON.stringify(request);
|
|
1912
|
+
if (serialized === void 0) {
|
|
1913
|
+
throw new ConnectorRelayProtocolError("IDENTITY_INVALID");
|
|
1914
|
+
}
|
|
1915
|
+
return createHash6("sha256").update(serialized, "utf8").digest("base64url");
|
|
1916
|
+
}
|
|
1917
|
+
function safeDigestEqual(expected, actual) {
|
|
1918
|
+
const expectedBytes = Buffer.from(expected, "utf8");
|
|
1919
|
+
const actualBytes = Buffer.from(actual, "utf8");
|
|
1920
|
+
return expectedBytes.length === actualBytes.length && timingSafeEqual(expectedBytes, actualBytes);
|
|
1921
|
+
}
|
|
1922
|
+
function decodeSignature(value) {
|
|
1923
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value)) {
|
|
1924
|
+
throw new ConnectorRelayProtocolError("IDENTITY_INVALID");
|
|
1925
|
+
}
|
|
1926
|
+
const signature = Buffer.from(value, "base64url");
|
|
1927
|
+
if (signature.length !== 64) {
|
|
1928
|
+
throw new ConnectorRelayProtocolError("IDENTITY_INVALID");
|
|
1929
|
+
}
|
|
1930
|
+
return signature;
|
|
340
1931
|
}
|
|
341
|
-
function
|
|
342
|
-
return
|
|
1932
|
+
function isNonEmptyString(value) {
|
|
1933
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
343
1934
|
}
|
|
344
1935
|
|
|
1936
|
+
// src/workspace-volume-lifecycle.ts
|
|
1937
|
+
import { constants as constants3 } from "fs";
|
|
1938
|
+
import {
|
|
1939
|
+
copyFile as copyFile2,
|
|
1940
|
+
lstat as lstat2,
|
|
1941
|
+
mkdir as mkdir6,
|
|
1942
|
+
readdir as readdir3,
|
|
1943
|
+
readFile as readFile5,
|
|
1944
|
+
realpath as realpath2,
|
|
1945
|
+
rm as rm2,
|
|
1946
|
+
symlink as symlink2,
|
|
1947
|
+
writeFile as writeFile2
|
|
1948
|
+
} from "fs/promises";
|
|
1949
|
+
import path5 from "path";
|
|
1950
|
+
var MOUNT_DIRECTORY = "mount";
|
|
1951
|
+
var ARCHIVE_MARKER = "archived.json";
|
|
1952
|
+
var AGENT_CONTEXT_TEMPLATE_FILES = [
|
|
1953
|
+
"AGENTS.md",
|
|
1954
|
+
"SOUL.md",
|
|
1955
|
+
"IDENTITY.md",
|
|
1956
|
+
"USER.md",
|
|
1957
|
+
"TOOLS.md",
|
|
1958
|
+
"BOOTSTRAP.md",
|
|
1959
|
+
"MEMORY.md"
|
|
1960
|
+
];
|
|
1961
|
+
var deriveSystemVolume = (descriptor) => path5.join(
|
|
1962
|
+
path5.dirname(path5.dirname(path5.dirname(path5.dirname(descriptor.sharedVolume)))),
|
|
1963
|
+
"system"
|
|
1964
|
+
);
|
|
1965
|
+
var WorkspaceVolumeLifecycle = class {
|
|
1966
|
+
constructor(descriptor) {
|
|
1967
|
+
this.descriptor = descriptor;
|
|
1968
|
+
this.systemVolume = deriveSystemVolume(descriptor);
|
|
1969
|
+
}
|
|
1970
|
+
systemVolume;
|
|
1971
|
+
async mount() {
|
|
1972
|
+
await this.ensureVolumes();
|
|
1973
|
+
await this.bootstrapAgentTemplates((await readBundledManifest()).sharedDir);
|
|
1974
|
+
const mountRoot = path5.join(this.descriptor.stateVolume, MOUNT_DIRECTORY);
|
|
1975
|
+
await mkdir6(mountRoot, { recursive: true, mode: 448 });
|
|
1976
|
+
const mounts = {
|
|
1977
|
+
mountRoot,
|
|
1978
|
+
agent: path5.join(mountRoot, "agent"),
|
|
1979
|
+
space: path5.join(mountRoot, "space"),
|
|
1980
|
+
system: path5.join(mountRoot, "system"),
|
|
1981
|
+
temporary: path5.join(mountRoot, "tmp")
|
|
1982
|
+
};
|
|
1983
|
+
await Promise.all([
|
|
1984
|
+
ensureVolumeLink(this.descriptor.privateVolume, mounts.agent),
|
|
1985
|
+
ensureVolumeLink(this.descriptor.sharedVolume, mounts.space),
|
|
1986
|
+
ensureVolumeLink(this.systemVolume, mounts.system),
|
|
1987
|
+
ensureVolumeLink(this.descriptor.temporaryVolume, mounts.temporary)
|
|
1988
|
+
]);
|
|
1989
|
+
const daemonVolumes = path5.join(mountRoot, "volumes");
|
|
1990
|
+
await mkdir6(daemonVolumes, { recursive: true, mode: 448 });
|
|
1991
|
+
await Promise.all([
|
|
1992
|
+
ensureVolumeLink(this.descriptor.privateVolume, path5.join(daemonVolumes, "agent")),
|
|
1993
|
+
ensureVolumeLink(this.descriptor.sharedVolume, path5.join(daemonVolumes, "space")),
|
|
1994
|
+
ensureVolumeLink(this.systemVolume, path5.join(daemonVolumes, "system")),
|
|
1995
|
+
ensureVolumeLink(this.descriptor.temporaryVolume, path5.join(daemonVolumes, "tmp"))
|
|
1996
|
+
]);
|
|
1997
|
+
return mounts;
|
|
1998
|
+
}
|
|
1999
|
+
async unmount() {
|
|
2000
|
+
await rm2(path5.join(this.descriptor.stateVolume, MOUNT_DIRECTORY), {
|
|
2001
|
+
recursive: true,
|
|
2002
|
+
force: true
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
2005
|
+
async archiveAgent() {
|
|
2006
|
+
await this.unmount();
|
|
2007
|
+
await mkdir6(this.descriptor.stateVolume, { recursive: true, mode: 448 });
|
|
2008
|
+
await writeFile2(
|
|
2009
|
+
path5.join(this.descriptor.stateVolume, ARCHIVE_MARKER),
|
|
2010
|
+
`${JSON.stringify({ archivedAt: (/* @__PURE__ */ new Date()).toISOString() })}
|
|
2011
|
+
`,
|
|
2012
|
+
{ encoding: "utf8", mode: 384 }
|
|
2013
|
+
);
|
|
2014
|
+
}
|
|
2015
|
+
async restoreAgent() {
|
|
2016
|
+
await rm2(path5.join(this.descriptor.stateVolume, ARCHIVE_MARKER), { force: true });
|
|
2017
|
+
return this.mount();
|
|
2018
|
+
}
|
|
2019
|
+
async deleteAgent(options) {
|
|
2020
|
+
requireConfirmation(options);
|
|
2021
|
+
await this.unmount();
|
|
2022
|
+
const agentRoot = path5.dirname(this.descriptor.privateVolume);
|
|
2023
|
+
await rm2(agentRoot, { recursive: true, force: true });
|
|
2024
|
+
}
|
|
2025
|
+
async archiveSpace() {
|
|
2026
|
+
await mkdir6(this.descriptor.sharedVolume, { recursive: true, mode: 448 });
|
|
2027
|
+
await writeFile2(
|
|
2028
|
+
path5.join(this.descriptor.sharedVolume, ARCHIVE_MARKER),
|
|
2029
|
+
`${JSON.stringify({ archivedAt: (/* @__PURE__ */ new Date()).toISOString() })}
|
|
2030
|
+
`,
|
|
2031
|
+
{ encoding: "utf8", mode: 384 }
|
|
2032
|
+
);
|
|
2033
|
+
}
|
|
2034
|
+
async restoreSpace() {
|
|
2035
|
+
await rm2(path5.join(this.descriptor.sharedVolume, ARCHIVE_MARKER), { force: true });
|
|
2036
|
+
}
|
|
2037
|
+
async deleteSpace(options) {
|
|
2038
|
+
requireConfirmation(options);
|
|
2039
|
+
await this.unmount();
|
|
2040
|
+
await rm2(this.descriptor.sharedVolume, { recursive: true, force: true });
|
|
2041
|
+
}
|
|
2042
|
+
async bootstrapAgentTemplates(templateDirectory) {
|
|
2043
|
+
await mkdir6(this.descriptor.privateVolume, { recursive: true, mode: 448 });
|
|
2044
|
+
const bootstrapped = [];
|
|
2045
|
+
const skipped = [];
|
|
2046
|
+
const entries = new Set(
|
|
2047
|
+
(await readdir3(templateDirectory, { withFileTypes: true }).catch(() => [])).filter((entry) => entry.isFile()).map((entry) => entry.name)
|
|
2048
|
+
);
|
|
2049
|
+
for (const filename of AGENT_CONTEXT_TEMPLATE_FILES) {
|
|
2050
|
+
if (!entries.has(filename)) continue;
|
|
2051
|
+
const destination = path5.join(this.descriptor.privateVolume, filename);
|
|
2052
|
+
try {
|
|
2053
|
+
await lstat2(destination);
|
|
2054
|
+
skipped.push(filename);
|
|
2055
|
+
} catch (error) {
|
|
2056
|
+
if (!isMissingFile(error)) throw error;
|
|
2057
|
+
try {
|
|
2058
|
+
await copyFile2(
|
|
2059
|
+
path5.join(templateDirectory, filename),
|
|
2060
|
+
destination,
|
|
2061
|
+
constants3.COPYFILE_EXCL
|
|
2062
|
+
);
|
|
2063
|
+
bootstrapped.push(filename);
|
|
2064
|
+
} catch (copyError) {
|
|
2065
|
+
if (!isAlreadyExists3(copyError)) throw copyError;
|
|
2066
|
+
skipped.push(filename);
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
return { bootstrapped, skipped };
|
|
2071
|
+
}
|
|
2072
|
+
async ensureVolumes() {
|
|
2073
|
+
for (const volume of [
|
|
2074
|
+
this.descriptor.privateVolume,
|
|
2075
|
+
this.descriptor.sharedVolume,
|
|
2076
|
+
this.descriptor.stateVolume,
|
|
2077
|
+
this.descriptor.temporaryVolume,
|
|
2078
|
+
this.systemVolume
|
|
2079
|
+
]) {
|
|
2080
|
+
await mkdir6(volume, { recursive: true, mode: 448 });
|
|
2081
|
+
const info = await lstat2(volume);
|
|
2082
|
+
if (info.isSymbolicLink() || !info.isDirectory() || await realpath2(volume) !== volume) {
|
|
2083
|
+
throw new Error("Workspace volume must be a canonical real directory");
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
};
|
|
2088
|
+
var requireConfirmation = (options) => {
|
|
2089
|
+
if (options.confirmed !== true) throw new Error("Workspace deletion requires confirmation");
|
|
2090
|
+
};
|
|
2091
|
+
var isMissingFile = (error) => error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
2092
|
+
var ensureVolumeLink = async (target, linkPath) => {
|
|
2093
|
+
try {
|
|
2094
|
+
const info = await lstat2(linkPath);
|
|
2095
|
+
if (!info.isSymbolicLink() || await realpath2(linkPath) !== target) {
|
|
2096
|
+
throw new Error("Workspace mount link does not match its canonical volume");
|
|
2097
|
+
}
|
|
2098
|
+
} catch (error) {
|
|
2099
|
+
if (!isMissingFile(error)) throw error;
|
|
2100
|
+
try {
|
|
2101
|
+
await symlink2(target, linkPath, "dir");
|
|
2102
|
+
} catch (createError) {
|
|
2103
|
+
if (!isAlreadyExists3(createError)) throw createError;
|
|
2104
|
+
const info = await lstat2(linkPath);
|
|
2105
|
+
if (!info.isSymbolicLink() || await realpath2(linkPath) !== target) {
|
|
2106
|
+
throw new Error("Workspace mount link raced with an invalid target");
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
};
|
|
2111
|
+
var isAlreadyExists3 = (error) => error instanceof Error && "code" in error && error.code === "EEXIST";
|
|
2112
|
+
|
|
345
2113
|
// src/relay.ts
|
|
346
|
-
|
|
347
|
-
|
|
2114
|
+
var RELAY_PING_INTERVAL_MS = 2e4;
|
|
2115
|
+
var RELAY_PONG_TIMEOUT_MS = 1e4;
|
|
2116
|
+
var RELAY_RECONNECT_MAX_MS = 3e4;
|
|
2117
|
+
var RELAY_RECONNECT_JITTER = 0.2;
|
|
2118
|
+
var RELAY_FAST_RECONNECT_MS = 500;
|
|
2119
|
+
var CONNECTOR_SOCKET_REPLACED_CLOSE_CODE = 4001;
|
|
2120
|
+
var CONNECTOR_SOCKET_REVOKED_CLOSE_CODE = 4003;
|
|
2121
|
+
var shouldReconnectConnectorRelay = (closeCode) => closeCode !== CONNECTOR_SOCKET_REPLACED_CLOSE_CODE && closeCode !== CONNECTOR_SOCKET_REVOKED_CLOSE_CODE;
|
|
2122
|
+
var getConnectorRelayReconnectBaseDelay = (hadOpenConnection, backoffMs) => hadOpenConnection ? RELAY_FAST_RECONNECT_MS : backoffMs;
|
|
2123
|
+
function startConnectorRelay(config, bunnyDaemon, relayProtocol, workspaceRegistry, launchConfig) {
|
|
348
2124
|
let socket = null;
|
|
349
2125
|
let reconnectTimer = null;
|
|
2126
|
+
let pingTimer = null;
|
|
2127
|
+
let pongTimer = null;
|
|
350
2128
|
let stopped = false;
|
|
351
|
-
|
|
2129
|
+
let reconnectDelayMs = RELAY_RECONNECT_INTERVAL_MS;
|
|
2130
|
+
let hadOpenConnection = false;
|
|
2131
|
+
let activeRelayProtocol = relayProtocol;
|
|
2132
|
+
const replayGuard = new ConnectorExecutionReplayGuard();
|
|
2133
|
+
const clearTimers = () => {
|
|
2134
|
+
if (pingTimer) {
|
|
2135
|
+
clearInterval(pingTimer);
|
|
2136
|
+
pingTimer = null;
|
|
2137
|
+
}
|
|
2138
|
+
if (pongTimer) {
|
|
2139
|
+
clearTimeout(pongTimer);
|
|
2140
|
+
pongTimer = null;
|
|
2141
|
+
}
|
|
2142
|
+
};
|
|
2143
|
+
const nextReconnectDelay = () => {
|
|
2144
|
+
const reconnectingEstablishedSocket = hadOpenConnection;
|
|
2145
|
+
const base = getConnectorRelayReconnectBaseDelay(
|
|
2146
|
+
reconnectingEstablishedSocket,
|
|
2147
|
+
reconnectDelayMs
|
|
2148
|
+
);
|
|
2149
|
+
hadOpenConnection = false;
|
|
2150
|
+
reconnectDelayMs = reconnectingEstablishedSocket ? RELAY_RECONNECT_INTERVAL_MS : Math.min(reconnectDelayMs * 2, RELAY_RECONNECT_MAX_MS);
|
|
2151
|
+
const jitter = base * RELAY_RECONNECT_JITTER * (Math.random() * 2 - 1);
|
|
2152
|
+
return Math.max(reconnectingEstablishedSocket ? 250 : 500, Math.round(base + jitter));
|
|
2153
|
+
};
|
|
2154
|
+
const scheduleReconnect = (reason) => {
|
|
2155
|
+
if (stopped || reconnectTimer) return;
|
|
2156
|
+
const delay = nextReconnectDelay();
|
|
2157
|
+
void writeLog("info", `relay reconnecting in ${delay}ms (${reason})`);
|
|
2158
|
+
clearTimers();
|
|
2159
|
+
reconnectTimer = setTimeout(() => {
|
|
2160
|
+
reconnectTimer = null;
|
|
2161
|
+
try {
|
|
2162
|
+
connect();
|
|
2163
|
+
} catch (error) {
|
|
2164
|
+
void writeLog(
|
|
2165
|
+
"warn",
|
|
2166
|
+
`relay reconnect threw: ${error instanceof Error ? error.message : error}`
|
|
2167
|
+
);
|
|
2168
|
+
scheduleReconnect("connect-throw");
|
|
2169
|
+
}
|
|
2170
|
+
}, delay);
|
|
2171
|
+
};
|
|
352
2172
|
const connect = () => {
|
|
353
2173
|
if (stopped) return;
|
|
354
2174
|
const relayUrl = getConnectorRelayUrl(config.serverUrl, config.connectorId);
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
2175
|
+
const oauthToken = launchConfig?.oauthToken?.trim();
|
|
2176
|
+
const token = config.token?.trim() || getConnectorToken(launchConfig);
|
|
2177
|
+
const headers = {};
|
|
2178
|
+
if (oauthToken) headers.Authorization = `Bearer ${oauthToken}`;
|
|
2179
|
+
if (token) headers["X-Buda-Connector-Token"] = token;
|
|
2180
|
+
headers["X-Buda-Connector-Id"] = config.connectorId;
|
|
2181
|
+
if (config.deviceToken) headers["X-Buda-Connector-Device-Token"] = config.deviceToken;
|
|
2182
|
+
const ws = new WebSocket(relayUrl, {
|
|
2183
|
+
headers: Object.keys(headers).length > 0 ? headers : void 0
|
|
358
2184
|
});
|
|
359
|
-
socket
|
|
2185
|
+
socket = ws;
|
|
2186
|
+
const inflight = /* @__PURE__ */ new Map();
|
|
2187
|
+
let detached = false;
|
|
2188
|
+
const detach = () => {
|
|
2189
|
+
if (detached) return;
|
|
2190
|
+
detached = true;
|
|
2191
|
+
ws.removeAllListeners();
|
|
2192
|
+
for (const controller of inflight.values()) controller.abort();
|
|
2193
|
+
inflight.clear();
|
|
2194
|
+
};
|
|
2195
|
+
ws.on("open", () => {
|
|
360
2196
|
void writeLog("info", `relay connected ${relayUrl}`);
|
|
2197
|
+
hadOpenConnection = true;
|
|
2198
|
+
reconnectDelayMs = RELAY_RECONNECT_INTERVAL_MS;
|
|
2199
|
+
pingTimer = setInterval(() => {
|
|
2200
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
2201
|
+
try {
|
|
2202
|
+
ws.ping();
|
|
2203
|
+
} catch (error) {
|
|
2204
|
+
void writeLog(
|
|
2205
|
+
"warn",
|
|
2206
|
+
`relay ping failed: ${error instanceof Error ? error.message : error}`
|
|
2207
|
+
);
|
|
2208
|
+
ws.terminate();
|
|
2209
|
+
return;
|
|
2210
|
+
}
|
|
2211
|
+
if (pongTimer) clearTimeout(pongTimer);
|
|
2212
|
+
pongTimer = setTimeout(() => {
|
|
2213
|
+
void writeLog("warn", "relay pong timeout, terminating");
|
|
2214
|
+
ws.terminate();
|
|
2215
|
+
}, RELAY_PONG_TIMEOUT_MS);
|
|
2216
|
+
}, RELAY_PING_INTERVAL_MS);
|
|
2217
|
+
});
|
|
2218
|
+
ws.on("pong", () => {
|
|
2219
|
+
if (pongTimer) {
|
|
2220
|
+
clearTimeout(pongTimer);
|
|
2221
|
+
pongTimer = null;
|
|
2222
|
+
}
|
|
2223
|
+
});
|
|
2224
|
+
ws.on("message", (raw) => {
|
|
2225
|
+
void handleRelayMessage(
|
|
2226
|
+
ws,
|
|
2227
|
+
bunnyDaemon,
|
|
2228
|
+
raw.toString(),
|
|
2229
|
+
inflight,
|
|
2230
|
+
config.connectorId,
|
|
2231
|
+
activeRelayProtocol,
|
|
2232
|
+
replayGuard,
|
|
2233
|
+
workspaceRegistry
|
|
2234
|
+
);
|
|
361
2235
|
});
|
|
362
|
-
|
|
363
|
-
|
|
2236
|
+
ws.on("close", (code) => {
|
|
2237
|
+
detach();
|
|
2238
|
+
if (!shouldReconnectConnectorRelay(code)) {
|
|
2239
|
+
stopped = true;
|
|
2240
|
+
void writeLog(
|
|
2241
|
+
"warn",
|
|
2242
|
+
code === CONNECTOR_SOCKET_REPLACED_CLOSE_CODE ? "relay replaced by another process for this connector" : "relay access revoked; restore access and restart the Connector to reconnect"
|
|
2243
|
+
);
|
|
2244
|
+
return;
|
|
2245
|
+
}
|
|
364
2246
|
void writeLog("warn", "relay disconnected");
|
|
365
|
-
|
|
366
|
-
reconnectTimer = setTimeout(connect, RELAY_RECONNECT_INTERVAL_MS);
|
|
2247
|
+
scheduleReconnect("close");
|
|
367
2248
|
});
|
|
368
|
-
|
|
2249
|
+
ws.on("error", (error) => {
|
|
369
2250
|
void writeLog("warn", `relay error: ${error instanceof Error ? error.message : error}`);
|
|
2251
|
+
detach();
|
|
2252
|
+
scheduleReconnect("error");
|
|
370
2253
|
});
|
|
371
2254
|
};
|
|
372
|
-
|
|
2255
|
+
try {
|
|
2256
|
+
connect();
|
|
2257
|
+
} catch (error) {
|
|
2258
|
+
void writeLog(
|
|
2259
|
+
"warn",
|
|
2260
|
+
`relay initial connect threw: ${error instanceof Error ? error.message : error}`
|
|
2261
|
+
);
|
|
2262
|
+
scheduleReconnect("connect-throw");
|
|
2263
|
+
}
|
|
373
2264
|
return {
|
|
2265
|
+
updateRelayProtocol(nextRelayProtocol) {
|
|
2266
|
+
const previousKeyId = activeRelayProtocol?.keyId;
|
|
2267
|
+
activeRelayProtocol = nextRelayProtocol;
|
|
2268
|
+
if (nextRelayProtocol?.keyId && nextRelayProtocol.keyId !== previousKeyId) {
|
|
2269
|
+
void writeLog("info", `relay protocol key updated ${nextRelayProtocol.keyId}`);
|
|
2270
|
+
}
|
|
2271
|
+
},
|
|
374
2272
|
stop() {
|
|
375
2273
|
stopped = true;
|
|
376
|
-
if (reconnectTimer)
|
|
2274
|
+
if (reconnectTimer) {
|
|
2275
|
+
clearTimeout(reconnectTimer);
|
|
2276
|
+
reconnectTimer = null;
|
|
2277
|
+
}
|
|
2278
|
+
clearTimers();
|
|
377
2279
|
socket?.close();
|
|
378
2280
|
}
|
|
379
2281
|
};
|
|
380
2282
|
}
|
|
381
|
-
function
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
2283
|
+
async function handleRelayMessage(socket, bunnyDaemon, raw, inflight, expectedConnectorId, relayProtocol, replayGuard, workspaceRegistry) {
|
|
2284
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
|
2285
|
+
let message;
|
|
2286
|
+
try {
|
|
2287
|
+
message = JSON.parse(raw);
|
|
2288
|
+
} catch {
|
|
2289
|
+
await writeLog("warn", "relay received invalid json");
|
|
2290
|
+
return;
|
|
2291
|
+
}
|
|
2292
|
+
if (message.type === "abort") {
|
|
2293
|
+
const controller2 = inflight.get(message.requestId);
|
|
2294
|
+
if (controller2) {
|
|
2295
|
+
void writeLog("info", `relay abort ${message.requestId}`);
|
|
2296
|
+
controller2.abort();
|
|
2297
|
+
}
|
|
2298
|
+
return;
|
|
2299
|
+
}
|
|
2300
|
+
if (message.type === "event") {
|
|
2301
|
+
await writeLog("warn", "relay rejected unsigned browser input event");
|
|
2302
|
+
return;
|
|
2303
|
+
}
|
|
2304
|
+
if (message.type !== "request") return;
|
|
2305
|
+
const hasProtocolVersion = message.protocolVersion !== void 0;
|
|
2306
|
+
const hasExecutionIdentity = message.executionIdentity !== void 0;
|
|
2307
|
+
let executionIdentity;
|
|
2308
|
+
if (hasProtocolVersion || hasExecutionIdentity) {
|
|
2309
|
+
try {
|
|
2310
|
+
verifyConnectorExecutionIdentity({
|
|
2311
|
+
protocolVersion: message.protocolVersion,
|
|
2312
|
+
executionIdentity: message.executionIdentity,
|
|
2313
|
+
request: message.request,
|
|
2314
|
+
protocol: relayProtocol,
|
|
2315
|
+
expectedConnectorId,
|
|
2316
|
+
replayGuard
|
|
2317
|
+
});
|
|
2318
|
+
executionIdentity = message.executionIdentity;
|
|
2319
|
+
} catch (error) {
|
|
2320
|
+
sendRelayError(socket, message.requestId, error);
|
|
2321
|
+
return;
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
const controller = new AbortController();
|
|
2325
|
+
inflight.set(message.requestId, controller);
|
|
2326
|
+
try {
|
|
2327
|
+
const requestPath = message.request.path;
|
|
2328
|
+
const isBrowserRequest = requestPath.startsWith("/browser/") || requestPath === "/browser";
|
|
2329
|
+
const isWorkspaceDescriptorRequest = requestPath === "/workspace/descriptor";
|
|
2330
|
+
const isAgentRequest = isBrowserRequest || requestPath.startsWith("/api/") || isWorkspaceDescriptorRequest;
|
|
2331
|
+
if (isAgentRequest && (!executionIdentity || !workspaceRegistry)) {
|
|
2332
|
+
throw new ConnectorRelayProtocolError("IDENTITY_INVALID");
|
|
2333
|
+
}
|
|
2334
|
+
const descriptor = executionIdentity ? await workspaceRegistry?.resolve(executionIdentity.spaceId, executionIdentity.agentId) : void 0;
|
|
2335
|
+
if (descriptor && (descriptor.spaceId !== executionIdentity?.spaceId || descriptor.agentId !== executionIdentity.agentId)) {
|
|
2336
|
+
throw new ConnectorRelayProtocolError("WORKSPACE_MISMATCH");
|
|
2337
|
+
}
|
|
2338
|
+
const mounts = descriptor ? await new WorkspaceVolumeLifecycle(descriptor).mount() : void 0;
|
|
2339
|
+
let response;
|
|
2340
|
+
if (isWorkspaceDescriptorRequest && descriptor) {
|
|
2341
|
+
response = Response.json({
|
|
2342
|
+
ok: true,
|
|
2343
|
+
data: {
|
|
2344
|
+
workspaceKey: descriptor.workspaceKey,
|
|
2345
|
+
workspacePath: descriptor.privateVolume,
|
|
2346
|
+
sharedPath: descriptor.sharedVolume,
|
|
2347
|
+
systemPath: deriveSystemVolume(descriptor),
|
|
2348
|
+
isolationMode: descriptor.isolationMode
|
|
2349
|
+
}
|
|
2350
|
+
});
|
|
2351
|
+
} else if (isBrowserRequest) {
|
|
2352
|
+
await writeLog("info", `relay browser ${message.request.method} ${requestPath}`);
|
|
2353
|
+
response = await handleBrowserRequest(
|
|
2354
|
+
requestPath,
|
|
2355
|
+
message.request.method,
|
|
2356
|
+
message.request.body,
|
|
2357
|
+
{
|
|
2358
|
+
sessionId: `${descriptor?.workspaceKey}:${getHeaderValue(message.request.headers, "x-buda-browser-session-id") ?? "default"}`,
|
|
2359
|
+
workdirRoot: mounts?.mountRoot
|
|
2360
|
+
}
|
|
2361
|
+
);
|
|
2362
|
+
} else if (descriptor && mounts) {
|
|
2363
|
+
response = await handleWorkspaceDaemonRequest(message.request, mounts, controller.signal);
|
|
2364
|
+
} else {
|
|
2365
|
+
const requestUrl = new URL(requestPath, bunnyDaemon.baseUrl);
|
|
2366
|
+
if (requestUrl.origin !== new URL(bunnyDaemon.baseUrl).origin) {
|
|
2367
|
+
throw new Error("Relay request must target the embedded bunny daemon.");
|
|
2368
|
+
}
|
|
2369
|
+
await writeLog("info", `relay request ${message.request.method} ${requestUrl.pathname}`, {
|
|
2370
|
+
actorEmail: getHeaderValue(message.request.headers, "x-buda-relay-actor-email") || null,
|
|
2371
|
+
actorId: getHeaderValue(message.request.headers, "x-buda-relay-actor-id") || null,
|
|
2372
|
+
source: getHeaderValue(message.request.headers, "x-buda-relay-source") || "unknown",
|
|
2373
|
+
action: getHeaderValue(message.request.headers, "x-buda-relay-action") || "unknown",
|
|
2374
|
+
target: requestUrl.toString()
|
|
2375
|
+
});
|
|
2376
|
+
response = await fetch(requestUrl, {
|
|
2377
|
+
method: message.request.method,
|
|
2378
|
+
headers: {
|
|
2379
|
+
"Content-Type": "application/json",
|
|
2380
|
+
...message.request.headers ?? {}
|
|
2381
|
+
},
|
|
2382
|
+
body: message.request.body === void 0 ? void 0 : JSON.stringify(message.request.body),
|
|
2383
|
+
signal: controller.signal
|
|
2384
|
+
});
|
|
2385
|
+
}
|
|
2386
|
+
sendRelayMessage(socket, {
|
|
2387
|
+
type: "start",
|
|
2388
|
+
requestId: message.requestId,
|
|
2389
|
+
status: response.status,
|
|
2390
|
+
headers: headersToRecord(response.headers)
|
|
394
2391
|
});
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
2392
|
+
if (response.body) {
|
|
2393
|
+
const reader = response.body.getReader();
|
|
2394
|
+
const decoder = new TextDecoder();
|
|
2395
|
+
const cancelReader = () => {
|
|
2396
|
+
void reader.cancel().catch(() => {
|
|
2397
|
+
});
|
|
2398
|
+
};
|
|
2399
|
+
controller.signal.addEventListener("abort", cancelReader, { once: true });
|
|
2400
|
+
if (controller.signal.aborted) cancelReader();
|
|
2401
|
+
try {
|
|
2402
|
+
while (true) {
|
|
2403
|
+
const { value, done } = await reader.read();
|
|
2404
|
+
if (done) break;
|
|
2405
|
+
sendRelayMessage(socket, {
|
|
2406
|
+
type: "chunk",
|
|
2407
|
+
requestId: message.requestId,
|
|
2408
|
+
data: decoder.decode(value, { stream: true })
|
|
2409
|
+
});
|
|
2410
|
+
}
|
|
2411
|
+
const tail = decoder.decode();
|
|
2412
|
+
if (tail) {
|
|
2413
|
+
sendRelayMessage(socket, {
|
|
2414
|
+
type: "chunk",
|
|
2415
|
+
requestId: message.requestId,
|
|
2416
|
+
data: tail
|
|
2417
|
+
});
|
|
2418
|
+
}
|
|
2419
|
+
} finally {
|
|
2420
|
+
controller.signal.removeEventListener("abort", cancelReader);
|
|
2421
|
+
reader.releaseLock();
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
sendRelayMessage(socket, {
|
|
2425
|
+
type: "end",
|
|
2426
|
+
requestId: message.requestId
|
|
402
2427
|
});
|
|
403
|
-
}
|
|
2428
|
+
} catch (error) {
|
|
2429
|
+
sendRelayError(socket, message.requestId, error);
|
|
2430
|
+
} finally {
|
|
2431
|
+
inflight.delete(message.requestId);
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
var handleWorkspaceDaemonRequest = async (request, mounts, signal) => {
|
|
2435
|
+
const normalized = normalizeWorkspaceRequest(request, mounts);
|
|
2436
|
+
const handler = createNextHandler({ root: mounts.mountRoot });
|
|
2437
|
+
return handler(
|
|
2438
|
+
new Request(new URL(normalized.path, "http://connector.workspace"), {
|
|
2439
|
+
method: normalized.method,
|
|
2440
|
+
headers: normalized.headers,
|
|
2441
|
+
body: normalized.body === void 0 ? void 0 : JSON.stringify(normalized.body),
|
|
2442
|
+
signal
|
|
2443
|
+
})
|
|
2444
|
+
);
|
|
2445
|
+
};
|
|
2446
|
+
var normalizeWorkspaceRequest = (request, mounts) => {
|
|
2447
|
+
if (request.path !== "/api/coding/run") return request;
|
|
2448
|
+
if (!request.body || typeof request.body !== "object" || Array.isArray(request.body)) {
|
|
2449
|
+
throw new ConnectorRelayProtocolError("WORKSPACE_MISMATCH");
|
|
2450
|
+
}
|
|
2451
|
+
const body = request.body;
|
|
2452
|
+
const cwd = typeof body.cwd === "string" ? body.cwd : "/agent";
|
|
2453
|
+
const normalized = path6.posix.normalize(cwd);
|
|
2454
|
+
const [mount, relative] = normalized === "/agent" || normalized.startsWith("/agent/") ? [mounts.agent, normalized.slice("/agent".length)] : normalized === "/space" || normalized.startsWith("/space/") ? [mounts.space, normalized.slice("/space".length)] : [];
|
|
2455
|
+
if (!mount || relative === void 0) {
|
|
2456
|
+
throw new ConnectorRelayProtocolError("WORKSPACE_MISMATCH");
|
|
2457
|
+
}
|
|
2458
|
+
return { ...request, body: { ...body, cwd: path6.join(mount, relative) } };
|
|
2459
|
+
};
|
|
2460
|
+
function sendRelayError(socket, requestId, error) {
|
|
2461
|
+
sendRelayMessage(socket, {
|
|
2462
|
+
type: "error",
|
|
2463
|
+
requestId,
|
|
2464
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2465
|
+
...error instanceof ConnectorRelayProtocolError ? { code: error.code } : {}
|
|
2466
|
+
});
|
|
2467
|
+
}
|
|
2468
|
+
function sendRelayMessage(socket, message) {
|
|
2469
|
+
if (socket.readyState !== WebSocket.OPEN) return;
|
|
2470
|
+
socket.send(JSON.stringify(message));
|
|
2471
|
+
}
|
|
2472
|
+
function headersToRecord(headers) {
|
|
2473
|
+
const result = {};
|
|
2474
|
+
headers.forEach((value, key) => {
|
|
2475
|
+
result[key] = value;
|
|
2476
|
+
});
|
|
2477
|
+
return result;
|
|
404
2478
|
}
|
|
405
2479
|
function getHeaderValue(headers, name) {
|
|
406
2480
|
if (!headers) return void 0;
|
|
@@ -411,30 +2485,460 @@ function getHeaderValue(headers, name) {
|
|
|
411
2485
|
return void 0;
|
|
412
2486
|
}
|
|
413
2487
|
|
|
414
|
-
// src/
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
await
|
|
419
|
-
await
|
|
420
|
-
await
|
|
421
|
-
|
|
2488
|
+
// src/workdir-layout.ts
|
|
2489
|
+
import { lstat as lstat3, mkdir as mkdir7, symlink as symlink3, unlink as unlink4 } from "fs/promises";
|
|
2490
|
+
import path7 from "path";
|
|
2491
|
+
async function provisionWorkdirLayout(workdirRoot) {
|
|
2492
|
+
const manifest = await readBundledManifest();
|
|
2493
|
+
await mkdir7(workdirRoot, { recursive: true });
|
|
2494
|
+
await mkdir7(path7.join(workdirRoot, "system", ".agents"), { recursive: true });
|
|
2495
|
+
const systemSkillsLink = path7.join(workdirRoot, "system", ".agents", "skills");
|
|
2496
|
+
const systemScriptsLink = path7.join(workdirRoot, "system", "scripts");
|
|
2497
|
+
await ensureSymlink(manifest.systemSkillsDir, systemSkillsLink);
|
|
2498
|
+
await ensureSymlink(manifest.scriptsDir, systemScriptsLink);
|
|
2499
|
+
await writeLog("info", "workdir layout provisioned", {
|
|
2500
|
+
workdirRoot,
|
|
2501
|
+
systemSkillsLink,
|
|
2502
|
+
systemScriptsLink,
|
|
2503
|
+
bundleVersion: manifest.version
|
|
2504
|
+
});
|
|
2505
|
+
return {
|
|
2506
|
+
workdirRoot,
|
|
2507
|
+
systemSkillsLink,
|
|
2508
|
+
systemScriptsLink,
|
|
2509
|
+
bundleVersion: manifest.version
|
|
2510
|
+
};
|
|
422
2511
|
}
|
|
423
|
-
async function
|
|
424
|
-
const config = await ensureDeviceConfig(launchConfig);
|
|
425
|
-
const bunnyDaemon = await startEmbeddedBunnyDaemon(config, launchConfig);
|
|
2512
|
+
async function ensureSymlink(target, linkPath) {
|
|
426
2513
|
try {
|
|
427
|
-
await
|
|
2514
|
+
const existing = await lstat3(linkPath);
|
|
2515
|
+
if (existing.isSymbolicLink()) {
|
|
2516
|
+
await unlink4(linkPath);
|
|
2517
|
+
} else if (existing.isDirectory()) {
|
|
2518
|
+
await writeLog(
|
|
2519
|
+
"warn",
|
|
2520
|
+
`skipping symlink ${linkPath}: existing directory at path; remove it to enable bundle`
|
|
2521
|
+
);
|
|
2522
|
+
return;
|
|
2523
|
+
} else {
|
|
2524
|
+
await unlink4(linkPath);
|
|
2525
|
+
}
|
|
2526
|
+
} catch {
|
|
2527
|
+
}
|
|
2528
|
+
await mkdir7(path7.dirname(linkPath), { recursive: true });
|
|
2529
|
+
await symlink3(target, linkPath, "dir");
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
// src/workspace-registry.ts
|
|
2533
|
+
import { constants as constants4 } from "fs";
|
|
2534
|
+
import {
|
|
2535
|
+
chmod as chmod3,
|
|
2536
|
+
lstat as lstat4,
|
|
2537
|
+
mkdir as mkdir8,
|
|
2538
|
+
open as open3,
|
|
2539
|
+
readFile as readFile6,
|
|
2540
|
+
realpath as realpath3,
|
|
2541
|
+
rename as rename3,
|
|
2542
|
+
statfs,
|
|
2543
|
+
unlink as unlink5
|
|
2544
|
+
} from "fs/promises";
|
|
2545
|
+
import path8 from "path";
|
|
2546
|
+
var REGISTRY_VERSION = 1;
|
|
2547
|
+
var REGISTRY_DIRECTORY = ".buda";
|
|
2548
|
+
var REGISTRY_FILENAME = "workspace-registry.json";
|
|
2549
|
+
var IMMUTABLE_ID_PATTERN2 = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
2550
|
+
var UNSAFE_FILESYSTEM_TYPES = /* @__PURE__ */ new Set([26985, 20859, 4283649346, 4266872130]);
|
|
2551
|
+
var WorkspaceRegistryError = class extends Error {
|
|
2552
|
+
constructor(code, message) {
|
|
2553
|
+
super(message);
|
|
2554
|
+
this.code = code;
|
|
2555
|
+
this.name = "WorkspaceRegistryError";
|
|
2556
|
+
}
|
|
2557
|
+
recoverable = true;
|
|
2558
|
+
};
|
|
2559
|
+
var LocalWorkspaceRegistry = class _LocalWorkspaceRegistry {
|
|
2560
|
+
constructor(registryPath, storageRoot, document) {
|
|
2561
|
+
this.registryPath = registryPath;
|
|
2562
|
+
this.storageRoot = storageRoot;
|
|
2563
|
+
this.document = document;
|
|
2564
|
+
}
|
|
2565
|
+
static async open(storageRoot) {
|
|
2566
|
+
await mkdir8(storageRoot, { recursive: true, mode: 448 });
|
|
2567
|
+
await rejectUnsafeFilesystem(storageRoot);
|
|
2568
|
+
const canonicalRoot = await canonicalDirectory(storageRoot, "storage root");
|
|
2569
|
+
const registryDirectory = path8.join(canonicalRoot, REGISTRY_DIRECTORY);
|
|
2570
|
+
await ensurePrivateDirectory2(registryDirectory);
|
|
2571
|
+
const registryPath = path8.join(registryDirectory, REGISTRY_FILENAME);
|
|
2572
|
+
const document = await withExclusiveFileLock(registryPath, async () => {
|
|
2573
|
+
let current;
|
|
2574
|
+
try {
|
|
2575
|
+
current = parseRegistry(await readFile6(registryPath, "utf8"));
|
|
2576
|
+
} catch (error) {
|
|
2577
|
+
if (isMissingFile2(error)) {
|
|
2578
|
+
current = { version: REGISTRY_VERSION, storageRoot: canonicalRoot, workspaces: {} };
|
|
2579
|
+
await writeRegistryAtomically(registryPath, current);
|
|
2580
|
+
} else if (error instanceof WorkspaceRegistryError) {
|
|
2581
|
+
throw error;
|
|
2582
|
+
} else {
|
|
2583
|
+
throw corrupt2(`Cannot read local workspace registry: ${errorMessage2(error)}`);
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
if (current.storageRoot !== canonicalRoot) {
|
|
2587
|
+
throw corrupt2("Registry storage root does not match the selected storage root");
|
|
2588
|
+
}
|
|
2589
|
+
await chmod3(registryPath, 384);
|
|
2590
|
+
await reconcileDocument(current, canonicalRoot);
|
|
2591
|
+
return current;
|
|
2592
|
+
});
|
|
2593
|
+
await chmod3(registryPath, 384);
|
|
2594
|
+
return new _LocalWorkspaceRegistry(registryPath, canonicalRoot, document);
|
|
2595
|
+
}
|
|
2596
|
+
async resolve(spaceId, agentId) {
|
|
2597
|
+
validateImmutableId("spaceId", spaceId);
|
|
2598
|
+
validateImmutableId("agentId", agentId);
|
|
2599
|
+
return withExclusiveFileLock(this.registryPath, async () => {
|
|
2600
|
+
const document = await readRegistryDocument(this.registryPath);
|
|
2601
|
+
if (document.storageRoot !== this.storageRoot) {
|
|
2602
|
+
throw corrupt2("Registry storage root does not match the selected storage root");
|
|
2603
|
+
}
|
|
2604
|
+
await reconcileDocument(document, this.storageRoot);
|
|
2605
|
+
const expected = deriveWorkspace(this.storageRoot, spaceId, agentId);
|
|
2606
|
+
const existing = document.workspaces[expected.workspaceKey];
|
|
2607
|
+
if (existing) {
|
|
2608
|
+
assertWorkspaceMatches(existing, expected);
|
|
2609
|
+
await reconcileWorkspace(existing, this.storageRoot);
|
|
2610
|
+
this.document = document;
|
|
2611
|
+
return toDescriptor(existing);
|
|
2612
|
+
}
|
|
2613
|
+
assertNoCollision(document.workspaces, expected);
|
|
2614
|
+
await Promise.all([
|
|
2615
|
+
ensurePrivateDirectory2(expected.privateVolume),
|
|
2616
|
+
ensurePrivateDirectory2(expected.sharedVolume),
|
|
2617
|
+
ensurePrivateDirectory2(expected.stateVolume),
|
|
2618
|
+
ensurePrivateDirectory2(expected.temporaryVolume)
|
|
2619
|
+
]);
|
|
2620
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2621
|
+
const workspace = { ...expected, createdAt: now, verifiedAt: now };
|
|
2622
|
+
document.workspaces[workspace.workspaceKey] = workspace;
|
|
2623
|
+
await writeRegistryAtomically(this.registryPath, document);
|
|
2624
|
+
this.document = document;
|
|
2625
|
+
return toDescriptor(workspace);
|
|
2626
|
+
});
|
|
2627
|
+
}
|
|
2628
|
+
sanitizedMetadata() {
|
|
2629
|
+
return Object.values(this.document.workspaces).map((workspace) => ({
|
|
2630
|
+
workspaceKey: workspace.workspaceKey,
|
|
2631
|
+
spaceId: workspace.spaceId,
|
|
2632
|
+
agentId: workspace.agentId,
|
|
2633
|
+
isolationMode: "limited",
|
|
2634
|
+
verifiedAt: workspace.verifiedAt
|
|
2635
|
+
}));
|
|
2636
|
+
}
|
|
2637
|
+
};
|
|
2638
|
+
var readRegistryDocument = async (registryPath) => {
|
|
2639
|
+
try {
|
|
2640
|
+
return parseRegistry(await readFile6(registryPath, "utf8"));
|
|
428
2641
|
} catch (error) {
|
|
429
|
-
|
|
2642
|
+
if (error instanceof WorkspaceRegistryError) throw error;
|
|
2643
|
+
throw corrupt2(`Cannot read local workspace registry: ${errorMessage2(error)}`);
|
|
2644
|
+
}
|
|
2645
|
+
};
|
|
2646
|
+
var deriveWorkspace = (storageRoot, spaceId, agentId) => {
|
|
2647
|
+
const spaceRoot = path8.join(storageRoot, REGISTRY_DIRECTORY, "spaces", spaceId);
|
|
2648
|
+
const agentRoot = path8.join(spaceRoot, "agents", agentId);
|
|
2649
|
+
return {
|
|
2650
|
+
workspaceKey: `${spaceId}:${agentId}`,
|
|
2651
|
+
spaceId,
|
|
2652
|
+
agentId,
|
|
2653
|
+
privateVolume: path8.join(agentRoot, "workspace"),
|
|
2654
|
+
sharedVolume: path8.join(spaceRoot, "shared"),
|
|
2655
|
+
stateVolume: path8.join(agentRoot, "state"),
|
|
2656
|
+
temporaryVolume: path8.join(agentRoot, "tmp")
|
|
2657
|
+
};
|
|
2658
|
+
};
|
|
2659
|
+
var parseRegistry = (raw) => {
|
|
2660
|
+
let value;
|
|
2661
|
+
try {
|
|
2662
|
+
value = JSON.parse(raw);
|
|
2663
|
+
} catch {
|
|
2664
|
+
throw corrupt2("Local workspace registry contains invalid JSON");
|
|
2665
|
+
}
|
|
2666
|
+
if (!isRecord(value) || value.version !== REGISTRY_VERSION) {
|
|
2667
|
+
throw corrupt2("Local workspace registry has an unsupported version");
|
|
2668
|
+
}
|
|
2669
|
+
if (typeof value.storageRoot !== "string" || !isRecord(value.workspaces)) {
|
|
2670
|
+
throw corrupt2("Local workspace registry is missing required fields");
|
|
2671
|
+
}
|
|
2672
|
+
for (const workspace of Object.values(value.workspaces)) {
|
|
2673
|
+
if (!isRegistryWorkspace(workspace)) {
|
|
2674
|
+
throw corrupt2("Local workspace registry contains a malformed workspace entry");
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
return value;
|
|
2678
|
+
};
|
|
2679
|
+
var reconcileDocument = async (document, storageRoot) => {
|
|
2680
|
+
const seenPrivateVolumes = /* @__PURE__ */ new Set();
|
|
2681
|
+
for (const [key, workspace] of Object.entries(document.workspaces)) {
|
|
2682
|
+
validateStoredId("spaceId", workspace.spaceId);
|
|
2683
|
+
validateStoredId("agentId", workspace.agentId);
|
|
2684
|
+
const expected = deriveWorkspace(storageRoot, workspace.spaceId, workspace.agentId);
|
|
2685
|
+
if (key !== workspace.workspaceKey) throw corrupt2("Workspace registry key is inconsistent");
|
|
2686
|
+
if (seenPrivateVolumes.has(workspace.privateVolume)) {
|
|
2687
|
+
throw collision("Two Agents map to the same private workspace");
|
|
2688
|
+
}
|
|
2689
|
+
seenPrivateVolumes.add(workspace.privateVolume);
|
|
2690
|
+
assertWorkspaceMatches(workspace, expected);
|
|
2691
|
+
await reconcileWorkspace(workspace, storageRoot);
|
|
2692
|
+
}
|
|
2693
|
+
};
|
|
2694
|
+
var reconcileWorkspace = async (workspace, storageRoot) => {
|
|
2695
|
+
const volumes = [
|
|
2696
|
+
workspace.privateVolume,
|
|
2697
|
+
workspace.sharedVolume,
|
|
2698
|
+
workspace.stateVolume,
|
|
2699
|
+
workspace.temporaryVolume
|
|
2700
|
+
];
|
|
2701
|
+
for (const volume of volumes) {
|
|
2702
|
+
assertContained(storageRoot, volume);
|
|
2703
|
+
await ensurePrivateDirectory2(volume);
|
|
2704
|
+
const canonical = await realpath3(volume);
|
|
2705
|
+
if (canonical !== volume) throw corrupt2("Workspace path resolves through a symbolic link");
|
|
2706
|
+
}
|
|
2707
|
+
};
|
|
2708
|
+
var assertWorkspaceMatches = (actual, expected) => {
|
|
2709
|
+
for (const field of [
|
|
2710
|
+
"workspaceKey",
|
|
2711
|
+
"spaceId",
|
|
2712
|
+
"agentId",
|
|
2713
|
+
"privateVolume",
|
|
2714
|
+
"sharedVolume",
|
|
2715
|
+
"stateVolume",
|
|
2716
|
+
"temporaryVolume"
|
|
2717
|
+
]) {
|
|
2718
|
+
if (actual[field] !== expected[field]) {
|
|
2719
|
+
throw corrupt2(`Workspace registry ${field} does not match its canonical value`);
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
};
|
|
2723
|
+
var assertNoCollision = (workspaces, candidate) => {
|
|
2724
|
+
for (const existing of Object.values(workspaces)) {
|
|
2725
|
+
if (existing.privateVolume === candidate.privateVolume) {
|
|
2726
|
+
throw collision("Two Agents map to the same private workspace");
|
|
2727
|
+
}
|
|
2728
|
+
for (const left of [existing.privateVolume, existing.sharedVolume]) {
|
|
2729
|
+
for (const right of [candidate.privateVolume, candidate.sharedVolume]) {
|
|
2730
|
+
if (left !== right && (isPathInside2(left, right) || isPathInside2(right, left))) {
|
|
2731
|
+
throw collision("Workspace mappings overlap");
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
};
|
|
2737
|
+
var ensurePrivateDirectory2 = async (directory) => {
|
|
2738
|
+
await mkdir8(directory, { recursive: true, mode: 448 });
|
|
2739
|
+
const info = await lstat4(directory);
|
|
2740
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
2741
|
+
throw corrupt2("Workspace registry path is not a real directory");
|
|
2742
|
+
}
|
|
2743
|
+
await chmod3(directory, 448);
|
|
2744
|
+
};
|
|
2745
|
+
var canonicalDirectory = async (directory, label) => {
|
|
2746
|
+
const info = await lstat4(directory);
|
|
2747
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
2748
|
+
throw new WorkspaceRegistryError("STORAGE_UNSAFE", `${label} must be a real directory`);
|
|
2749
|
+
}
|
|
2750
|
+
return realpath3(directory);
|
|
2751
|
+
};
|
|
2752
|
+
var rejectUnsafeFilesystem = async (storageRoot) => {
|
|
2753
|
+
const stats = await statfs(storageRoot, { bigint: true });
|
|
2754
|
+
const type = Number(stats.type);
|
|
2755
|
+
if (UNSAFE_FILESYSTEM_TYPES.has(type)) {
|
|
2756
|
+
throw new WorkspaceRegistryError(
|
|
2757
|
+
"STORAGE_UNSAFE",
|
|
2758
|
+
"Storage root uses a network filesystem that cannot guarantee workspace isolation"
|
|
2759
|
+
);
|
|
2760
|
+
}
|
|
2761
|
+
};
|
|
2762
|
+
var writeRegistryAtomically = async (registryPath, document) => {
|
|
2763
|
+
const temporaryPath = `${registryPath}.${process.pid}.${Date.now()}.tmp`;
|
|
2764
|
+
const handle = await open3(
|
|
2765
|
+
temporaryPath,
|
|
2766
|
+
constants4.O_CREAT | constants4.O_EXCL | constants4.O_WRONLY,
|
|
2767
|
+
384
|
|
2768
|
+
);
|
|
2769
|
+
try {
|
|
2770
|
+
await handle.writeFile(`${JSON.stringify(document, null, 2)}
|
|
2771
|
+
`, "utf8");
|
|
2772
|
+
await handle.sync();
|
|
2773
|
+
} finally {
|
|
2774
|
+
await handle.close();
|
|
2775
|
+
}
|
|
2776
|
+
try {
|
|
2777
|
+
await rename3(temporaryPath, registryPath);
|
|
2778
|
+
await chmod3(registryPath, 384);
|
|
2779
|
+
} catch (error) {
|
|
2780
|
+
await unlink5(temporaryPath).catch(() => void 0);
|
|
430
2781
|
throw error;
|
|
431
2782
|
}
|
|
432
|
-
|
|
2783
|
+
};
|
|
2784
|
+
var validateImmutableId = (label, value) => {
|
|
2785
|
+
if (!IMMUTABLE_ID_PATTERN2.test(value)) {
|
|
2786
|
+
throw new WorkspaceRegistryError(
|
|
2787
|
+
"WORKSPACE_INVALID",
|
|
2788
|
+
`${label} must contain only letters, numbers, underscores, or hyphens`
|
|
2789
|
+
);
|
|
2790
|
+
}
|
|
2791
|
+
};
|
|
2792
|
+
var validateStoredId = (label, value) => {
|
|
2793
|
+
try {
|
|
2794
|
+
validateImmutableId(label, value);
|
|
2795
|
+
} catch (error) {
|
|
2796
|
+
throw corrupt2(errorMessage2(error));
|
|
2797
|
+
}
|
|
2798
|
+
};
|
|
2799
|
+
var assertContained = (root, candidate) => {
|
|
2800
|
+
if (!isPathInside2(root, candidate)) throw corrupt2("Workspace path escapes the storage root");
|
|
2801
|
+
};
|
|
2802
|
+
var isPathInside2 = (parent, child) => {
|
|
2803
|
+
const relative = path8.relative(parent, child);
|
|
2804
|
+
return relative !== "" && relative !== ".." && !relative.startsWith(`..${path8.sep}`) && !path8.isAbsolute(relative);
|
|
2805
|
+
};
|
|
2806
|
+
var toDescriptor = (workspace) => ({
|
|
2807
|
+
workspaceKey: workspace.workspaceKey,
|
|
2808
|
+
spaceId: workspace.spaceId,
|
|
2809
|
+
agentId: workspace.agentId,
|
|
2810
|
+
privateVolume: workspace.privateVolume,
|
|
2811
|
+
sharedVolume: workspace.sharedVolume,
|
|
2812
|
+
stateVolume: workspace.stateVolume,
|
|
2813
|
+
temporaryVolume: workspace.temporaryVolume,
|
|
2814
|
+
isolationMode: "limited"
|
|
2815
|
+
});
|
|
2816
|
+
var isRegistryWorkspace = (value) => isRecord(value) && [
|
|
2817
|
+
"workspaceKey",
|
|
2818
|
+
"spaceId",
|
|
2819
|
+
"agentId",
|
|
2820
|
+
"privateVolume",
|
|
2821
|
+
"sharedVolume",
|
|
2822
|
+
"stateVolume",
|
|
2823
|
+
"temporaryVolume",
|
|
2824
|
+
"createdAt",
|
|
2825
|
+
"verifiedAt"
|
|
2826
|
+
].every((field) => typeof value[field] === "string");
|
|
2827
|
+
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2828
|
+
var isMissingFile2 = (error) => isRecord(error) && "code" in error && error.code === "ENOENT";
|
|
2829
|
+
var errorMessage2 = (error) => error instanceof Error ? error.message : String(error);
|
|
2830
|
+
var corrupt2 = (message) => new WorkspaceRegistryError("REGISTRY_CORRUPT", message);
|
|
2831
|
+
var collision = (message) => new WorkspaceRegistryError("WORKSPACE_COLLISION", message);
|
|
2832
|
+
|
|
2833
|
+
// src/onboard.ts
|
|
2834
|
+
async function runOnboard(launchConfig) {
|
|
2835
|
+
intro("Welcome to Buda Connector");
|
|
2836
|
+
const existing = await readDeviceConfig(launchConfig);
|
|
2837
|
+
let relayProtocol;
|
|
2838
|
+
if (existing) {
|
|
2839
|
+
log.info(`Already registered: ${existing.connectorId}`);
|
|
2840
|
+
log.info(`Server: ${existing.serverUrl}`);
|
|
2841
|
+
log.info(`Workdir: ${existing.workdirRoot}`);
|
|
2842
|
+
log.step("Starting daemon with existing config...");
|
|
2843
|
+
} else {
|
|
2844
|
+
log.step("First-time setup");
|
|
2845
|
+
const serverUrl = launchConfig?.serverUrl || await (async () => {
|
|
2846
|
+
const answer = await text({
|
|
2847
|
+
message: "Server URL",
|
|
2848
|
+
placeholder: DEFAULT_SERVER_URL,
|
|
2849
|
+
defaultValue: DEFAULT_SERVER_URL
|
|
2850
|
+
});
|
|
2851
|
+
if (isCancel(answer)) {
|
|
2852
|
+
cancel("Setup cancelled.");
|
|
2853
|
+
process.exit(0);
|
|
2854
|
+
}
|
|
2855
|
+
return answer;
|
|
2856
|
+
})();
|
|
2857
|
+
const oauthToken = launchConfig?.oauthToken || await (async () => {
|
|
2858
|
+
const answer = await text({
|
|
2859
|
+
message: "Auth token (from buda.im \u2192 Settings \u2192 Connectors)",
|
|
2860
|
+
placeholder: "paste your token here",
|
|
2861
|
+
validate: (value) => {
|
|
2862
|
+
if (!value || !value.trim()) return "Token is required";
|
|
2863
|
+
}
|
|
2864
|
+
});
|
|
2865
|
+
if (isCancel(answer)) {
|
|
2866
|
+
cancel("Setup cancelled.");
|
|
2867
|
+
process.exit(0);
|
|
2868
|
+
}
|
|
2869
|
+
return answer;
|
|
2870
|
+
})();
|
|
2871
|
+
launchConfig = { ...launchConfig, serverUrl, oauthToken };
|
|
2872
|
+
const s2 = spinner();
|
|
2873
|
+
s2.start("Registering connector...");
|
|
2874
|
+
const config2 = await createDeviceConfig(launchConfig);
|
|
2875
|
+
const paths2 = getConnectorPaths(config2.serverUrl);
|
|
2876
|
+
await migrateLegacyWorkspaceRoot(config2.workdirRoot);
|
|
2877
|
+
await provisionWorkdirLayout(config2.workdirRoot);
|
|
2878
|
+
try {
|
|
2879
|
+
const registration = await registerConnector(config2, launchConfig, void 0, {
|
|
2880
|
+
recoverMissingProfile: true
|
|
2881
|
+
});
|
|
2882
|
+
config2.connectorId = registration.connectorId;
|
|
2883
|
+
relayProtocol = registration.relayProtocol;
|
|
2884
|
+
await writeDeviceConfig(config2);
|
|
2885
|
+
} catch (error) {
|
|
2886
|
+
s2.stop("Registration failed");
|
|
2887
|
+
log.error(error instanceof Error ? error.message : String(error));
|
|
2888
|
+
process.exit(1);
|
|
2889
|
+
}
|
|
2890
|
+
s2.stop("Registered successfully");
|
|
2891
|
+
note(
|
|
2892
|
+
[
|
|
2893
|
+
`Connector ID: ${config2.connectorId}`,
|
|
2894
|
+
`Config: ${paths2.configPath}`,
|
|
2895
|
+
`Workdir: ${config2.workdirRoot}`
|
|
2896
|
+
].join("\n"),
|
|
2897
|
+
"Device info"
|
|
2898
|
+
);
|
|
2899
|
+
}
|
|
2900
|
+
const config = await ensureDeviceConfig(launchConfig);
|
|
2901
|
+
const paths = getConnectorPaths(config.serverUrl);
|
|
2902
|
+
await migrateLegacyWorkspaceRoot(config.workdirRoot);
|
|
2903
|
+
await provisionWorkdirLayout(config.workdirRoot);
|
|
2904
|
+
const workspaceRegistry = await LocalWorkspaceRegistry.open(config.workdirRoot);
|
|
2905
|
+
const s = spinner();
|
|
2906
|
+
s.start("Starting daemon...");
|
|
2907
|
+
const bunnyDaemon = await startEmbeddedBunnyDaemon(config, launchConfig);
|
|
2908
|
+
if (!existing) {
|
|
2909
|
+
} else {
|
|
2910
|
+
try {
|
|
2911
|
+
relayProtocol = (await registerConnector(config, launchConfig, bunnyDaemon)).relayProtocol;
|
|
2912
|
+
} catch (error) {
|
|
2913
|
+
await stopEmbeddedBunnyDaemon(bunnyDaemon);
|
|
2914
|
+
s.stop("Failed to register");
|
|
2915
|
+
log.error(error instanceof Error ? error.message : String(error));
|
|
2916
|
+
process.exit(1);
|
|
2917
|
+
}
|
|
2918
|
+
}
|
|
2919
|
+
const relay = startConnectorRelay(
|
|
2920
|
+
config,
|
|
2921
|
+
bunnyDaemon,
|
|
2922
|
+
relayProtocol,
|
|
2923
|
+
workspaceRegistry,
|
|
2924
|
+
launchConfig
|
|
2925
|
+
);
|
|
2926
|
+
s.stop("Daemon running");
|
|
433
2927
|
await writeLog("info", `server ${config.serverUrl}`);
|
|
434
|
-
await writeLog("info", `config ${
|
|
2928
|
+
await writeLog("info", `config ${paths.configPath}`);
|
|
435
2929
|
await writeLog("info", `workdir root ${config.workdirRoot}`);
|
|
436
|
-
await writeLog("info", `log file ${LOG_PATH}`);
|
|
437
2930
|
await writeLog("info", `bunny daemon ${bunnyDaemon.baseUrl}`);
|
|
2931
|
+
await writeLog("info", `log file ${paths.logPath}`);
|
|
2932
|
+
note(
|
|
2933
|
+
[
|
|
2934
|
+
`Server: ${config.serverUrl}`,
|
|
2935
|
+
`Bunny daemon: ${bunnyDaemon.baseUrl}`,
|
|
2936
|
+
`Relay: connected`,
|
|
2937
|
+
`Logs: ${paths.logPath}`
|
|
2938
|
+
].join("\n"),
|
|
2939
|
+
"Status"
|
|
2940
|
+
);
|
|
2941
|
+
outro("Buda Connector is running. Press Ctrl+C to stop.");
|
|
438
2942
|
const interval = setInterval(() => {
|
|
439
2943
|
heartbeatConnector(config, launchConfig, bunnyDaemon).catch((error) => {
|
|
440
2944
|
void writeLog(
|
|
@@ -454,10 +2958,117 @@ async function runConnectorDaemon(launchConfig) {
|
|
|
454
2958
|
process.on("SIGTERM", shutdown);
|
|
455
2959
|
}
|
|
456
2960
|
|
|
2961
|
+
// src/runtime.ts
|
|
2962
|
+
import { mkdir as mkdir9 } from "fs/promises";
|
|
2963
|
+
import path9 from "path";
|
|
2964
|
+
async function runNewConnector(launchConfig) {
|
|
2965
|
+
const existing = await readDeviceConfig(launchConfig);
|
|
2966
|
+
const config = await ensureDeviceConfig(launchConfig);
|
|
2967
|
+
const paths = getConnectorPaths(config.serverUrl);
|
|
2968
|
+
await migrateLegacyWorkspaceRoot(config.workdirRoot);
|
|
2969
|
+
await provisionWorkdirLayout(config.workdirRoot);
|
|
2970
|
+
await LocalWorkspaceRegistry.open(config.workdirRoot);
|
|
2971
|
+
const registration = await registerConnector(config, launchConfig, void 0, {
|
|
2972
|
+
recoverMissingProfile: !existing
|
|
2973
|
+
});
|
|
2974
|
+
config.connectorId = registration.connectorId;
|
|
2975
|
+
await writeDeviceConfig(config);
|
|
2976
|
+
await writeLog("info", `wrote ${paths.configPath}`);
|
|
2977
|
+
await writeLog("info", `workdir root ${config.workdirRoot}`);
|
|
2978
|
+
await writeLog("info", `log file ${paths.logPath}`);
|
|
2979
|
+
}
|
|
2980
|
+
async function runConnectorDaemon(launchConfig) {
|
|
2981
|
+
const existing = await readDeviceConfig(launchConfig);
|
|
2982
|
+
const config = await ensureDeviceConfig(launchConfig);
|
|
2983
|
+
const paths = getConnectorPaths(config.serverUrl);
|
|
2984
|
+
const daemonLockTarget = getConnectorDaemonLockTarget(config.serverUrl, config.connectorId);
|
|
2985
|
+
await mkdir9(path9.dirname(daemonLockTarget), { recursive: true, mode: 448 });
|
|
2986
|
+
let daemonLock = await acquireExclusiveFileLock(daemonLockTarget, { timeoutMs: 0 }).catch(() => {
|
|
2987
|
+
throw new Error(
|
|
2988
|
+
`Another Buda Connector daemon is already running for this profile on ${config.serverUrl}. Stop it before starting another instance.`
|
|
2989
|
+
);
|
|
2990
|
+
});
|
|
2991
|
+
let bunnyDaemon;
|
|
2992
|
+
let relayProtocol;
|
|
2993
|
+
try {
|
|
2994
|
+
await migrateLegacyWorkspaceRoot(config.workdirRoot);
|
|
2995
|
+
await provisionWorkdirLayout(config.workdirRoot);
|
|
2996
|
+
const workspaceRegistry = await LocalWorkspaceRegistry.open(config.workdirRoot);
|
|
2997
|
+
bunnyDaemon = await startEmbeddedBunnyDaemon(config, launchConfig);
|
|
2998
|
+
const activeBunnyDaemon = bunnyDaemon;
|
|
2999
|
+
const registration = await registerConnector(config, launchConfig, activeBunnyDaemon, {
|
|
3000
|
+
recoverMissingProfile: !existing
|
|
3001
|
+
});
|
|
3002
|
+
const connectorIdChanged = config.connectorId !== registration.connectorId;
|
|
3003
|
+
if (connectorIdChanged) {
|
|
3004
|
+
const registeredLockTarget = getConnectorDaemonLockTarget(
|
|
3005
|
+
config.serverUrl,
|
|
3006
|
+
registration.connectorId
|
|
3007
|
+
);
|
|
3008
|
+
await mkdir9(path9.dirname(registeredLockTarget), { recursive: true, mode: 448 });
|
|
3009
|
+
const registeredLock = await acquireExclusiveFileLock(registeredLockTarget, {
|
|
3010
|
+
timeoutMs: 0
|
|
3011
|
+
}).catch(() => {
|
|
3012
|
+
throw new Error(
|
|
3013
|
+
`Another Buda Connector daemon is already running for this profile on ${config.serverUrl}. Stop it before starting another instance.`
|
|
3014
|
+
);
|
|
3015
|
+
});
|
|
3016
|
+
await daemonLock.release();
|
|
3017
|
+
daemonLock = registeredLock;
|
|
3018
|
+
}
|
|
3019
|
+
config.connectorId = registration.connectorId;
|
|
3020
|
+
relayProtocol = registration.relayProtocol;
|
|
3021
|
+
if (!existing || connectorIdChanged) await writeDeviceConfig(config);
|
|
3022
|
+
const relay = startConnectorRelay(
|
|
3023
|
+
config,
|
|
3024
|
+
activeBunnyDaemon,
|
|
3025
|
+
relayProtocol,
|
|
3026
|
+
workspaceRegistry,
|
|
3027
|
+
launchConfig
|
|
3028
|
+
);
|
|
3029
|
+
await writeLog("info", `server ${config.serverUrl}`);
|
|
3030
|
+
await writeLog("info", `config ${paths.configPath}`);
|
|
3031
|
+
await writeLog("info", `workdir root ${config.workdirRoot}`);
|
|
3032
|
+
await writeLog("info", `log file ${paths.logPath}`);
|
|
3033
|
+
await writeLog("info", `bunny daemon ${activeBunnyDaemon.baseUrl}`);
|
|
3034
|
+
const interval = setInterval(() => {
|
|
3035
|
+
heartbeatConnector(config, launchConfig, activeBunnyDaemon).then(({ relayProtocol: nextRelayProtocol }) => {
|
|
3036
|
+
relay.updateRelayProtocol(nextRelayProtocol);
|
|
3037
|
+
}).catch((error) => {
|
|
3038
|
+
void writeLog(
|
|
3039
|
+
"warn",
|
|
3040
|
+
`heartbeat failed: ${error instanceof Error ? error.message : String(error)}`
|
|
3041
|
+
);
|
|
3042
|
+
});
|
|
3043
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
3044
|
+
let shuttingDown = false;
|
|
3045
|
+
const shutdown = async () => {
|
|
3046
|
+
if (shuttingDown) return;
|
|
3047
|
+
shuttingDown = true;
|
|
3048
|
+
clearInterval(interval);
|
|
3049
|
+
relay.stop();
|
|
3050
|
+
try {
|
|
3051
|
+
await closeAllBrowserSessions();
|
|
3052
|
+
await unregisterConnector(config, launchConfig);
|
|
3053
|
+
await stopEmbeddedBunnyDaemon(activeBunnyDaemon);
|
|
3054
|
+
} finally {
|
|
3055
|
+
await daemonLock.release();
|
|
3056
|
+
process.exit(0);
|
|
3057
|
+
}
|
|
3058
|
+
};
|
|
3059
|
+
process.once("SIGINT", () => void shutdown());
|
|
3060
|
+
process.once("SIGTERM", () => void shutdown());
|
|
3061
|
+
} catch (error) {
|
|
3062
|
+
if (bunnyDaemon) await stopEmbeddedBunnyDaemon(bunnyDaemon);
|
|
3063
|
+
await daemonLock.release();
|
|
3064
|
+
throw error;
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
|
|
457
3068
|
// src/cli.ts
|
|
458
|
-
function
|
|
3069
|
+
function printHelp() {
|
|
459
3070
|
console.log(
|
|
460
|
-
"
|
|
3071
|
+
"Help: buda-connector [onboard | new | dev | daemon] [--token <token>] [--server-url <url>] [--workdir <path>] [--launch-config-stdin]\n pnpm --filter @buda-ai/connector dev\n\nCommands (default: onboard in TTY, daemon otherwise):\n onboard Interactive first-time setup + start daemon\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
3072
|
);
|
|
462
3073
|
}
|
|
463
3074
|
function readFlagValue(name) {
|
|
@@ -471,8 +3082,8 @@ function readCliLaunchConfig() {
|
|
|
471
3082
|
return {
|
|
472
3083
|
serverUrl: readFlagValue("--server-url"),
|
|
473
3084
|
workdirRoot: readFlagValue("--workdir"),
|
|
474
|
-
oauthToken: readFlagValue("--token"),
|
|
475
|
-
|
|
3085
|
+
oauthToken: readFlagValue("--oauth-token"),
|
|
3086
|
+
token: readFlagValue("--token"),
|
|
476
3087
|
bunnyDaemonHost: readFlagValue("--bunny-daemon-host"),
|
|
477
3088
|
bunnyDaemonPort: bunnyDaemonPort && Number.isInteger(Number(bunnyDaemonPort)) ? Number(bunnyDaemonPort) : void 0
|
|
478
3089
|
};
|
|
@@ -509,21 +3120,34 @@ async function readLaunchConfig() {
|
|
|
509
3120
|
});
|
|
510
3121
|
}
|
|
511
3122
|
async function main() {
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
printUsage();
|
|
3123
|
+
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
3124
|
+
printHelp();
|
|
515
3125
|
return;
|
|
516
3126
|
}
|
|
3127
|
+
const rawCommand = process.argv[2];
|
|
3128
|
+
const isTTY = process.stdin.isTTY && process.stdout.isTTY;
|
|
3129
|
+
const defaultCommand = isTTY ? "onboard" : "daemon";
|
|
3130
|
+
const command = !rawCommand || rawCommand.startsWith("-") ? defaultCommand : rawCommand;
|
|
3131
|
+
process.on("unhandledRejection", (reason) => {
|
|
3132
|
+
console.error("[buda-connector] unhandledRejection:", reason);
|
|
3133
|
+
});
|
|
3134
|
+
process.on("uncaughtException", (error) => {
|
|
3135
|
+
console.error("[buda-connector] uncaughtException:", error);
|
|
3136
|
+
});
|
|
517
3137
|
const launchConfig = await readLaunchConfig();
|
|
518
3138
|
if (command === "new") {
|
|
519
3139
|
await runNewConnector(launchConfig);
|
|
520
3140
|
return;
|
|
521
3141
|
}
|
|
3142
|
+
if (command === "onboard") {
|
|
3143
|
+
await runOnboard(launchConfig);
|
|
3144
|
+
return;
|
|
3145
|
+
}
|
|
522
3146
|
if (command === "dev" || command === "daemon") {
|
|
523
3147
|
await runConnectorDaemon(launchConfig);
|
|
524
3148
|
return;
|
|
525
3149
|
}
|
|
526
|
-
|
|
3150
|
+
printHelp();
|
|
527
3151
|
process.exit(1);
|
|
528
3152
|
}
|
|
529
3153
|
main().catch((error) => {
|