@ricsam/r5d-worker 0.0.10 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cjs/main.cjs CHANGED
@@ -29,7 +29,9 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
29
29
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
30
30
  var main_exports = {};
31
31
  __export(main_exports, {
32
- ensureVisibleGitCheckout: () => ensureVisibleGitCheckout
32
+ ensureVisibleGitCheckout: () => ensureVisibleGitCheckout,
33
+ isArtifactEnvPath: () => isArtifactEnvPath,
34
+ syncSessionArtifacts: () => syncSessionArtifacts
33
35
  });
34
36
  module.exports = __toCommonJS(main_exports);
35
37
  var import_node_fs = __toESM(require("node:fs"), 1);
@@ -64,6 +66,9 @@ function defaultProjectsRoot() {
64
66
  function defaultSyncRoot() {
65
67
  return import_node_path.default.join(import_node_os.default.homedir(), ".r5d", "sync");
66
68
  }
69
+ function defaultArtifactRoot() {
70
+ return import_node_path.default.join(import_node_os.default.homedir(), ".r5d", "artifacts");
71
+ }
67
72
  function printHelp() {
68
73
  process.stdout.write(`Usage:
69
74
  r5d-worker start --label <label> [--base-url <url>] [--token <token>] [--api-key <key>]
@@ -77,6 +82,7 @@ Options:
77
82
  --config <path> Config path (default ~/.config/r5d/r5dctl/config.json)
78
83
  --project-root <dir> Override projects checkout root (default ~/.r5d/projects)
79
84
  --sync-root <dir> Override r5d internal sync git root (default ~/.r5d/sync)
85
+ --artifact-root <dir> Override session artifact root (default from R5D_ARTIFACTS_ROOT or ~/.r5d/artifacts)
80
86
  -v, --version Show r5d-worker version
81
87
  -h, --help Show this help
82
88
  `);
@@ -193,6 +199,11 @@ function parseArgs(argv) {
193
199
  options.syncRoot = syncRoot;
194
200
  continue;
195
201
  }
202
+ const artifactRoot = readOption("--artifact-root");
203
+ if (artifactRoot !== void 0) {
204
+ options.artifactRoot = artifactRoot;
205
+ continue;
206
+ }
196
207
  rest.push(arg);
197
208
  }
198
209
  if (options.version) {
@@ -235,6 +246,170 @@ async function readResponseText(response) {
235
246
  return "";
236
247
  }
237
248
  }
249
+ const R5D_ARTIFACTS_DIR_REF = "$R5D_ARTIFACTS_DIR";
250
+ function validateArtifactSessionId(sessionId) {
251
+ if (!/^(migration-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) {
252
+ throw new Error(`Invalid artifact session id: ${sessionId}`);
253
+ }
254
+ }
255
+ function assertInsideRoot(rootPath, candidatePath, label) {
256
+ const relative = import_node_path.default.relative(rootPath, candidatePath);
257
+ if (relative === ".." || relative.startsWith(`..${import_node_path.default.sep}`) || import_node_path.default.isAbsolute(relative)) {
258
+ throw new Error(`${label} escapes the artifact directory`);
259
+ }
260
+ }
261
+ function sessionArtifactDir(artifactRoot, sessionId) {
262
+ validateArtifactSessionId(sessionId);
263
+ return import_node_path.default.join(artifactRoot, sessionId);
264
+ }
265
+ function artifactEnv(artifactRoot, sessionId) {
266
+ if (!sessionId) {
267
+ return {};
268
+ }
269
+ return {
270
+ R5D_ARTIFACTS_DIR: sessionArtifactDir(artifactRoot, sessionId)
271
+ };
272
+ }
273
+ function isArtifactEnvPath(filePath) {
274
+ return filePath === R5D_ARTIFACTS_DIR_REF || filePath.startsWith(`${R5D_ARTIFACTS_DIR_REF}/`);
275
+ }
276
+ function artifactApiBasePath(projectId, branchName, sessionId) {
277
+ return `/preview-api/${encodeURIComponent(projectId)}/${encodeURIComponent(branchName)}/artifacts/${encodeURIComponent(sessionId)}`;
278
+ }
279
+ function artifactManifestUrl(baseUrl, projectId, branchName, sessionId) {
280
+ return new URL(artifactApiBasePath(projectId, branchName, sessionId), baseUrl);
281
+ }
282
+ function artifactDownloadUrl(baseUrl, projectId, branchName, sessionId, filename) {
283
+ return new URL(`${artifactApiBasePath(projectId, branchName, sessionId)}/${encodeURIComponent(filename)}`, baseUrl);
284
+ }
285
+ function sha256Path(filePath) {
286
+ return (0, import_node_crypto.createHash)("sha256").update(import_node_fs.default.readFileSync(filePath)).digest("hex");
287
+ }
288
+ function parseSessionArtifactManifest(value) {
289
+ if (!value || typeof value !== "object" || !Array.isArray(value.artifacts)) {
290
+ throw new Error("Artifact manifest response is invalid");
291
+ }
292
+ return value.artifacts.map((entry) => {
293
+ if (!entry || typeof entry !== "object") {
294
+ throw new Error("Artifact manifest entry is invalid");
295
+ }
296
+ const artifact = entry;
297
+ if (typeof artifact.filename !== "string" || artifact.filename.includes("/") || artifact.filename.includes("\\")) {
298
+ throw new Error("Artifact manifest entry has an invalid filename");
299
+ }
300
+ if (typeof artifact.size !== "number" || !Number.isFinite(artifact.size) || artifact.size < 0) {
301
+ throw new Error(`Artifact manifest entry has an invalid size: ${artifact.filename}`);
302
+ }
303
+ if (typeof artifact.sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(artifact.sha256)) {
304
+ throw new Error(`Artifact manifest entry has an invalid sha256: ${artifact.filename}`);
305
+ }
306
+ return {
307
+ filename: artifact.filename,
308
+ size: artifact.size,
309
+ sha256: artifact.sha256.toLowerCase()
310
+ };
311
+ });
312
+ }
313
+ async function fetchSessionArtifactManifest(input) {
314
+ const response = await fetch(artifactManifestUrl(input.baseUrl, input.projectId, input.branchName, input.sessionId), {
315
+ headers: {
316
+ Authorization: `Bearer ${input.token}`
317
+ }
318
+ });
319
+ if (!response.ok) {
320
+ const detail = (await readResponseText(response)).trim();
321
+ throw new Error(`Failed to fetch artifact manifest (${response.status})${detail ? `: ${detail}` : ""}`);
322
+ }
323
+ return parseSessionArtifactManifest(await response.json());
324
+ }
325
+ async function downloadSessionArtifact(input) {
326
+ const targetDir = sessionArtifactDir(input.artifactRoot, input.sessionId);
327
+ const targetPath = import_node_path.default.join(targetDir, input.artifact.filename);
328
+ assertInsideRoot(targetDir, targetPath, "Artifact path");
329
+ const response = await fetch(
330
+ artifactDownloadUrl(input.baseUrl, input.projectId, input.branchName, input.sessionId, input.artifact.filename),
331
+ {
332
+ headers: {
333
+ Authorization: `Bearer ${input.token}`
334
+ }
335
+ }
336
+ );
337
+ if (!response.ok) {
338
+ const detail = (await readResponseText(response)).trim();
339
+ throw new Error(`Failed to download artifact ${input.artifact.filename} (${response.status})${detail ? `: ${detail}` : ""}`);
340
+ }
341
+ const buffer = Buffer.from(await response.arrayBuffer());
342
+ const receivedHash = (0, import_node_crypto.createHash)("sha256").update(buffer).digest("hex");
343
+ if (receivedHash !== input.artifact.sha256) {
344
+ throw new Error(`Downloaded artifact ${input.artifact.filename} failed sha256 verification`);
345
+ }
346
+ if (buffer.length !== input.artifact.size) {
347
+ throw new Error(`Downloaded artifact ${input.artifact.filename} has unexpected size`);
348
+ }
349
+ const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
350
+ import_node_fs.default.writeFileSync(tempPath, buffer);
351
+ import_node_fs.default.renameSync(tempPath, targetPath);
352
+ }
353
+ async function syncSessionArtifacts(input) {
354
+ const targetDir = sessionArtifactDir(input.artifactRoot, input.sessionId);
355
+ import_node_fs.default.mkdirSync(targetDir, { recursive: true });
356
+ const artifacts = await fetchSessionArtifactManifest(input);
357
+ const manifestNames = new Set(artifacts.map((artifact) => artifact.filename));
358
+ for (const artifact of artifacts) {
359
+ const localPath = import_node_path.default.join(targetDir, artifact.filename);
360
+ assertInsideRoot(targetDir, localPath, "Artifact path");
361
+ if (import_node_fs.default.existsSync(localPath)) {
362
+ const stats = import_node_fs.default.statSync(localPath);
363
+ if (stats.isFile() && stats.size === artifact.size && sha256Path(localPath) === artifact.sha256) {
364
+ continue;
365
+ }
366
+ import_node_fs.default.rmSync(localPath, { recursive: true, force: true });
367
+ }
368
+ await downloadSessionArtifact({ ...input, artifact });
369
+ }
370
+ for (const localName of import_node_fs.default.readdirSync(targetDir)) {
371
+ if (!manifestNames.has(localName)) {
372
+ const localPath = import_node_path.default.join(targetDir, localName);
373
+ assertInsideRoot(targetDir, localPath, "Artifact path");
374
+ import_node_fs.default.rmSync(localPath, { recursive: true, force: true });
375
+ }
376
+ }
377
+ return targetDir;
378
+ }
379
+ async function resolveArtifactEnvFilePath(input) {
380
+ if (!isArtifactEnvPath(input.filePath)) {
381
+ return null;
382
+ }
383
+ if (!input.sessionId) {
384
+ throw new Error(`${R5D_ARTIFACTS_DIR_REF} paths require an active chat session`);
385
+ }
386
+ if (!input.filePath.startsWith(`${R5D_ARTIFACTS_DIR_REF}/`)) {
387
+ throw new Error(`Artifact path must reference a file under ${R5D_ARTIFACTS_DIR_REF}/`);
388
+ }
389
+ const targetDir = await syncSessionArtifacts({
390
+ baseUrl: input.baseUrl,
391
+ token: input.token,
392
+ projectId: input.projectId,
393
+ branchName: input.branchName,
394
+ sessionId: input.sessionId,
395
+ artifactRoot: input.artifactRoot
396
+ });
397
+ const relativePath = import_node_path.default.posix.normalize(input.filePath.slice(`${R5D_ARTIFACTS_DIR_REF}/`.length));
398
+ if (!relativePath || relativePath === "." || relativePath.startsWith("../") || relativePath.includes("\0")) {
399
+ throw new Error(`Invalid artifact path: ${input.filePath}`);
400
+ }
401
+ const absolutePath = import_node_path.default.resolve(targetDir, ...relativePath.split("/"));
402
+ assertInsideRoot(targetDir, absolutePath, "Artifact path");
403
+ return {
404
+ absolutePath,
405
+ virtualPath: `${R5D_ARTIFACTS_DIR_REF}/${relativePath}`
406
+ };
407
+ }
408
+ function rejectArtifactWritePath(filePath) {
409
+ if (isArtifactEnvPath(filePath)) {
410
+ throw new Error(`${R5D_ARTIFACTS_DIR_REF} attachments are read-only. Copy them into a project path before editing or creating files.`);
411
+ }
412
+ }
238
413
  async function verifyR5dctlAuth(baseUrl, token) {
239
414
  const statusUrl = new URL("/api/r5dctl/auth/status", baseUrl);
240
415
  let response;
@@ -823,6 +998,28 @@ function readImageDimensions(bytes, mediaType) {
823
998
  return dimensions;
824
999
  }
825
1000
  async function executeReadFileOperation(input) {
1001
+ const artifact = await resolveArtifactEnvFilePath({
1002
+ filePath: input.message.filePath,
1003
+ baseUrl: input.baseUrl,
1004
+ token: input.token,
1005
+ projectId: input.projectId,
1006
+ branchName: input.message.branchName,
1007
+ sessionId: input.message.sessionId,
1008
+ artifactRoot: input.artifactRoot
1009
+ });
1010
+ if (artifact) {
1011
+ if (!import_node_fs.default.existsSync(artifact.absolutePath) || !import_node_fs.default.statSync(artifact.absolutePath).isFile()) {
1012
+ throw new Error(`File not found: ${artifact.virtualPath}`);
1013
+ }
1014
+ const buffer2 = import_node_fs.default.readFileSync(artifact.absolutePath);
1015
+ const formatted2 = formatLineNumberedContent(buffer2.toString("utf8"), input.message.offset, input.message.limit);
1016
+ return {
1017
+ type: "read_file",
1018
+ file: artifact.virtualPath,
1019
+ gitBlobHash: getGitBlobHashForContent(buffer2),
1020
+ ...formatted2
1021
+ };
1022
+ }
826
1023
  const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
827
1024
  const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
828
1025
  if (!import_node_fs.default.existsSync(resolved.absolutePath) || !import_node_fs.default.statSync(resolved.absolutePath).isFile()) {
@@ -838,6 +1035,7 @@ async function executeReadFileOperation(input) {
838
1035
  };
839
1036
  }
840
1037
  async function executeCreateFileOperation(input) {
1038
+ rejectArtifactWritePath(input.message.filePath);
841
1039
  const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
842
1040
  const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
843
1041
  if (import_node_fs.default.existsSync(resolved.absolutePath)) {
@@ -852,6 +1050,7 @@ async function executeCreateFileOperation(input) {
852
1050
  };
853
1051
  }
854
1052
  async function executeEditFileOperation(input) {
1053
+ rejectArtifactWritePath(input.message.filePath);
855
1054
  const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
856
1055
  const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
857
1056
  if (input.message.oldString === input.message.newString) {
@@ -901,6 +1100,7 @@ function normalizePatchPath(rawPath) {
901
1100
  if (patchPath.startsWith("a/") || patchPath.startsWith("b/")) {
902
1101
  patchPath = patchPath.slice(2);
903
1102
  }
1103
+ rejectArtifactWritePath(patchPath);
904
1104
  if (!patchPath || import_node_path.default.isAbsolute(patchPath)) {
905
1105
  throw new Error(`Invalid patch path: ${rawPath}`);
906
1106
  }
@@ -1047,6 +1247,36 @@ async function executeApplyPatchOperation(input) {
1047
1247
  };
1048
1248
  }
1049
1249
  async function executeViewFileBytesOperation(input) {
1250
+ const artifact = await resolveArtifactEnvFilePath({
1251
+ filePath: input.message.filePath,
1252
+ baseUrl: input.baseUrl,
1253
+ token: input.token,
1254
+ projectId: input.projectId,
1255
+ branchName: input.message.branchName,
1256
+ sessionId: input.message.sessionId,
1257
+ artifactRoot: input.artifactRoot
1258
+ });
1259
+ if (artifact) {
1260
+ if (!import_node_fs.default.existsSync(artifact.absolutePath) || !import_node_fs.default.statSync(artifact.absolutePath).isFile()) {
1261
+ throw new Error(`File not found: ${artifact.virtualPath}`);
1262
+ }
1263
+ const extension2 = import_node_path.default.extname(artifact.absolutePath).toLowerCase();
1264
+ const mediaType2 = extension2 === ".jpg" || extension2 === ".jpeg" ? "image/jpeg" : extension2 === ".png" ? "image/png" : null;
1265
+ if (!mediaType2) {
1266
+ throw new Error("view_image supports PNG and JPEG files only");
1267
+ }
1268
+ const bytes2 = import_node_fs.default.readFileSync(artifact.absolutePath);
1269
+ const dimensions2 = readImageDimensions(bytes2, mediaType2);
1270
+ return {
1271
+ type: "view_file_bytes",
1272
+ filePath: artifact.virtualPath,
1273
+ mediaType: mediaType2,
1274
+ base64: bytes2.toString("base64"),
1275
+ fileSizeBytes: bytes2.length,
1276
+ width: dimensions2.width,
1277
+ height: dimensions2.height
1278
+ };
1279
+ }
1050
1280
  const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1051
1281
  const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
1052
1282
  if (!import_node_fs.default.existsSync(resolved.absolutePath) || !import_node_fs.default.statSync(resolved.absolutePath).isFile()) {
@@ -1223,6 +1453,18 @@ async function executeCommand(input) {
1223
1453
  branchName: input.message.branchName,
1224
1454
  manifest: input.manifest
1225
1455
  });
1456
+ let artifactProcessEnv = {};
1457
+ if (input.message.sessionId) {
1458
+ await syncSessionArtifacts({
1459
+ baseUrl: input.baseUrl,
1460
+ token: input.token,
1461
+ projectId: input.projectId,
1462
+ branchName: input.message.branchName,
1463
+ sessionId: input.message.sessionId,
1464
+ artifactRoot: input.artifactRoot
1465
+ });
1466
+ artifactProcessEnv = artifactEnv(input.artifactRoot, input.message.sessionId);
1467
+ }
1226
1468
  const cwd = resolveCommandCwd(workspace.branchPath, input.message.cwd);
1227
1469
  const subprocess = Bun.spawn(input.message.argv, {
1228
1470
  cwd,
@@ -1232,7 +1474,8 @@ async function executeCommand(input) {
1232
1474
  ...process.env,
1233
1475
  ...containerRegistryEnv(),
1234
1476
  ...input.message.env ?? {},
1235
- ...internalGitProcessEnv(workspace, input.message.env)
1477
+ ...internalGitProcessEnv(workspace, input.message.env),
1478
+ ...artifactProcessEnv
1236
1479
  }
1237
1480
  });
1238
1481
  activeProcesses.set(input.message.runId, { process: subprocess, command: input.message.argv });
@@ -1295,6 +1538,15 @@ async function executeStreamingCommand(input) {
1295
1538
  branchName: input.message.branchName,
1296
1539
  manifest: input.manifest
1297
1540
  });
1541
+ await syncSessionArtifacts({
1542
+ baseUrl: input.baseUrl,
1543
+ token: input.token,
1544
+ projectId: input.projectId,
1545
+ branchName: input.message.branchName,
1546
+ sessionId: input.message.sessionId,
1547
+ artifactRoot: input.artifactRoot
1548
+ });
1549
+ const artifactProcessEnv = artifactEnv(input.artifactRoot, input.message.sessionId);
1298
1550
  const cwd = resolveCommandCwd(workspace.branchPath, input.message.cwd);
1299
1551
  const subprocess = Bun.spawn(input.message.argv, {
1300
1552
  cwd,
@@ -1304,7 +1556,8 @@ async function executeStreamingCommand(input) {
1304
1556
  ...process.env,
1305
1557
  ...containerRegistryEnv(),
1306
1558
  ...input.message.env ?? {},
1307
- ...internalGitProcessEnv(workspace, input.message.env)
1559
+ ...internalGitProcessEnv(workspace, input.message.env),
1560
+ ...artifactProcessEnv
1308
1561
  }
1309
1562
  });
1310
1563
  activeProcesses.set(input.message.runId, { process: subprocess, command: input.message.argv });
@@ -1679,11 +1932,14 @@ async function startWorker(options) {
1679
1932
  validateLabel(label);
1680
1933
  const projectsRoot = import_node_path.default.resolve(options.projectRoot ?? defaultProjectsRoot());
1681
1934
  const syncRoot = import_node_path.default.resolve(options.syncRoot ?? process.env.R5D_SYNC_ROOT ?? defaultSyncRoot());
1935
+ const artifactRoot = import_node_path.default.resolve(options.artifactRoot ?? process.env.R5D_ARTIFACTS_ROOT ?? defaultArtifactRoot());
1682
1936
  process.stdout.write(`[r5d-worker] label: ${label}
1683
1937
  `);
1684
1938
  process.stdout.write(`[r5d-worker] projects root: ${projectsRoot}
1685
1939
  `);
1686
1940
  process.stdout.write(`[r5d-worker] sync root: ${syncRoot}
1941
+ `);
1942
+ process.stdout.write(`[r5d-worker] artifact root: ${artifactRoot}
1687
1943
  `);
1688
1944
  process.stdout.write(`[r5d-worker] server: ${baseUrl}
1689
1945
  `);
@@ -1691,6 +1947,7 @@ async function startWorker(options) {
1691
1947
  await verifyR5dctlAuth(baseUrl, token);
1692
1948
  import_node_fs.default.mkdirSync(projectsRoot, { recursive: true });
1693
1949
  import_node_fs.default.mkdirSync(syncRoot, { recursive: true });
1950
+ import_node_fs.default.mkdirSync(artifactRoot, { recursive: true });
1694
1951
  const manifestByProjectId = /* @__PURE__ */ new Map();
1695
1952
  const ws = new WebSocket(websocketUrl(baseUrl, label), {
1696
1953
  headers: {
@@ -1706,7 +1963,8 @@ async function startWorker(options) {
1706
1963
  arch: process.arch,
1707
1964
  pid: process.pid,
1708
1965
  version: getWorkerVersion(),
1709
- projectRoot: projectsRoot
1966
+ projectRoot: projectsRoot,
1967
+ artifactRoot
1710
1968
  }
1711
1969
  };
1712
1970
  ws.send(JSON.stringify(hello));
@@ -1803,7 +2061,7 @@ async function startWorker(options) {
1803
2061
  const result = await withBranchLock(
1804
2062
  message.projectId,
1805
2063
  message.branchName,
1806
- () => executeCommand({ message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, manifest })
2064
+ () => executeCommand({ message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, manifest })
1807
2065
  );
1808
2066
  ws.send(JSON.stringify(result));
1809
2067
  return;
@@ -1822,7 +2080,7 @@ async function startWorker(options) {
1822
2080
  void withBranchLock(
1823
2081
  message.projectId,
1824
2082
  message.branchName,
1825
- () => executeStreamingCommand({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, manifest })
2083
+ () => executeStreamingCommand({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, manifest })
1826
2084
  ).catch((error) => {
1827
2085
  sendWorkerMessage(ws, {
1828
2086
  type: "exec_start_error",
@@ -1848,7 +2106,7 @@ async function startWorker(options) {
1848
2106
  const result = await withBranchLock(
1849
2107
  message.projectId,
1850
2108
  message.branchName,
1851
- () => executeOperation({ message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, manifest })
2109
+ () => executeOperation({ message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, manifest })
1852
2110
  );
1853
2111
  ws.send(
1854
2112
  JSON.stringify({
@@ -1925,5 +2183,7 @@ if (isCliEntrypoint()) {
1925
2183
  }
1926
2184
  // Annotate the CommonJS export names for ESM import in node:
1927
2185
  0 && (module.exports = {
1928
- ensureVisibleGitCheckout
2186
+ ensureVisibleGitCheckout,
2187
+ isArtifactEnvPath,
2188
+ syncSessionArtifacts
1929
2189
  });
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
4
4
  "type": "commonjs"
5
5
  }
package/dist/mjs/main.mjs CHANGED
@@ -30,6 +30,9 @@ function defaultProjectsRoot() {
30
30
  function defaultSyncRoot() {
31
31
  return path.join(os.homedir(), ".r5d", "sync");
32
32
  }
33
+ function defaultArtifactRoot() {
34
+ return path.join(os.homedir(), ".r5d", "artifacts");
35
+ }
33
36
  function printHelp() {
34
37
  process.stdout.write(`Usage:
35
38
  r5d-worker start --label <label> [--base-url <url>] [--token <token>] [--api-key <key>]
@@ -43,6 +46,7 @@ Options:
43
46
  --config <path> Config path (default ~/.config/r5d/r5dctl/config.json)
44
47
  --project-root <dir> Override projects checkout root (default ~/.r5d/projects)
45
48
  --sync-root <dir> Override r5d internal sync git root (default ~/.r5d/sync)
49
+ --artifact-root <dir> Override session artifact root (default from R5D_ARTIFACTS_ROOT or ~/.r5d/artifacts)
46
50
  -v, --version Show r5d-worker version
47
51
  -h, --help Show this help
48
52
  `);
@@ -159,6 +163,11 @@ function parseArgs(argv) {
159
163
  options.syncRoot = syncRoot;
160
164
  continue;
161
165
  }
166
+ const artifactRoot = readOption("--artifact-root");
167
+ if (artifactRoot !== void 0) {
168
+ options.artifactRoot = artifactRoot;
169
+ continue;
170
+ }
162
171
  rest.push(arg);
163
172
  }
164
173
  if (options.version) {
@@ -201,6 +210,170 @@ async function readResponseText(response) {
201
210
  return "";
202
211
  }
203
212
  }
213
+ const R5D_ARTIFACTS_DIR_REF = "$R5D_ARTIFACTS_DIR";
214
+ function validateArtifactSessionId(sessionId) {
215
+ if (!/^(migration-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) {
216
+ throw new Error(`Invalid artifact session id: ${sessionId}`);
217
+ }
218
+ }
219
+ function assertInsideRoot(rootPath, candidatePath, label) {
220
+ const relative = path.relative(rootPath, candidatePath);
221
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
222
+ throw new Error(`${label} escapes the artifact directory`);
223
+ }
224
+ }
225
+ function sessionArtifactDir(artifactRoot, sessionId) {
226
+ validateArtifactSessionId(sessionId);
227
+ return path.join(artifactRoot, sessionId);
228
+ }
229
+ function artifactEnv(artifactRoot, sessionId) {
230
+ if (!sessionId) {
231
+ return {};
232
+ }
233
+ return {
234
+ R5D_ARTIFACTS_DIR: sessionArtifactDir(artifactRoot, sessionId)
235
+ };
236
+ }
237
+ function isArtifactEnvPath(filePath) {
238
+ return filePath === R5D_ARTIFACTS_DIR_REF || filePath.startsWith(`${R5D_ARTIFACTS_DIR_REF}/`);
239
+ }
240
+ function artifactApiBasePath(projectId, branchName, sessionId) {
241
+ return `/preview-api/${encodeURIComponent(projectId)}/${encodeURIComponent(branchName)}/artifacts/${encodeURIComponent(sessionId)}`;
242
+ }
243
+ function artifactManifestUrl(baseUrl, projectId, branchName, sessionId) {
244
+ return new URL(artifactApiBasePath(projectId, branchName, sessionId), baseUrl);
245
+ }
246
+ function artifactDownloadUrl(baseUrl, projectId, branchName, sessionId, filename) {
247
+ return new URL(`${artifactApiBasePath(projectId, branchName, sessionId)}/${encodeURIComponent(filename)}`, baseUrl);
248
+ }
249
+ function sha256Path(filePath) {
250
+ return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
251
+ }
252
+ function parseSessionArtifactManifest(value) {
253
+ if (!value || typeof value !== "object" || !Array.isArray(value.artifacts)) {
254
+ throw new Error("Artifact manifest response is invalid");
255
+ }
256
+ return value.artifacts.map((entry) => {
257
+ if (!entry || typeof entry !== "object") {
258
+ throw new Error("Artifact manifest entry is invalid");
259
+ }
260
+ const artifact = entry;
261
+ if (typeof artifact.filename !== "string" || artifact.filename.includes("/") || artifact.filename.includes("\\")) {
262
+ throw new Error("Artifact manifest entry has an invalid filename");
263
+ }
264
+ if (typeof artifact.size !== "number" || !Number.isFinite(artifact.size) || artifact.size < 0) {
265
+ throw new Error(`Artifact manifest entry has an invalid size: ${artifact.filename}`);
266
+ }
267
+ if (typeof artifact.sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(artifact.sha256)) {
268
+ throw new Error(`Artifact manifest entry has an invalid sha256: ${artifact.filename}`);
269
+ }
270
+ return {
271
+ filename: artifact.filename,
272
+ size: artifact.size,
273
+ sha256: artifact.sha256.toLowerCase()
274
+ };
275
+ });
276
+ }
277
+ async function fetchSessionArtifactManifest(input) {
278
+ const response = await fetch(artifactManifestUrl(input.baseUrl, input.projectId, input.branchName, input.sessionId), {
279
+ headers: {
280
+ Authorization: `Bearer ${input.token}`
281
+ }
282
+ });
283
+ if (!response.ok) {
284
+ const detail = (await readResponseText(response)).trim();
285
+ throw new Error(`Failed to fetch artifact manifest (${response.status})${detail ? `: ${detail}` : ""}`);
286
+ }
287
+ return parseSessionArtifactManifest(await response.json());
288
+ }
289
+ async function downloadSessionArtifact(input) {
290
+ const targetDir = sessionArtifactDir(input.artifactRoot, input.sessionId);
291
+ const targetPath = path.join(targetDir, input.artifact.filename);
292
+ assertInsideRoot(targetDir, targetPath, "Artifact path");
293
+ const response = await fetch(
294
+ artifactDownloadUrl(input.baseUrl, input.projectId, input.branchName, input.sessionId, input.artifact.filename),
295
+ {
296
+ headers: {
297
+ Authorization: `Bearer ${input.token}`
298
+ }
299
+ }
300
+ );
301
+ if (!response.ok) {
302
+ const detail = (await readResponseText(response)).trim();
303
+ throw new Error(`Failed to download artifact ${input.artifact.filename} (${response.status})${detail ? `: ${detail}` : ""}`);
304
+ }
305
+ const buffer = Buffer.from(await response.arrayBuffer());
306
+ const receivedHash = createHash("sha256").update(buffer).digest("hex");
307
+ if (receivedHash !== input.artifact.sha256) {
308
+ throw new Error(`Downloaded artifact ${input.artifact.filename} failed sha256 verification`);
309
+ }
310
+ if (buffer.length !== input.artifact.size) {
311
+ throw new Error(`Downloaded artifact ${input.artifact.filename} has unexpected size`);
312
+ }
313
+ const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
314
+ fs.writeFileSync(tempPath, buffer);
315
+ fs.renameSync(tempPath, targetPath);
316
+ }
317
+ async function syncSessionArtifacts(input) {
318
+ const targetDir = sessionArtifactDir(input.artifactRoot, input.sessionId);
319
+ fs.mkdirSync(targetDir, { recursive: true });
320
+ const artifacts = await fetchSessionArtifactManifest(input);
321
+ const manifestNames = new Set(artifacts.map((artifact) => artifact.filename));
322
+ for (const artifact of artifacts) {
323
+ const localPath = path.join(targetDir, artifact.filename);
324
+ assertInsideRoot(targetDir, localPath, "Artifact path");
325
+ if (fs.existsSync(localPath)) {
326
+ const stats = fs.statSync(localPath);
327
+ if (stats.isFile() && stats.size === artifact.size && sha256Path(localPath) === artifact.sha256) {
328
+ continue;
329
+ }
330
+ fs.rmSync(localPath, { recursive: true, force: true });
331
+ }
332
+ await downloadSessionArtifact({ ...input, artifact });
333
+ }
334
+ for (const localName of fs.readdirSync(targetDir)) {
335
+ if (!manifestNames.has(localName)) {
336
+ const localPath = path.join(targetDir, localName);
337
+ assertInsideRoot(targetDir, localPath, "Artifact path");
338
+ fs.rmSync(localPath, { recursive: true, force: true });
339
+ }
340
+ }
341
+ return targetDir;
342
+ }
343
+ async function resolveArtifactEnvFilePath(input) {
344
+ if (!isArtifactEnvPath(input.filePath)) {
345
+ return null;
346
+ }
347
+ if (!input.sessionId) {
348
+ throw new Error(`${R5D_ARTIFACTS_DIR_REF} paths require an active chat session`);
349
+ }
350
+ if (!input.filePath.startsWith(`${R5D_ARTIFACTS_DIR_REF}/`)) {
351
+ throw new Error(`Artifact path must reference a file under ${R5D_ARTIFACTS_DIR_REF}/`);
352
+ }
353
+ const targetDir = await syncSessionArtifacts({
354
+ baseUrl: input.baseUrl,
355
+ token: input.token,
356
+ projectId: input.projectId,
357
+ branchName: input.branchName,
358
+ sessionId: input.sessionId,
359
+ artifactRoot: input.artifactRoot
360
+ });
361
+ const relativePath = path.posix.normalize(input.filePath.slice(`${R5D_ARTIFACTS_DIR_REF}/`.length));
362
+ if (!relativePath || relativePath === "." || relativePath.startsWith("../") || relativePath.includes("\0")) {
363
+ throw new Error(`Invalid artifact path: ${input.filePath}`);
364
+ }
365
+ const absolutePath = path.resolve(targetDir, ...relativePath.split("/"));
366
+ assertInsideRoot(targetDir, absolutePath, "Artifact path");
367
+ return {
368
+ absolutePath,
369
+ virtualPath: `${R5D_ARTIFACTS_DIR_REF}/${relativePath}`
370
+ };
371
+ }
372
+ function rejectArtifactWritePath(filePath) {
373
+ if (isArtifactEnvPath(filePath)) {
374
+ throw new Error(`${R5D_ARTIFACTS_DIR_REF} attachments are read-only. Copy them into a project path before editing or creating files.`);
375
+ }
376
+ }
204
377
  async function verifyR5dctlAuth(baseUrl, token) {
205
378
  const statusUrl = new URL("/api/r5dctl/auth/status", baseUrl);
206
379
  let response;
@@ -789,6 +962,28 @@ function readImageDimensions(bytes, mediaType) {
789
962
  return dimensions;
790
963
  }
791
964
  async function executeReadFileOperation(input) {
965
+ const artifact = await resolveArtifactEnvFilePath({
966
+ filePath: input.message.filePath,
967
+ baseUrl: input.baseUrl,
968
+ token: input.token,
969
+ projectId: input.projectId,
970
+ branchName: input.message.branchName,
971
+ sessionId: input.message.sessionId,
972
+ artifactRoot: input.artifactRoot
973
+ });
974
+ if (artifact) {
975
+ if (!fs.existsSync(artifact.absolutePath) || !fs.statSync(artifact.absolutePath).isFile()) {
976
+ throw new Error(`File not found: ${artifact.virtualPath}`);
977
+ }
978
+ const buffer2 = fs.readFileSync(artifact.absolutePath);
979
+ const formatted2 = formatLineNumberedContent(buffer2.toString("utf8"), input.message.offset, input.message.limit);
980
+ return {
981
+ type: "read_file",
982
+ file: artifact.virtualPath,
983
+ gitBlobHash: getGitBlobHashForContent(buffer2),
984
+ ...formatted2
985
+ };
986
+ }
792
987
  const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
793
988
  const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
794
989
  if (!fs.existsSync(resolved.absolutePath) || !fs.statSync(resolved.absolutePath).isFile()) {
@@ -804,6 +999,7 @@ async function executeReadFileOperation(input) {
804
999
  };
805
1000
  }
806
1001
  async function executeCreateFileOperation(input) {
1002
+ rejectArtifactWritePath(input.message.filePath);
807
1003
  const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
808
1004
  const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
809
1005
  if (fs.existsSync(resolved.absolutePath)) {
@@ -818,6 +1014,7 @@ async function executeCreateFileOperation(input) {
818
1014
  };
819
1015
  }
820
1016
  async function executeEditFileOperation(input) {
1017
+ rejectArtifactWritePath(input.message.filePath);
821
1018
  const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
822
1019
  const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
823
1020
  if (input.message.oldString === input.message.newString) {
@@ -867,6 +1064,7 @@ function normalizePatchPath(rawPath) {
867
1064
  if (patchPath.startsWith("a/") || patchPath.startsWith("b/")) {
868
1065
  patchPath = patchPath.slice(2);
869
1066
  }
1067
+ rejectArtifactWritePath(patchPath);
870
1068
  if (!patchPath || path.isAbsolute(patchPath)) {
871
1069
  throw new Error(`Invalid patch path: ${rawPath}`);
872
1070
  }
@@ -1013,6 +1211,36 @@ async function executeApplyPatchOperation(input) {
1013
1211
  };
1014
1212
  }
1015
1213
  async function executeViewFileBytesOperation(input) {
1214
+ const artifact = await resolveArtifactEnvFilePath({
1215
+ filePath: input.message.filePath,
1216
+ baseUrl: input.baseUrl,
1217
+ token: input.token,
1218
+ projectId: input.projectId,
1219
+ branchName: input.message.branchName,
1220
+ sessionId: input.message.sessionId,
1221
+ artifactRoot: input.artifactRoot
1222
+ });
1223
+ if (artifact) {
1224
+ if (!fs.existsSync(artifact.absolutePath) || !fs.statSync(artifact.absolutePath).isFile()) {
1225
+ throw new Error(`File not found: ${artifact.virtualPath}`);
1226
+ }
1227
+ const extension2 = path.extname(artifact.absolutePath).toLowerCase();
1228
+ const mediaType2 = extension2 === ".jpg" || extension2 === ".jpeg" ? "image/jpeg" : extension2 === ".png" ? "image/png" : null;
1229
+ if (!mediaType2) {
1230
+ throw new Error("view_image supports PNG and JPEG files only");
1231
+ }
1232
+ const bytes2 = fs.readFileSync(artifact.absolutePath);
1233
+ const dimensions2 = readImageDimensions(bytes2, mediaType2);
1234
+ return {
1235
+ type: "view_file_bytes",
1236
+ filePath: artifact.virtualPath,
1237
+ mediaType: mediaType2,
1238
+ base64: bytes2.toString("base64"),
1239
+ fileSizeBytes: bytes2.length,
1240
+ width: dimensions2.width,
1241
+ height: dimensions2.height
1242
+ };
1243
+ }
1016
1244
  const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1017
1245
  const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
1018
1246
  if (!fs.existsSync(resolved.absolutePath) || !fs.statSync(resolved.absolutePath).isFile()) {
@@ -1189,6 +1417,18 @@ async function executeCommand(input) {
1189
1417
  branchName: input.message.branchName,
1190
1418
  manifest: input.manifest
1191
1419
  });
1420
+ let artifactProcessEnv = {};
1421
+ if (input.message.sessionId) {
1422
+ await syncSessionArtifacts({
1423
+ baseUrl: input.baseUrl,
1424
+ token: input.token,
1425
+ projectId: input.projectId,
1426
+ branchName: input.message.branchName,
1427
+ sessionId: input.message.sessionId,
1428
+ artifactRoot: input.artifactRoot
1429
+ });
1430
+ artifactProcessEnv = artifactEnv(input.artifactRoot, input.message.sessionId);
1431
+ }
1192
1432
  const cwd = resolveCommandCwd(workspace.branchPath, input.message.cwd);
1193
1433
  const subprocess = Bun.spawn(input.message.argv, {
1194
1434
  cwd,
@@ -1198,7 +1438,8 @@ async function executeCommand(input) {
1198
1438
  ...process.env,
1199
1439
  ...containerRegistryEnv(),
1200
1440
  ...input.message.env ?? {},
1201
- ...internalGitProcessEnv(workspace, input.message.env)
1441
+ ...internalGitProcessEnv(workspace, input.message.env),
1442
+ ...artifactProcessEnv
1202
1443
  }
1203
1444
  });
1204
1445
  activeProcesses.set(input.message.runId, { process: subprocess, command: input.message.argv });
@@ -1261,6 +1502,15 @@ async function executeStreamingCommand(input) {
1261
1502
  branchName: input.message.branchName,
1262
1503
  manifest: input.manifest
1263
1504
  });
1505
+ await syncSessionArtifacts({
1506
+ baseUrl: input.baseUrl,
1507
+ token: input.token,
1508
+ projectId: input.projectId,
1509
+ branchName: input.message.branchName,
1510
+ sessionId: input.message.sessionId,
1511
+ artifactRoot: input.artifactRoot
1512
+ });
1513
+ const artifactProcessEnv = artifactEnv(input.artifactRoot, input.message.sessionId);
1264
1514
  const cwd = resolveCommandCwd(workspace.branchPath, input.message.cwd);
1265
1515
  const subprocess = Bun.spawn(input.message.argv, {
1266
1516
  cwd,
@@ -1270,7 +1520,8 @@ async function executeStreamingCommand(input) {
1270
1520
  ...process.env,
1271
1521
  ...containerRegistryEnv(),
1272
1522
  ...input.message.env ?? {},
1273
- ...internalGitProcessEnv(workspace, input.message.env)
1523
+ ...internalGitProcessEnv(workspace, input.message.env),
1524
+ ...artifactProcessEnv
1274
1525
  }
1275
1526
  });
1276
1527
  activeProcesses.set(input.message.runId, { process: subprocess, command: input.message.argv });
@@ -1645,11 +1896,14 @@ async function startWorker(options) {
1645
1896
  validateLabel(label);
1646
1897
  const projectsRoot = path.resolve(options.projectRoot ?? defaultProjectsRoot());
1647
1898
  const syncRoot = path.resolve(options.syncRoot ?? process.env.R5D_SYNC_ROOT ?? defaultSyncRoot());
1899
+ const artifactRoot = path.resolve(options.artifactRoot ?? process.env.R5D_ARTIFACTS_ROOT ?? defaultArtifactRoot());
1648
1900
  process.stdout.write(`[r5d-worker] label: ${label}
1649
1901
  `);
1650
1902
  process.stdout.write(`[r5d-worker] projects root: ${projectsRoot}
1651
1903
  `);
1652
1904
  process.stdout.write(`[r5d-worker] sync root: ${syncRoot}
1905
+ `);
1906
+ process.stdout.write(`[r5d-worker] artifact root: ${artifactRoot}
1653
1907
  `);
1654
1908
  process.stdout.write(`[r5d-worker] server: ${baseUrl}
1655
1909
  `);
@@ -1657,6 +1911,7 @@ async function startWorker(options) {
1657
1911
  await verifyR5dctlAuth(baseUrl, token);
1658
1912
  fs.mkdirSync(projectsRoot, { recursive: true });
1659
1913
  fs.mkdirSync(syncRoot, { recursive: true });
1914
+ fs.mkdirSync(artifactRoot, { recursive: true });
1660
1915
  const manifestByProjectId = /* @__PURE__ */ new Map();
1661
1916
  const ws = new WebSocket(websocketUrl(baseUrl, label), {
1662
1917
  headers: {
@@ -1672,7 +1927,8 @@ async function startWorker(options) {
1672
1927
  arch: process.arch,
1673
1928
  pid: process.pid,
1674
1929
  version: getWorkerVersion(),
1675
- projectRoot: projectsRoot
1930
+ projectRoot: projectsRoot,
1931
+ artifactRoot
1676
1932
  }
1677
1933
  };
1678
1934
  ws.send(JSON.stringify(hello));
@@ -1769,7 +2025,7 @@ async function startWorker(options) {
1769
2025
  const result = await withBranchLock(
1770
2026
  message.projectId,
1771
2027
  message.branchName,
1772
- () => executeCommand({ message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, manifest })
2028
+ () => executeCommand({ message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, manifest })
1773
2029
  );
1774
2030
  ws.send(JSON.stringify(result));
1775
2031
  return;
@@ -1788,7 +2044,7 @@ async function startWorker(options) {
1788
2044
  void withBranchLock(
1789
2045
  message.projectId,
1790
2046
  message.branchName,
1791
- () => executeStreamingCommand({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, manifest })
2047
+ () => executeStreamingCommand({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, manifest })
1792
2048
  ).catch((error) => {
1793
2049
  sendWorkerMessage(ws, {
1794
2050
  type: "exec_start_error",
@@ -1814,7 +2070,7 @@ async function startWorker(options) {
1814
2070
  const result = await withBranchLock(
1815
2071
  message.projectId,
1816
2072
  message.branchName,
1817
- () => executeOperation({ message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, manifest })
2073
+ () => executeOperation({ message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, manifest })
1818
2074
  );
1819
2075
  ws.send(
1820
2076
  JSON.stringify({
@@ -1890,5 +2146,7 @@ if (isCliEntrypoint()) {
1890
2146
  });
1891
2147
  }
1892
2148
  export {
1893
- ensureVisibleGitCheckout
2149
+ ensureVisibleGitCheckout,
2150
+ isArtifactEnvPath,
2151
+ syncSessionArtifacts
1894
2152
  };
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
4
4
  "type": "module"
5
5
  }
@@ -1,4 +1,13 @@
1
1
  #!/usr/bin/env bun
2
+ export declare function isArtifactEnvPath(filePath: string): boolean;
3
+ export declare function syncSessionArtifacts(input: {
4
+ baseUrl: string;
5
+ token: string;
6
+ projectId: string;
7
+ branchName: string;
8
+ sessionId: string;
9
+ artifactRoot: string;
10
+ }): Promise<string>;
2
11
  type GitAuth = {
3
12
  extraHeaderUrl: string;
4
13
  header: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
4
4
  "type": "module",
5
5
  "main": "./dist/cjs/main.cjs",
6
6
  "module": "./dist/mjs/main.mjs",