@botlearn-course/daemon 0.0.19 → 0.0.20-beta.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.
Files changed (37) hide show
  1. package/dist/agent-service-sandbox.d.ts +9 -1
  2. package/dist/agent-service-sandbox.js +490 -16
  3. package/dist/agent-service-ws-protocol.d.ts +3 -3
  4. package/dist/agent-service-ws-protocol.js +6 -2
  5. package/dist/cli.js +19 -1
  6. package/dist/file-candidates.d.ts +28 -1
  7. package/dist/file-candidates.js +57 -11
  8. package/dist/index.d.ts +6 -0
  9. package/dist/index.js +6 -0
  10. package/dist/run-dispatcher.d.ts +3 -2
  11. package/dist/run-dispatcher.js +62 -5
  12. package/dist/runtime-env.js +4 -4
  13. package/dist/runtime-quiescence.d.ts +16 -0
  14. package/dist/runtime-quiescence.js +42 -0
  15. package/dist/runtimes/engine.js +1 -1
  16. package/dist/tool-observation.d.ts +7 -4
  17. package/dist/tool-observation.js +40 -18
  18. package/dist/trace-projection.d.ts +21 -0
  19. package/dist/trace-projection.js +56 -0
  20. package/dist/types.d.ts +1 -1
  21. package/dist/workspace-entry-set.d.ts +31 -0
  22. package/dist/workspace-entry-set.js +164 -0
  23. package/dist/workspace-materialization.d.ts +16 -0
  24. package/dist/workspace-materialization.js +136 -0
  25. package/dist/workspace-quota.d.ts +4 -0
  26. package/dist/workspace-quota.js +42 -0
  27. package/dist/workspace-restore.d.ts +42 -0
  28. package/dist/workspace-restore.js +347 -0
  29. package/dist/workspace-snapshot-control.d.ts +29 -0
  30. package/dist/workspace-snapshot-control.js +169 -0
  31. package/dist/workspace-snapshot-policy.d.ts +24 -0
  32. package/dist/workspace-snapshot-policy.js +45 -0
  33. package/dist/workspace-snapshot-staging.d.ts +27 -0
  34. package/dist/workspace-snapshot-staging.js +275 -0
  35. package/dist/workspace.d.ts +56 -0
  36. package/dist/workspace.js +553 -1
  37. package/package.json +1 -1
package/dist/workspace.js CHANGED
@@ -1,5 +1,8 @@
1
- import { chmodSync, mkdirSync, rmSync } from "node:fs";
1
+ import { chmodSync, constants, existsSync, mkdirSync, rmSync, } from "node:fs";
2
+ import { promises as fs } from "node:fs";
3
+ import { createHash, randomUUID } from "node:crypto";
2
4
  import path from "node:path";
5
+ import { TextDecoder } from "node:util";
3
6
  import { daemonHome } from "./auth-store.js";
