@botlearn-course/daemon 0.0.20-beta.15 → 0.0.20-beta.16

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/workspace.js CHANGED
@@ -1,8 +1,5 @@
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";
1
+ import { chmodSync, existsSync, mkdirSync, readdirSync, rmSync, } from "node:fs";
4
2
  import path from "node:path";
5
- import { TextDecoder } from "node:util";
6
3
  import { daemonHome } from "./auth-store.js";
7
4
  /**
8
5
  * 每 run 隔离工作区(spec §4):
@@ -53,27 +50,27 @@ export function runtimeSessionRootDir(runtimeSessionId, sandboxGeneration) {
53
50
  return path.join(daemonHome(), "agent-service-sessions", runtimeSessionId, `generation-${sandboxGeneration}`);
54
51
  }
55
52
  /**
56
- * per-session workspace(ADR-015 §7):managed sandbox 使用稳定路径
57
- * `<root>/sessions/<runtime_session_id>/workspace`。该路径不含 generation,
53
+ * durable workspacemanaged sandbox 使用稳定路径
54
+ * `<root>/sessions/<workspace_id>/workspace`。该路径不含 generation,
58
55
  * 因为 NAS image 跨 generation 重新挂载后必须解析到同一个目录。
59
56
  */
60
- export function runtimeSessionWorkspaceDir(runtimeSessionId, sandboxGeneration) {
61
- assertSafeId(runtimeSessionId, "runtime_session_id");
57
+ export function runtimeSessionWorkspaceDir(workspaceId, sandboxGeneration) {
58
+ assertSafeId(workspaceId, "workspace_id");
62
59
  if (!Number.isInteger(sandboxGeneration) || sandboxGeneration < 1) {
63
60
  throw new Error("unsafe sandbox_generation: expected a positive integer");
64
61
  }
65
62
  const managedRoot = process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT?.trim();
66
63
  if (managedRoot) {
67
- return path.join(managedRoot, "sessions", runtimeSessionId, "workspace");
64
+ return path.join(managedRoot, "sessions", workspaceId, "workspace");
68
65
  }
69
- return path.join(runtimeSessionRootDir(runtimeSessionId, sandboxGeneration), "workspace");
66
+ return path.join(runtimeSessionRootDir(workspaceId, sandboxGeneration), "workspace");
70
67
  }
71
- function managedRuntimeSessionParentDir(runtimeSessionId) {
72
- assertSafeId(runtimeSessionId, "runtime_session_id");
68
+ function managedWorkspaceParentDir(workspaceId) {
69
+ assertSafeId(workspaceId, "workspace_id");
73
70
  const managedRoot = process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT?.trim();
74
71
  if (!managedRoot)
75
72
  return null;
76
- return path.join(managedRoot, "sessions", runtimeSessionId);
73
+ return path.join(managedRoot, "sessions", workspaceId);
77
74
  }
78
75
  export function runtimeSessionTranscriptPath(runtimeSessionId, sandboxGeneration, agentRunId) {
79
76
  assertSafeId(agentRunId, "agent_run_id");
@@ -92,555 +89,6 @@ function mkdirTolerant(dir, mode = 0o700) {
92
89
  function isMissingPathError(error) {
93
90
  return error instanceof Error && "code" in error && error.code === "ENOENT";
94
91
  }
95
- export const WORKSPACE_COPY_POLICY_V1 = Object.freeze({
96
- policyId: "WorkspaceCopyPolicyV1",
97
- maxTotalEntries: 25_000,
98
- maxFiles: 20_000,
99
- maxSymlinks: 2_000,
100
- maxBytes: 512 * 1024 * 1024,
101
- maxDepth: 64,
102
- maxRelativePathBytes: 4096,
103
- maxDurationSeconds: 600,
104
- markerName: ".botlearn-workspace-copy",
105
- });
106
- export class WorkspaceCopyError extends Error {
107
- code;
108
- constructor(code) {
109
- super(code);
110
- this.code = code;
111
- }
112
- }
113
- const fatalUtf8 = new TextDecoder("utf-8", { fatal: true });
114
- function workspaceCopyDeadline(startedAt) {
115
- if (Date.now() - startedAt > WORKSPACE_COPY_POLICY_V1.maxDurationSeconds * 1000) {
116
- throw new WorkspaceCopyError("workspace_migration_limit_exceeded");
117
- }
118
- }
119
- function isReservedTopLevelName(name) {
120
- return name === ".botlearn" || name.startsWith(".botlearn-");
121
- }
122
- function safeUtf8(buffer) {
123
- try {
124
- const value = fatalUtf8.decode(buffer);
125
- if (value.includes("\0"))
126
- throw new Error("nul");
127
- return value;
128
- }
129
- catch {
130
- throw new WorkspaceCopyError("workspace_migration_content_unsafe");
131
- }
132
- }
133
- function relativeWorkspacePath(components) {
134
- const value = components.join("/");
135
- if (Buffer.byteLength(value, "utf8") > WORKSPACE_COPY_POLICY_V1.maxRelativePathBytes) {
136
- throw new WorkspaceCopyError("workspace_migration_limit_exceeded");
137
- }
138
- if (components.length > WORKSPACE_COPY_POLICY_V1.maxDepth) {
139
- throw new WorkspaceCopyError("workspace_migration_limit_exceeded");
140
- }
141
- return value;
142
- }
143
- function countEntry(counters, type, bytes = 0) {
144
- if (type === "file") {
145
- counters.fileCount += 1;
146
- counters.totalBytes += bytes;
147
- }
148
- else if (type === "dir")
149
- counters.directoryCount += 1;
150
- else
151
- counters.symlinkCount += 1;
152
- const total = counters.fileCount + counters.directoryCount + counters.symlinkCount;
153
- if (total > WORKSPACE_COPY_POLICY_V1.maxTotalEntries ||
154
- counters.fileCount > WORKSPACE_COPY_POLICY_V1.maxFiles ||
155
- counters.symlinkCount > WORKSPACE_COPY_POLICY_V1.maxSymlinks ||
156
- counters.totalBytes > WORKSPACE_COPY_POLICY_V1.maxBytes) {
157
- throw new WorkspaceCopyError("workspace_migration_limit_exceeded");
158
- }
159
- }
160
- function uint32(value) {
161
- const result = Buffer.allocUnsafe(4);
162
- result.writeUInt32BE(value);
163
- return result;
164
- }
165
- function uint64(value) {
166
- const result = Buffer.allocUnsafe(8);
167
- result.writeBigUInt64BE(BigInt(value));
168
- return result;
169
- }
170
- function encodedString(value) {
171
- const bytes = Buffer.from(value, "utf8");
172
- return [uint32(bytes.length), bytes];
173
- }
174
- export function workspaceCopyManifestDigest(entries) {
175
- const digest = createHash("sha256");
176
- const sorted = [...entries].sort((left, right) => Buffer.compare(Buffer.from(left.relativePath, "utf8"), Buffer.from(right.relativePath, "utf8")));
177
- for (const entry of sorted) {
178
- for (const item of encodedString(entry.type))
179
- digest.update(item);
180
- for (const item of encodedString(entry.relativePath))
181
- digest.update(item);
182
- for (const item of encodedString(entry.modeTag))
183
- digest.update(item);
184
- if (entry.type === "file") {
185
- digest.update(uint64(entry.byteSize ?? 0));
186
- digest.update(entry.contentDigest ?? Buffer.alloc(32));
187
- }
188
- else if (entry.type === "link") {
189
- for (const item of encodedString(entry.linkTarget ?? ""))
190
- digest.update(item);
191
- }
192
- }
193
- return digest.digest("hex");
194
- }
195
- function sameFileSnapshot(before, after) {
196
- return before.dev === after.dev && before.ino === after.ino && before.size === after.size &&
197
- before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs;
198
- }
199
- function directoryLookupPath(handle, fallbackPath) {
200
- // Linux production sandboxes expose traversable /proc descriptors. Local
201
- // non-Linux tests retain functional coverage through the original path, but
202
- // those builds must not advertise workspace_copy_v1.
203
- return process.platform === "linux" ? `/proc/self/fd/${handle.fd}` : fallbackPath;
204
- }
205
- function sourceTraversalSupported() {
206
- return process.platform === "linux" &&
207
- typeof constants.O_DIRECTORY === "number" &&
208
- typeof constants.O_NOFOLLOW === "number";
209
- }
210
- export function workspaceCopyV1Supported() {
211
- return sourceTraversalSupported();
212
- }
213
- const COPY_STAGING_SUFFIX = "[A-Za-z0-9][A-Za-z0-9_-]{0,127}-[0-9a-f]{8}-[0-9a-f]{4}-" +
214
- "[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
215
- const COPY_STAGING_PATTERN = new RegExp(`^workspace\\.workspace-copy-${COPY_STAGING_SUFFIX}$`);
216
- async function childDirectories(root) {
217
- try {
218
- return (await fs.readdir(root, { withFileTypes: true }))
219
- .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
220
- .map((entry) => entry.name);
221
- }
222
- catch (error) {
223
- if (isMissingPathError(error))
224
- return [];
225
- throw new WorkspaceCopyError("workspace_migration_io_failed");
226
- }
227
- }
228
- /** Remove crash-left staging directories before advertising workspace_copy_v1. */
229
- export async function cleanupRuntimeSessionWorkspaceCopyStaging() {
230
- const managedRoot = process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT?.trim();
231
- if (managedRoot) {
232
- const sessionRoot = path.join(managedRoot, "sessions");
233
- for (const sessionName of await childDirectories(sessionRoot)) {
234
- if (!SAFE_ID_PATTERN.test(sessionName))
235
- continue;
236
- const sessionParent = path.join(sessionRoot, sessionName);
237
- for (const name of await childDirectories(sessionParent)) {
238
- if (COPY_STAGING_PATTERN.test(name)) {
239
- await fs.rm(path.join(sessionParent, name), { recursive: true, force: true });
240
- }
241
- }
242
- }
243
- }
244
- const localRoot = path.join(daemonHome(), "agent-service-sessions");
245
- for (const sessionName of await childDirectories(localRoot)) {
246
- if (!SAFE_ID_PATTERN.test(sessionName))
247
- continue;
248
- const sessionParent = path.join(localRoot, sessionName);
249
- for (const generationName of await childDirectories(sessionParent)) {
250
- if (!/^generation-[1-9][0-9]*$/.test(generationName))
251
- continue;
252
- const generationParent = path.join(sessionParent, generationName);
253
- for (const name of await childDirectories(generationParent)) {
254
- if (COPY_STAGING_PATTERN.test(name)) {
255
- await fs.rm(path.join(generationParent, name), { recursive: true, force: true });
256
- }
257
- }
258
- }
259
- }
260
- }
261
- function assertFileBudget(counters, size) {
262
- if (!Number.isSafeInteger(size) ||
263
- size < 0 ||
264
- counters.fileCount + 1 > WORKSPACE_COPY_POLICY_V1.maxFiles ||
265
- counters.fileCount + counters.directoryCount + counters.symlinkCount + 1 >
266
- WORKSPACE_COPY_POLICY_V1.maxTotalEntries ||
267
- counters.totalBytes + size > WORKSPACE_COPY_POLICY_V1.maxBytes) {
268
- throw new WorkspaceCopyError("workspace_migration_limit_exceeded");
269
- }
270
- }
271
- async function copyRegularFile(sourcePath, targetPath, counters, startedAt) {
272
- let sourceHandle;
273
- let targetHandle;
274
- try {
275
- sourceHandle = await fs.open(sourcePath, constants.O_RDONLY | constants.O_NOFOLLOW);
276
- const before = await sourceHandle.stat();
277
- if (!before.isFile())
278
- throw new WorkspaceCopyError("workspace_migration_content_unsafe");
279
- assertFileBudget(counters, before.size);
280
- const mode = (before.mode & 0o111) !== 0 ? 0o700 : 0o600;
281
- targetHandle = await fs.open(targetPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, mode);
282
- const contentDigest = createHash("sha256");
283
- const buffer = Buffer.allocUnsafe(64 * 1024);
284
- let offset = 0;
285
- while (offset < before.size) {
286
- workspaceCopyDeadline(startedAt);
287
- const { bytesRead } = await sourceHandle.read(buffer, 0, Math.min(buffer.length, before.size - offset), offset);
288
- if (bytesRead <= 0)
289
- throw new WorkspaceCopyError("workspace_migration_io_failed");
290
- const chunk = buffer.subarray(0, bytesRead);
291
- let bytesWritten = 0;
292
- while (bytesWritten < bytesRead) {
293
- const write = await targetHandle.write(chunk, bytesWritten, bytesRead - bytesWritten, offset + bytesWritten);
294
- if (write.bytesWritten <= 0) {
295
- throw new WorkspaceCopyError("workspace_migration_io_failed");
296
- }
297
- bytesWritten += write.bytesWritten;
298
- }
299
- contentDigest.update(chunk);
300
- offset += bytesRead;
301
- await new Promise((resolve) => setImmediate(resolve));
302
- }
303
- const after = await sourceHandle.stat();
304
- if (!sameFileSnapshot(before, after)) {
305
- throw new WorkspaceCopyError("workspace_migration_content_unsafe");
306
- }
307
- await targetHandle.chmod(mode);
308
- await targetHandle.sync();
309
- return {
310
- size: before.size,
311
- digest: contentDigest.digest(),
312
- modeTag: mode === 0o700 ? "file-0700" : "file-0600",
313
- };
314
- }
315
- catch (error) {
316
- if (error instanceof WorkspaceCopyError)
317
- throw error;
318
- throw new WorkspaceCopyError("workspace_migration_io_failed");
319
- }
320
- finally {
321
- await targetHandle?.close().catch(() => undefined);
322
- await sourceHandle?.close().catch(() => undefined);
323
- }
324
- }
325
- async function validateSymbolicLink(sourceRootRealPath, sourceDirectoryPath, relativeParent, target) {
326
- if (path.posix.isAbsolute(target) || path.isAbsolute(target) || target.includes("\0")) {
327
- throw new WorkspaceCopyError("workspace_migration_content_unsafe");
328
- }
329
- const lexical = path.posix.normalize(path.posix.join(relativeParent, target));
330
- if (lexical === ".." || lexical.startsWith("../")) {
331
- throw new WorkspaceCopyError("workspace_migration_content_unsafe");
332
- }
333
- const top = lexical.split("/")[0] ?? "";
334
- if (isReservedTopLevelName(top)) {
335
- throw new WorkspaceCopyError("workspace_migration_content_unsafe");
336
- }
337
- try {
338
- // Resolve the captured target text from the open parent directory, not by
339
- // following the source symlink pathname again after readlink().
340
- const resolved = await fs.realpath(path.join(sourceDirectoryPath, target));
341
- if (resolved !== sourceRootRealPath &&
342
- !resolved.startsWith(`${sourceRootRealPath}${path.sep}`)) {
343
- throw new WorkspaceCopyError("workspace_migration_content_unsafe");
344
- }
345
- const resolvedRelative = path.relative(sourceRootRealPath, resolved).split(path.sep).join("/");
346
- if (isReservedTopLevelName(resolvedRelative.split("/")[0] ?? "")) {
347
- throw new WorkspaceCopyError("workspace_migration_content_unsafe");
348
- }
349
- }
350
- catch (error) {
351
- if (error instanceof WorkspaceCopyError)
352
- throw error;
353
- // Dangling links and cycles cannot be proven safe in V1.
354
- throw new WorkspaceCopyError("workspace_migration_content_unsafe");
355
- }
356
- }
357
- async function copyWorkspaceTree(sourceRoot, stagingRoot, startedAt) {
358
- const entries = [];
359
- const counters = {
360
- fileCount: 0,
361
- directoryCount: 0,
362
- symlinkCount: 0,
363
- totalBytes: 0,
364
- };
365
- let rootHandle;
366
- try {
367
- rootHandle = await fs.open(sourceRoot, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
368
- const rootInfo = await rootHandle.stat();
369
- if (!rootInfo.isDirectory()) {
370
- throw new WorkspaceCopyError("workspace_migration_content_unsafe");
371
- }
372
- }
373
- catch (error) {
374
- if (error instanceof WorkspaceCopyError)
375
- throw error;
376
- throw new WorkspaceCopyError("workspace_migration_source_unavailable");
377
- }
378
- const rootLookupPath = directoryLookupPath(rootHandle, sourceRoot);
379
- const sourceRootRealPath = await fs.realpath(rootLookupPath);
380
- const visit = async (sourceDirectory, sourceDirectoryPath, components, targetDir) => {
381
- workspaceCopyDeadline(startedAt);
382
- const sourceDirectoryLookup = directoryLookupPath(sourceDirectory, sourceDirectoryPath);
383
- let children;
384
- try {
385
- children = await fs.readdir(sourceDirectoryLookup, {
386
- encoding: "buffer",
387
- withFileTypes: true,
388
- });
389
- }
390
- catch {
391
- throw new WorkspaceCopyError("workspace_migration_io_failed");
392
- }
393
- children.sort((left, right) => Buffer.compare(left.name, right.name));
394
- for (const child of children) {
395
- workspaceCopyDeadline(startedAt);
396
- const name = safeUtf8(child.name);
397
- if (components.length === 0 && isReservedTopLevelName(name))
398
- continue;
399
- const childComponents = [...components, name];
400
- const relativePath = relativeWorkspacePath(childComponents);
401
- const sourcePath = path.join(sourceDirectoryLookup, name);
402
- const targetPath = path.join(targetDir, name);
403
- let info;
404
- try {
405
- info = await fs.lstat(sourcePath);
406
- }
407
- catch {
408
- throw new WorkspaceCopyError("workspace_migration_io_failed");
409
- }
410
- if (info.isDirectory() && !info.isSymbolicLink()) {
411
- let childDirectory;
412
- try {
413
- childDirectory = await fs.open(sourcePath, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
414
- if (!(await childDirectory.stat()).isDirectory()) {
415
- throw new WorkspaceCopyError("workspace_migration_content_unsafe");
416
- }
417
- countEntry(counters, "dir");
418
- await fs.mkdir(targetPath, { mode: 0o700 });
419
- await fs.chmod(targetPath, 0o700);
420
- entries.push({ type: "dir", relativePath, modeTag: "dir-0700" });
421
- await visit(childDirectory, sourcePath, childComponents, targetPath);
422
- }
423
- catch (error) {
424
- if (error instanceof WorkspaceCopyError)
425
- throw error;
426
- throw new WorkspaceCopyError("workspace_migration_content_unsafe");
427
- }
428
- finally {
429
- await childDirectory?.close().catch(() => undefined);
430
- }
431
- }
432
- else if (info.isSymbolicLink()) {
433
- let targetBuffer;
434
- try {
435
- targetBuffer = await fs.readlink(sourcePath, { encoding: "buffer" });
436
- }
437
- catch {
438
- throw new WorkspaceCopyError("workspace_migration_io_failed");
439
- }
440
- const linkTarget = safeUtf8(targetBuffer);
441
- await validateSymbolicLink(sourceRootRealPath, sourceDirectoryLookup, components.join("/"), linkTarget);
442
- countEntry(counters, "link");
443
- await fs.symlink(linkTarget, targetPath);
444
- entries.push({ type: "link", relativePath, modeTag: "link", linkTarget });
445
- }
446
- else if (info.isFile()) {
447
- const copied = await copyRegularFile(sourcePath, targetPath, counters, startedAt);
448
- countEntry(counters, "file", copied.size);
449
- entries.push({
450
- type: "file",
451
- relativePath,
452
- modeTag: copied.modeTag,
453
- byteSize: copied.size,
454
- contentDigest: copied.digest,
455
- });
456
- }
457
- else {
458
- throw new WorkspaceCopyError("workspace_migration_content_unsafe");
459
- }
460
- }
461
- };
462
- try {
463
- await visit(rootHandle, sourceRoot, [], stagingRoot);
464
- return { entries, counters };
465
- }
466
- finally {
467
- await rootHandle.close().catch(() => undefined);
468
- }
469
- }
470
- async function manifestFromWorkspace(root, startedAt) {
471
- const scratch = `${root}.manifest-${randomUUID()}`;
472
- await fs.mkdir(scratch, { mode: 0o700 });
473
- try {
474
- return await copyWorkspaceTree(root, scratch, startedAt);
475
- }
476
- finally {
477
- await fs.rm(scratch, { recursive: true, force: true });
478
- }
479
- }
480
- function receiptMatchesRequest(receipt, request) {
481
- return receipt.schema_version === "botlearn-workspace-copy-receipt/1" &&
482
- receipt.policy_id === WORKSPACE_COPY_POLICY_V1.policyId &&
483
- receipt.copy_id === request.copyId &&
484
- receipt.source_runtime_session_id === request.sourceRuntimeSessionId &&
485
- receipt.target_runtime_session_id === request.targetRuntimeSessionId &&
486
- receipt.source_sandbox_id === request.sandboxId &&
487
- receipt.source_generation === request.sandboxGeneration &&
488
- Number.isSafeInteger(receipt.file_count) && receipt.file_count >= 0 &&
489
- Number.isSafeInteger(receipt.directory_count) && receipt.directory_count >= 0 &&
490
- Number.isSafeInteger(receipt.symlink_count) && receipt.symlink_count >= 0 &&
491
- Number.isSafeInteger(receipt.total_bytes) && receipt.total_bytes >= 0 &&
492
- /^[0-9a-f]{64}$/.test(receipt.manifest_digest);
493
- }
494
- async function readWorkspaceCopyMarker(markerPath) {
495
- let marker;
496
- try {
497
- marker = await fs.open(markerPath, constants.O_RDONLY | constants.O_NOFOLLOW);
498
- const info = await marker.stat();
499
- if (!info.isFile() || info.size < 2 || info.size > 4096) {
500
- throw new WorkspaceCopyError("workspace_migration_receipt_mismatch");
501
- }
502
- return JSON.parse(await marker.readFile("utf8"));
503
- }
504
- catch (error) {
505
- if (error instanceof WorkspaceCopyError)
506
- throw error;
507
- throw new WorkspaceCopyError("workspace_migration_receipt_mismatch");
508
- }
509
- finally {
510
- await marker?.close().catch(() => undefined);
511
- }
512
- }
513
- /** Copy learner-owned files into a new Session and durably stage a content-free receipt. */
514
- export async function copyRuntimeSessionWorkspace(request) {
515
- const startedAt = Date.now();
516
- const { copyId, sourceRuntimeSessionId, targetRuntimeSessionId, sandboxId, sandboxGeneration, } = request;
517
- assertSafeId(copyId, "copy_id");
518
- assertSafeId(sourceRuntimeSessionId, "source_runtime_session_id");
519
- assertSafeId(targetRuntimeSessionId, "target_runtime_session_id");
520
- if (sourceRuntimeSessionId === targetRuntimeSessionId) {
521
- throw new WorkspaceCopyError("workspace_migration_content_unsafe");
522
- }
523
- const source = runtimeSessionWorkspaceDir(sourceRuntimeSessionId, sandboxGeneration);
524
- const target = runtimeSessionWorkspaceDir(targetRuntimeSessionId, sandboxGeneration);
525
- const markerPath = path.join(target, WORKSPACE_COPY_POLICY_V1.markerName);
526
- try {
527
- const sourceInfo = await fs.lstat(source);
528
- if (!sourceInfo.isDirectory() || sourceInfo.isSymbolicLink()) {
529
- throw new WorkspaceCopyError("workspace_migration_source_unavailable");
530
- }
531
- }
532
- catch (error) {
533
- if (error instanceof WorkspaceCopyError)
534
- throw error;
535
- throw new WorkspaceCopyError("workspace_migration_source_unavailable");
536
- }
537
- if (existsSync(target)) {
538
- const receipt = await readWorkspaceCopyMarker(markerPath);
539
- if (!receiptMatchesRequest(receipt, request)) {
540
- throw new WorkspaceCopyError("workspace_migration_receipt_mismatch");
541
- }
542
- const staged = await manifestFromWorkspace(target, startedAt);
543
- if (workspaceCopyManifestDigest(staged.entries) !== receipt.manifest_digest ||
544
- staged.counters.fileCount !== receipt.file_count ||
545
- staged.counters.directoryCount !== receipt.directory_count ||
546
- staged.counters.symlinkCount !== receipt.symlink_count ||
547
- staged.counters.totalBytes !== receipt.total_bytes) {
548
- throw new WorkspaceCopyError("workspace_migration_receipt_mismatch");
549
- }
550
- return { receipt, markerPath };
551
- }
552
- const targetParent = path.dirname(target);
553
- await fs.mkdir(targetParent, { recursive: true, mode: 0o700 });
554
- await fs.chmod(targetParent, 0o700);
555
- const stagingPrefix = `${path.basename(target)}.workspace-copy-${copyId}-`;
556
- for (const name of await fs.readdir(targetParent)) {
557
- if (name.startsWith(stagingPrefix)) {
558
- await fs.rm(path.join(targetParent, name), { recursive: true, force: true });
559
- }
560
- }
561
- const staging = path.join(targetParent, `${stagingPrefix}${randomUUID()}`);
562
- await fs.mkdir(staging, { mode: 0o700 });
563
- try {
564
- const sourceManifest = await copyWorkspaceTree(source, staging, startedAt);
565
- const sourceDigest = workspaceCopyManifestDigest(sourceManifest.entries);
566
- const stagedManifest = await manifestFromWorkspace(staging, startedAt);
567
- const stagedDigest = workspaceCopyManifestDigest(stagedManifest.entries);
568
- if (sourceDigest !== stagedDigest) {
569
- throw new WorkspaceCopyError("workspace_migration_content_unsafe");
570
- }
571
- const receipt = {
572
- schema_version: "botlearn-workspace-copy-receipt/1",
573
- policy_id: WORKSPACE_COPY_POLICY_V1.policyId,
574
- copy_id: copyId,
575
- source_runtime_session_id: sourceRuntimeSessionId,
576
- target_runtime_session_id: targetRuntimeSessionId,
577
- source_sandbox_id: sandboxId,
578
- source_generation: sandboxGeneration,
579
- file_count: sourceManifest.counters.fileCount,
580
- directory_count: sourceManifest.counters.directoryCount,
581
- symlink_count: sourceManifest.counters.symlinkCount,
582
- total_bytes: sourceManifest.counters.totalBytes,
583
- manifest_digest: sourceDigest,
584
- };
585
- const stagingMarker = path.join(staging, WORKSPACE_COPY_POLICY_V1.markerName);
586
- const marker = await fs.open(stagingMarker, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
587
- try {
588
- await marker.writeFile(JSON.stringify(receipt));
589
- await marker.sync();
590
- }
591
- finally {
592
- await marker.close();
593
- }
594
- const stagingHandle = await fs.open(staging, constants.O_RDONLY | constants.O_DIRECTORY);
595
- try {
596
- await stagingHandle.sync();
597
- }
598
- finally {
599
- await stagingHandle.close();
600
- }
601
- workspaceCopyDeadline(startedAt);
602
- if (existsSync(target)) {
603
- throw new WorkspaceCopyError("workspace_migration_receipt_mismatch");
604
- }
605
- await fs.rename(staging, target);
606
- await fs.chmod(target, 0o700);
607
- const parentHandle = await fs.open(targetParent, constants.O_RDONLY | constants.O_DIRECTORY);
608
- try {
609
- await parentHandle.sync();
610
- }
611
- finally {
612
- await parentHandle.close();
613
- }
614
- return { receipt, markerPath };
615
- }
616
- catch (error) {
617
- await fs.rm(staging, { recursive: true, force: true });
618
- if (error instanceof WorkspaceCopyError)
619
- throw error;
620
- throw new WorkspaceCopyError("workspace_migration_io_failed");
621
- }
622
- }
623
- export async function finalizeRuntimeSessionWorkspaceCopy(markerPath) {
624
- try {
625
- await fs.unlink(markerPath);
626
- }
627
- catch (error) {
628
- if (!isMissingPathError(error)) {
629
- throw new WorkspaceCopyError("workspace_migration_io_failed");
630
- }
631
- }
632
- let parentHandle;
633
- try {
634
- parentHandle = await fs.open(path.dirname(markerPath), constants.O_RDONLY | constants.O_DIRECTORY);
635
- await parentHandle.sync();
636
- }
637
- catch {
638
- throw new WorkspaceCopyError("workspace_migration_io_failed");
639
- }
640
- finally {
641
- await parentHandle?.close().catch(() => undefined);
642
- }
643
- }
644
92
  export function ensureRunWorkspace(agentRunId) {
645
93
  const rootDir = runRootDir(agentRunId);
646
94
  const workspaceDir = runWorkspaceDir(agentRunId);
@@ -652,11 +100,11 @@ export function ensureRunWorkspace(agentRunId) {
652
100
  * session.open 时创建 per-session 目录。managed workspace 默认保持 0700,只有当前
653
101
  * activation 会通过 exposeRuntimeSessionWorkspace() 临时开放给 runtime 组。
654
102
  */
655
- export function ensureRuntimeSessionDirectories(runtimeSessionId, sandboxGeneration) {
103
+ export function ensureRuntimeSessionDirectories(runtimeSessionId, sandboxGeneration, workspaceId = runtimeSessionId) {
656
104
  const rootDir = runtimeSessionRootDir(runtimeSessionId, sandboxGeneration);
657
- const workspaceDir = runtimeSessionWorkspaceDir(runtimeSessionId, sandboxGeneration);
105
+ const workspaceDir = runtimeSessionWorkspaceDir(workspaceId, sandboxGeneration);
658
106
  mkdirTolerant(rootDir);
659
- const managedParent = managedRuntimeSessionParentDir(runtimeSessionId);
107
+ const managedParent = managedWorkspaceParentDir(workspaceId);
660
108
  if (managedParent)
661
109
  mkdirTolerant(managedParent, 0o700);
662
110
  mkdirTolerant(workspaceDir, 0o700);
@@ -667,22 +115,22 @@ export function ensureRuntimeSessionDirectories(runtimeSessionId, sandboxGenerat
667
115
  * 0710,因此 runtime 只能穿过根目录,不能列举其他 session id;未激活 session
668
116
  * 的父目录与 workspace 始终为 0700。
669
117
  */
670
- export function exposeRuntimeSessionWorkspace(runtimeSessionId, sandboxGeneration) {
671
- const managedParent = managedRuntimeSessionParentDir(runtimeSessionId);
118
+ export function exposeRuntimeSessionWorkspace(runtimeSessionId, sandboxGeneration, workspaceId = runtimeSessionId) {
119
+ const managedParent = managedWorkspaceParentDir(workspaceId);
672
120
  if (!managedParent)
673
121
  return;
674
- const { workspaceDir } = ensureRuntimeSessionDirectories(runtimeSessionId, sandboxGeneration);
122
+ const { workspaceDir } = ensureRuntimeSessionDirectories(runtimeSessionId, sandboxGeneration, workspaceId);
675
123
  // Parent is traverse-only for the runtime group: allowing group write here would let
676
124
  // the runtime replace the generation directory with a symlink before revoke/cleanup.
677
125
  chmodSync(managedParent, 0o710);
678
126
  chmodSync(workspaceDir, 0o770);
679
127
  }
680
128
  /** Revoke a managed workspace without deleting the session's durable local materialization. */
681
- export function revokeRuntimeSessionWorkspace(runtimeSessionId, sandboxGeneration) {
682
- const managedParent = managedRuntimeSessionParentDir(runtimeSessionId);
129
+ export function revokeRuntimeSessionWorkspace(runtimeSessionId, sandboxGeneration, workspaceId = runtimeSessionId) {
130
+ const managedParent = managedWorkspaceParentDir(workspaceId);
683
131
  if (!managedParent)
684
132
  return;
685
- const workspaceDir = runtimeSessionWorkspaceDir(runtimeSessionId, sandboxGeneration);
133
+ const workspaceDir = runtimeSessionWorkspaceDir(workspaceId, sandboxGeneration);
686
134
  // Revoke the leaf before the parent so a runtime loses access as early as possible.
687
135
  try {
688
136
  chmodSync(workspaceDir, 0o700);
@@ -701,22 +149,36 @@ export function revokeRuntimeSessionWorkspace(runtimeSessionId, sandboxGeneratio
701
149
  throw error;
702
150
  }
703
151
  }
704
- /** session.close 时删除 workspace 与本地 session 物化状态(transcripts 等)。幂等。 */
705
- export function removeRuntimeSessionWorkspace(runtimeSessionId, sandboxGeneration, preserveManagedWorkspace = false) {
706
- // 校验路径段安全后删除本地物化状态;generation 换代可保留 NAS 正文。
152
+ /** session.close 时删除本地 Session 状态;Workspace 由独立生命周期拥有。 */
153
+ export function removeRuntimeSessionWorkspace(runtimeSessionId, sandboxGeneration, preserveManagedWorkspace = false, workspaceId = runtimeSessionId) {
707
154
  runtimeSessionRootDir(runtimeSessionId, sandboxGeneration);
708
- rmSync(path.join(daemonHome(), "agent-service-sessions", runtimeSessionId), {
709
- recursive: true,
710
- force: true,
711
- });
155
+ const localSessionRoot = path.join(daemonHome(), "agent-service-sessions", runtimeSessionId);
712
156
  const managedRoot = process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT?.trim();
157
+ if (!managedRoot && preserveManagedWorkspace && workspaceId === runtimeSessionId) {
158
+ // Local development historically stored the Workspace below the Session generation.
159
+ // Keep that directory for the backfilled Workspace identity and remove only
160
+ // Session-owned transcripts from every generation.
161
+ if (existsSync(localSessionRoot)) {
162
+ for (const generation of readdirSync(localSessionRoot, { withFileTypes: true })) {
163
+ if (!generation.isDirectory())
164
+ continue;
165
+ rmSync(path.join(localSessionRoot, generation.name, "transcripts"), {
166
+ recursive: true,
167
+ force: true,
168
+ });
169
+ }
170
+ }
171
+ }
172
+ else {
173
+ rmSync(localSessionRoot, { recursive: true, force: true });
174
+ }
713
175
  if (managedRoot && !preserveManagedWorkspace) {
714
- const sessionRoot = path.join(managedRoot, "sessions", runtimeSessionId);
176
+ const sessionRoot = path.join(managedRoot, "sessions", workspaceId);
715
177
  rmSync(sessionRoot, { recursive: true, force: true });
716
178
  }
717
179
  }
718
- export function ensureRuntimeSessionWorkspace(runtimeSessionId, sandboxGeneration, agentRunId) {
719
- const { rootDir, workspaceDir } = ensureRuntimeSessionDirectories(runtimeSessionId, sandboxGeneration);
180
+ export function ensureRuntimeSessionWorkspace(runtimeSessionId, sandboxGeneration, agentRunId, workspaceId = runtimeSessionId) {
181
+ const { rootDir, workspaceDir } = ensureRuntimeSessionDirectories(runtimeSessionId, sandboxGeneration, workspaceId);
720
182
  const transcriptFile = runtimeSessionTranscriptPath(runtimeSessionId, sandboxGeneration, agentRunId);
721
183
  mkdirTolerant(path.dirname(transcriptFile));
722
184
  return { rootDir, workspaceDir, transcriptFile };