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

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.
@@ -0,0 +1,169 @@
1
+ import { createReadStream, rmSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { ensureDaemonHome } from "./auth-store.js";
4
+ import { writeWorkspaceMaterializationMarker } from "./workspace-materialization.js";
5
+ import { WORKSPACE_SNAPSHOT_POLICY_V1 } from "./workspace-snapshot-policy.js";
6
+ import { stageWorkspaceSnapshot } from "./workspace-snapshot-staging.js";
7
+ export class WorkspaceSnapshotControlError extends Error {
8
+ code;
9
+ retryable;
10
+ constructor(code, retryable) {
11
+ super(code);
12
+ this.code = code;
13
+ this.retryable = retryable;
14
+ this.name = "WorkspaceSnapshotControlError";
15
+ }
16
+ }
17
+ function controlBase(wsUrl) {
18
+ const url = new URL(wsUrl);
19
+ url.protocol = url.protocol === "wss:" ? "https:" : "http:";
20
+ url.pathname = "/internal/v1/workspace-checkpoints";
21
+ url.search = "";
22
+ url.hash = "";
23
+ return url.toString().replace(/\/$/, "");
24
+ }
25
+ async function requestJson(url, token, body, signal) {
26
+ const response = await fetch(url, {
27
+ method: "POST",
28
+ headers: {
29
+ Authorization: `Bearer ${token}`,
30
+ "Content-Type": "application/json",
31
+ },
32
+ body: JSON.stringify(body),
33
+ signal,
34
+ });
35
+ if (!response.ok) {
36
+ let code = `workspace_snapshot_control_${response.status}`;
37
+ let explicitRetryable;
38
+ try {
39
+ const payload = await response.json();
40
+ const detail = payload.detail;
41
+ if (detail && typeof detail === "object" && !Array.isArray(detail)) {
42
+ const stable = detail.code;
43
+ if (typeof stable === "string" && /^workspace_[a-z0-9_]+$/u.test(stable))
44
+ code = stable;
45
+ const retryable = detail.retryable;
46
+ if (typeof retryable === "boolean")
47
+ explicitRetryable = retryable;
48
+ }
49
+ }
50
+ catch {
51
+ // Content-free HTTP fallback remains stable and retry classification stays status-based.
52
+ }
53
+ throw new WorkspaceSnapshotControlError(code, explicitRetryable ?? (response.status >= 500 || response.status === 408 || response.status === 429 ||
54
+ response.status === 409));
55
+ }
56
+ return await response.json();
57
+ }
58
+ function fence(scope) {
59
+ return {
60
+ sandbox_id: scope.sandboxId,
61
+ sandbox_generation: scope.sandboxGeneration,
62
+ connection_epoch: scope.connectionEpoch,
63
+ runtime_session_id: scope.runtimeSessionId,
64
+ ...(scope.agentRunId ? { agent_run_id: scope.agentRunId } : {}),
65
+ ...(scope.workerAttempt ? { worker_attempt: scope.workerAttempt } : {}),
66
+ ...(scope.activationId ? { activation_id: scope.activationId } : {}),
67
+ };
68
+ }
69
+ export async function checkpointWorkspace(options) {
70
+ const { scope } = options;
71
+ const signal = AbortSignal.timeout(WORKSPACE_SNAPSHOT_POLICY_V1.checkpointAttemptDeadlineMs);
72
+ const checkpointUrl = `${controlBase(options.wsUrl)}/${scope.checkpointId}`;
73
+ const controlDirectory = path.join(ensureDaemonHome(), "agent-service-sandboxes", scope.sandboxId, "workspace-control");
74
+ const staged = stageWorkspaceSnapshot({
75
+ workspaceDirectory: options.workspaceDirectory,
76
+ controlDirectory,
77
+ checkpointId: scope.checkpointId,
78
+ limits: WORKSPACE_SNAPSHOT_POLICY_V1.limits,
79
+ maxStabilityRetries: WORKSPACE_SNAPSHOT_POLICY_V1.maxStabilityRetries,
80
+ freezeProof: options.freezeProof,
81
+ });
82
+ try {
83
+ const entrySet = {
84
+ schemaVersion: "agent-workspace-entry-set/1",
85
+ entries: staged.entrySet.entries,
86
+ };
87
+ const proposal = await requestJson(`${checkpointUrl}/proposal`, options.reconnectToken, {
88
+ fence: fence(scope),
89
+ policy_id: WORKSPACE_SNAPSHOT_POLICY_V1.policyId,
90
+ base_revision: scope.baseRevision,
91
+ entry_count: staged.entrySet.entries.length,
92
+ file_count: staged.entrySet.fileCount,
93
+ total_bytes: staged.entrySet.totalBytes,
94
+ content_sha256: staged.entrySet.contentSha256,
95
+ entry_set: entrySet,
96
+ }, signal);
97
+ if (proposal.unchanged) {
98
+ if (proposal.revision === 0 && proposal.snapshot_id === null) {
99
+ writeWorkspaceMaterializationMarker({
100
+ schemaVersion: "agent-workspace-materialization/1",
101
+ sandboxId: scope.sandboxId,
102
+ sandboxGeneration: scope.sandboxGeneration,
103
+ runtimeSessionId: scope.runtimeSessionId,
104
+ workspaceContinuityState: "healthy",
105
+ snapshotId: null,
106
+ revision: 0,
107
+ contentSha256: null,
108
+ writtenAt: new Date().toISOString(),
109
+ });
110
+ }
111
+ return {
112
+ revision: proposal.revision,
113
+ snapshotId: proposal.snapshot_id,
114
+ contentSha256: proposal.content_sha256,
115
+ };
116
+ }
117
+ for (let offset = 0; offset < proposal.missing_blob_ids.length; offset += 25) {
118
+ const requestedBlobIds = proposal.missing_blob_ids.slice(offset, offset + 25);
119
+ const grants = await requestJson(`${checkpointUrl}/upload-grants`, options.reconnectToken, { fence: fence(scope), blob_ids: requestedBlobIds }, signal);
120
+ if (grants.length !== requestedBlobIds.length) {
121
+ throw new Error("workspace_snapshot_upload_grant_count_mismatch");
122
+ }
123
+ for (const item of grants) {
124
+ const source = staged.files.find((file) => file.sha256 === item.sha256 && file.sizeBytes === item.size_bytes);
125
+ if (!source)
126
+ throw new Error("workspace_snapshot_upload_source_missing");
127
+ const response = await fetch(item.grant.url, {
128
+ method: item.grant.method,
129
+ headers: item.grant.headers,
130
+ body: createReadStream(source.stagingPath),
131
+ duplex: "half",
132
+ signal,
133
+ });
134
+ if (!response.ok)
135
+ throw new Error(`workspace_snapshot_upload_${response.status}`);
136
+ }
137
+ }
138
+ let committed = await requestJson(`${checkpointUrl}/complete`, options.reconnectToken, { fence: fence(scope), policy_id: WORKSPACE_SNAPSHOT_POLICY_V1.policyId }, signal);
139
+ while (committed.status === "pending") {
140
+ await new Promise((resolve) => setTimeout(resolve, 500));
141
+ committed = await requestJson(`${checkpointUrl}/status`, options.reconnectToken, { fence: fence(scope) }, signal);
142
+ }
143
+ if (committed.status !== "committed" ||
144
+ committed.snapshot_id === null ||
145
+ committed.revision === null ||
146
+ committed.content_sha256 === null) {
147
+ throw new WorkspaceSnapshotControlError(committed.error_code || "workspace_snapshot_verification_failed", false);
148
+ }
149
+ writeWorkspaceMaterializationMarker({
150
+ schemaVersion: "agent-workspace-materialization/1",
151
+ sandboxId: scope.sandboxId,
152
+ sandboxGeneration: scope.sandboxGeneration,
153
+ runtimeSessionId: scope.runtimeSessionId,
154
+ workspaceContinuityState: "healthy",
155
+ snapshotId: committed.snapshot_id,
156
+ revision: committed.revision,
157
+ contentSha256: committed.content_sha256,
158
+ writtenAt: new Date().toISOString(),
159
+ });
160
+ return {
161
+ revision: committed.revision,
162
+ snapshotId: committed.snapshot_id,
163
+ contentSha256: committed.content_sha256,
164
+ };
165
+ }
166
+ finally {
167
+ rmSync(staged.stagingDirectory, { recursive: true, force: true });
168
+ }
169
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Protocol limits frozen with Course Service's WorkspaceSnapshotPolicyV1.
3
+ * Deployment may enable the capability, but must never override these values.
4
+ */
5
+ export declare const WORKSPACE_SNAPSHOT_POLICY_V1: {
6
+ readonly policyId: "WorkspaceSnapshotPolicyV1";
7
+ readonly limits: {
8
+ maxPathSegmentBytes: number;
9
+ maxPathBytes: number;
10
+ maxDepth: number;
11
+ maxFileBytes: number;
12
+ maxFileCount: number;
13
+ maxTotalBytes: number;
14
+ maxEntrySetBytes: number;
15
+ };
16
+ readonly maxStabilityRetries: 2;
17
+ readonly checkpointAttemptDeadlineMs: 30000;
18
+ readonly requiredRestoreFreeBytes: number;
19
+ readonly sessionRetainedHardLimitBytes: number;
20
+ readonly checkpointReservationBytes: number;
21
+ readonly restorePageEntries: 25;
22
+ };
23
+ /** Wire representation validated exactly by Agent Service during the v0.3 handshake. */
24
+ export declare function workspaceSnapshotPolicyCapability(): Record<string, unknown>;
@@ -0,0 +1,45 @@
1
+ import { RUNTIME_WORKSPACE_INODE_QUOTA, RUNTIME_WORKSPACE_QUOTA_BYTES, } from "./workspace-quota.js";
2
+ /**
3
+ * Protocol limits frozen with Course Service's WorkspaceSnapshotPolicyV1.
4
+ * Deployment may enable the capability, but must never override these values.
5
+ */
6
+ export const WORKSPACE_SNAPSHOT_POLICY_V1 = {
7
+ policyId: "WorkspaceSnapshotPolicyV1",
8
+ limits: {
9
+ maxPathSegmentBytes: 255,
10
+ maxPathBytes: 1024,
11
+ maxDepth: 32,
12
+ maxFileBytes: 50 * 1024 * 1024,
13
+ maxFileCount: 2_000,
14
+ maxTotalBytes: 512 * 1024 * 1024,
15
+ maxEntrySetBytes: 1024 * 1024,
16
+ },
17
+ maxStabilityRetries: 2,
18
+ checkpointAttemptDeadlineMs: 30_000,
19
+ requiredRestoreFreeBytes: 1024 * 1024,
20
+ sessionRetainedHardLimitBytes: 2 * 1024 * 1024 * 1024,
21
+ checkpointReservationBytes: 512 * 1024 * 1024,
22
+ restorePageEntries: 25,
23
+ };
24
+ /** Wire representation validated exactly by Agent Service during the v0.3 handshake. */
25
+ export function workspaceSnapshotPolicyCapability() {
26
+ return {
27
+ policy_id: WORKSPACE_SNAPSHOT_POLICY_V1.policyId,
28
+ max_path_segment_bytes: WORKSPACE_SNAPSHOT_POLICY_V1.limits.maxPathSegmentBytes,
29
+ max_path_bytes: WORKSPACE_SNAPSHOT_POLICY_V1.limits.maxPathBytes,
30
+ max_depth: WORKSPACE_SNAPSHOT_POLICY_V1.limits.maxDepth,
31
+ max_file_bytes: WORKSPACE_SNAPSHOT_POLICY_V1.limits.maxFileBytes,
32
+ max_file_count: WORKSPACE_SNAPSHOT_POLICY_V1.limits.maxFileCount,
33
+ max_total_bytes: WORKSPACE_SNAPSHOT_POLICY_V1.limits.maxTotalBytes,
34
+ max_entry_set_bytes: WORKSPACE_SNAPSHOT_POLICY_V1.limits.maxEntrySetBytes,
35
+ required_restore_free_bytes: WORKSPACE_SNAPSHOT_POLICY_V1.requiredRestoreFreeBytes,
36
+ session_retained_hard_limit_bytes: WORKSPACE_SNAPSHOT_POLICY_V1.sessionRetainedHardLimitBytes,
37
+ checkpoint_reservation_bytes: WORKSPACE_SNAPSHOT_POLICY_V1.checkpointReservationBytes,
38
+ restore_page_entries: WORKSPACE_SNAPSHOT_POLICY_V1.restorePageEntries,
39
+ max_stability_retries: WORKSPACE_SNAPSHOT_POLICY_V1.maxStabilityRetries,
40
+ checkpoint_attempt_deadline_ms: WORKSPACE_SNAPSHOT_POLICY_V1.checkpointAttemptDeadlineMs,
41
+ prepare_deadline_seconds: 15 * 60,
42
+ runtime_filesystem_quota_bytes: RUNTIME_WORKSPACE_QUOTA_BYTES,
43
+ runtime_inode_quota: RUNTIME_WORKSPACE_INODE_QUOTA,
44
+ };
45
+ }
@@ -1,3 +1,4 @@
1
+ import { type WorkspaceWriterFreezeProof } from "./runtime-quiescence.js";
1
2
  import { type CanonicalWorkspaceEntrySet, type WorkspaceEntrySetLimits } from "./workspace-entry-set.js";
2
3
  export declare class WorkspaceSnapshotStagingError extends Error {
3
4
  readonly code: string;
@@ -21,14 +22,6 @@ export interface WorkspaceSnapshotStagingOptions {
21
22
  checkpointId: string;
22
23
  limits: WorkspaceEntrySetLimits;
23
24
  maxStabilityRetries: number;
24
- quiescenceProof: WorkspaceQuiescenceProof;
25
- }
26
- export interface WorkspaceQuiescenceProof {
27
- proofVersion: "workspace-quiescence-proof/1";
28
- sandboxId: string;
29
- sandboxGeneration: number;
30
- runtimeSessionId: string;
31
- checkpointId: string;
32
- allWritersStopped: true;
25
+ freezeProof: WorkspaceWriterFreezeProof;
33
26
  }
34
27
  export declare function stageWorkspaceSnapshot(options: WorkspaceSnapshotStagingOptions): StagedWorkspaceSnapshot;
@@ -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");
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.3",
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": {