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