@love-moon/conductor-cli 0.15.2 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,6 +38,13 @@ const MAX_CONCURRENT_TRANSFERS = 4;
38
38
  const NUL = String.fromCharCode(0);
39
39
 
40
40
  export const REMOTE_FILE_CAPABILITY = "remote_file";
41
+ /**
42
+ * This daemon enforces `rootPath` on `push` and resolves a relative path against
43
+ * a task's working directory. The backend must see it before opening a file
44
+ * preview: an older daemon would silently ignore `rootPath` and serve the whole
45
+ * disk to a preview link.
46
+ */
47
+ export const REMOTE_FILE_PREVIEW_CAPABILITY = "remote_file_preview";
41
48
 
42
49
  /**
43
50
  * Move single files between this daemon's disk and the account that owns it,
@@ -68,6 +75,7 @@ export const REMOTE_FILE_CAPABILITY = "remote_file";
68
75
  * @param {number} [opts.chunkBytes]
69
76
  * @param {(ms:number)=>Promise<unknown>} [opts.sleep] backoff hook, injectable for tests
70
77
  * @param {string|null} [opts.guestRoot] when set, paths outside it are refused
78
+ * @param {(taskId:string)=>string|null} [opts.resolveTaskCwd] working directory of a live task
71
79
  */
72
80
  export function createRemoteFileHandlers(opts = {}) {
73
81
  const config = opts.config || null;
@@ -77,6 +85,7 @@ export function createRemoteFileHandlers(opts = {}) {
77
85
  const chunkBytes = resolveRemoteChunkBytes(opts.chunkBytes);
78
86
  const sleep = typeof opts.sleep === "function" ? opts.sleep : delay;
79
87
  const guestRoot = opts.guestRoot || null;
88
+ const resolveTaskCwd = typeof opts.resolveTaskCwd === "function" ? opts.resolveTaskCwd : () => null;
80
89
  // One controller for the whole daemon: `abortAll()` on shutdown tears down
81
90
  // every in-flight stream instead of letting them hold the process open.
82
91
  const shutdownController = new AbortController();
@@ -114,14 +123,57 @@ export function createRemoteFileHandlers(opts = {}) {
114
123
  * mistakes rather than a determined escape — same caveat as everywhere else
115
124
  * in this repo.
116
125
  */
117
- function resolveRemotePath(value) {
118
- const resolved = normalizeRemotePath(value);
126
+ function resolveRemotePath(value, taskId) {
127
+ const resolved = normalizeRemotePath(taskId ? resolveAgainstTask(value, taskId) : value);
119
128
  if (guestRoot && !isPathInsideGuestRoot(resolved, guestRoot)) {
120
129
  throw new Error(`path is outside this daemon's shared root: ${resolved}`);
121
130
  }
122
131
  return resolved;
123
132
  }
124
133
 
134
+ /** A link the AI wrote as `docs/report.md` means "relative to where I ran". */
135
+ function resolveAgainstTask(value, taskId) {
136
+ const raw = typeof value === "string" ? value.trim() : "";
137
+ if (!raw || path.isAbsolute(raw) || raw === "~" || raw.startsWith("~/")) return value;
138
+ const cwd = resolveTaskCwd(String(taskId));
139
+ if (!cwd) {
140
+ throw new Error(`cannot resolve relative path ${raw}: task ${taskId} is not running on this daemon`);
141
+ }
142
+ return path.join(cwd, raw);
143
+ }
144
+
145
+ /**
146
+ * Confine a preview read to `rootPath`. Unlike the guest-root check this one
147
+ * follows symlinks: the paths under a preview link are chosen by the page
148
+ * being previewed, not typed by the machine's owner, so a symlink into
149
+ * `~/.ssh` must not be a way out. Dotfiles below the root are refused for the
150
+ * same reason (`.env`, `.git/config`).
151
+ */
152
+ async function resolveInsideRoot(source, rootPath) {
153
+ let real;
154
+ let root;
155
+ try {
156
+ root = await fsp.realpath(normalizeRemotePath(rootPath));
157
+ real = await fsp.realpath(source);
158
+ } catch (error) {
159
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR") {
160
+ throw new Error(`no such file: ${source}`);
161
+ }
162
+ throw error;
163
+ }
164
+ const relative = path.relative(root, real);
165
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
166
+ throw new Error(`path is outside the preview root: ${source}`);
167
+ }
168
+ if (relative.split(path.sep).some((segment) => segment.startsWith("."))) {
169
+ throw new Error(`hidden files are not available to preview: ${source}`);
170
+ }
171
+ if (guestRoot && !isPathInsideGuestRoot(real, await fsp.realpath(guestRoot))) {
172
+ throw new Error(`path is outside this daemon's shared root: ${source}`);
173
+ }
174
+ return real;
175
+ }
176
+
125
177
  async function acquireSlot() {
126
178
  if (activeTransfers >= MAX_CONCURRENT_TRANSFERS) {
127
179
  throw new Error(
@@ -306,7 +358,10 @@ export function createRemoteFileHandlers(opts = {}) {
306
358
  async function push(args = {}) {
307
359
  const transferId = requireId(args.transferId ?? args.transfer_id, "transferId");
308
360
  const transferToken = requireId(args.transferToken ?? args.transfer_token, "transferToken");
309
- const source = resolveRemotePath(args.remotePath ?? args.remote_path);
361
+ let source = resolveRemotePath(args.remotePath ?? args.remote_path);
362
+ const rootPath = args.rootPath ?? args.root_path;
363
+ if (rootPath) source = await resolveInsideRoot(source, rootPath);
364
+ const limit = Math.min(maxBytes, toPositiveInt(args.maxBytes ?? args.max_bytes) || maxBytes);
310
365
 
311
366
  const stat = await statOrNull(source);
312
367
  if (!stat) {
@@ -318,8 +373,8 @@ export function createRemoteFileHandlers(opts = {}) {
318
373
  if (!stat.isFile()) {
319
374
  throw new Error(`${source} is not a regular file`);
320
375
  }
321
- if (stat.size > maxBytes) {
322
- throw new Error(`${source} is ${stat.size} bytes, over the ${maxBytes} byte limit`);
376
+ if (stat.size > limit) {
377
+ throw new Error(`${source} is ${stat.size} bytes, over the ${limit} byte limit`);
323
378
  }
324
379
 
325
380
  await acquireSlot();
@@ -414,11 +469,12 @@ export function createRemoteFileHandlers(opts = {}) {
414
469
 
415
470
  /** Probe a path. A missing path is an answer, not an error. */
416
471
  async function stat(args = {}) {
417
- const target = resolveRemotePath(args.remotePath ?? args.remote_path);
472
+ const target = resolveRemotePath(args.remotePath ?? args.remote_path, args.taskId ?? args.task_id);
418
473
  const info = await statOrNull(target);
419
474
  if (!info) {
420
475
  return {
421
476
  path: target,
477
+ realPath: null,
422
478
  exists: false,
423
479
  isFile: false,
424
480
  isDirectory: false,
@@ -429,6 +485,9 @@ export function createRemoteFileHandlers(opts = {}) {
429
485
  }
430
486
  return {
431
487
  path: target,
488
+ // Symlinks resolved: a preview roots itself here, so that the root it
489
+ // later enforces is the directory the file really lives in.
490
+ realPath: await fsp.realpath(target),
432
491
  exists: true,
433
492
  isFile: info.isFile(),
434
493
  isDirectory: info.isDirectory(),