@botlearn-course/daemon 0.0.2 → 0.0.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,320 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { EventEmitter } from "node:events";
3
+ import net from "node:net";
4
+ import tls from "node:tls";
5
+ const CONNECTING = 0;
6
+ const OPEN = 1;
7
+ const CLOSING = 2;
8
+ const CLOSED = 3;
9
+ const HANDSHAKE_MAX_BYTES = 16 * 1024;
10
+ const WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
11
+ function framePayload(opcode, payload) {
12
+ const mask = randomBytes(4);
13
+ let header;
14
+ if (payload.length < 126) {
15
+ header = Buffer.allocUnsafe(2);
16
+ header[1] = 0x80 | payload.length;
17
+ }
18
+ else if (payload.length <= 0xffff) {
19
+ header = Buffer.allocUnsafe(4);
20
+ header[1] = 0x80 | 126;
21
+ header.writeUInt16BE(payload.length, 2);
22
+ }
23
+ else {
24
+ header = Buffer.allocUnsafe(10);
25
+ header[1] = 0x80 | 127;
26
+ header.writeBigUInt64BE(BigInt(payload.length), 2);
27
+ }
28
+ header[0] = 0x80 | opcode;
29
+ const masked = Buffer.allocUnsafe(payload.length);
30
+ for (let index = 0; index < payload.length; index += 1) {
31
+ masked[index] = payload[index] ^ mask[index % 4];
32
+ }
33
+ return Buffer.concat([header, mask, masked]);
34
+ }
35
+ /**
36
+ * Narrow RFC 6455 client for the daemon control plane.
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
40
+ * production-dependency release invariant.
41
+ */
42
+ export class WebSocketClient extends EventEmitter {
43
+ options;
44
+ static CONNECTING = CONNECTING;
45
+ static OPEN = OPEN;
46
+ static CLOSING = CLOSING;
47
+ static CLOSED = CLOSED;
48
+ url;
49
+ requestedProtocol;
50
+ maxPayload;
51
+ protocol = "";
52
+ readyState = CONNECTING;
53
+ socket = null;
54
+ handshakeBuffer = Buffer.alloc(0);
55
+ frameBuffer = Buffer.alloc(0);
56
+ fragmentedOpcode = null;
57
+ fragmentedChunks = [];
58
+ fragmentedBytes = 0;
59
+ closeEmitted = false;
60
+ closeSent = false;
61
+ constructor(rawUrl, protocol, options = {}) {
62
+ super();
63
+ this.options = options;
64
+ this.url = new URL(rawUrl);
65
+ if (!["ws:", "wss:"].includes(this.url.protocol)) {
66
+ throw new Error("WebSocket URL must use ws:// or wss://");
67
+ }
68
+ if (!protocol || /[,\s]/.test(protocol)) {
69
+ throw new Error("WebSocket subprotocol is invalid");
70
+ }
71
+ this.requestedProtocol = protocol;
72
+ this.maxPayload = options.maxPayload ?? 262_144;
73
+ queueMicrotask(() => this.connect());
74
+ }
75
+ send(data, callback) {
76
+ if (this.readyState !== OPEN || !this.socket) {
77
+ callback?.(new Error("WebSocket is not open"));
78
+ return;
79
+ }
80
+ const payload = Buffer.from(data, "utf8");
81
+ if (payload.length > this.maxPayload) {
82
+ callback?.(new Error("WebSocket payload exceeds configured limit"));
83
+ return;
84
+ }
85
+ this.socket.write(framePayload(0x1, payload), (error) => callback?.(error ?? undefined));
86
+ }
87
+ close(code = 1000, reason = "") {
88
+ if (this.readyState === CLOSED)
89
+ return;
90
+ if (this.readyState === CONNECTING) {
91
+ this.readyState = CLOSING;
92
+ this.socket?.destroy();
93
+ return;
94
+ }
95
+ if (this.readyState === OPEN && this.socket && !this.closeSent) {
96
+ const reasonBytes = Buffer.from(reason, "utf8").subarray(0, 123);
97
+ const payload = Buffer.allocUnsafe(2 + reasonBytes.length);
98
+ payload.writeUInt16BE(code, 0);
99
+ reasonBytes.copy(payload, 2);
100
+ this.closeSent = true;
101
+ this.readyState = CLOSING;
102
+ this.socket.write(framePayload(0x8, payload));
103
+ const timer = setTimeout(() => this.socket?.end(), 1_000);
104
+ timer.unref();
105
+ }
106
+ }
107
+ connect() {
108
+ const secure = this.url.protocol === "wss:";
109
+ const port = Number(this.url.port || (secure ? 443 : 80));
110
+ const host = this.url.hostname;
111
+ const onConnect = () => this.sendHandshake();
112
+ const socket = secure
113
+ ? tls.connect({ host, port, servername: host }, onConnect)
114
+ : net.connect({ host, port }, onConnect);
115
+ this.socket = socket;
116
+ socket.on("data", (chunk) => this.onData(Buffer.from(chunk)));
117
+ socket.on("error", (error) => this.emit("error", error));
118
+ socket.on("close", () => this.emitClose(1006, "connection_closed"));
119
+ }
120
+ sendHandshake() {
121
+ if (!this.socket || this.readyState !== CONNECTING)
122
+ return;
123
+ const key = randomBytes(16).toString("base64");
124
+ const requestPath = `${this.url.pathname || "/"}${this.url.search}`;
125
+ const host = this.url.port ? `${this.url.hostname}:${this.url.port}` : this.url.hostname;
126
+ const headers = {
127
+ Host: host,
128
+ Upgrade: "websocket",
129
+ Connection: "Upgrade",
130
+ "Sec-WebSocket-Key": key,
131
+ "Sec-WebSocket-Version": "13",
132
+ "Sec-WebSocket-Protocol": this.requestedProtocol,
133
+ ...this.options.headers,
134
+ };
135
+ const expectedAccept = createHash("sha1")
136
+ .update(`${key}${WEBSOCKET_GUID}`)
137
+ .digest("base64");
138
+ this.once("handshake", (responseHeaders) => {
139
+ if (responseHeaders["sec-websocket-accept"] !== expectedAccept) {
140
+ this.failProtocol("invalid Sec-WebSocket-Accept");
141
+ }
142
+ });
143
+ const lines = [
144
+ `GET ${requestPath} HTTP/1.1`,
145
+ ...Object.entries(headers).map(([name, value]) => `${name}: ${value}`),
146
+ "",
147
+ "",
148
+ ];
149
+ this.socket.write(lines.join("\r\n"));
150
+ }
151
+ onData(chunk) {
152
+ if (this.readyState === CONNECTING) {
153
+ this.handshakeBuffer = Buffer.concat([this.handshakeBuffer, chunk]);
154
+ if (this.handshakeBuffer.length > HANDSHAKE_MAX_BYTES) {
155
+ this.failProtocol("WebSocket upgrade response is too large");
156
+ return;
157
+ }
158
+ const boundary = this.handshakeBuffer.indexOf("\r\n\r\n");
159
+ if (boundary < 0)
160
+ return;
161
+ const head = this.handshakeBuffer.subarray(0, boundary).toString("latin1");
162
+ const rest = this.handshakeBuffer.subarray(boundary + 4);
163
+ this.handshakeBuffer = Buffer.alloc(0);
164
+ const lines = head.split("\r\n");
165
+ const match = /^HTTP\/1\.[01] (\d{3})/.exec(lines.shift() ?? "");
166
+ const statusCode = Number(match?.[1] ?? 0);
167
+ const headers = {};
168
+ for (const line of lines) {
169
+ const separator = line.indexOf(":");
170
+ if (separator <= 0)
171
+ continue;
172
+ headers[line.slice(0, separator).trim().toLowerCase()] = line
173
+ .slice(separator + 1)
174
+ .trim();
175
+ }
176
+ if (statusCode !== 101) {
177
+ this.emit("unexpected-response", null, { statusCode });
178
+ this.socket?.destroy();
179
+ return;
180
+ }
181
+ if (headers.upgrade?.toLowerCase() !== "websocket") {
182
+ this.failProtocol("invalid WebSocket Upgrade header");
183
+ return;
184
+ }
185
+ if (!headers.connection
186
+ ?.split(",")
187
+ .some((value) => value.trim().toLowerCase() === "upgrade")) {
188
+ this.failProtocol("invalid WebSocket Connection header");
189
+ return;
190
+ }
191
+ if (headers["sec-websocket-protocol"] !== this.requestedProtocol) {
192
+ this.failProtocol("WebSocket subprotocol mismatch");
193
+ return;
194
+ }
195
+ this.protocol = headers["sec-websocket-protocol"];
196
+ this.emit("handshake", headers);
197
+ if (!this.socket || this.socket.destroyed)
198
+ return;
199
+ this.readyState = OPEN;
200
+ this.emit("open");
201
+ if (rest.length > 0)
202
+ this.consumeFrames(rest);
203
+ return;
204
+ }
205
+ this.consumeFrames(chunk);
206
+ }
207
+ consumeFrames(chunk) {
208
+ this.frameBuffer = Buffer.concat([this.frameBuffer, chunk]);
209
+ while (this.frameBuffer.length >= 2) {
210
+ const first = this.frameBuffer[0];
211
+ const second = this.frameBuffer[1];
212
+ const fin = (first & 0x80) !== 0;
213
+ const rsv = first & 0x70;
214
+ const opcode = first & 0x0f;
215
+ const masked = (second & 0x80) !== 0;
216
+ let payloadLength = second & 0x7f;
217
+ let offset = 2;
218
+ if (rsv !== 0 || masked) {
219
+ this.failProtocol("unsupported or masked server WebSocket frame");
220
+ return;
221
+ }
222
+ if (payloadLength === 126) {
223
+ if (this.frameBuffer.length < 4)
224
+ return;
225
+ payloadLength = this.frameBuffer.readUInt16BE(2);
226
+ offset = 4;
227
+ }
228
+ else if (payloadLength === 127) {
229
+ if (this.frameBuffer.length < 10)
230
+ return;
231
+ const longLength = this.frameBuffer.readBigUInt64BE(2);
232
+ if (longLength > BigInt(Number.MAX_SAFE_INTEGER)) {
233
+ this.failProtocol("WebSocket frame length is unsafe");
234
+ return;
235
+ }
236
+ payloadLength = Number(longLength);
237
+ offset = 10;
238
+ }
239
+ if (payloadLength > this.maxPayload || this.fragmentedBytes + payloadLength > this.maxPayload) {
240
+ this.failProtocol("WebSocket payload exceeds configured limit");
241
+ return;
242
+ }
243
+ if (this.frameBuffer.length < offset + payloadLength)
244
+ return;
245
+ const payload = this.frameBuffer.subarray(offset, offset + payloadLength);
246
+ this.frameBuffer = this.frameBuffer.subarray(offset + payloadLength);
247
+ if (opcode >= 0x8 && (!fin || payloadLength > 125)) {
248
+ this.failProtocol("invalid WebSocket control frame");
249
+ return;
250
+ }
251
+ if (opcode === 0x8) {
252
+ const code = payload.length >= 2 ? payload.readUInt16BE(0) : 1000;
253
+ const reason = payload.length > 2 ? payload.subarray(2).toString("utf8") : "closed";
254
+ if (!this.closeSent && this.socket) {
255
+ this.closeSent = true;
256
+ this.socket.write(framePayload(0x8, payload));
257
+ }
258
+ this.socket?.end();
259
+ this.emitClose(code, reason);
260
+ return;
261
+ }
262
+ if (opcode === 0x9) {
263
+ this.socket?.write(framePayload(0xA, payload));
264
+ continue;
265
+ }
266
+ if (opcode === 0xA)
267
+ continue;
268
+ if (opcode === 0x0) {
269
+ if (this.fragmentedOpcode === null) {
270
+ this.failProtocol("unexpected WebSocket continuation frame");
271
+ return;
272
+ }
273
+ this.fragmentedChunks.push(Buffer.from(payload));
274
+ this.fragmentedBytes += payload.length;
275
+ if (fin)
276
+ this.emitFragmentedMessage();
277
+ continue;
278
+ }
279
+ if (opcode !== 0x1 && opcode !== 0x2) {
280
+ this.failProtocol("unsupported WebSocket data opcode");
281
+ return;
282
+ }
283
+ if (this.fragmentedOpcode !== null) {
284
+ this.failProtocol("interleaved fragmented WebSocket message");
285
+ return;
286
+ }
287
+ if (fin) {
288
+ this.emit("message", Buffer.from(payload), opcode === 0x2);
289
+ }
290
+ else {
291
+ this.fragmentedOpcode = opcode;
292
+ this.fragmentedChunks = [Buffer.from(payload)];
293
+ this.fragmentedBytes = payload.length;
294
+ }
295
+ }
296
+ }
297
+ emitFragmentedMessage() {
298
+ const opcode = this.fragmentedOpcode;
299
+ const payload = Buffer.concat(this.fragmentedChunks);
300
+ this.fragmentedOpcode = null;
301
+ this.fragmentedChunks = [];
302
+ this.fragmentedBytes = 0;
303
+ this.emit("message", payload, opcode === 0x2);
304
+ }
305
+ failProtocol(reason) {
306
+ const error = new Error(reason);
307
+ this.emit("error", error);
308
+ if (this.readyState === OPEN)
309
+ this.close(1002, "protocol_error");
310
+ else
311
+ this.socket?.destroy();
312
+ }
313
+ emitClose(code, reason) {
314
+ if (this.closeEmitted)
315
+ return;
316
+ this.closeEmitted = true;
317
+ this.readyState = CLOSED;
318
+ this.emit("close", code, Buffer.from(reason, "utf8"));
319
+ }
320
+ }
@@ -17,7 +17,16 @@ export declare function runRootDir(agentRunId: string): string;
17
17
  export declare function runWorkspaceDir(agentRunId: string): string;
