@botlearn-course/daemon 0.0.17 → 0.0.18-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.
@@ -64,6 +64,7 @@ export declare class AgentServiceSandboxClient implements RunReportingClient, Pe
64
64
  private runtimeLogSuppressedEvents;
65
65
  private runtimeLogSuppressedBytes;
66
66
  private runtimeLogSendChain;
67
+ private workspaceFileReadInFlight;
67
68
  private readonly removeOperationalLogSink;
68
69
  constructor(options: AgentServiceSandboxOptions);
69
70
  prepareTurn(payload: RunStartPayload): PreparedPersistentTurn;
@@ -109,9 +110,12 @@ export declare class AgentServiceSandboxClient implements RunReportingClient, Pe
109
110
  private replaySpool;
110
111
  private sendHeartbeat;
111
112
  private sendCommandAck;
113
+ private handleWorkspaceFileRead;
114
+ private sendWorkspaceFileResult;
112
115
  private sendControlFrame;
113
116
  private sendSessionFrame;
114
117
  private sendFrame;
118
+ private sendBinaryFrame;
115
119
  private nextOutboundSeq;
116
120
  private waitForEventCapacity;
117
121
  private waitForPendingEventAcks;
@@ -1,14 +1,15 @@
1
1
  import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { ensureDaemonHome } from "./auth-store.js";
4
- import { AGENT_SERVICE_WS_SCHEMA, AGENT_SERVICE_WS_SUBPROTOCOL, createSandboxFrame, parseSandboxFrame, UnsupportedSandboxProtocolError, } from "./agent-service-ws-protocol.js";
4
+ import { AGENT_SERVICE_WS_SCHEMA, AGENT_SERVICE_WS_SUBPROTOCOL, createSandboxFrame, createWorkspaceFileChunk, parseSandboxFrame, UnsupportedSandboxProtocolError, WORKSPACE_FILE_READ_BINARY_CAPABILITY, } from "./agent-service-ws-protocol.js";
5
5
  import { log as defaultLog, setOperationalLogSink, } from "./log.js";
6
6
  import { availableRunCapabilities, runtimeSupportsCourseSkills, } from "./runtime-capabilities.js";
7
7
  import { activationRuntimeEnv, runtimeChildEnv } from "./runtime-env.js";
8
8
  import { redactSecretString } from "./redaction.js";
9
9
  import { parseRuntimeSkillProviderGrantSet, prepareRuntimeSkillProvider, RuntimeSkillProviderError, } from "./runtime-skills.js";
10
10
  import { RunDispatcher, } from "./run-dispatcher.js";
11
- import { ensureRuntimeSessionDirectories, ensureRuntimeSessionWorkspace, exposeRuntimeSessionWorkspace, removeRuntimeSessionWorkspace, revokeRuntimeSessionWorkspace, } from "./workspace.js";
11
+ import { ensureRuntimeSessionDirectories, ensureRuntimeSessionWorkspace, exposeRuntimeSessionWorkspace, removeRuntimeSessionWorkspace, revokeRuntimeSessionWorkspace, runtimeSessionWorkspaceDir, } from "./workspace.js";
12
+ import { readWorkspaceFile, WorkspaceFileReadError } from "./workspace-file-read.js";
12
13
  import { WebSocketClient, } from "./websocket-client.js";
13
14
  const RECONNECT_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
14
15
  const MAX_SPOOL_FRAMES = 1024;
@@ -19,6 +20,7 @@ const INPUT_ATTACHMENT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-
19
20
  /** 出站 seq 基址:seq = connection_epoch * SEQ_EPOCH_BASE + n,跨重连单调(合同 §1.1)。 */
20
21
  const SEQ_EPOCH_BASE = 1_000_000_000;
21
22
  const RUNTIME_LOG_WINDOW_MS = 60_000;
