@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
@@ -0,0 +1,275 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, realpathSync, rmSync, writeSync, } from "node:fs";
3
+ import path from "node:path";
4
+ import { isWorkspaceQuiescenceProof, } from "./runtime-quiescence.js";
5
+ import { defaultCaseFold, validateWorkspaceEntrySet, WORKSPACE_ENTRY_SET_SCHEMA, } from "./workspace-entry-set.js";
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;
7
+ const EXCLUDED_NAMES = new Set(["node_modules"]);
8
+ const COPY_CHUNK_BYTES = 64 * 1024;
9
+ export class WorkspaceSnapshotStagingError extends Error {
10
+ code;
11
+ retryable;
12
+ constructor(code, retryable = false) {
13
+ super(code);
14
+ this.code = code;
15
+ this.retryable = retryable;
16
+ this.name = "WorkspaceSnapshotStagingError";
17
+ }
18
+ }
19
+ function fail(code, retryable = false) {
20
+ throw new WorkspaceSnapshotStagingError(code, retryable);
21
+ }
22
+ function containsPath(parent, candidate) {
23
+ const relative = path.relative(parent, candidate);
24
+ return relative === "" || (!relative.startsWith(".." + path.sep) && relative !== "..");
25
+ }
26
+ function sameIdentity(left, right) {
27
+ return left.dev === right.dev && left.ino === right.ino && left.size === right.size &&
28
+ left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
29
+ }
30
+ function decodeName(name) {
31
+ const decoded = name.toString("utf8");
32
+ if (!Buffer.from(decoded, "utf8").equals(name) || decoded.includes("\0")) {
33
+ fail("workspace_snapshot_invalid_utf8");
34
+ }
35
+ return decoded;
36
+ }
37
+ function excludedName(name) {
38
+ return name.startsWith(".") || EXCLUDED_NAMES.has(defaultCaseFold(name));
39
+ }
40
+ function normalizedMode(stats) {
41
+ return (stats.mode & 73n) !== 0n ? "0700" : "0600";
42
+ }
43
+ function assertSourceNode(stats, options) {
44
+ if (stats.dev !== options.rootDevice)
45
+ fail("workspace_snapshot_cross_device");
46
+ if (options.expected === "file") {
47
+ if (!stats.isFile())
48
+ fail("workspace_snapshot_unsupported_file_type");
49
+ if (stats.nlink !== 1n)
50
+ fail("workspace_snapshot_hard_link");
51
+ }
52
+ else if (!stats.isDirectory()) {
53
+ fail("workspace_snapshot_unsupported_file_type");
54
+ }
55
+ }
56
+ function copyStableFile(source, destination, options) {
57
+ const before = lstatSync(source, { bigint: true });
58
+ assertSourceNode(before, { rootDevice: options.rootDevice, expected: "file" });
59
+ if (before.size > BigInt(options.limits.maxFileBytes)) {
60
+ fail("workspace_snapshot_limit_exceeded");
61
+ }
62
+ const sourceHandle = openSync(source, constants.O_RDONLY | constants.O_NOFOLLOW);
63
+ let destinationHandle = null;
64
+ try {
65
+ const opened = fstatSync(sourceHandle, { bigint: true });
66
+ assertSourceNode(opened, { rootDevice: options.rootDevice, expected: "file" });
67
+ if (!sameIdentity(before, opened))
68
+ fail("workspace_snapshot_source_changed", true);
69
+ destinationHandle = openSync(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL |
70
+ constants.O_NOFOLLOW, 0o600);
71
+ const hash = createHash("sha256");
72
+ const buffer = Buffer.allocUnsafe(COPY_CHUNK_BYTES);
73
+ let copied = 0;
74
+ while (true) {
75
+ const bytesRead = readSync(sourceHandle, buffer, 0, buffer.length, null);
76
+ if (bytesRead === 0)
77
+ break;
78
+ hash.update(buffer.subarray(0, bytesRead));
79
+ let written = 0;
80
+ while (written < bytesRead) {
81
+ written += writeSync(destinationHandle, buffer, written, bytesRead - written, null);
82
+ }
83
+ copied += bytesRead;
84
+ if (copied > options.limits.maxFileBytes)
85
+ fail("workspace_snapshot_limit_exceeded");
86
+ }
87
+ const after = fstatSync(sourceHandle, { bigint: true });
88
+ if (!sameIdentity(opened, after) || BigInt(copied) !== after.size) {
89
+ fail("workspace_snapshot_source_changed", true);
90
+ }
91
+ fsyncSync(destinationHandle);
92
+ const mode = normalizedMode(after);
93
+ chmodSync(destination, mode === "0700" ? 0o700 : 0o600);
94
+ return { sizeBytes: copied, sha256: hash.digest("hex"), mode };
95
+ }
96
+ finally {
97
+ if (destinationHandle !== null)
98
+ closeSync(destinationHandle);
99
+ closeSync(sourceHandle);
100
+ }
101
+ }
102
+ function fsyncDirectory(directory) {
103
+ const handle = openSync(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
104
+ try {
105
+ fsyncSync(handle);
106
+ }
107
+ finally {
108
+ closeSync(handle);
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
+ }
122
+ function stageOnce(options, paths) {
123
+ const stagingDirectory = path.join(paths.checkpointRoot, randomUUID());
124
+ mkdirSync(stagingDirectory, { mode: 0o700 });
125
+ chmodSync(stagingDirectory, 0o700);
126
+ try {
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);
131
+ fail("workspace_snapshot_root_invalid");
132
+ }
133
+ const entries = [];
134
+ const files = [];
135
+ let fileCount = 0;
136
+ let totalBytes = 0;
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 });
147
+ assertSourceNode(directoryBefore, {
148
+ rootDevice: rootStats.dev,
149
+ expected: "directory",
150
+ });
151
+ const sourceDirectory = descriptorPath(sourceHandle, sourceFallback);
152
+ const children = readdirSync(sourceDirectory, {
153
+ encoding: "buffer",
154
+ }).sort((left, right) => Buffer.compare(left, right));
155
+ for (const child of children) {
156
+ const name = decodeName(child);
157
+ if (excludedName(name))
158
+ continue;
159
+ const relativeSegments = [...segments, name];
160
+ if (relativeSegments.length > options.limits.maxDepth) {
161
+ fail("workspace_snapshot_limit_exceeded");
162
+ }
163
+ const relativePath = relativeSegments.join("/");
164
+ const source = path.join(sourceDirectory, name);
165
+ const destination = path.join(destinationDirectory, name);
166
+ const stats = lstatSync(source, { bigint: true });
167
+ if (stats.isSymbolicLink())
168
+ fail("workspace_snapshot_unsupported_file_type");
169
+ if (stats.isDirectory()) {
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
+ }
177
+ mkdirSync(destination, { mode: 0o700 });
178
+ chmodSync(destination, 0o700);
179
+ appendEntry({ type: "directory", path: relativePath, mode: "0700" });
180
+ try {
181
+ walk(childHandle, source, destination, relativeSegments);
182
+ }
183
+ finally {
184
+ closeSync(childHandle);
185
+ }
186
+ continue;
187
+ }
188
+ const copied = copyStableFile(source, destination, {
189
+ rootDevice: rootStats.dev,
190
+ limits: options.limits,
191
+ });
192
+ fileCount += 1;
193
+ totalBytes += copied.sizeBytes;
194
+ if (fileCount > options.limits.maxFileCount ||
195
+ totalBytes > options.limits.maxTotalBytes)
196
+ fail("workspace_snapshot_limit_exceeded");
197
+ appendEntry({
198
+ type: "file",
199
+ path: relativePath,
200
+ mode: copied.mode,
201
+ sizeBytes: copied.sizeBytes,
202
+ sha256: copied.sha256,
203
+ });
204
+ files.push({
205
+ path: relativePath,
206
+ stagingPath: destination,
207
+ sizeBytes: copied.sizeBytes,
208
+ sha256: copied.sha256,
209
+ });
210
+ }
211
+ const directoryAfter = fstatSync(sourceHandle, { bigint: true });
212
+ if (!sameIdentity(directoryBefore, directoryAfter)) {
213
+ fail("workspace_snapshot_source_changed", true);
214
+ }
215
+ fsyncDirectory(destinationDirectory);
216
+ }
217
+ try {
218
+ walk(rootHandle, paths.workspace, stagingDirectory, []);
219
+ }
220
+ finally {
221
+ closeSync(rootHandle);
222
+ }
223
+ entries.sort((left, right) => Buffer.compare(Buffer.from(left.path, "utf8"), Buffer.from(right.path, "utf8")));
224
+ files.sort((left, right) => Buffer.compare(Buffer.from(left.path, "utf8"), Buffer.from(right.path, "utf8")));
225
+ const entrySet = validateWorkspaceEntrySet({ schemaVersion: WORKSPACE_ENTRY_SET_SCHEMA, entries }, options.limits);
226
+ return { stagingDirectory, entrySet, files };
227
+ }
228
+ catch (error) {
229
+ rmSync(stagingDirectory, { recursive: true, force: true });
230
+ throw error;
231
+ }
232
+ }
233
+ export function stageWorkspaceSnapshot(options) {
234
+ if (!UUID.test(options.checkpointId))
235
+ fail("workspace_snapshot_checkpoint_invalid");
236
+ if (!isWorkspaceQuiescenceProof(options.quiescenceProof) ||
237
+ options.quiescenceProof.proofVersion !== "workspace-quiescence-proof/1" ||
238
+ !UUID.test(options.quiescenceProof.sandboxId) ||
239
+ !UUID.test(options.quiescenceProof.runtimeSessionId) ||
240
+ options.quiescenceProof.checkpointId !== options.checkpointId ||
241
+ !Number.isSafeInteger(options.quiescenceProof.sandboxGeneration) ||
242
+ options.quiescenceProof.sandboxGeneration < 1 ||
243
+ options.quiescenceProof.allWritersStopped !== true)
244
+ fail("workspace_snapshot_writer_isolation_unproven");
245
+ if (!Number.isSafeInteger(options.maxStabilityRetries) || options.maxStabilityRetries < 0) {
246
+ fail("workspace_snapshot_retry_limit_invalid");
247
+ }
248
+ const workspace = realpathSync(options.workspaceDirectory);
249
+ const requestedControl = path.resolve(options.controlDirectory);
250
+ if (containsPath(workspace, requestedControl)) {
251
+ fail("workspace_snapshot_control_path_invalid");
252
+ }
253
+ mkdirSync(options.controlDirectory, { recursive: true, mode: 0o700 });
254
+ chmodSync(options.controlDirectory, 0o700);
255
+ const control = realpathSync(options.controlDirectory);
256
+ if (containsPath(workspace, control))
257
+ fail("workspace_snapshot_control_path_invalid");
258
+ const checkpointRoot = path.join(control, "workspace-snapshot-staging", options.checkpointId);
259
+ mkdirSync(checkpointRoot, { recursive: true, mode: 0o700 });
260
+ chmodSync(checkpointRoot, 0o700);
261
+ let attempt = 0;
262
+ while (true) {
263
+ try {
264
+ return stageOnce(options, { workspace, checkpointRoot });
265
+ }
266
+ catch (error) {
267
+ if (error instanceof WorkspaceSnapshotStagingError && error.retryable &&
268
+ attempt < options.maxStabilityRetries) {
269
+ attempt += 1;
270
+ continue;
271
+ }
272
+ throw error;
273
+ }
274
+ }
275
+ }
@@ -26,6 +26,61 @@ export declare function runtimeSessionRootDir(runtimeSessionId: string, sandboxG
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;
29
+ export declare const WORKSPACE_COPY_POLICY_V1: Readonly<{
30
+ policyId: "WorkspaceCopyPolicyV1";
31
+ maxTotalEntries: 25000;
32
+ maxFiles: 20000;
33
+ maxSymlinks: 2000;
34
+ maxBytes: number;
35
+ maxDepth: 64;
36
+ maxRelativePathBytes: 4096;
37
+ maxDurationSeconds: 600;
38
+ markerName: ".botlearn-workspace-copy";
39
+ }>;
40
+ export type WorkspaceCopyErrorCode = "workspace_migration_source_unavailable" | "workspace_migration_content_unsafe" | "workspace_migration_limit_exceeded" | "workspace_migration_io_failed" | "workspace_migration_receipt_mismatch";
41
+ export declare class WorkspaceCopyError extends Error {
42
+ readonly code: WorkspaceCopyErrorCode;
43
+ constructor(code: WorkspaceCopyErrorCode);
44
+ }
45
+ export interface WorkspaceCopyReceiptV1 {
46
+ schema_version: "botlearn-workspace-copy-receipt/1";
47
+ policy_id: "WorkspaceCopyPolicyV1";
48
+ copy_id: string;
49
+ source_runtime_session_id: string;
50
+ target_runtime_session_id: string;
51
+ source_sandbox_id: string;
52
+ source_generation: number;
53
+ file_count: number;
54
+ directory_count: number;
55
+ symlink_count: number;
56
+ total_bytes: number;
57
+ manifest_digest: string;
58
+ }
59
+ export interface WorkspaceCopyResult {
60
+ receipt: WorkspaceCopyReceiptV1;
61
+ markerPath: string;
62
+ }
63
+ interface ManifestEntry {
64
+ type: "file" | "dir" | "link";
65
+ relativePath: string;
66
+ modeTag: "file-0600" | "file-0700" | "dir-0700" | "link";
67
+ byteSize?: number;
68
+ contentDigest?: Buffer;
69
+ linkTarget?: string;
70
+ }
71
+ export declare function workspaceCopyManifestDigest(entries: ManifestEntry[]): string;
72
+ export declare function workspaceCopyV1Supported(): boolean;
73
+ /** Remove crash-left staging directories before advertising workspace_copy_v1. */
74
+ export declare function cleanupRuntimeSessionWorkspaceCopyStaging(): Promise<void>;
75
+ /** Copy learner-owned files into a new Session and durably stage a content-free receipt. */
76
+ export declare function copyRuntimeSessionWorkspace(request: {
77
+ copyId: string;
78
+ sourceRuntimeSessionId: string;
79
+ targetRuntimeSessionId: string;
80
+ sandboxId: string;
81
+ sandboxGeneration: number;
82
+ }): Promise<WorkspaceCopyResult>;
83
+ export declare function finalizeRuntimeSessionWorkspaceCopy(markerPath: string): Promise<void>;
29
84
  export declare function ensureRunWorkspace(agentRunId: string): {
30
85
  rootDir: string;
31
86
  workspaceDir: string;
@@ -53,3 +108,4 @@ export declare function ensureRuntimeSessionWorkspace(runtimeSessionId: string,
53
108
  workspaceDir: string;
54
109
  transcriptFile: string;
55
110
  };
111
+ export {};