18
18
  export declare function transcriptPath(agentRunId: string): string;
19
19
  export declare function runtimeProfileDir(agentRunId: string): string;
20
+ export declare function runtimeProfileRunRootDir(agentRunId: string): string;
21
+ export declare function runtimeSessionRootDir(runtimeSessionId: string, sessionGeneration: number): string;
22
+ export declare function runtimeSessionWorkspaceDir(runtimeSessionId: string, sessionGeneration: number): string;
23
+ export declare function runtimeSessionTranscriptPath(runtimeSessionId: string, sessionGeneration: number, agentRunId: string): string;
20
24
  export declare function ensureRunWorkspace(agentRunId: string): {
21
25
  rootDir: string;
22
26
  workspaceDir: string;
23
27
  };
28
+ export declare function ensureRuntimeSessionWorkspace(runtimeSessionId: string, sessionGeneration: number, agentRunId: string): {
29
+ rootDir: string;
30
+ workspaceDir: string;
31
+ transcriptFile: string;
32
+ };
package/dist/workspace.js CHANGED
@@ -1,4 +1,4 @@
1
- import { chmodSync, mkdirSync } from "node:fs";
1
+ import { chmodSync, existsSync, mkdirSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { daemonHome } from "./auth-store.js";
4
4
  /**
@@ -33,7 +33,32 @@ export function transcriptPath(agentRunId) {
33
33
  return path.join(runRootDir(agentRunId), "transcript.jsonl");
34
34
  }
35
35
  export function runtimeProfileDir(agentRunId) {
36
- return path.join(runRootDir(agentRunId), "runtime-profile");
36
+ assertSafeId(agentRunId, "agent_run_id");
37
+ const managedRoot = process.env.BOTLEARN_AGENT_SERVICE_PROFILE_ROOT?.trim();
38
+ return managedRoot
39
+ ? path.join(managedRoot, agentRunId, "runtime-profile")
40
+ : path.join(runRootDir(agentRunId), "runtime-profile");
41
+ }
42
+ export function runtimeProfileRunRootDir(agentRunId) {
43
+ return path.dirname(runtimeProfileDir(agentRunId));
44
+ }
45
+ export function runtimeSessionRootDir(runtimeSessionId, sessionGeneration) {
46
+ assertSafeId(runtimeSessionId, "runtime_session_id");
47
+ if (!Number.isInteger(sessionGeneration) || sessionGeneration < 1) {
48
+ throw new Error("unsafe session_generation: expected a positive integer");
49
+ }
50
+ return path.join(daemonHome(), "agent-service-sessions", runtimeSessionId, `generation-${sessionGeneration}`);
51
+ }
52
+ export function runtimeSessionWorkspaceDir(runtimeSessionId, sessionGeneration) {
53
+ const managedRoot = process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT?.trim();
54
+ if (managedRoot) {
55
+ return path.join(managedRoot, runtimeSessionId, `generation-${sessionGeneration}`);
56
+ }
57
+ return path.join(runtimeSessionRootDir(runtimeSessionId, sessionGeneration), "workspace");
58
+ }
59
+ export function runtimeSessionTranscriptPath(runtimeSessionId, sessionGeneration, agentRunId) {
60
+ assertSafeId(agentRunId, "agent_run_id");
61
+ return path.join(runtimeSessionRootDir(runtimeSessionId, sessionGeneration), "transcripts", `${agentRunId}.jsonl`);
37
62
  }
38
63
  // recursive mkdir 只对新建目录生效 mode,已存在目录需 best-effort 收紧。
39
64
  function mkdirTolerant(dir) {
@@ -52,3 +77,19 @@ export function ensureRunWorkspace(agentRunId) {
52
77
  mkdirTolerant(workspaceDir);
53
78
  return { rootDir, workspaceDir };
54
79
  }
80
+ export function ensureRuntimeSessionWorkspace(runtimeSessionId, sessionGeneration, agentRunId) {
81
+ const rootDir = runtimeSessionRootDir(runtimeSessionId, sessionGeneration);
82
+ const workspaceDir = runtimeSessionWorkspaceDir(runtimeSessionId, sessionGeneration);
83
+ const transcriptFile = runtimeSessionTranscriptPath(runtimeSessionId, sessionGeneration, agentRunId);
84
+ mkdirTolerant(rootDir);
85
+ if (process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT?.trim()) {
86
+ if (!existsSync(workspaceDir)) {
87
+ throw new Error("managed runtime session workspace was not prepared by the supervisor");
88
+ }
89
+ }
90
+ else {
91
+ mkdirTolerant(workspaceDir);
92
+ }
93
+ mkdirTolerant(path.dirname(transcriptFile));
94
+ return { rootDir, workspaceDir, transcriptFile };
95
+ }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@botlearn-course/daemon",
3
- "version": "0.0.2",
3
+ "version": "0.0.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": {
7
- "botlearn-course-daemon": "dist/cli.js"
7
+ "botlearn-course-daemon": "dist/cli.js",
8
+ "botlearn-sandbox-supervisor": "dist/sandbox-supervisor.js"
8
9
  },
9
10
  "main": "./dist/index.js",
10
11
  "types": "./dist/index.d.ts",