23
+ const WORKSPACE_FILE_TRANSFER_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
22
24
  const DISABLED_RUNTIME_LOG_POLICY = {
23
25
  enabled: false,
24
26
  maxEventBytes: 4096,
@@ -368,6 +370,7 @@ export class AgentServiceSandboxClient {
368
370
  runtimeLogSuppressedEvents = 0;
369
371
  runtimeLogSuppressedBytes = 0;
370
372
  runtimeLogSendChain = Promise.resolve();
373
+ workspaceFileReadInFlight = false;
371
374
  removeOperationalLogSink;
372
375
  constructor(options) {
373
376
  this.options = options;
@@ -784,6 +787,7 @@ export class AgentServiceSandboxClient {
784
787
  this.persist();
785
788
  await this.sendControlFrame("sandbox.ready", {
786
789
  protocol_versions: [AGENT_SERVICE_WS_SCHEMA],
790
+ capabilities: [WORKSPACE_FILE_READ_BINARY_CAPABILITY],
787
791
  daemon_version: this.options.daemonVersion,
788
792
  runtime_versions: {
789
793
  course_daemon: this.options.daemonVersion,
@@ -906,6 +910,15 @@ export class AgentServiceSandboxClient {
906
910
  await this.sendCommandAck(frame, "ok");
907
911
  return;
908
912
  }
913
+ case "workspace.file.read":
914
+ void this.handleWorkspaceFileRead(frame).catch((error) => {
915
+ this.log.warn("Agent Service workspace file read response failed", {
916
+ sandboxId: this.options.sandboxId,
917
+ runtimeSessionId: frame.runtime_session_id,
918
+ error: error instanceof Error ? redactSecretString(error.message) : "unexpected",
919
+ });
920
+ });
921
+ return;
909
922
  case "sandbox.shutdown":
910
923
  this.stop();
911
924
  return;
@@ -1578,6 +1591,78 @@ export class AgentServiceSandboxClient {
1578
1591
  ...(error !== undefined ? { error } : {}),
1579
1592
  });
1580
1593
  }
1594
+ async handleWorkspaceFileRead(frame) {
1595
+ const transferId = frame.payload.transfer_id;
1596
+ const relativePath = frame.payload.path;
1597
+ const maxBytes = frame.payload.max_bytes;
1598
+ const expectedSizeBytes = frame.payload.expected_size_bytes;
1599
+ const expectedSha256 = frame.payload.expected_sha256;
1600
+ const sessionId = frame.runtime_session_id;
1601
+ const reject = async (errorCode) => {
1602
+ await this.sendWorkspaceFileResult(frame, {
1603
+ transfer_id: typeof transferId === "string" ? transferId : "invalid",
1604
+ status: "rejected",
1605
+ error_code: errorCode,
1606
+ });
1607
+ };
1608
+ if (typeof transferId !== "string" ||
1609
+ !WORKSPACE_FILE_TRANSFER_ID.test(transferId) ||
1610
+ typeof relativePath !== "string" ||
1611
+ !Number.isSafeInteger(maxBytes) ||
1612
+ !Number.isSafeInteger(expectedSizeBytes) ||
1613
+ typeof expectedSha256 !== "string") {
1614
+ await reject("workspace_file_invalid_request");
1615
+ return;
1616
+ }
1617
+ if (!this.state.sessions[sessionId]) {
1618
+ await reject("workspace_file_unavailable");
1619
+ return;
1620
+ }
1621
+ if (this.workspaceFileReadInFlight) {
1622
+ await reject("workspace_file_busy");
1623
+ return;
1624
+ }
1625
+ this.workspaceFileReadInFlight = true;
1626
+ try {
1627
+ const result = await readWorkspaceFile(runtimeSessionWorkspaceDir(sessionId, this.sandboxGeneration), relativePath, {
1628
+ maxBytes: maxBytes,
1629
+ expectedSizeBytes: expectedSizeBytes,
1630
+ expectedSha256,
1631
+ onChunk: async (chunkIndex, content) => {
1632
+ await this.sendBinaryFrame(createWorkspaceFileChunk(transferId, chunkIndex, content), frame);
1633
+ },
1634
+ });
1635
+ await this.sendWorkspaceFileResult(frame, {
1636
+ transfer_id: transferId,
1637
+ status: "ok",
1638
+ size_bytes: result.sizeBytes,
1639
+ sha256: result.sha256,
1640
+ chunk_count: result.chunkCount,
1641
+ });
1642
+ }
1643
+ catch (error) {
1644
+ await reject(error instanceof WorkspaceFileReadError
1645
+ ? error.code
1646
+ : "workspace_file_unavailable");
1647
+ }
1648
+ finally {
1649
+ this.workspaceFileReadInFlight = false;
1650
+ }
1651
+ }
1652
+ async sendWorkspaceFileResult(source, payload) {
1653
+ if (source.sandbox_generation !== this.sandboxGeneration ||
1654
+ source.connection_epoch !== this.connectionEpoch)
1655
+ return;
1656
+ await this.sendFrame(createSandboxFrame({
1657
+ type: "workspace.file.result",
1658
+ sandboxId: this.options.sandboxId,
1659
+ sandboxGeneration: this.sandboxGeneration,
1660
+ connectionEpoch: this.connectionEpoch,
1661
+ seq: this.nextOutboundSeq(),
1662
+ runtimeSessionId: source.runtime_session_id,
1663
+ payload,
1664
+ }));
1665
+ }
1581
1666
  async sendControlFrame(type, payload) {
1582
1667
  await this.sendFrame(createSandboxFrame({
1583
1668
  type,
@@ -1611,6 +1696,19 @@ export class AgentServiceSandboxClient {
1611
1696
  socket.send(JSON.stringify(frame), (error) => (error ? reject(error) : resolve()));
1612
1697
  });
1613
1698
  }
1699
+ async sendBinaryFrame(content, source) {
1700
+ if (source.sandbox_generation !== this.sandboxGeneration ||
1701
+ source.connection_epoch !== this.connectionEpoch) {
1702
+ throw new Error("Agent Service sandbox file transfer was fenced");
1703
+ }
1704
+ const socket = this.socket;
1705
+ if (!socket || socket.readyState !== WebSocketClient.OPEN) {
1706
+ throw new Error("Agent Service sandbox file transport is disconnected");
1707
+ }
1708
+ await new Promise((resolve, reject) => {
1709
+ socket.send(content, (error) => (error ? reject(error) : resolve()));
1710
+ });
1711
+ }
1614
1712
  nextOutboundSeq() {
1615
1713
  this.outboundSeq += 1;
1616
1714
  return this.outboundSeq;
@@ -1,6 +1,9 @@
1
1
  export declare const AGENT_SERVICE_WS_SCHEMA: "botlearn-agent-sandbox-ws/0.2";
2
2
  export declare const AGENT_SERVICE_WS_SUBPROTOCOL: "botlearn-agent-sandbox.v2";
3
- export type SandboxFrameType = "sandbox.hello" | "sandbox.sync" | "session.open" | "session.activate" | "turn.start" | "turn.cancel" | "session.close" | "sandbox.drain" | "sandbox.shutdown" | "event.ack" | "auth.rotate" | "ping" | "sandbox.ready" | "sandbox.heartbeat" | "command.ack" | "session.opened" | "session.closed" | "turn.event" | "turn.file.report" | "sandbox.drained" | "sandbox.log" | "pong" | "protocol.error";
3
+ export declare const WORKSPACE_FILE_READ_BINARY_CAPABILITY: "workspace_file_read_binary_v1";
4
+ export declare const WORKSPACE_FILE_CHUNK_BYTES: number;
5
+ export declare const WORKSPACE_FILE_CHUNK_HEADER_BYTES = 29;
6
+ export type SandboxFrameType = "sandbox.hello" | "sandbox.sync" | "session.open" | "session.activate" | "turn.start" | "turn.cancel" | "session.close" | "sandbox.drain" | "sandbox.shutdown" | "event.ack" | "auth.rotate" | "workspace.file.read" | "ping" | "sandbox.ready" | "sandbox.heartbeat" | "command.ack" | "session.opened" | "session.closed" | "turn.event" | "turn.file.report" | "workspace.file.result" | "sandbox.drained" | "sandbox.log" | "pong" | "protocol.error";
4
7
  export declare class UnsupportedSandboxProtocolError extends Error {
5
8
  readonly schemaVersion: unknown;
6
9
  constructor(schemaVersion: unknown);
@@ -20,6 +23,13 @@ export interface SandboxFrame {
20
23
  activation_id: string | null;
21
24
  payload: Record<string, unknown>;
22
25
  }
26
+ export interface WorkspaceFileChunk {
27
+ transferId: string;
28
+ chunkIndex: number;
29
+ content: Buffer;
30
+ }
31
+ export declare function createWorkspaceFileChunk(transferId: string, chunkIndex: number, content: Buffer): Buffer;
32
+ export declare function parseWorkspaceFileChunk(raw: Buffer): WorkspaceFileChunk;
23
33
  export declare function parseSandboxFrame(raw: string, maxBytes?: number): SandboxFrame;
24
34
  export declare function createSandboxFrame(input: {
25
35
  type: SandboxFrameType;
@@ -1,6 +1,10 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  export const AGENT_SERVICE_WS_SCHEMA = "botlearn-agent-sandbox-ws/0.2";
3
3
  export const AGENT_SERVICE_WS_SUBPROTOCOL = "botlearn-agent-sandbox.v2";
4
+ export const WORKSPACE_FILE_READ_BINARY_CAPABILITY = "workspace_file_read_binary_v1";
5
+ export const WORKSPACE_FILE_CHUNK_BYTES = 64 * 1024;
6
+ export const WORKSPACE_FILE_CHUNK_HEADER_BYTES = 29;
7
+ const WORKSPACE_FILE_CHUNK_MAGIC = Buffer.from("BLWF", "ascii");
4
8
  const FRAME_TYPES = new Set([
5
9
  "sandbox.hello",
6
10
  "sandbox.sync",
@@ -13,6 +17,7 @@ const FRAME_TYPES = new Set([
13
17
  "sandbox.shutdown",
14
18
  "event.ack",
15
19
  "auth.rotate",
20
+ "workspace.file.read",
16
21
  "ping",
17
22
  "sandbox.ready",
18
23
  "sandbox.heartbeat",
@@ -21,6 +26,7 @@ const FRAME_TYPES = new Set([
21
26
  "session.closed",
22
27
  "turn.event",
23
28
  "turn.file.report",
29
+ "workspace.file.result",
24
30
  "sandbox.drained",
25
31
  "sandbox.log",
26
32
  "pong",
@@ -33,6 +39,8 @@ const SESSION_TYPES = new Set([
33
39
  "session.close",
34
40
  "session.opened",
35
41
  "session.closed",
42
+ "workspace.file.read",
43
+ "workspace.file.result",
36
44
  ]);
37
45
  /** TURN 帧:必须带 runtime_session_id + agent_run_id + worker_attempt + activation_id 四元组。 */
38
46
  const TURN_TYPES = new Set([
@@ -66,6 +74,48 @@ export class UnsupportedSandboxProtocolError extends Error {
66
74
  this.name = "UnsupportedSandboxProtocolError";
67
75
  }
68
76
  }
77
+ function uuidBytes(value) {
78
+ if (!UUID_PATTERN.test(value))
79
+ throw new Error("workspace file transfer_id must be a UUID");
80
+ return Buffer.from(value.replaceAll("-", ""), "hex");
81
+ }
82
+ function bytesUuid(value) {
83
+ const hex = value.toString("hex");
84
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
85
+ }
86
+ export function createWorkspaceFileChunk(transferId, chunkIndex, content) {
87
+ if (!Number.isSafeInteger(chunkIndex) || chunkIndex < 0 || chunkIndex > 0xffff_ffff) {
88
+ throw new Error("workspace file chunk_index is invalid");
89
+ }
90
+ if (content.length > WORKSPACE_FILE_CHUNK_BYTES) {
91
+ throw new Error("workspace file chunk exceeds size limit");
92
+ }
93
+ const frame = Buffer.allocUnsafe(WORKSPACE_FILE_CHUNK_HEADER_BYTES + content.length);
94
+ WORKSPACE_FILE_CHUNK_MAGIC.copy(frame, 0);
95
+ frame[4] = 1;
96
+ uuidBytes(transferId).copy(frame, 5);
97
+ frame.writeUInt32BE(chunkIndex, 21);
98
+ frame.writeUInt32BE(content.length, 25);
99
+ content.copy(frame, WORKSPACE_FILE_CHUNK_HEADER_BYTES);
100
+ return frame;
101
+ }
102
+ export function parseWorkspaceFileChunk(raw) {
103
+ if (raw.length < WORKSPACE_FILE_CHUNK_HEADER_BYTES ||
104
+ !raw.subarray(0, 4).equals(WORKSPACE_FILE_CHUNK_MAGIC) ||
105
+ raw[4] !== 1) {
106
+ throw new Error("invalid workspace file binary frame");
107
+ }
108
+ const declaredBytes = raw.readUInt32BE(25);
109
+ const content = raw.subarray(WORKSPACE_FILE_CHUNK_HEADER_BYTES);
110
+ if (declaredBytes !== content.length || content.length > WORKSPACE_FILE_CHUNK_BYTES) {
111
+ throw new Error("invalid workspace file binary frame length");
112
+ }
113
+ return {
114
+ transferId: bytesUuid(raw.subarray(5, 21)),
115
+ chunkIndex: raw.readUInt32BE(21),
116
+ content: Buffer.from(content),
117
+ };
118
+ }
69
119
  function positiveInteger(value, label) {
70
120
  if (!Number.isInteger(value) || value < 1) {
71
121
  throw new Error(`${label} must be a positive integer`);
@@ -1,4 +1,9 @@
1
1
  import type { MaterializedInputAttachment, RunInputAttachment } from "./types.js";
2
+ export type InputAttachmentErrorCode = "attachment_download_failed" | "attachment_download_timeout" | "attachment_read_failed" | "attachment_verification_failed";
3
+ export declare class InputAttachmentMaterializationError extends Error {
4
+ readonly code: InputAttachmentErrorCode;
5
+ constructor(message: string, code: InputAttachmentErrorCode, options?: ErrorOptions);
6
+ }
2
7
  export interface InputAttachmentGrant {
3
8
  downloads: Array<{
4
9
  attachmentId: string;
@@ -10,6 +10,20 @@ const ATTACHMENT_DOWNLOAD_IDLE_TIMEOUT_MS = 60_000;
10
10
  const ATTACHMENT_DOWNLOAD_MAX_DURATION_MS = 10 * 60_000;
11
11
  const ATTACHMENT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
12
12
  const SHA256 = /^[0-9a-f]{64}$/i;
13
+ export class InputAttachmentMaterializationError extends Error {
14
+ code;
15
+ constructor(message, code, options) {
16
+ super(message, options);
17
+ this.name = "InputAttachmentMaterializationError";
18
+ this.code = code;
19
+ }
20
+ }
21
+ function attachmentVerificationError(message) {
22
+ return new InputAttachmentMaterializationError(message, "attachment_verification_failed");
23
+ }
24
+ function attachmentDownloadError(message) {
25
+ return new InputAttachmentMaterializationError(message, "attachment_download_failed");
26
+ }
13
27
  const CONTENT_TYPE_EXTENSIONS = {
14
28
  "image/png": ".png",
15
29
  "image/jpeg": ".jpg",
@@ -42,43 +56,44 @@ function normalizedContentType(value) {
42
56
  }
43
57
  function validateAttachment(attachment) {
44
58
  if (!ATTACHMENT_ID.test(attachment.attachment_id)) {
45
- throw new Error("invalid input attachment id");
59
+ throw attachmentVerificationError("invalid input attachment id");
46
60
  }
47
61
  if (typeof attachment.filename !== "string" ||
48
62
  attachment.filename.length < 1 ||
49
63
  attachment.filename.length > 255) {
50
- throw new Error("invalid input attachment filename");
64
+ throw attachmentVerificationError("invalid input attachment filename");
51
65
  }
52
66
  const contentType = normalizedContentType(attachment.content_type);
53
67
  const extension = CONTENT_TYPE_EXTENSIONS[contentType];
54
- if (!extension)
55
- throw new Error(`unsupported input attachment type: ${contentType}`);
68
+ if (!extension) {
69
+ throw attachmentVerificationError(`unsupported input attachment type: ${contentType}`);
70
+ }
56
71
  if (!Number.isInteger(attachment.size_bytes) ||
57
72
  attachment.size_bytes < 1 ||
58
73
  attachment.size_bytes > MAX_ATTACHMENT_BYTES) {
59
- throw new Error("invalid input attachment size");
74
+ throw attachmentVerificationError("invalid input attachment size");
60
75
  }
61
76
  if (!SHA256.test(attachment.sha256)) {
62
- throw new Error("invalid input attachment sha256");
77
+ throw attachmentVerificationError("invalid input attachment sha256");
63
78
  }
64
79
  return extension;
65
80
  }
66
81
  function validateDownloadUrl(value) {
67
82
  if (!value || value.length > 4096) {
68
- throw new Error("invalid input attachment download URL");
83
+ throw attachmentDownloadError("invalid input attachment download URL");
69
84
  }
70
85
  let parsed;
71
86
  try {
72
87
  parsed = new URL(value);
73
88
  }
74
89
  catch {
75
- throw new Error("invalid input attachment download URL");
90
+ throw attachmentDownloadError("invalid input attachment download URL");
76
91
  }
77
92
  if (!["http:", "https:"].includes(parsed.protocol) ||
78
93
  parsed.username ||
79
94
  parsed.password ||
80
95
  parsed.hash) {
81
- throw new Error("invalid input attachment download URL");
96
+ throw attachmentDownloadError("invalid input attachment download URL");
82
97
  }
83
98
  return value;
84
99
  }
@@ -86,12 +101,12 @@ function validateGrant(grant) {
86
101
  if (!Array.isArray(grant.downloads) ||
87
102
  grant.downloads.length < 1 ||
88
103
  grant.downloads.length > MAX_ATTACHMENTS) {
89
- throw new Error("invalid input attachment download grant");
104
+ throw attachmentDownloadError("invalid input attachment download grant");
90
105
  }
91
106
  const downloads = new Map();
92
107
  for (const item of grant.downloads) {
93
108
  if (!ATTACHMENT_ID.test(item.attachmentId) || downloads.has(item.attachmentId)) {
94
- throw new Error("invalid input attachment download grant");
109
+ throw attachmentDownloadError("invalid input attachment download grant");
95
110
  }
96
111
  downloads.set(item.attachmentId, validateDownloadUrl(item.downloadUrl));
97
112
  }
@@ -133,15 +148,15 @@ async function verifyExisting(filePath, attachment) {
133
148
  }
134
149
  async function writeResponseToFile(response, partPath, attachment, signal, onDownloadProgress) {
135
150
  if (!response.body)
136
- throw new Error("input attachment response has no body");
151
+ throw attachmentDownloadError("input attachment response has no body");
137
152
  const declaredLength = response.headers.get("content-length");
138
153
  if (declaredLength !== null && Number(declaredLength) !== attachment.size_bytes) {
139
- throw new Error("input attachment response size does not match metadata");
154
+ throw attachmentVerificationError("input attachment response size does not match metadata");
140
155
  }
141
156
  const responseType = normalizedContentType(response.headers.get("content-type") ?? "");
142
157
  const expectedType = normalizedContentType(attachment.content_type);
143
158
  if (responseType !== expectedType) {
144
- throw new Error("input attachment response type does not match metadata");
159
+ throw attachmentVerificationError("input attachment response type does not match metadata");
145
160
  }
146
161
  const digest = createHash("sha256");
147
162
  const signatureChunks = [];
@@ -163,7 +178,7 @@ async function writeResponseToFile(response, partPath, attachment, signal, onDow
163
178
  received += chunk.length;
164
179
  onDownloadProgress?.();
165
180
  if (received > attachment.size_bytes || received > MAX_ATTACHMENT_BYTES) {
166
- throw new Error("input attachment response exceeds declared size");
181
+ throw attachmentVerificationError("input attachment response exceeds declared size");
167
182
  }
168
183
  digest.update(chunk);
169
184
  if (signatureBytes < 16) {
@@ -178,13 +193,13 @@ async function writeResponseToFile(response, partPath, attachment, signal, onDow
178
193
  }
179
194
  }
180
195
  if (received !== attachment.size_bytes) {
181
- throw new Error("input attachment response size does not match metadata");
196
+ throw attachmentVerificationError("input attachment response size does not match metadata");
182
197
  }
183
198
  if (digest.digest("hex") !== attachment.sha256.toLowerCase()) {
184
- throw new Error("input attachment sha256 mismatch");
199
+ throw attachmentVerificationError("input attachment sha256 mismatch");
185
200
  }
186
201
  if (!hasExpectedImageSignature(expectedType, Buffer.concat(signatureChunks))) {
187
- throw new Error("input attachment file signature does not match content type");
202
+ throw attachmentVerificationError("input attachment file signature does not match content type");
188
203
  }
189
204
  await file.sync();
190
205
  }
@@ -195,8 +210,8 @@ async function writeResponseToFile(response, partPath, attachment, signal, onDow
195
210
  function startAttachmentDownloadBudget(parentSignal) {
196
211
  const controller = new AbortController();
197
212
  const abortFromParent = () => controller.abort(parentSignal.reason);
198
- const abortStalledDownload = () => controller.abort(new Error("input attachment download stalled for 60s"));
199
- const abortOverlongDownload = () => controller.abort(new Error("input attachment download exceeded the 600s maximum duration"));
213
+ const abortStalledDownload = () => controller.abort(new InputAttachmentMaterializationError("input attachment download stalled for 60s", "attachment_download_timeout"));
214
+ const abortOverlongDownload = () => controller.abort(new InputAttachmentMaterializationError("input attachment download exceeded the 600s maximum duration", "attachment_download_timeout"));
200
215
  if (parentSignal.aborted) {
201
216
  abortFromParent();
202
217
  }
@@ -222,23 +237,23 @@ function startAttachmentDownloadBudget(parentSignal) {
222
237
  },
223
238
  };
224
239
  }
225
- export async function materializeInputAttachments(workspaceDir, attachments, grant, signal) {
240
+ async function materializeInputAttachmentsImplementation(workspaceDir, attachments, grant, signal) {
226
241
  if (attachments.length > MAX_ATTACHMENTS) {
227
- throw new Error("too many input attachments");
242
+ throw attachmentVerificationError("too many input attachments");
228
243
  }
229
244
  if (new Set(attachments.map((item) => item.attachment_id)).size !== attachments.length) {
230
- throw new Error("duplicate input attachment id");
245
+ throw attachmentVerificationError("duplicate input attachment id");
231
246
  }
232
247
  const extensions = new Map(attachments.map((attachment) => [attachment.attachment_id, validateAttachment(attachment)]));
233
248
  const downloadUrls = validateGrant(grant);
234
249
  if (downloadUrls.size !== attachments.length ||
235
250
  attachments.some((attachment) => !downloadUrls.has(attachment.attachment_id))) {
236
- throw new Error("input attachment download grant does not match run attachments");
251
+ throw attachmentDownloadError("input attachment download grant does not match run attachments");
237
252
  }
238
253
  const inputDir = path.resolve(workspaceDir, ".botlearn", "input-attachments");
239
254
  const workspaceRoot = path.resolve(workspaceDir);
240
255
  if (!inputDir.startsWith(`${workspaceRoot}${path.sep}`)) {
241
- throw new Error("input attachment directory escapes workspace");
256
+ throw attachmentVerificationError("input attachment directory escapes workspace");
242
257
  }
243
258
  await mkdir(inputDir, { recursive: true, mode: 0o770 });
244
259
  await chmod(path.dirname(inputDir), 0o770);
@@ -256,13 +271,22 @@ export async function materializeInputAttachments(workspaceDir, attachments, gra
256
271
  const downloadUrl = downloadUrls.get(attachment.attachment_id);
257
272
  const budget = startAttachmentDownloadBudget(signal);
258
273
  try {
259
- const response = await fetch(downloadUrl, {
260
- headers: { "accept-encoding": "identity" },
261
- redirect: "error",
262
- signal: budget.signal,
263
- });
274
+ let response;
275
+ try {
276
+ response = await fetch(downloadUrl, {
277
+ headers: { "accept-encoding": "identity" },
278
+ redirect: "error",
279
+ signal: budget.signal,
280
+ });
281
+ }
282
+ catch (error) {
283
+ if (budget.signal.reason instanceof InputAttachmentMaterializationError) {
284
+ throw budget.signal.reason;
285
+ }
286
+ throw new InputAttachmentMaterializationError("input attachment download request failed", "attachment_download_failed", { cause: error });
287
+ }
264
288
  if (!response.ok) {
265
- throw new Error(`input attachment download failed with HTTP ${response.status}`);
289
+ throw attachmentDownloadError(`input attachment download failed with HTTP ${response.status}`);
266
290
  }
267
291
  await writeResponseToFile(response, partPath, attachment, budget.signal, budget.recordProgress);
268
292
  await rename(partPath, filePath);
@@ -282,3 +306,13 @@ export async function materializeInputAttachments(workspaceDir, attachments, gra
282
306
  }
283
307
  return results;
284
308
  }
309
+ export async function materializeInputAttachments(workspaceDir, attachments, grant, signal) {
310
+ try {
311
+ return await materializeInputAttachmentsImplementation(workspaceDir, attachments, grant, signal);
312
+ }
313
+ catch (error) {
314
+ if (error instanceof InputAttachmentMaterializationError)
315
+ throw error;
316
+ throw new InputAttachmentMaterializationError("input attachment could not be prepared", "attachment_read_failed", { cause: error });
317
+ }
318
+ }
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { CourseClientError, isRunTerminal } from "./course-client.js";
3
3
  import { reportFileCandidates } from "./file-candidates.js";
4
- import { materializeInputAttachments, } from "./input-attachments.js";
4
+ import { InputAttachmentMaterializationError, materializeInputAttachments, } from "./input-attachments.js";
5
5
  import { log as defaultLog } from "./log.js";
6
6
  import { MAX_PROGRESS_EVENTS_PER_ATTEMPT, tryNormalizeProgressReport, } from "./mcp/report-progress.js";
7
7
  import { errorInfo, redactSecretString, sanitizeRuntimeFailureText, truncateText, } from "./redaction.js";
@@ -444,7 +444,7 @@ export class RunDispatcher {
444
444
  let inputAttachments = [];
445
445
  if (requestedAttachments.length > 0) {
446
446
  if (!persistentTurn?.inputAttachmentGrant) {
447
- throw new Error("input attachment download authorization is unavailable");
447
+ throw new InputAttachmentMaterializationError("input attachment download authorization is unavailable", "attachment_download_failed");
448
448
  }
449
449
  inputAttachments = await materializeInputAttachments(workspaceDir, requestedAttachments, persistentTurn.inputAttachmentGrant, controller.signal);
450
450
  }
@@ -887,7 +887,13 @@ export class RunDispatcher {
887
887
  }
888
888
  else {
889
889
  const info = errorInfo(err);
890
- const errorType = err instanceof RuntimeExecutionError ? err.errorType : "runtime_error";
890
+ const errorType = err instanceof RuntimeProfileApplyError
891
+ ? err.code
892
+ : err instanceof InputAttachmentMaterializationError
893
+ ? err.code
894
+ : err instanceof RuntimeExecutionError
895
+ ? err.errorType
896
+ : "runtime_error";
891
897
  await sendFailure(errorType, info.error_message, err, err instanceof RuntimeProfileApplyError
892
898
  ? { code: err.code, profile_apply_status: "failed" }
893
899
  : {});
@@ -259,6 +259,10 @@ export class DeepseekTuiAdapter {
259
259
  if (compacted.error) {
260
260
  throw new Error(`native compaction failed: ${compacted.error}`);
261
261
  }
262
+ // The marker arms exactly one compaction. Disarm it here so later turns
263
+ // start work directly; the post-turn threshold check re-arms when the
264
+ // compacted thread grows past the limit again.
265
+ this.clearCompactionRequired(opts);
262
266
  }
263
267
  }
264
268
  runResult = await this.startTurnAndReadEvents({
@@ -7,8 +7,8 @@ export interface WebSocketClientOptions {
7
7
  /**
8
8
  * Narrow RFC 6455 client for the daemon control plane.
9
9
  *
10
- * It intentionally supports only text/control frames, no extensions, and one configured
11
- * subprotocol. Keeping this transport on Node built-ins preserves the daemon's zero
10
+ * It intentionally supports only text, binary, and control frames, no extensions, and one
11
+ * configured subprotocol. Keeping this transport on Node built-ins preserves the daemon's zero
12
12
  * production-dependency release invariant.
13
13
  */
14
14
  export declare class WebSocketClient extends EventEmitter {
@@ -31,7 +31,7 @@ export declare class WebSocketClient extends EventEmitter {
31
31
  private closeEmitted;
32
32
  private closeSent;
33
33
  constructor(rawUrl: string, protocol: string, options?: WebSocketClientOptions);
34
- send(data: string, callback?: (error?: Error) => void): void;
34
+ send(data: string | Buffer, callback?: (error?: Error) => void): void;
35
35
  close(code?: number, reason?: string): void;
36
36
  private connect;
37
37
  private sendHandshake;
@@ -35,8 +35,8 @@ function framePayload(opcode, payload) {
35
35
  /**
36
36
  * Narrow RFC 6455 client for the daemon control plane.
37
37
  *
38
- * It intentionally supports only text/control frames, no extensions, and one configured
39
- * subprotocol. Keeping this transport on Node built-ins preserves the daemon's zero
38
+ * It intentionally supports only text, binary, and control frames, no extensions, and one
39
+ * configured subprotocol. Keeping this transport on Node built-ins preserves the daemon's zero
40
40
  * production-dependency release invariant.
41
41
  */
42
42
  export class WebSocketClient extends EventEmitter {
@@ -77,12 +77,15 @@ export class WebSocketClient extends EventEmitter {
77
77
  callback?.(new Error("WebSocket is not open"));
78
78
  return;
79
79
  }
80
- const payload = Buffer.from(data, "utf8");
80
+ const binary = Buffer.isBuffer(data);
81
+ const payload = binary ? data : Buffer.from(data, "utf8");
81
82
  if (payload.length > this.maxPayload) {
82
83
  callback?.(new Error("WebSocket payload exceeds configured limit"));
83
84
  return;
84
85
  }
85
- this.socket.write(framePayload(0x1, payload), (error) => callback?.(error ?? undefined));
86
+ this.socket.write(framePayload(binary ? 0x2 : 0x1, payload), (error) => {
87
+ callback?.(error ?? undefined);
88
+ });
86
89
  }
87
90
  close(code = 1000, reason = "") {
88
91
  if (this.readyState === CLOSED)
@@ -0,0 +1,16 @@
1
+ export type WorkspaceFileReadErrorCode = "workspace_file_invalid_request" | "workspace_file_unavailable" | "workspace_file_changed";
2
+ export declare class WorkspaceFileReadError extends Error {
3
+ readonly code: WorkspaceFileReadErrorCode;
4
+ constructor(code: WorkspaceFileReadErrorCode);
5
+ }
6
+ export interface WorkspaceFileReadResult {
7
+ sizeBytes: number;
8
+ sha256: string;
9
+ chunkCount: number;
10
+ }
11
+ export declare function readWorkspaceFile(workspaceRoot: string, relativePath: string, options: {
12
+ maxBytes: number;
13
+ expectedSizeBytes: number;
14
+ expectedSha256: string;
15
+ onChunk(chunkIndex: number, content: Buffer): Promise<void>;
16
+ }): Promise<WorkspaceFileReadResult>;
@@ -0,0 +1,95 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { lstat, open, realpath } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { WORKSPACE_FILE_CHUNK_BYTES } from "./agent-service-ws-protocol.js";
6
+ export class WorkspaceFileReadError extends Error {
7
+ code;
8
+ constructor(code) {
9
+ super(code);
10
+ this.code = code;
11
+ this.name = "WorkspaceFileReadError";
12
+ }
13
+ }
14
+ function relativePathParts(relativePath) {
15
+ if (!relativePath || path.isAbsolute(relativePath) || relativePath.includes("\\")) {
16
+ throw new WorkspaceFileReadError("workspace_file_invalid_request");
17
+ }
18
+ const parts = relativePath.split("/");
19
+ if (parts.some((part) => !part || part === "." || part === "..")) {
20
+ throw new WorkspaceFileReadError("workspace_file_invalid_request");
21
+ }
22
+ return parts;
23
+ }
24
+ async function resolveRegularWorkspaceFile(workspaceRoot, relativePath) {
25
+ const parts = relativePathParts(relativePath);
26
+ const resolvedRoot = await realpath(workspaceRoot).catch(() => {
27
+ throw new WorkspaceFileReadError("workspace_file_unavailable");
28
+ });
29
+ let candidate = resolvedRoot;
30
+ for (const part of parts) {
31
+ candidate = path.join(candidate, part);
32
+ const metadata = await lstat(candidate).catch(() => {
33
+ throw new WorkspaceFileReadError("workspace_file_unavailable");
34
+ });
35
+ if (metadata.isSymbolicLink()) {
36
+ throw new WorkspaceFileReadError("workspace_file_unavailable");
37
+ }
38
+ }
39
+ const resolved = await realpath(candidate).catch(() => {
40
+ throw new WorkspaceFileReadError("workspace_file_unavailable");
41
+ });
42
+ if (resolved === resolvedRoot || !resolved.startsWith(`${resolvedRoot}${path.sep}`)) {
43
+ throw new WorkspaceFileReadError("workspace_file_unavailable");
44
+ }
45
+ return resolved;
46
+ }
47
+ export async function readWorkspaceFile(workspaceRoot, relativePath, options) {
48
+ if (!Number.isSafeInteger(options.maxBytes) ||
49
+ options.maxBytes < 0 ||
50
+ !Number.isSafeInteger(options.expectedSizeBytes) ||
51
+ options.expectedSizeBytes < 0 ||
52
+ options.expectedSizeBytes > options.maxBytes ||
53
+ !/^[0-9a-f]{64}$/.test(options.expectedSha256)) {
54
+ throw new WorkspaceFileReadError("workspace_file_invalid_request");
55
+ }
56
+ const resolved = await resolveRegularWorkspaceFile(workspaceRoot, relativePath);
57
+ let handle;
58
+ try {
59
+ handle = await open(resolved, constants.O_RDONLY | constants.O_NOFOLLOW);
60
+ const metadata = await handle.stat();
61
+ if (!metadata.isFile())
62
+ throw new WorkspaceFileReadError("workspace_file_unavailable");
63
+ if (metadata.size !== options.expectedSizeBytes || metadata.size > options.maxBytes) {
64
+ throw new WorkspaceFileReadError("workspace_file_changed");
65
+ }
66
+ const digest = createHash("sha256");
67
+ let offset = 0;
68
+ let chunkCount = 0;
69
+ while (offset < metadata.size) {
70
+ const requested = Math.min(WORKSPACE_FILE_CHUNK_BYTES, metadata.size - offset);
71
+ const buffer = Buffer.allocUnsafe(requested);
72
+ const { bytesRead } = await handle.read(buffer, 0, requested, offset);
73
+ if (bytesRead < 1)
74
+ throw new WorkspaceFileReadError("workspace_file_changed");
75
+ const content = buffer.subarray(0, bytesRead);
76
+ digest.update(content);
77
+ await options.onChunk(chunkCount, content);
78
+ offset += bytesRead;
79
+ chunkCount += 1;
80
+ }
81
+ const sha256 = digest.digest("hex");
82
+ if (offset !== options.expectedSizeBytes || sha256 !== options.expectedSha256) {
83
+ throw new WorkspaceFileReadError("workspace_file_changed");
84
+ }
85
+ return { sizeBytes: offset, sha256, chunkCount };
86
+ }
87
+ catch (error) {
88
+ if (error instanceof WorkspaceFileReadError)
89
+ throw error;
90
+ throw new WorkspaceFileReadError("workspace_file_unavailable");
91
+ }
92
+ finally {
93
+ await handle?.close().catch(() => undefined);
94
+ }
95
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botlearn-course/daemon",
3
- "version": "0.0.17",
3
+ "version": "0.0.18-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": {