@botlearn-course/daemon 0.0.20-beta.1 → 0.0.20-beta.10

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.
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { chmodSync, closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, realpathSync, rmSync, writeSync, } from "node:fs";
2
+ import { chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, realpathSync, rmSync, writeSync, } from "node:fs";
3
3
  import path from "node:path";
4
+ import { isWorkspaceWriterFreezeProof, } from "./runtime-quiescence.js";
4
5
  import { defaultCaseFold, validateWorkspaceEntrySet, WORKSPACE_ENTRY_SET_SCHEMA, } from "./workspace-entry-set.js";
5
6
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
6
7
  const EXCLUDED_NAMES = new Set(["node_modules"]);
@@ -107,30 +108,52 @@ function fsyncDirectory(directory) {
107
108
  closeSync(handle);
108
109
  }
109
110
  }
111
+ function descriptorPath(handle, fallback) {
112
+ if (existsSync("/proc/self/fd")) {
113
+ const value = path.join("/proc/self/fd", String(handle));
114
+ if (!existsSync(value))
115
+ fail("workspace_snapshot_fd_traversal_unavailable");
116
+ return value;
117
+ }
118
+ // Darwin has no procfs open-file path that supports relative child lookup. It is used
119
+ // only by local tests/BYOA; managed durable sandboxes are Linux and fail closed above.
120
+ return fallback;
121
+ }
110
122
  function stageOnce(options, paths) {
111
123
  const stagingDirectory = path.join(paths.checkpointRoot, randomUUID());
112
124
  mkdirSync(stagingDirectory, { mode: 0o700 });
113
125
  chmodSync(stagingDirectory, 0o700);
114
126
  try {
115
- const rootStats = lstatSync(paths.workspace, { bigint: true });
116
- if (!rootStats.isDirectory())
127
+ const rootHandle = openSync(paths.workspace, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
128
+ const rootStats = fstatSync(rootHandle, { bigint: true });
129
+ if (!rootStats.isDirectory()) {
130
+ closeSync(rootHandle);
117
131
  fail("workspace_snapshot_root_invalid");
132
+ }
118
133
  const entries = [];
119
134
  const files = [];
120
135
  let fileCount = 0;
121
136
  let totalBytes = 0;
122
- function walk(sourceDirectory, destinationDirectory, segments) {
123
- const directoryBefore = lstatSync(sourceDirectory, { bigint: true });
137
+ let estimatedEntrySetBytes = Buffer.byteLength('{"schemaVersion":"agent-workspace-entry-set/1","entries":[]}', "utf8");
138
+ function appendEntry(entry) {
139
+ estimatedEntrySetBytes += Buffer.byteLength(JSON.stringify(entry), "utf8") + 1;
140
+ if (estimatedEntrySetBytes > options.limits.maxEntrySetBytes) {
141
+ fail("workspace_snapshot_limit_exceeded");
142
+ }
143
+ entries.push(entry);
144
+ }
145
+ function walk(sourceHandle, sourceFallback, destinationDirectory, segments) {
146
+ const directoryBefore = fstatSync(sourceHandle, { bigint: true });
124
147
  assertSourceNode(directoryBefore, {
125
148
  rootDevice: rootStats.dev,
126
149
  expected: "directory",
127
150
  });
151
+ const sourceDirectory = descriptorPath(sourceHandle, sourceFallback);
128
152
  const children = readdirSync(sourceDirectory, {
129
- withFileTypes: true,
130
153
  encoding: "buffer",
131
- }).sort((left, right) => Buffer.compare(left.name, right.name));
154
+ }).sort((left, right) => Buffer.compare(left, right));
132
155
  for (const child of children) {
133
- const name = decodeName(child.name);
156
+ const name = decodeName(child);
134
157
  if (excludedName(name))
135
158
  continue;
136
159
  const relativeSegments = [...segments, name];
@@ -145,13 +168,21 @@ function stageOnce(options, paths) {
145
168
  fail("workspace_snapshot_unsupported_file_type");
146
169
  if (stats.isDirectory()) {
147
170
  assertSourceNode(stats, { rootDevice: rootStats.dev, expected: "directory" });
171
+ const childHandle = openSync(source, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
172
+ const openedDirectory = fstatSync(childHandle, { bigint: true });
173
+ if (!sameIdentity(stats, openedDirectory)) {
174
+ closeSync(childHandle);
175
+ fail("workspace_snapshot_source_changed", true);
176
+ }
148
177
  mkdirSync(destination, { mode: 0o700 });
149
178
  chmodSync(destination, 0o700);
150
- entries.push({ type: "directory", path: relativePath, mode: "0700" });
151
- if (entries.length > options.limits.maxEntrySetBytes) {
152
- fail("workspace_snapshot_limit_exceeded");
179
+ appendEntry({ type: "directory", path: relativePath, mode: "0700" });
180
+ try {
181
+ walk(childHandle, source, destination, relativeSegments);
182
+ }
183
+ finally {
184
+ closeSync(childHandle);
153
185
  }
154
- walk(source, destination, relativeSegments);
155
186
  continue;
156
187
  }
157
188
  const copied = copyStableFile(source, destination, {
@@ -163,7 +194,7 @@ function stageOnce(options, paths) {
163
194
  if (fileCount > options.limits.maxFileCount ||
164
195
  totalBytes > options.limits.maxTotalBytes)
165
196
  fail("workspace_snapshot_limit_exceeded");
166
- entries.push({
197
+ appendEntry({
167
198
  type: "file",
168
199
  path: relativePath,
169
200
  mode: copied.mode,
@@ -176,17 +207,19 @@ function stageOnce(options, paths) {
176
207
  sizeBytes: copied.sizeBytes,
177
208
  sha256: copied.sha256,
178
209
  });
179
- if (entries.length > options.limits.maxEntrySetBytes) {
180
- fail("workspace_snapshot_limit_exceeded");
181
- }
182
210
  }
183
- const directoryAfter = lstatSync(sourceDirectory, { bigint: true });
211
+ const directoryAfter = fstatSync(sourceHandle, { bigint: true });
184
212
  if (!sameIdentity(directoryBefore, directoryAfter)) {
185
213
  fail("workspace_snapshot_source_changed", true);
186
214
  }
187
215
  fsyncDirectory(destinationDirectory);
188
216
  }
189
- walk(paths.workspace, stagingDirectory, []);
217
+ try {
218
+ walk(rootHandle, paths.workspace, stagingDirectory, []);
219
+ }
220
+ finally {
221
+ closeSync(rootHandle);
222
+ }
190
223
  entries.sort((left, right) => Buffer.compare(Buffer.from(left.path, "utf8"), Buffer.from(right.path, "utf8")));
191
224
  files.sort((left, right) => Buffer.compare(Buffer.from(left.path, "utf8"), Buffer.from(right.path, "utf8")));
192
225
  const entrySet = validateWorkspaceEntrySet({ schemaVersion: WORKSPACE_ENTRY_SET_SCHEMA, entries }, options.limits);
@@ -200,13 +233,14 @@ function stageOnce(options, paths) {
200
233
  export function stageWorkspaceSnapshot(options) {
201
234
  if (!UUID.test(options.checkpointId))
202
235
  fail("workspace_snapshot_checkpoint_invalid");
203
- if (options.quiescenceProof.proofVersion !== "workspace-quiescence-proof/1" ||
204
- !UUID.test(options.quiescenceProof.sandboxId) ||
205
- !UUID.test(options.quiescenceProof.runtimeSessionId) ||
206
- options.quiescenceProof.checkpointId !== options.checkpointId ||
207
- !Number.isSafeInteger(options.quiescenceProof.sandboxGeneration) ||
208
- options.quiescenceProof.sandboxGeneration < 1 ||
209
- options.quiescenceProof.allWritersStopped !== true)
236
+ if (!isWorkspaceWriterFreezeProof(options.freezeProof) ||
237
+ options.freezeProof.proofVersion !== "workspace-writer-freeze-proof/1" ||
238
+ !UUID.test(options.freezeProof.sandboxId) ||
239
+ !UUID.test(options.freezeProof.runtimeSessionId) ||
240
+ options.freezeProof.checkpointId !== options.checkpointId ||
241
+ !Number.isSafeInteger(options.freezeProof.sandboxGeneration) ||
242
+ options.freezeProof.sandboxGeneration < 1 ||
243
+ options.freezeProof.allWritersFrozen !== true)
210
244
  fail("workspace_snapshot_writer_isolation_unproven");
211
245
  if (!Number.isSafeInteger(options.maxStabilityRetries) || options.maxStabilityRetries < 0) {
212
246
  fail("workspace_snapshot_retry_limit_invalid");
@@ -20,9 +20,9 @@ export declare function runtimeProfileDir(agentRunId: string): string;
20
20
  export declare function runtimeProfileRunRootDir(agentRunId: string): string;
21
21
  export declare function runtimeSessionRootDir(runtimeSessionId: string, sandboxGeneration: number): string;
22
22
  /**
23
- * per-session workspace(ADR-015 §7):managed sandbox 下为
24
- * `<BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT>/<runtime_session_id>/generation-<sandbox_generation>`,
25
- * daemon 在 session.open 时创建。
23
+ * per-session workspace(ADR-015 §7):NAS managed sandbox 使用稳定路径
24
+ * `<root>/sessions/<runtime_session_id>/workspace`;旧 provider 保留 generation 路径。
25
+ * 两种路径都由 daemon 在 session.open 时创建。
26
26
  */
27
27
  export declare function runtimeSessionWorkspaceDir(runtimeSessionId: string, sandboxGeneration: number): string;
28
28
  export declare function runtimeSessionTranscriptPath(runtimeSessionId: string, sandboxGeneration: number, agentRunId: string): string;
@@ -102,7 +102,7 @@ export declare function exposeRuntimeSessionWorkspace(runtimeSessionId: string,
102
102
  /** Revoke a managed workspace without deleting the session's durable local materialization. */
103
103
  export declare function revokeRuntimeSessionWorkspace(runtimeSessionId: string, sandboxGeneration: number): void;
104
104
  /** session.close 时删除 workspace 与本地 session 物化状态(transcripts 等)。幂等。 */
105
- export declare function removeRuntimeSessionWorkspace(runtimeSessionId: string, sandboxGeneration: number): void;
105
+ export declare function removeRuntimeSessionWorkspace(runtimeSessionId: string, sandboxGeneration: number, preserveManagedWorkspace?: boolean): void;
106
106
  export declare function ensureRuntimeSessionWorkspace(runtimeSessionId: string, sandboxGeneration: number, agentRunId: string): {
107
107
  rootDir: string;
108
108
  workspaceDir: string;
package/dist/workspace.js CHANGED
@@ -4,6 +4,7 @@ import { createHash, randomUUID } from "node:crypto";
4
4
  import path from "node:path";
5
5
  import { TextDecoder } from "node:util";
6
6
  import { daemonHome } from "./auth-store.js";
7
+ import { nasWorkspaceEnabled } from "./workspace-filesystem.js";
7
8
  /**
8
9
  * 每 run 隔离工作区(spec §4):
9
10
  *
@@ -53,9 +54,9 @@ export function runtimeSessionRootDir(runtimeSessionId, sandboxGeneration) {
53
54
  return path.join(daemonHome(), "agent-service-sessions", runtimeSessionId, `generation-${sandboxGeneration}`);
54
55
  }
55
56
  /**
56
- * per-session workspace(ADR-015 §7):managed sandbox 下为
57
- * `<BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT>/<runtime_session_id>/generation-<sandbox_generation>`,
58
- * daemon 在 session.open 时创建。
57
+ * per-session workspace(ADR-015 §7):NAS managed sandbox 使用稳定路径
58
+ * `<root>/sessions/<runtime_session_id>/workspace`;旧 provider 保留 generation 路径。
59
+ * 两种路径都由 daemon 在 session.open 时创建。
59
60
  */
60
61
  export function runtimeSessionWorkspaceDir(runtimeSessionId, sandboxGeneration) {
61
62
  assertSafeId(runtimeSessionId, "runtime_session_id");
@@ -64,6 +65,9 @@ export function runtimeSessionWorkspaceDir(runtimeSessionId, sandboxGeneration)
64
65
  }
65
66
  const managedRoot = process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT?.trim();
66
67
  if (managedRoot) {
68
+ if (nasWorkspaceEnabled()) {
69
+ return path.join(managedRoot, "sessions", runtimeSessionId, "workspace");
70
+ }
67
71
  return path.join(managedRoot, runtimeSessionId, `generation-${sandboxGeneration}`);
68
72
  }
69
73
  return path.join(runtimeSessionRootDir(runtimeSessionId, sandboxGeneration), "workspace");
@@ -71,7 +75,11 @@ export function runtimeSessionWorkspaceDir(runtimeSessionId, sandboxGeneration)
71
75
  function managedRuntimeSessionParentDir(runtimeSessionId) {
72
76
  assertSafeId(runtimeSessionId, "runtime_session_id");
73
77
  const managedRoot = process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT?.trim();
74
- return managedRoot ? path.join(managedRoot, runtimeSessionId) : null;
78
+ if (!managedRoot)
79
+ return null;
80
+ return nasWorkspaceEnabled()
81
+ ? path.join(managedRoot, "sessions", runtimeSessionId)
82
+ : path.join(managedRoot, runtimeSessionId);
75
83
  }
76
84
  export function runtimeSessionTranscriptPath(runtimeSessionId, sandboxGeneration, agentRunId) {
77
85
  assertSafeId(agentRunId, "agent_run_id");
@@ -228,12 +236,16 @@ async function childDirectories(root) {
228
236
  export async function cleanupRuntimeSessionWorkspaceCopyStaging() {
229
237
  const managedRoot = process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT?.trim();
230
238
  if (managedRoot) {
231
- for (const sessionName of await childDirectories(managedRoot)) {
239
+ const sessionRoot = nasWorkspaceEnabled()
240
+ ? path.join(managedRoot, "sessions")
241
+ : managedRoot;
242
+ for (const sessionName of await childDirectories(sessionRoot)) {
232
243
  if (!SAFE_ID_PATTERN.test(sessionName))
233
244
  continue;
234
- const sessionParent = path.join(managedRoot, sessionName);
245
+ const sessionParent = path.join(sessionRoot, sessionName);
235
246
  for (const name of await childDirectories(sessionParent)) {
236
- if (MANAGED_COPY_STAGING_PATTERN.test(name)) {
247
+ if (MANAGED_COPY_STAGING_PATTERN.test(name)
248
+ || LOCAL_COPY_STAGING_PATTERN.test(name)) {
237
249
  await fs.rm(path.join(sessionParent, name), { recursive: true, force: true });
238
250
  }
239
251
  }
@@ -700,16 +712,19 @@ export function revokeRuntimeSessionWorkspace(runtimeSessionId, sandboxGeneratio
700
712
  }
701
713
  }
702
714
  /** session.close 时删除 workspace 与本地 session 物化状态(transcripts 等)。幂等。 */
703
- export function removeRuntimeSessionWorkspace(runtimeSessionId, sandboxGeneration) {
704
- // 校验路径段安全后按 session 整体删除(含全部 generation 与 transcripts)。
715
+ export function removeRuntimeSessionWorkspace(runtimeSessionId, sandboxGeneration, preserveManagedWorkspace = false) {
716
+ // 校验路径段安全后删除本地物化状态;generation 换代可保留 NAS 正文。
705
717
  runtimeSessionRootDir(runtimeSessionId, sandboxGeneration);
706
718
  rmSync(path.join(daemonHome(), "agent-service-sessions", runtimeSessionId), {
707
719
  recursive: true,
708
720
  force: true,
709
721
  });
710
722
  const managedRoot = process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT?.trim();
711
- if (managedRoot) {
712
- rmSync(path.join(managedRoot, runtimeSessionId), { recursive: true, force: true });
723
+ if (managedRoot && !preserveManagedWorkspace) {
724
+ const sessionRoot = nasWorkspaceEnabled()
725
+ ? path.join(managedRoot, "sessions", runtimeSessionId)
726
+ : path.join(managedRoot, runtimeSessionId);
727
+ rmSync(sessionRoot, { recursive: true, force: true });
713
728
  }
714
729
  }
715
730
  export function ensureRuntimeSessionWorkspace(runtimeSessionId, sandboxGeneration, agentRunId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botlearn-course/daemon",
3
- "version": "0.0.20-beta.1",
3
+ "version": "0.0.20-beta.10",
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": {