4
7
  /**
5
8
  * 每 run 隔离工作区(spec §4):
@@ -87,6 +90,555 @@ function mkdirTolerant(dir, mode = 0o700) {
87
90
  function isMissingPathError(error) {
88
91
  return error instanceof Error && "code" in error && error.code === "ENOENT";
89
92
  }
93
+ export const WORKSPACE_COPY_POLICY_V1 = Object.freeze({
94
+ policyId: "WorkspaceCopyPolicyV1",
95
+ maxTotalEntries: 25_000,
96
+ maxFiles: 20_000,
97
+ maxSymlinks: 2_000,
98
+ maxBytes: 512 * 1024 * 1024,
99
+ maxDepth: 64,
100
+ maxRelativePathBytes: 4096,
101
+ maxDurationSeconds: 600,
102
+ markerName: ".botlearn-workspace-copy",
103
+ });
104
+ export class WorkspaceCopyError extends Error {
105
+ code;
106
+ constructor(code) {
107
+ super(code);
108
+ this.code = code;
109
+ }
110
+ }
111
+ const fatalUtf8 = new TextDecoder("utf-8", { fatal: true });
112
+ function workspaceCopyDeadline(startedAt) {
113
+ if (Date.now() - startedAt > WORKSPACE_COPY_POLICY_V1.maxDurationSeconds * 1000) {
114
+ throw new WorkspaceCopyError("workspace_migration_limit_exceeded");
115
+ }
116
+ }
117
+ function isReservedTopLevelName(name) {
118
+ return name === ".botlearn" || name.startsWith(".botlearn-");
119
+ }
120
+ function safeUtf8(buffer) {
121
+ try {
122
+ const value = fatalUtf8.decode(buffer);
123
+ if (value.includes("\0"))
124
+ throw new Error("nul");
125
+ return value;
126
+ }
127
+ catch {
128
+ throw new WorkspaceCopyError("workspace_migration_content_unsafe");
129
+ }
130
+ }
131
+ function relativeWorkspacePath(components) {
132
+ const value = components.join("/");
133
+ if (Buffer.byteLength(value, "utf8") > WORKSPACE_COPY_POLICY_V1.maxRelativePathBytes) {
134
+ throw new WorkspaceCopyError("workspace_migration_limit_exceeded");
135
+ }
136
+ if (components.length > WORKSPACE_COPY_POLICY_V1.maxDepth) {
137
+ throw new WorkspaceCopyError("workspace_migration_limit_exceeded");
138
+ }
139
+ return value;
140
+ }
141
+ function countEntry(counters, type, bytes = 0) {
142
+ if (type === "file") {
143
+ counters.fileCount += 1;
144
+ counters.totalBytes += bytes;
145
+ }
146
+ else if (type === "dir")
147
+ counters.directoryCount += 1;
148
+ else
149
+ counters.symlinkCount += 1;
150
+ const total = counters.fileCount + counters.directoryCount + counters.symlinkCount;
151
+ if (total > WORKSPACE_COPY_POLICY_V1.maxTotalEntries ||
152
+ counters.fileCount > WORKSPACE_COPY_POLICY_V1.maxFiles ||
153
+ counters.symlinkCount > WORKSPACE_COPY_POLICY_V1.maxSymlinks ||
154
+ counters.totalBytes > WORKSPACE_COPY_POLICY_V1.maxBytes) {
155
+ throw new WorkspaceCopyError("workspace_migration_limit_exceeded");
156
+ }
157
+ }
158
+ function uint32(value) {
159
+ const result = Buffer.allocUnsafe(4);
160
+ result.writeUInt32BE(value);
161
+ return result;
162
+ }
163
+ function uint64(value) {
164
+ const result = Buffer.allocUnsafe(8);
165
+ result.writeBigUInt64BE(BigInt(value));
166
+ return result;
167
+ }
168
+ function encodedString(value) {
169
+ const bytes = Buffer.from(value, "utf8");
170
+ return [uint32(bytes.length), bytes];
171
+ }
172
+ export function workspaceCopyManifestDigest(entries) {
173
+ const digest = createHash("sha256");
174
+ const sorted = [...entries].sort((left, right) => Buffer.compare(Buffer.from(left.relativePath, "utf8"), Buffer.from(right.relativePath, "utf8")));
175
+ for (const entry of sorted) {
176
+ for (const item of encodedString(entry.type))
177
+ digest.update(item);
178
+ for (const item of encodedString(entry.relativePath))
179
+ digest.update(item);
180
+ for (const item of encodedString(entry.modeTag))
181
+ digest.update(item);
182
+ if (entry.type === "file") {
183
+ digest.update(uint64(entry.byteSize ?? 0));
184
+ digest.update(entry.contentDigest ?? Buffer.alloc(32));
185
+ }
186
+ else if (entry.type === "link") {
187
+ for (const item of encodedString(entry.linkTarget ?? ""))
188
+ digest.update(item);
189
+ }
190
+ }
191
+ return digest.digest("hex");
192
+ }
193
+ function sameFileSnapshot(before, after) {
194
+ return before.dev === after.dev && before.ino === after.ino && before.size === after.size &&
195
+ before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs;
196
+ }
197
+ function directoryLookupPath(handle, fallbackPath) {
198
+ // Linux production sandboxes expose traversable /proc descriptors. Local
199
+ // non-Linux tests retain functional coverage through the original path, but
200
+ // those builds must not advertise workspace_copy_v1.
201
+ return process.platform === "linux" ? `/proc/self/fd/${handle.fd}` : fallbackPath;
202
+ }
203
+ function sourceTraversalSupported() {
204
+ return process.platform === "linux" &&
205
+ typeof constants.O_DIRECTORY === "number" &&
206
+ typeof constants.O_NOFOLLOW === "number";
207
+ }
208
+ export function workspaceCopyV1Supported() {
209
+ return sourceTraversalSupported();
210
+ }
211
+ const COPY_STAGING_SUFFIX = "[A-Za-z0-9][A-Za-z0-9_-]{0,127}-[0-9a-f]{8}-[0-9a-f]{4}-" +
212
+ "[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
213
+ const MANAGED_COPY_STAGING_PATTERN = new RegExp(`^generation-[1-9][0-9]*\\.workspace-copy-${COPY_STAGING_SUFFIX}$`);
214
+ const LOCAL_COPY_STAGING_PATTERN = new RegExp(`^workspace\\.workspace-copy-${COPY_STAGING_SUFFIX}$`);
215
+ async function childDirectories(root) {
216
+ try {
217
+ return (await fs.readdir(root, { withFileTypes: true }))
218
+ .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
219
+ .map((entry) => entry.name);
220
+ }
221
+ catch (error) {
222
+ if (isMissingPathError(error))
223
+ return [];
224
+ throw new WorkspaceCopyError("workspace_migration_io_failed");
225
+ }
226
+ }
227
+ /** Remove crash-left staging directories before advertising workspace_copy_v1. */
228
+ export async function cleanupRuntimeSessionWorkspaceCopyStaging() {
229
+ const managedRoot = process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT?.trim();
230
+ if (managedRoot) {
231
+ for (const sessionName of await childDirectories(managedRoot)) {
232
+ if (!SAFE_ID_PATTERN.test(sessionName))
233
+ continue;
234
+ const sessionParent = path.join(managedRoot, sessionName);
235
+ for (const name of await childDirectories(sessionParent)) {
236
+ if (MANAGED_COPY_STAGING_PATTERN.test(name)) {
237
+ await fs.rm(path.join(sessionParent, name), { recursive: true, force: true });
238
+ }
239
+ }
240
+ }
241
+ }
242
+ const localRoot = path.join(daemonHome(), "agent-service-sessions");
243
+ for (const sessionName of await childDirectories(localRoot)) {
244
+ if (!SAFE_ID_PATTERN.test(sessionName))
245
+ continue;
246
+ const sessionParent = path.join(localRoot, sessionName);
247
+ for (const generationName of await childDirectories(sessionParent)) {
248
+ if (!/^generation-[1-9][0-9]*$/.test(generationName))
249
+ continue;
250
+ const generationParent = path.join(sessionParent, generationName);
251
+ for (const name of await childDirectories(generationParent)) {
252
+ if (LOCAL_COPY_STAGING_PATTERN.test(name)) {
253
+ await fs.rm(path.join(generationParent, name), { recursive: true, force: true });
254
+ }
255
+ }
256
+ }
257
+ }
258
+ }
259
+ function assertFileBudget(counters, size) {
260
+ if (!Number.isSafeInteger(size) ||
261
+ size < 0 ||
262
+ counters.fileCount + 1 > WORKSPACE_COPY_POLICY_V1.maxFiles ||
263
+ counters.fileCount + counters.directoryCount + counters.symlinkCount + 1 >
264
+ WORKSPACE_COPY_POLICY_V1.maxTotalEntries ||
265
+ counters.totalBytes + size > WORKSPACE_COPY_POLICY_V1.maxBytes) {
266
+ throw new WorkspaceCopyError("workspace_migration_limit_exceeded");
267
+ }
268
+ }
269
+ async function copyRegularFile(sourcePath, targetPath, counters, startedAt) {
270
+ let sourceHandle;
271
+ let targetHandle;
272
+ try {
273
+ sourceHandle = await fs.open(sourcePath, constants.O_RDONLY | constants.O_NOFOLLOW);
274
+ const before = await sourceHandle.stat();
275
+ if (!before.isFile())
276
+ throw new WorkspaceCopyError("workspace_migration_content_unsafe");
277
+ assertFileBudget(counters, before.size);
278
+ const mode = (before.mode & 0o111) !== 0 ? 0o700 : 0o600;
279
+ targetHandle = await fs.open(targetPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, mode);
280
+ const contentDigest = createHash("sha256");
281
+ const buffer = Buffer.allocUnsafe(64 * 1024);
282
+ let offset = 0;
283
+ while (offset < before.size) {
284
+ workspaceCopyDeadline(startedAt);
285
+ const { bytesRead } = await sourceHandle.read(buffer, 0, Math.min(buffer.length, before.size - offset), offset);
286
+ if (bytesRead <= 0)
287
+ throw new WorkspaceCopyError("workspace_migration_io_failed");
288
+ const chunk = buffer.subarray(0, bytesRead);
289
+ let bytesWritten = 0;
290
+ while (bytesWritten < bytesRead) {
291
+ const write = await targetHandle.write(chunk, bytesWritten, bytesRead - bytesWritten, offset + bytesWritten);
292
+ if (write.bytesWritten <= 0) {
293
+ throw new WorkspaceCopyError("workspace_migration_io_failed");
294
+ }
295
+ bytesWritten += write.bytesWritten;
296
+ }
297
+ contentDigest.update(chunk);
298
+ offset += bytesRead;
299
+ await new Promise((resolve) => setImmediate(resolve));
300
+ }
301
+ const after = await sourceHandle.stat();
302
+ if (!sameFileSnapshot(before, after)) {
303
+ throw new WorkspaceCopyError("workspace_migration_content_unsafe");
304
+ }
305
+ await targetHandle.chmod(mode);
306
+ await targetHandle.sync();
307
+ return {
308
+ size: before.size,
309
+ digest: contentDigest.digest(),
310
+ modeTag: mode === 0o700 ? "file-0700" : "file-0600",
311
+ };
312
+ }
313
+ catch (error) {
314
+ if (error instanceof WorkspaceCopyError)
315
+ throw error;
316
+ throw new WorkspaceCopyError("workspace_migration_io_failed");
317
+ }
318
+ finally {
319
+ await targetHandle?.close().catch(() => undefined);
320
+ await sourceHandle?.close().catch(() => undefined);
321
+ }
322
+ }
323
+ async function validateSymbolicLink(sourceRootRealPath, sourceDirectoryPath, relativeParent, target) {
324
+ if (path.posix.isAbsolute(target) || path.isAbsolute(target) || target.includes("\0")) {
325
+ throw new WorkspaceCopyError("workspace_migration_content_unsafe");
326
+ }
327
+ const lexical = path.posix.normalize(path.posix.join(relativeParent, target));
328
+ if (lexical === ".." || lexical.startsWith("../")) {
329
+ throw new WorkspaceCopyError("workspace_migration_content_unsafe");
330
+ }
331
+ const top = lexical.split("/")[0] ?? "";
332
+ if (isReservedTopLevelName(top)) {
333
+ throw new WorkspaceCopyError("workspace_migration_content_unsafe");
334
+ }
335
+ try {
336
+ // Resolve the captured target text from the open parent directory, not by
337
+ // following the source symlink pathname again after readlink().
338
+ const resolved = await fs.realpath(path.join(sourceDirectoryPath, target));
339
+ if (resolved !== sourceRootRealPath &&
340
+ !resolved.startsWith(`${sourceRootRealPath}${path.sep}`)) {
341
+ throw new WorkspaceCopyError("workspace_migration_content_unsafe");
342
+ }
343
+ const resolvedRelative = path.relative(sourceRootRealPath, resolved).split(path.sep).join("/");
344
+ if (isReservedTopLevelName(resolvedRelative.split("/")[0] ?? "")) {
345
+ throw new WorkspaceCopyError("workspace_migration_content_unsafe");
346
+ }
347
+ }
348
+ catch (error) {
349
+ if (error instanceof WorkspaceCopyError)
350
+ throw error;
351
+ // Dangling links and cycles cannot be proven safe in V1.
352
+ throw new WorkspaceCopyError("workspace_migration_content_unsafe");
353
+ }
354
+ }
355
+ async function copyWorkspaceTree(sourceRoot, stagingRoot, startedAt) {
356
+ const entries = [];
357
+ const counters = {
358
+ fileCount: 0,
359
+ directoryCount: 0,
360
+ symlinkCount: 0,
361
+ totalBytes: 0,
362
+ };
363
+ let rootHandle;
364
+ try {
365
+ rootHandle = await fs.open(sourceRoot, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
366
+ const rootInfo = await rootHandle.stat();
367
+ if (!rootInfo.isDirectory()) {
368
+ throw new WorkspaceCopyError("workspace_migration_content_unsafe");
369
+ }
370
+ }
371
+ catch (error) {
372
+ if (error instanceof WorkspaceCopyError)
373
+ throw error;
374
+ throw new WorkspaceCopyError("workspace_migration_source_unavailable");
375
+ }
376
+ const rootLookupPath = directoryLookupPath(rootHandle, sourceRoot);
377
+ const sourceRootRealPath = await fs.realpath(rootLookupPath);
378
+ const visit = async (sourceDirectory, sourceDirectoryPath, components, targetDir) => {
379
+ workspaceCopyDeadline(startedAt);
380
+ const sourceDirectoryLookup = directoryLookupPath(sourceDirectory, sourceDirectoryPath);
381
+ let children;
382
+ try {
383
+ children = await fs.readdir(sourceDirectoryLookup, {
384
+ encoding: "buffer",
385
+ withFileTypes: true,
386
+ });
387
+ }
388
+ catch {
389
+ throw new WorkspaceCopyError("workspace_migration_io_failed");
390
+ }
391
+ children.sort((left, right) => Buffer.compare(left.name, right.name));
392
+ for (const child of children) {
393
+ workspaceCopyDeadline(startedAt);
394
+ const name = safeUtf8(child.name);
395
+ if (components.length === 0 && isReservedTopLevelName(name))
396
+ continue;
397
+ const childComponents = [...components, name];
398
+ const relativePath = relativeWorkspacePath(childComponents);
399
+ const sourcePath = path.join(sourceDirectoryLookup, name);
400
+ const targetPath = path.join(targetDir, name);
401
+ let info;
402
+ try {
403
+ info = await fs.lstat(sourcePath);
404
+ }
405
+ catch {
406
+ throw new WorkspaceCopyError("workspace_migration_io_failed");
407
+ }
408
+ if (info.isDirectory() && !info.isSymbolicLink()) {
409
+ let childDirectory;
410
+ try {
411
+ childDirectory = await fs.open(sourcePath, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
412
+ if (!(await childDirectory.stat()).isDirectory()) {
413
+ throw new WorkspaceCopyError("workspace_migration_content_unsafe");
414
+ }
415
+ countEntry(counters, "dir");
416
+ await fs.mkdir(targetPath, { mode: 0o700 });
417
+ await fs.chmod(targetPath, 0o700);
418
+ entries.push({ type: "dir", relativePath, modeTag: "dir-0700" });
419
+ await visit(childDirectory, sourcePath, childComponents, targetPath);
420
+ }
421
+ catch (error) {
422
+ if (error instanceof WorkspaceCopyError)
423
+ throw error;
424
+ throw new WorkspaceCopyError("workspace_migration_content_unsafe");
425
+ }
426
+ finally {
427
+ await childDirectory?.close().catch(() => undefined);
428
+ }
429
+ }
430
+ else if (info.isSymbolicLink()) {
431
+ let targetBuffer;
432
+ try {
433
+ targetBuffer = await fs.readlink(sourcePath, { encoding: "buffer" });
434
+ }
435
+ catch {
436
+ throw new WorkspaceCopyError("workspace_migration_io_failed");
437
+ }
438
+ const linkTarget = safeUtf8(targetBuffer);
439
+ await validateSymbolicLink(sourceRootRealPath, sourceDirectoryLookup, components.join("/"), linkTarget);
440
+ countEntry(counters, "link");
441
+ await fs.symlink(linkTarget, targetPath);
442
+ entries.push({ type: "link", relativePath, modeTag: "link", linkTarget });
443
+ }
444
+ else if (info.isFile()) {
445
+ const copied = await copyRegularFile(sourcePath, targetPath, counters, startedAt);
446
+ countEntry(counters, "file", copied.size);
447
+ entries.push({
448
+ type: "file",
449
+ relativePath,
450
+ modeTag: copied.modeTag,
451
+ byteSize: copied.size,
452
+ contentDigest: copied.digest,
453
+ });
454
+ }
455
+ else {
456
+ throw new WorkspaceCopyError("workspace_migration_content_unsafe");
457
+ }
458
+ }
459
+ };
460
+ try {
461
+ await visit(rootHandle, sourceRoot, [], stagingRoot);
462
+ return { entries, counters };
463
+ }
464
+ finally {
465
+ await rootHandle.close().catch(() => undefined);
466
+ }
467
+ }
468
+ async function manifestFromWorkspace(root, startedAt) {
469
+ const scratch = `${root}.manifest-${randomUUID()}`;
470
+ await fs.mkdir(scratch, { mode: 0o700 });
471
+ try {
472
+ return await copyWorkspaceTree(root, scratch, startedAt);
473
+ }
474
+ finally {
475
+ await fs.rm(scratch, { recursive: true, force: true });
476
+ }
477
+ }
478
+ function receiptMatchesRequest(receipt, request) {
479
+ return receipt.schema_version === "botlearn-workspace-copy-receipt/1" &&
480
+ receipt.policy_id === WORKSPACE_COPY_POLICY_V1.policyId &&
481
+ receipt.copy_id === request.copyId &&
482
+ receipt.source_runtime_session_id === request.sourceRuntimeSessionId &&
483
+ receipt.target_runtime_session_id === request.targetRuntimeSessionId &&
484
+ receipt.source_sandbox_id === request.sandboxId &&
485
+ receipt.source_generation === request.sandboxGeneration &&
486
+ Number.isSafeInteger(receipt.file_count) && receipt.file_count >= 0 &&
487
+ Number.isSafeInteger(receipt.directory_count) && receipt.directory_count >= 0 &&
488
+ Number.isSafeInteger(receipt.symlink_count) && receipt.symlink_count >= 0 &&
489
+ Number.isSafeInteger(receipt.total_bytes) && receipt.total_bytes >= 0 &&
490
+ /^[0-9a-f]{64}$/.test(receipt.manifest_digest);
491
+ }
492
+ async function readWorkspaceCopyMarker(markerPath) {
493
+ let marker;
494
+ try {
495
+ marker = await fs.open(markerPath, constants.O_RDONLY | constants.O_NOFOLLOW);
496
+ const info = await marker.stat();
497
+ if (!info.isFile() || info.size < 2 || info.size > 4096) {
498
+ throw new WorkspaceCopyError("workspace_migration_receipt_mismatch");
499
+ }
500
+ return JSON.parse(await marker.readFile("utf8"));
501
+ }
502
+ catch (error) {
503
+ if (error instanceof WorkspaceCopyError)
504
+ throw error;
505
+ throw new WorkspaceCopyError("workspace_migration_receipt_mismatch");
506
+ }
507
+ finally {
508
+ await marker?.close().catch(() => undefined);
509
+ }
510
+ }
511
+ /** Copy learner-owned files into a new Session and durably stage a content-free receipt. */
512
+ export async function copyRuntimeSessionWorkspace(request) {
513
+ const startedAt = Date.now();
514
+ const { copyId, sourceRuntimeSessionId, targetRuntimeSessionId, sandboxId, sandboxGeneration, } = request;
515
+ assertSafeId(copyId, "copy_id");
516
+ assertSafeId(sourceRuntimeSessionId, "source_runtime_session_id");
517
+ assertSafeId(targetRuntimeSessionId, "target_runtime_session_id");
518
+ if (sourceRuntimeSessionId === targetRuntimeSessionId) {
519
+ throw new WorkspaceCopyError("workspace_migration_content_unsafe");
520
+ }
521
+ const source = runtimeSessionWorkspaceDir(sourceRuntimeSessionId, sandboxGeneration);
522
+ const target = runtimeSessionWorkspaceDir(targetRuntimeSessionId, sandboxGeneration);
523
+ const markerPath = path.join(target, WORKSPACE_COPY_POLICY_V1.markerName);
524
+ try {
525
+ const sourceInfo = await fs.lstat(source);
526
+ if (!sourceInfo.isDirectory() || sourceInfo.isSymbolicLink()) {
527
+ throw new WorkspaceCopyError("workspace_migration_source_unavailable");
528
+ }
529
+ }
530
+ catch (error) {
531
+ if (error instanceof WorkspaceCopyError)
532
+ throw error;
533
+ throw new WorkspaceCopyError("workspace_migration_source_unavailable");
534
+ }
535
+ if (existsSync(target)) {
536
+ const receipt = await readWorkspaceCopyMarker(markerPath);
537
+ if (!receiptMatchesRequest(receipt, request)) {
538
+ throw new WorkspaceCopyError("workspace_migration_receipt_mismatch");
539
+ }
540
+ const staged = await manifestFromWorkspace(target, startedAt);
541
+ if (workspaceCopyManifestDigest(staged.entries) !== receipt.manifest_digest ||
542
+ staged.counters.fileCount !== receipt.file_count ||
543
+ staged.counters.directoryCount !== receipt.directory_count ||
544
+ staged.counters.symlinkCount !== receipt.symlink_count ||
545
+ staged.counters.totalBytes !== receipt.total_bytes) {
546
+ throw new WorkspaceCopyError("workspace_migration_receipt_mismatch");
547
+ }
548
+ return { receipt, markerPath };
549
+ }
550
+ const targetParent = path.dirname(target);
551
+ await fs.mkdir(targetParent, { recursive: true, mode: 0o700 });
552
+ await fs.chmod(targetParent, 0o700);
553
+ const stagingPrefix = `${path.basename(target)}.workspace-copy-${copyId}-`;
554
+ for (const name of await fs.readdir(targetParent)) {
555
+ if (name.startsWith(stagingPrefix)) {
556
+ await fs.rm(path.join(targetParent, name), { recursive: true, force: true });
557
+ }
558
+ }
559
+ const staging = path.join(targetParent, `${stagingPrefix}${randomUUID()}`);
560
+ await fs.mkdir(staging, { mode: 0o700 });
561
+ try {
562
+ const sourceManifest = await copyWorkspaceTree(source, staging, startedAt);
563
+ const sourceDigest = workspaceCopyManifestDigest(sourceManifest.entries);
564
+ const stagedManifest = await manifestFromWorkspace(staging, startedAt);
565
+ const stagedDigest = workspaceCopyManifestDigest(stagedManifest.entries);
566
+ if (sourceDigest !== stagedDigest) {
567
+ throw new WorkspaceCopyError("workspace_migration_content_unsafe");
568
+ }
569
+ const receipt = {
570
+ schema_version: "botlearn-workspace-copy-receipt/1",
571
+ policy_id: WORKSPACE_COPY_POLICY_V1.policyId,
572
+ copy_id: copyId,
573
+ source_runtime_session_id: sourceRuntimeSessionId,
574
+ target_runtime_session_id: targetRuntimeSessionId,
575
+ source_sandbox_id: sandboxId,
576
+ source_generation: sandboxGeneration,
577
+ file_count: sourceManifest.counters.fileCount,
578
+ directory_count: sourceManifest.counters.directoryCount,
579
+ symlink_count: sourceManifest.counters.symlinkCount,
580
+ total_bytes: sourceManifest.counters.totalBytes,
581
+ manifest_digest: sourceDigest,
582
+ };
583
+ const stagingMarker = path.join(staging, WORKSPACE_COPY_POLICY_V1.markerName);
584
+ const marker = await fs.open(stagingMarker, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
585
+ try {
586
+ await marker.writeFile(JSON.stringify(receipt));
587
+ await marker.sync();
588
+ }
589
+ finally {
590
+ await marker.close();
591
+ }
592
+ const stagingHandle = await fs.open(staging, constants.O_RDONLY | constants.O_DIRECTORY);
593
+ try {
594
+ await stagingHandle.sync();
595
+ }
596
+ finally {
597
+ await stagingHandle.close();
598
+ }
599
+ workspaceCopyDeadline(startedAt);
600
+ if (existsSync(target)) {
601
+ throw new WorkspaceCopyError("workspace_migration_receipt_mismatch");
602
+ }
603
+ await fs.rename(staging, target);
604
+ await fs.chmod(target, 0o700);
605
+ const parentHandle = await fs.open(targetParent, constants.O_RDONLY | constants.O_DIRECTORY);
606
+ try {
607
+ await parentHandle.sync();
608
+ }
609
+ finally {
610
+ await parentHandle.close();
611
+ }
612
+ return { receipt, markerPath };
613
+ }
614
+ catch (error) {
615
+ await fs.rm(staging, { recursive: true, force: true });
616
+ if (error instanceof WorkspaceCopyError)
617
+ throw error;
618
+ throw new WorkspaceCopyError("workspace_migration_io_failed");
619
+ }
620
+ }
621
+ export async function finalizeRuntimeSessionWorkspaceCopy(markerPath) {
622
+ try {
623
+ await fs.unlink(markerPath);
624
+ }
625
+ catch (error) {
626
+ if (!isMissingPathError(error)) {
627
+ throw new WorkspaceCopyError("workspace_migration_io_failed");
628
+ }
629
+ }
630
+ let parentHandle;
631
+ try {
632
+ parentHandle = await fs.open(path.dirname(markerPath), constants.O_RDONLY | constants.O_DIRECTORY);
633
+ await parentHandle.sync();
634
+ }
635
+ catch {
636
+ throw new WorkspaceCopyError("workspace_migration_io_failed");
637
+ }
638
+ finally {
639
+ await parentHandle?.close().catch(() => undefined);
640
+ }
641
+ }
90
642
  export function ensureRunWorkspace(agentRunId) {
91
643
  const rootDir = runRootDir(agentRunId);
92
644
  const workspaceDir = runWorkspaceDir(agentRunId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botlearn-course/daemon",
3
- "version": "0.0.19",
3
+ "version": "0.0.20-beta.2",
4
4
  "description": "Lightweight BotLearn Course daemon: run course tasks on your own machine with your own agent runtime (BYOA).",
5
5
  "type": "module",
6
6
  "bin": {