@botlearn-course/daemon 0.0.7 → 0.0.9

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,1400 @@
1
+ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { ensureDaemonHome } from "./auth-store.js";
5
+ import { AGENT_SERVICE_WS_SCHEMA, AGENT_SERVICE_WS_SUBPROTOCOL, createSandboxFrame, parseSandboxFrame, UnsupportedSandboxProtocolError, } from "./agent-service-ws-protocol.js";
6
+ import { log as defaultLog } from "./log.js";
7
+ import { availableRunCapabilities } from "./runtime-capabilities.js";
8
+ import { activationRuntimeEnv, runtimeChildEnv } from "./runtime-env.js";
9
+ import { assertNoInjectedCredentials, redactSecretString } from "./redaction.js";
10
+ import { RunDispatcher, } from "./run-dispatcher.js";
11
+ import { ensureRuntimeSessionDirectories, ensureRuntimeSessionWorkspace, exposeRuntimeSessionWorkspace, removeRuntimeSessionWorkspace, revokeRuntimeSessionWorkspace, } from "./workspace.js";
12
+ import { WebSocketClient, } from "./websocket-client.js";
13
+ const RECONNECT_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
14
+ const MAX_SPOOL_FRAMES = 1024;
15
+ const MAX_SPOOL_BYTES = 8 * 1024 * 1024;
16
+ /** 出站 seq 基址:seq = connection_epoch * SEQ_EPOCH_BASE + n,跨重连单调(合同 §1.1)。 */
17
+ const SEQ_EPOCH_BASE = 1_000_000_000;
18
+ class SandboxClosedError extends Error {
19
+ code;
20
+ reason;
21
+ constructor(code, reason) {
22
+ super(`sandbox WebSocket closed (${code}): ${reason}`);
23
+ this.code = code;
24
+ this.reason = reason;
25
+ }
26
+ }
27
+ class SocketInbox {
28
+ queued = [];
29
+ waiting = [];
30
+ constructor(socket) {
31
+ socket.on("message", (data, isBinary) => {
32
+ this.push(isBinary ? new Error("binary WebSocket frames are not supported") : data.toString());
33
+ });
34
+ socket.on("error", (error) => this.push(error));
35
+ socket.on("close", (code, reason) => {
36
+ this.push(new SandboxClosedError(code, reason.toString() || "closed"));
37
+ });
38
+ }
39
+ next() {
40
+ const value = this.queued.shift();
41
+ if (value !== undefined) {
42
+ return value instanceof Error ? Promise.reject(value) : Promise.resolve(value);
43
+ }
44
+ return new Promise((resolve, reject) => this.waiting.push({ resolve, reject }));
45
+ }
46
+ push(value) {
47
+ const waiter = this.waiting.shift();
48
+ if (!waiter) {
49
+ this.queued.push(value);
50
+ return;
51
+ }
52
+ if (value instanceof Error)
53
+ waiter.reject(value);
54
+ else
55
+ waiter.resolve(value);
56
+ }
57
+ }
58
+ function waitForOpen(socket) {
59
+ return new Promise((resolve, reject) => {
60
+ const onOpen = () => {
61
+ cleanup();
62
+ resolve();
63
+ };
64
+ const onError = (error) => {
65
+ cleanup();
66
+ reject(error);
67
+ };
68
+ const onUnexpected = (_request, response) => {
69
+ cleanup();
70
+ reject(new Error(`sandbox WebSocket upgrade failed: ${response.statusCode ?? "unknown"}`));
71
+ };
72
+ const cleanup = () => {
73
+ socket.off("open", onOpen);
74
+ socket.off("error", onError);
75
+ socket.off("unexpected-response", onUnexpected);
76
+ };
77
+ socket.on("open", onOpen);
78
+ socket.on("error", onError);
79
+ socket.on("unexpected-response", onUnexpected);
80
+ });
81
+ }
82
+ function sandboxStatePath(sandboxId) {
83
+ const root = path.join(ensureDaemonHome(), "agent-service-sandboxes", sandboxId);
84
+ mkdirSync(root, { recursive: true, mode: 0o700 });
85
+ try {
86
+ chmodSync(root, 0o700);
87
+ }
88
+ catch {
89
+ // Windows best effort.
90
+ }
91
+ return path.join(root, "state.json");
92
+ }
93
+ function emptySessionState() {
94
+ return {
95
+ courseRunId: null,
96
+ workspaceRef: null,
97
+ runtimeId: null,
98
+ nativeSessionId: null,
99
+ contextRevision: 0,
100
+ activationId: null,
101
+ completedCommands: [],
102
+ completedActivations: [],
103
+ pendingActivationCleanup: null,
104
+ acceptedCommands: {},
105
+ spool: [],
106
+ };
107
+ }
108
+ function normalizeSessionState(value) {
109
+ const base = emptySessionState();
110
+ if (!value || typeof value !== "object")
111
+ return base;
112
+ return {
113
+ courseRunId: typeof value.courseRunId === "string" ? value.courseRunId : null,
114
+ workspaceRef: typeof value.workspaceRef === "string" ? value.workspaceRef : null,
115
+ runtimeId: typeof value.runtimeId === "string" ? value.runtimeId : null,
116
+ nativeSessionId: typeof value.nativeSessionId === "string" ? value.nativeSessionId : null,
117
+ contextRevision: Number(value.contextRevision ?? 0),
118
+ activationId: typeof value.activationId === "string" ? value.activationId : null,
119
+ completedCommands: Array.isArray(value.completedCommands)
120
+ ? value.completedCommands.filter((item) => typeof item === "string")
121
+ : [],
122
+ completedActivations: Array.isArray(value.completedActivations)
123
+ ? value.completedActivations.filter((item) => typeof item === "string")
124
+ : [],
125
+ pendingActivationCleanup: (value.pendingActivationCleanup &&
126
+ typeof value.pendingActivationCleanup === "object" &&
127
+ typeof value.pendingActivationCleanup.activationId === "string" &&
128
+ typeof value.pendingActivationCleanup.agentRunId === "string" &&
129
+ Number.isInteger(value.pendingActivationCleanup.workerAttempt) &&
130
+ value.pendingActivationCleanup.workerAttempt > 0)
131
+ ? {
132
+ activationId: value.pendingActivationCleanup.activationId,
133
+ agentRunId: value.pendingActivationCleanup.agentRunId,
134
+ workerAttempt: value.pendingActivationCleanup.workerAttempt,
135
+ }
136
+ : null,
137
+ acceptedCommands: value.acceptedCommands &&
138
+ typeof value.acceptedCommands === "object" &&
139
+ !Array.isArray(value.acceptedCommands)
140
+ ? value.acceptedCommands
141
+ : {},
142
+ spool: Array.isArray(value.spool) ? value.spool : [],
143
+ };
144
+ }
145
+ function loadState(sandboxId, token) {
146
+ const file = sandboxStatePath(sandboxId);
147
+ if (existsSync(file)) {
148
+ try {
149
+ const value = JSON.parse(readFileSync(file, "utf8"));
150
+ const sessions = {};
151
+ if (value.sessions && typeof value.sessions === "object" && !Array.isArray(value.sessions)) {
152
+ for (const [sessionId, session] of Object.entries(value.sessions)) {
153
+ sessions[sessionId] = normalizeSessionState(session);
154
+ }
155
+ }
156
+ return {
157
+ sandboxGeneration: Number(value.sandboxGeneration ?? 0),
158
+ // A controller launch always supplies a freshly minted sandbox-scoped bootstrap
159
+ // token. Preserve runtime/command continuity from disk, but never let an expired
160
+ // persisted reconnect token shadow the controller's recovery credential.
161
+ reconnectToken: token,
162
+ sessions,
163
+ };
164
+ }
165
+ catch {
166
+ // Corrupt state is reconstructed from the server desired state.
167
+ }
168
+ }
169
+ return {
170
+ sandboxGeneration: 0,
171
+ reconnectToken: token,
172
+ sessions: {},
173
+ };
174
+ }
175
+ function saveState(sandboxId, state) {
176
+ const file = sandboxStatePath(sandboxId);
177
+ const tmp = `${file}.tmp-${process.pid}`;
178
+ const fd = openSync(tmp, "w", 0o600);
179
+ try {
180
+ writeFileSync(fd, JSON.stringify(state), { encoding: "utf8" });
181
+ fsyncSync(fd);
182
+ }
183
+ finally {
184
+ closeSync(fd);
185
+ }
186
+ renameSync(tmp, file);
187
+ try {
188
+ const directoryFd = openSync(path.dirname(file), "r");
189
+ try {
190
+ fsyncSync(directoryFd);
191
+ }
192
+ finally {
193
+ closeSync(directoryFd);
194
+ }
195
+ }
196
+ catch {
197
+ // Directory fsync is unavailable on Windows; the file itself is still flushed.
198
+ }
199
+ try {
200
+ chmodSync(file, 0o600);
201
+ }
202
+ catch {
203
+ // Windows best effort.
204
+ }
205
+ }
206
+ /**
207
+ * Long-running daemon client for one user-scoped managed sandbox (ADR-015).
208
+ *
209
+ * 一个 sandbox 内可承载多个 runtime session(CourseRun Session)。server 通过显式命令帧
210
+ * (session.open/activate/close、turn.start/cancel、sandbox.drain/shutdown/sync)驱动;
211
+ * daemon 同时只允许一个 active session,且整个 sandbox 同时只有一个 active turn。
212
+ */
213
+ export class AgentServiceSandboxClient {
214
+ options;
215
+ log;
216
+ random;
217
+ sleep;
218
+ state;
219
+ dispatcher;
220
+ /** agent_run_id → TURN scope(session/attempt/activation),事件与文件帧路由用。 */
221
+ turnScopes = new Map();
222
+ /** 每 run 内嵌 profile(turn payload 自带时优先)。 */
223
+ runProfiles = new Map();
224
+ /** activate 时装配的 per-session profile,turn.start 复用。 */
225
+ sessionProfiles = new Map();
226
+ /** activation-scoped 模型短凭据;只保存在内存中,绝不进入 state.json。 */
227
+ activationContexts = new Map();
228
+ pendingAcks = new Map();
229
+ pendingFilePrepares = new Map();
230
+ pendingFileCommits = new Map();
231
+ fileGrants = new Map();
232
+ inflightCommands = new Set();
233
+ socket = null;
234
+ sandboxGeneration = 0;
235
+ connectionEpoch = 0;
236
+ outboundSeq = 0;
237
+ inboundSeq = 0;
238
+ activeSessionId = null;
239
+ /** 全局串行下当前正在执行 turn 的 session(persistNativeSession 路由用)。 */
240
+ currentTurnSessionId = null;
241
+ heartbeatMs = 15_000;
242
+ staleMs = 45_000;
243
+ stopped = false;
244
+ permanentFailure = false;
245
+ lifecycleChain = Promise.resolve();
246
+ constructor(options) {
247
+ this.options = options;
248
+ this.log = options.log ?? defaultLog;
249
+ this.random = options.random ?? Math.random;
250
+ this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
251
+ this.state = loadState(options.sandboxId, options.sandboxToken);
252
+ this.dispatcher = new RunDispatcher(this, options.runtimes, {
253
+ persistentSession: this,
254
+ });
255
+ }
256
+ prepareTurn(payload) {
257
+ const scope = this.turnScopes.get(payload.agent_run_id);
258
+ if (!scope)
259
+ throw new Error("managed turn has no runtime session scope");
260
+ const session = this.state.sessions[scope.sessionId];
261
+ if (!session)
262
+ throw new Error("managed turn runtime session is unknown");
263
+ const contextRevision = this.turnContextRevision(payload, session);
264
+ const activation = this.activationContexts.get(scope.sessionId);
265
+ if (!activation || activation.activationId !== scope.activationId) {
266
+ throw new Error("managed turn activation context is unavailable");
267
+ }
268
+ const prepared = ensureRuntimeSessionWorkspace(scope.sessionId, this.sandboxGeneration, payload.agent_run_id);
269
+ // ensureRuntimeSessionWorkspace creates missing paths with the closed-by-default
270
+ // mode; re-expose only after the activation fence above has been checked.
271
+ exposeRuntimeSessionWorkspace(scope.sessionId, this.sandboxGeneration);
272
+ this.currentTurnSessionId = scope.sessionId;
273
+ const runtimeEnv = runtimeChildEnv(process.env);
274
+ delete runtimeEnv.DEEPSEEK_API_KEY;
275
+ delete runtimeEnv.DEEPSEEK_BASE_URL;
276
+ Object.assign(runtimeEnv, activation.runtimeEnv);
277
+ runtimeEnv.BOTLEARN_AGENT_SERVICE_ACTIVATION_ID = scope.activationId;
278
+ payload.context.instructions = [
279
+ ...activation.instructions,
280
+ ...(Array.isArray(payload.context.instructions)
281
+ ? payload.context.instructions.filter((item) => typeof item === "string")
282
+ : []),
283
+ ];
284
+ return {
285
+ workspaceDir: prepared.workspaceDir,
286
+ transcriptFile: prepared.transcriptFile,
287
+ nativeSessionId: session.nativeSessionId,
288
+ contextRevision,
289
+ runtimeEnv,
290
+ };
291
+ }
292
+ persistNativeSession(nativeSessionId) {
293
+ const sessionId = this.currentTurnSessionId;
294
+ if (!sessionId)
295
+ return;
296
+ const session = this.state.sessions[sessionId];
297
+ if (!session)
298
+ return;
299
+ session.nativeSessionId = nativeSessionId.trim() || null;
300
+ this.persist();
301
+ }
302
+ finishTurn(payload) {
303
+ const scope = this.turnScopes.get(payload.agent_run_id);
304
+ if (!scope)
305
+ return;
306
+ const activation = this.activationContexts.get(scope.sessionId);
307
+ const session = this.state.sessions[scope.sessionId];
308
+ if (session && activation?.activationId === scope.activationId) {
309
+ // Persist the cleanup obligation before attempting chmod. A terminal event has
310
+ // already been durably ACKed at this point, so reconnect/restart must finish this
311
+ // revoke even when the server desired state has already moved to idle.
312
+ session.pendingActivationCleanup = {
313
+ activationId: scope.activationId,
314
+ agentRunId: payload.agent_run_id,
315
+ workerAttempt: scope.workerAttempt,
316
+ };
317
+ this.persist();
318
+ try {
319
+ this.completePendingActivationCleanup(scope.sessionId);
320
+ }
321
+ catch (error) {
322
+ this.log.error("failed to revoke a terminal runtime session workspace", {
323
+ sandboxId: this.options.sandboxId,
324
+ runtimeSessionId: scope.sessionId,
325
+ agentRunId: payload.agent_run_id,
326
+ error: error instanceof Error ? redactSecretString(error.message) : String(error),
327
+ });
328
+ this.socket?.close(1011, "workspace_revoke_failed");
329
+ throw error;
330
+ }
331
+ }
332
+ this.turnScopes.delete(payload.agent_run_id);
333
+ if (this.currentTurnSessionId === scope.sessionId)
334
+ this.currentTurnSessionId = null;
335
+ for (const [key, grant] of this.fileGrants) {
336
+ if (this.sameTurnScope(scope, grant.scope))
337
+ this.fileGrants.delete(key);
338
+ }
339
+ this.persist();
340
+ }
341
+ completePendingActivationCleanup(sessionId) {
342
+ const session = this.state.sessions[sessionId];
343
+ const pending = session?.pendingActivationCleanup;
344
+ if (!session || !pending)
345
+ return;
346
+ revokeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration);
347
+ const activation = this.activationContexts.get(sessionId);
348
+ if (activation?.activationId === pending.activationId) {
349
+ this.activationContexts.delete(sessionId);
350
+ this.sessionProfiles.delete(sessionId);
351
+ }
352
+ if (session.activationId === pending.activationId)
353
+ session.activationId = null;
354
+ if (!session.completedActivations.includes(pending.activationId)) {
355
+ // Session-close/generation-rotate are the retention bounds. Never evict an
356
+ // activation tombstone while the session is alive: activation IDs are opaque, so
357
+ // there is no safe monotonic ordering from which to infer that an old replay died.
358
+ session.completedActivations.push(pending.activationId);
359
+ }
360
+ const commandId = `run:${pending.agentRunId}:${pending.workerAttempt}`;
361
+ if (!session.completedCommands.includes(commandId)) {
362
+ session.completedCommands.push(commandId);
363
+ session.completedCommands = session.completedCommands.slice(-256);
364
+ }
365
+ delete session.acceptedCommands[commandId];
366
+ session.pendingActivationCleanup = null;
367
+ this.turnScopes.delete(pending.agentRunId);
368
+ if (this.activeSessionId === sessionId)
369
+ this.activeSessionId = null;
370
+ if (this.currentTurnSessionId === sessionId)
371
+ this.currentTurnSessionId = null;
372
+ for (const [key, grant] of this.fileGrants) {
373
+ if (grant.scope.sessionId === sessionId &&
374
+ grant.scope.workerAttempt === pending.workerAttempt &&
375
+ grant.scope.activationId === pending.activationId)
376
+ this.fileGrants.delete(key);
377
+ }
378
+ this.persist();
379
+ }
380
+ recoverPendingActivationCleanups() {
381
+ for (const sessionId of Object.keys(this.state.sessions)) {
382
+ this.completePendingActivationCleanup(sessionId);
383
+ }
384
+ }
385
+ async run() {
386
+ let failures = 0;
387
+ let authFailures = 0;
388
+ while (!this.stopped && !this.permanentFailure) {
389
+ try {
390
+ await this.connectOnce();
391
+ failures = 0;
392
+ }
393
+ catch (error) {
394
+ const close = error instanceof SandboxClosedError ? error : null;
395
+ if (close?.code === 4403 || close?.code === 4410) {
396
+ this.permanentFailure = true;
397
+ break;
398
+ }
399
+ if (close?.code === 4401) {
400
+ authFailures += 1;
401
+ if (authFailures >= 3) {
402
+ this.permanentFailure = true;
403
+ break;
404
+ }
405
+ }
406
+ else {
407
+ authFailures = 0;
408
+ }
409
+ if (this.stopped)
410
+ break;
411
+ const base = RECONNECT_DELAYS_MS[Math.min(failures, RECONNECT_DELAYS_MS.length - 1)];
412
+ failures += 1;
413
+ const delay = Math.round(base * (1 + this.random() * 0.25));
414
+ this.log.warn("Agent Service sandbox WebSocket disconnected; reconnecting", {
415
+ sandboxId: this.options.sandboxId,
416
+ delayMs: delay,
417
+ error: error instanceof Error ? redactSecretString(error.message) : String(error),
418
+ });
419
+ await this.sleep(delay);
420
+ }
421
+ }
422
+ if (this.permanentFailure) {
423
+ throw new Error("Agent Service sandbox stopped after a permanent protocol failure");
424
+ }
425
+ }
426
+ stop() {
427
+ this.stopped = true;
428
+ this.dispatcher.cancelAll();
429
+ for (const [sessionId, session] of Object.entries(this.state.sessions)) {
430
+ session.activationId = null;
431
+ revokeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration);
432
+ }
433
+ this.activationContexts.clear();
434
+ this.sessionProfiles.clear();
435
+ this.activeSessionId = null;
436
+ this.currentTurnSessionId = null;
437
+ this.persist();
438
+ this.socket?.close(1000, "daemon_stopping");
439
+ }
440
+ async postEvent(agentRunId, event) {
441
+ const scope = this.turnScopes.get(agentRunId);
442
+ if (!scope)
443
+ throw new Error("Agent Service sandbox event has no active turn scope");
444
+ const session = this.state.sessions[scope.sessionId];
445
+ if (!session)
446
+ throw new Error("Agent Service sandbox event session is unknown");
447
+ if (!this.sandboxGeneration || !this.connectionEpoch) {
448
+ throw new Error("Agent Service sandbox is not authenticated");
449
+ }
450
+ const frame = createSandboxFrame({
451
+ type: "turn.event",
452
+ sandboxId: this.options.sandboxId,
453
+ sandboxGeneration: this.sandboxGeneration,
454
+ connectionEpoch: this.connectionEpoch,
455
+ seq: this.nextOutboundSeq(),
456
+ runtimeSessionId: scope.sessionId,
457
+ agentRunId,
458
+ workerAttempt: scope.workerAttempt,
459
+ activationId: scope.activationId,
460
+ payload: event,
461
+ });
462
+ session.spool.push(frame);
463
+ try {
464
+ this.enforceSpoolLimit();
465
+ }
466
+ catch (error) {
467
+ session.spool.pop();
468
+ this.persist();
469
+ throw error;
470
+ }
471
+ this.persist();
472
+ const ack = new Promise((resolve, reject) => {
473
+ this.pendingAcks.set(frame.frame_id, { scope: { ...scope }, resolve, reject });
474
+ });
475
+ await this.sendFrame(frame);
476
+ await ack;
477
+ }
478
+ async postFile(agentRunId, file) {
479
+ const scope = this.turnScopes.get(agentRunId);
480
+ if (!scope)
481
+ throw new Error("Agent Service sandbox file has no active turn scope");
482
+ if (!file.sha256 || !Number.isInteger(file.size_bytes) || (file.size_bytes ?? -1) < 0) {
483
+ throw new Error("Agent Service sandbox file requires size_bytes and sha256");
484
+ }
485
+ const frame = createSandboxFrame({
486
+ type: "turn.file.prepare",
487
+ sandboxId: this.options.sandboxId,
488
+ sandboxGeneration: this.sandboxGeneration,
489
+ connectionEpoch: this.connectionEpoch,
490
+ seq: this.nextOutboundSeq(),
491
+ runtimeSessionId: scope.sessionId,
492
+ agentRunId,
493
+ workerAttempt: scope.workerAttempt,
494
+ activationId: scope.activationId,
495
+ payload: file,
496
+ });
497
+ const prepared = new Promise((resolve, reject) => {
498
+ this.pendingFilePrepares.set(frame.frame_id, {
499
+ scope: { ...scope },
500
+ resolve,
501
+ reject,
502
+ });
503
+ });
504
+ await this.sendFrame(frame);
505
+ const grant = await prepared;
506
+ this.fileGrants.set(`${agentRunId}:${grant.record.id}`, grant);
507
+ return grant.record;
508
+ }
509
+ async uploadFileContent(agentRunId, fileId, absPath, mimeType) {
510
+ const scope = this.turnScopes.get(agentRunId);
511
+ if (!scope)
512
+ throw new Error("Agent Service sandbox file has no active turn scope");
513
+ const key = `${agentRunId}:${fileId}`;
514
+ const grant = this.fileGrants.get(key);
515
+ if (!grant)
516
+ throw new Error("Agent Service sandbox file has no upload grant");
517
+ if (!this.sameTurnScope(scope, grant.scope)) {
518
+ throw new Error("Agent Service sandbox file grant turn scope mismatch");
519
+ }
520
+ const data = await readFile(absPath);
521
+ assertNoInjectedCredentials(data, [
522
+ this.state.reconnectToken,
523
+ grant.uploadGrant,
524
+ ...Object.values(this.activationContexts.get(scope.sessionId)?.runtimeEnv ?? {}),
525
+ ]);
526
+ const response = await fetch(grant.uploadUrl, {
527
+ method: "PUT",
528
+ headers: {
529
+ authorization: `Bearer ${grant.uploadGrant}`,
530
+ "content-type": mimeType ?? grant.record.mime_type ?? "application/octet-stream",
531
+ },
532
+ body: data,
533
+ });
534
+ if (!response.ok) {
535
+ const text = await response.text().catch(() => "");
536
+ throw new Error(`Agent Service file upload failed (${response.status}): ${redactSecretString(text.slice(0, 500))}`);
537
+ }
538
+ const frame = createSandboxFrame({
539
+ type: "turn.file.committed",
540
+ sandboxId: this.options.sandboxId,
541
+ sandboxGeneration: this.sandboxGeneration,
542
+ connectionEpoch: this.connectionEpoch,
543
+ seq: this.nextOutboundSeq(),
544
+ runtimeSessionId: scope.sessionId,
545
+ agentRunId,
546
+ workerAttempt: scope.workerAttempt,
547
+ activationId: scope.activationId,
548
+ payload: { file_id: fileId, sha256: grant.record.sha256 },
549
+ });
550
+ const committed = new Promise((resolve, reject) => {
551
+ this.pendingFileCommits.set(frame.frame_id, {
552
+ scope: { ...scope },
553
+ resolve,
554
+ reject,
555
+ });
556
+ });
557
+ await this.sendFrame(frame);
558
+ try {
559
+ return await committed;
560
+ }
561
+ finally {
562
+ this.fileGrants.delete(key);
563
+ }
564
+ }
565
+ async getRunRuntimeProfile(agentRunId) {
566
+ const embedded = this.runProfiles.get(agentRunId);
567
+ if (embedded)
568
+ return embedded;
569
+ const scope = this.turnScopes.get(agentRunId);
570
+ const activated = scope ? this.sessionProfiles.get(scope.sessionId) : undefined;
571
+ if (!activated)
572
+ throw new Error("Agent Service sandbox runtime profile is unavailable");
573
+ return activated;
574
+ }
575
+ async connectOnce() {
576
+ const socket = new WebSocketClient(this.options.wsUrl, AGENT_SERVICE_WS_SUBPROTOCOL, {
577
+ headers: { Authorization: `Bearer ${this.state.reconnectToken}` },
578
+ maxPayload: 262_144,
579
+ });
580
+ // Attach message/close listeners before awaiting open; the server may send
581
+ // sandbox.hello immediately after the upgrade completes.
582
+ const inbox = new SocketInbox(socket);
583
+ await waitForOpen(socket);
584
+ if (socket.protocol !== AGENT_SERVICE_WS_SUBPROTOCOL) {
585
+ socket.close(4410, "protocol_incompatible");
586
+ throw new SandboxClosedError(4410, "protocol_incompatible");
587
+ }
588
+ this.socket = socket;
589
+ let heartbeat = null;
590
+ let staleCheck = null;
591
+ let lastServerFrameAt = Date.now();
592
+ try {
593
+ while (!this.stopped) {
594
+ const raw = await inbox.next();
595
+ lastServerFrameAt = Date.now();
596
+ let frame;
597
+ try {
598
+ frame = parseSandboxFrame(raw);
599
+ }
600
+ catch (error) {
601
+ if (error instanceof UnsupportedSandboxProtocolError) {
602
+ socket.close(4410, "protocol_incompatible");
603
+ throw new SandboxClosedError(4410, "protocol_incompatible");
604
+ }
605
+ throw error;
606
+ }
607
+ if (frame.type === "sandbox.hello") {
608
+ await this.handleHello(frame);
609
+ if (heartbeat === null) {
610
+ heartbeat = setInterval(() => {
611
+ void this.sendHeartbeat().catch((error) => {
612
+ this.log.warn("Agent Service sandbox heartbeat failed", {
613
+ sandboxId: this.options.sandboxId,
614
+ error: error instanceof Error ? redactSecretString(error.message) : String(error),
615
+ });
616
+ });
617
+ }, this.heartbeatMs);
618
+ if (typeof heartbeat.unref === "function")
619
+ heartbeat.unref();
620
+ staleCheck = setInterval(() => {
621
+ if (Date.now() - lastServerFrameAt >= this.staleMs &&
622
+ socket.readyState === WebSocketClient.OPEN) {
623
+ this.log.warn("Agent Service sandbox server frame timeout; reconnecting", {
624
+ sandboxId: this.options.sandboxId,
625
+ staleMs: this.staleMs,
626
+ });
627
+ socket.close(4000, "server_stale");
628
+ }
629
+ }, Math.max(1_000, Math.min(this.heartbeatMs, Math.floor(this.staleMs / 3))));
630
+ if (typeof staleCheck.unref === "function")
631
+ staleCheck.unref();
632
+ }
633
+ continue;
634
+ }
635
+ this.assertServerFrame(frame);
636
+ this.inboundSeq = frame.seq;
637
+ await this.handleServerFrame(frame);
638
+ }
639
+ }
640
+ finally {
641
+ if (heartbeat !== null)
642
+ clearInterval(heartbeat);
643
+ if (staleCheck !== null)
644
+ clearInterval(staleCheck);
645
+ this.rejectPendingFiles(new Error("Agent Service file transport disconnected"));
646
+ if (this.socket === socket)
647
+ this.socket = null;
648
+ if (socket.readyState === WebSocketClient.OPEN)
649
+ socket.close(1000, "reconnecting");
650
+ }
651
+ }
652
+ async handleHello(frame) {
653
+ if (frame.sandbox_id !== this.options.sandboxId) {
654
+ throw new SandboxClosedError(4403, "sandbox_mismatch");
655
+ }
656
+ if (this.sandboxGeneration && frame.sandbox_generation < this.sandboxGeneration) {
657
+ throw new SandboxClosedError(4409, "stale_generation");
658
+ }
659
+ if (frame.sandbox_generation !== this.state.sandboxGeneration) {
660
+ // Generation change wipes every session shard (contract §1.4).
661
+ this.dispatcher.cancelAll();
662
+ this.rejectPending(new Error("Agent Service sandbox generation changed"));
663
+ for (const sessionId of Object.keys(this.state.sessions)) {
664
+ revokeRuntimeSessionWorkspace(sessionId, this.state.sandboxGeneration);
665
+ removeRuntimeSessionWorkspace(sessionId, this.state.sandboxGeneration);
666
+ }
667
+ this.state.sessions = {};
668
+ this.state.sandboxGeneration = frame.sandbox_generation;
669
+ this.sessionProfiles.clear();
670
+ this.activationContexts.clear();
671
+ this.runProfiles.clear();
672
+ this.turnScopes.clear();
673
+ this.activeSessionId = null;
674
+ this.currentTurnSessionId = null;
675
+ }
676
+ this.sandboxGeneration = frame.sandbox_generation;
677
+ this.connectionEpoch = frame.connection_epoch;
678
+ this.inboundSeq = frame.seq;
679
+ this.outboundSeq = frame.connection_epoch * SEQ_EPOCH_BASE;
680
+ this.recoverPendingActivationCleanups();
681
+ const heartbeatSeconds = Number(frame.payload.heartbeat_seconds ?? 15);
682
+ this.heartbeatMs = Math.max(1_000, Math.min(60_000, heartbeatSeconds * 1000));
683
+ const staleSeconds = Number(frame.payload.stale_after_seconds ?? 45);
684
+ // The server owns this deadline. Keep only a defensive protocol floor/ceiling rather
685
+ // than stretching it relative to the heartbeat and silently ignoring its contract.
686
+ this.staleMs = Math.min(300_000, Math.max(1_000, staleSeconds * 1000));
687
+ this.persist();
688
+ await this.sendControlFrame("sandbox.ready", {
689
+ protocol_versions: [AGENT_SERVICE_WS_SCHEMA],
690
+ daemon_version: this.options.daemonVersion,
691
+ resumed_sessions: Object.keys(this.state.sessions),
692
+ spool_frames: this.spoolFrameCount(),
693
+ });
694
+ }
695
+ assertServerFrame(frame) {
696
+ if (frame.sandbox_id !== this.options.sandboxId ||
697
+ frame.sandbox_generation !== this.sandboxGeneration ||
698
+ frame.connection_epoch !== this.connectionEpoch) {
699
+ throw new SandboxClosedError(4409, "connection_fenced");
700
+ }
701
+ if (frame.seq !== this.inboundSeq + 1)
702
+ throw new Error("server frame sequence gap");
703
+ }
704
+ async handleServerFrame(frame) {
705
+ switch (frame.type) {
706
+ case "sandbox.sync":
707
+ await this.applySync(frame);
708
+ return;
709
+ case "session.open":
710
+ this.scheduleLifecycle("session.open", () => this.handleSessionOpen(frame));
711
+ return;
712
+ case "session.activate":
713
+ this.scheduleLifecycle("session.activate", (fence) => this.handleSessionActivate(frame, fence));
714
+ return;
715
+ case "session.close":
716
+ this.scheduleLifecycle("session.close", (fence) => this.closeSession(frame.runtime_session_id, typeof frame.payload.reason === "string" ? frame.payload.reason : "closed", frame, fence));
717
+ return;
718
+ case "turn.start":
719
+ this.scheduleLifecycle("turn.start", () => this.handleTurnStart(frame));
720
+ return;
721
+ case "turn.cancel":
722
+ this.scheduleLifecycle("turn.cancel", () => this.handleTurnCancel(frame));
723
+ return;
724
+ case "event.ack":
725
+ this.ackEventOrFile(frame);
726
+ return;
727
+ case "turn.file.upload_grant":
728
+ this.acceptFileGrant(frame);
729
+ return;
730
+ case "ping":
731
+ await this.sendControlFrame("pong", { ping_frame_id: frame.frame_id });
732
+ return;
733
+ case "auth.rotate": {
734
+ const token = frame.payload.reconnect_token;
735
+ if (typeof token !== "string" || !token)
736
+ throw new Error("invalid reconnect token");
737
+ this.state.reconnectToken = token;
738
+ this.persist();
739
+ await this.sendCommandAck(frame, "ok");
740
+ return;
741
+ }
742
+ case "sandbox.shutdown":
743
+ this.stop();
744
+ return;
745
+ case "sandbox.drain":
746
+ await this.sendCommandAck(frame, "ok");
747
+ this.scheduleLifecycle("sandbox.drain", async (fence) => {
748
+ if (await this.dispatcher.drain(10_000) && this.isCurrentLifecycleFence(fence)) {
749
+ await this.sendControlFrame("sandbox.drained", {});
750
+ }
751
+ });
752
+ return;
753
+ case "protocol.error":
754
+ throw new Error(`server protocol error: ${String(frame.payload.code ?? "unknown")}`);
755
+ default:
756
+ throw new Error(`unexpected server frame: ${frame.type}`);
757
+ }
758
+ }
759
+ /** sandbox.sync 只做对账:重放 spool、关闭待关 session、应用 drain/shutdown。 */
760
+ async applySync(frame) {
761
+ await this.replaySpool();
762
+ const closeSessions = Array.isArray(frame.payload.close_sessions)
763
+ ? frame.payload.close_sessions.filter((item) => typeof item === "string" && item.length > 0)
764
+ : [];
765
+ for (const sessionId of closeSessions) {
766
+ this.scheduleLifecycle("sandbox.sync.close", (fence) => this.closeSession(sessionId, "sync_close", frame, fence));
767
+ }
768
+ const state = frame.payload.state;
769
+ if (state === "shutdown") {
770
+ this.stop();
771
+ return;
772
+ }
773
+ if (state === "drain") {
774
+ this.scheduleLifecycle("sandbox.sync.drain", async (fence) => {
775
+ if (await this.dispatcher.drain(10_000) && this.isCurrentLifecycleFence(fence)) {
776
+ await this.sendControlFrame("sandbox.drained", {});
777
+ }
778
+ });
779
+ }
780
+ // idle/run/cancel:实际工作全部由显式命令帧下发(合同 §1.3)。
781
+ }
782
+ async handleSessionOpen(frame) {
783
+ const sessionId = frame.runtime_session_id;
784
+ const session = this.state.sessions[sessionId] ?? emptySessionState();
785
+ const courseRunId = frame.payload.course_run_id;
786
+ const requestedWorkspaceRef = frame.payload.workspace_ref;
787
+ if (typeof courseRunId !== "string" ||
788
+ courseRunId.length < 1 ||
789
+ (requestedWorkspaceRef !== null &&
790
+ requestedWorkspaceRef !== undefined &&
791
+ (typeof requestedWorkspaceRef !== "string" || requestedWorkspaceRef.length < 1))) {
792
+ throw new Error("invalid session.open payload");
793
+ }
794
+ if (session.courseRunId !== null && session.courseRunId !== courseRunId) {
795
+ throw new Error("runtime session course_run_id cannot be rebound");
796
+ }
797
+ if (session.workspaceRef !== null &&
798
+ typeof requestedWorkspaceRef === "string" &&
799
+ session.workspaceRef !== requestedWorkspaceRef) {
800
+ throw new Error("runtime session workspace_ref cannot be rebound");
801
+ }
802
+ session.courseRunId = courseRunId;
803
+ session.workspaceRef = session.workspaceRef ?? (typeof requestedWorkspaceRef === "string"
804
+ ? requestedWorkspaceRef
805
+ : `ws_${sessionId.replaceAll("-", "")}_g${this.sandboxGeneration}`);
806
+ ensureRuntimeSessionDirectories(sessionId, this.sandboxGeneration);
807
+ this.state.sessions[sessionId] = session;
808
+ this.persist();
809
+ await this.sendSessionFrame("session.opened", sessionId, {
810
+ workspace_ref: session.workspaceRef,
811
+ native_session_id: session.nativeSessionId,
812
+ });
813
+ }
814
+ async handleSessionActivate(frame, fence) {
815
+ const sessionId = frame.runtime_session_id;
816
+ const activationId = frame.activation_id;
817
+ const session = this.state.sessions[sessionId];
818
+ if (!session) {
819
+ await this.sendCommandAck(frame, "rejected", "session_not_open");
820
+ return;
821
+ }
822
+ const contextRevision = Number(frame.payload.context_revision);
823
+ const runtimeId = typeof frame.payload.runtime_id === "string"
824
+ ? frame.payload.runtime_id.trim()
825
+ : "";
826
+ if (!Number.isInteger(contextRevision) || contextRevision < 1 || !runtimeId) {
827
+ await this.sendCommandAck(frame, "rejected", "invalid_activation");
828
+ return;
829
+ }
830
+ if (contextRevision < session.contextRevision) {
831
+ await this.sendCommandAck(frame, "rejected", "context_revision_regression");
832
+ return;
833
+ }
834
+ let runtimeEnv;
835
+ try {
836
+ runtimeEnv = activationRuntimeEnv(frame.payload.runtime_env);
837
+ }
838
+ catch (error) {
839
+ await this.sendCommandAck(frame, "rejected", error instanceof Error ? error.message : "invalid_runtime_env");
840
+ return;
841
+ }
842
+ let instructions;
843
+ try {
844
+ instructions = this.activationInstructions(frame.payload.instructions);
845
+ }
846
+ catch (error) {
847
+ await this.sendCommandAck(frame, "rejected", error instanceof Error ? error.message : "invalid_instructions");
848
+ return;
849
+ }
850
+ const capabilities = Array.isArray(frame.payload.capabilities)
851
+ ? frame.payload.capabilities.filter((item) => typeof item === "string" && item.length > 0)
852
+ : [];
853
+ if (capabilities.length > 0) {
854
+ const workspace = ensureRuntimeSessionDirectories(sessionId, this.sandboxGeneration);
855
+ const available = new Set(availableRunCapabilities({
856
+ agent_run_id: "activation-probe",
857
+ course_run_id: session.courseRunId ?? "",
858
+ lesson_id: null,
859
+ task_id: null,
860
+ agent_instance_id: null,
861
+ runtime: { id: runtimeId },
862
+ input: {},
863
+ context: {},
864
+ limits: {},
865
+ }, workspace.workspaceDir));
866
+ const missing = capabilities.filter((item) => !available.has(item)).sort();
867
+ if (missing.length > 0) {
868
+ await this.sendCommandAck(frame, "rejected", `missing_capabilities:${missing.join(",")}`);
869
+ return;
870
+ }
871
+ }
872
+ const existingActivation = this.activationContexts.get(sessionId);
873
+ if (existingActivation?.activationId === activationId) {
874
+ if (this.activeSessionId !== sessionId ||
875
+ session.runtimeId !== runtimeId ||
876
+ session.contextRevision !== contextRevision) {
877
+ await this.sendCommandAck(frame, "rejected", "activation_redefinition");
878
+ return;
879
+ }
880
+ // Idempotent desired-state replay. Keep the original short credential snapshot so
881
+ // a running turn cannot switch tokens midway through output/file redaction.
882
+ await this.sendCommandAck(frame, "ok");
883
+ return;
884
+ }
885
+ if (session.completedActivations.includes(activationId)) {
886
+ // Desired-state commands are at-least-once. A replay that arrives after this
887
+ // activation's terminal event must remain a no-op: re-exposing the workspace here
888
+ // would leave an idle session readable and restore an expired credential snapshot.
889
+ await this.sendCommandAck(frame, "ok");
890
+ return;
891
+ }
892
+ // 同一时刻只允许一个 active session/activation。先撤销上一 workspace 的
893
+ // runtime 访问并等待进程退出,再暴露新 workspace,避免同 UID 跨 Session 读取。
894
+ const previousSessionId = this.activeSessionId;
895
+ const changesActivation = previousSessionId !== null && (previousSessionId !== sessionId || session.activationId !== activationId);
896
+ if (changesActivation) {
897
+ const stopped = await this.deactivateSession(previousSessionId);
898
+ if (fence && !this.isCurrentLifecycleFence(fence))
899
+ return;
900
+ if (!stopped) {
901
+ await this.sendCommandAck(frame, "rejected", "previous_activation_did_not_stop");
902
+ return;
903
+ }
904
+ }
905
+ const profile = frame.payload.profile;
906
+ if (profile && typeof profile === "object" && !Array.isArray(profile)) {
907
+ this.sessionProfiles.set(sessionId, profile);
908
+ }
909
+ else if (profile === null) {
910
+ this.sessionProfiles.delete(sessionId);
911
+ }
912
+ session.runtimeId = runtimeId;
913
+ session.contextRevision = contextRevision;
914
+ session.activationId = activationId;
915
+ exposeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration);
916
+ this.activationContexts.set(sessionId, { activationId, runtimeEnv, instructions });
917
+ this.activeSessionId = sessionId;
918
+ this.persist();
919
+ await this.sendCommandAck(frame, "ok");
920
+ }
921
+ async handleTurnStart(frame) {
922
+ const sessionId = frame.runtime_session_id;
923
+ const runId = frame.agent_run_id;
924
+ const attempt = frame.worker_attempt;
925
+ const activationId = frame.activation_id;
926
+ const session = this.state.sessions[sessionId];
927
+ const activation = this.activationContexts.get(sessionId);
928
+ if (!session ||
929
+ session.activationId === null ||
930
+ this.activeSessionId !== sessionId ||
931
+ !activation ||
932
+ activation.activationId !== activationId) {
933
+ await this.sendCommandAck(frame, "rejected", "session_not_activated");
934
+ return;
935
+ }
936
+ if (session.activationId !== activationId) {
937
+ await this.sendCommandAck(frame, "rejected", "activation_mismatch");
938
+ return;
939
+ }
940
+ const payload = frame.payload;
941
+ if (!this.validTurnPayload(payload)) {
942
+ await this.sendCommandAck(frame, "rejected", "invalid_turn_payload");
943
+ return;
944
+ }
945
+ if (payload.agent_run_id !== runId) {
946
+ await this.sendCommandAck(frame, "rejected", "agent_run_mismatch");
947
+ return;
948
+ }
949
+ if (session.courseRunId !== payload.course_run_id) {
950
+ await this.sendCommandAck(frame, "rejected", "course_run_mismatch");
951
+ return;
952
+ }
953
+ if (session.runtimeId !== payload.runtime.id) {
954
+ await this.sendCommandAck(frame, "rejected", "runtime_mismatch");
955
+ return;
956
+ }
957
+ try {
958
+ this.turnContextRevision(payload, session);
959
+ }
960
+ catch {
961
+ await this.sendCommandAck(frame, "rejected", "context_revision_mismatch");
962
+ return;
963
+ }
964
+ const activatedProfile = this.sessionProfiles.get(sessionId);
965
+ const expectedProfileHash = payload.context?.profileHash;
966
+ if (typeof expectedProfileHash === "string" &&
967
+ activatedProfile &&
968
+ activatedProfile.profileHash !== expectedProfileHash) {
969
+ await this.sendCommandAck(frame, "rejected", "profile_hash_mismatch");
970
+ return;
971
+ }
972
+ const scope = { sessionId, workerAttempt: attempt, activationId };
973
+ const existingScope = this.turnScopes.get(runId);
974
+ if (existingScope && !this.sameTurnScope(existingScope, scope)) {
975
+ await this.sendCommandAck(frame, "rejected", "turn_scope_mismatch");
976
+ return;
977
+ }
978
+ const commandId = `run:${runId}:${attempt}`;
979
+ if (session.completedCommands.includes(commandId) || this.inflightCommands.has(commandId)) {
980
+ await this.sendCommandAck(frame, "ok");
981
+ return;
982
+ }
983
+ this.turnScopes.set(runId, scope);
984
+ const accepted = session.acceptedCommands[commandId];
985
+ if (accepted && !this.inflightCommands.has(commandId)) {
986
+ await this.sendCommandAck(frame, "ok");
987
+ void this.reconcileInterruptedCommand(sessionId, commandId, accepted);
988
+ return;
989
+ }
990
+ if (!accepted) {
991
+ session.acceptedCommands[commandId] = {
992
+ agentRunId: runId,
993
+ workerAttempt: attempt,
994
+ contextRevision: session.contextRevision,
995
+ activationId,
996
+ acceptedAt: new Date().toISOString(),
997
+ };
998
+ this.persist();
999
+ }
1000
+ const embeddedProfile = payload.context?.runtimeProfile;
1001
+ if (embeddedProfile && typeof embeddedProfile === "object") {
1002
+ this.runProfiles.set(runId, embeddedProfile);
1003
+ }
1004
+ this.inflightCommands.add(commandId);
1005
+ await this.sendCommandAck(frame, "ok");
1006
+ void this.dispatcher.dispatch(payload)
1007
+ .then(() => {
1008
+ if (!session.completedCommands.includes(commandId)) {
1009
+ session.completedCommands.push(commandId);
1010
+ session.completedCommands = session.completedCommands.slice(-256);
1011
+ }
1012
+ delete session.acceptedCommands[commandId];
1013
+ this.persist();
1014
+ })
1015
+ .catch((error) => {
1016
+ this.log.error("managed turn failed during terminal cleanup", {
1017
+ sandboxId: this.options.sandboxId,
1018
+ runtimeSessionId: sessionId,
1019
+ agentRunId: runId,
1020
+ error: error instanceof Error ? redactSecretString(error.message) : String(error),
1021
+ });
1022
+ })
1023
+ .finally(() => {
1024
+ this.inflightCommands.delete(commandId);
1025
+ this.runProfiles.delete(runId);
1026
+ });
1027
+ }
1028
+ async handleTurnCancel(frame) {
1029
+ if (!this.matchesTurnScope(frame, this.turnScopes.get(frame.agent_run_id))) {
1030
+ await this.sendCommandAck(frame, "rejected", "turn_scope_mismatch");
1031
+ return;
1032
+ }
1033
+ this.dispatcher.cancel(frame.agent_run_id);
1034
+ await this.sendCommandAck(frame, "ok");
1035
+ }
1036
+ /**
1037
+ * 幂等关闭一个 runtime session:终止其 runtime 子进程、删除 workspace/native state、
1038
+ * 清空本地状态分片,并回 session.closed。未知 session 直接回 closed。
1039
+ */
1040
+ async closeSession(sessionId, reason, sourceFrame, fence) {
1041
+ const session = this.state.sessions[sessionId];
1042
+ if (session) {
1043
+ const runIds = this.runIdsForSession(sessionId);
1044
+ session.activationId = null;
1045
+ this.activationContexts.delete(sessionId);
1046
+ this.sessionProfiles.delete(sessionId);
1047
+ revokeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration);
1048
+ for (const runId of runIds)
1049
+ this.dispatcher.cancel(runId);
1050
+ if (!await this.dispatcher.waitForRuns(runIds, 10_000)) {
1051
+ throw new Error(`runtime session ${sessionId} did not stop before close`);
1052
+ }
1053
+ if (fence && !this.isCurrentLifecycleFence(fence))
1054
+ return;
1055
+ if (this.activeSessionId === sessionId)
1056
+ this.activeSessionId = null;
1057
+ if (this.currentTurnSessionId === sessionId)
1058
+ this.currentTurnSessionId = null;
1059
+ for (const runId of runIds)
1060
+ this.turnScopes.delete(runId);
1061
+ removeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration);
1062
+ delete this.state.sessions[sessionId];
1063
+ this.persist();
1064
+ }
1065
+ await this.sendSessionFrame("session.closed", sessionId, { reason }, sourceFrame);
1066
+ }
1067
+ async reconcileInterruptedCommand(sessionId, commandId, accepted) {
1068
+ try {
1069
+ this.turnScopes.set(accepted.agentRunId, {
1070
+ sessionId,
1071
+ workerAttempt: accepted.workerAttempt,
1072
+ activationId: accepted.activationId,
1073
+ });
1074
+ await this.postEvent(accepted.agentRunId, {
1075
+ type: "run.failed",
1076
+ event_id: `recovery-${commandId.replaceAll(":", "-")}`,
1077
+ seq: 1,
1078
+ error: "daemon restarted after accepting the turn; execution outcome is unknown",
1079
+ payload: {
1080
+ error_type: "execution_outcome_unknown",
1081
+ retryable: true,
1082
+ session_disposition: "close_generation",
1083
+ context_revision: accepted.contextRevision,
1084
+ },
1085
+ });
1086
+ const session = this.state.sessions[sessionId];
1087
+ if (session) {
1088
+ session.completedCommands.push(commandId);
1089
+ session.completedCommands = session.completedCommands.slice(-256);
1090
+ delete session.acceptedCommands[commandId];
1091
+ this.persist();
1092
+ }
1093
+ this.finishTurn({ agent_run_id: accepted.agentRunId });
1094
+ }
1095
+ catch (error) {
1096
+ this.log.error("failed to reconcile an interrupted accepted turn", {
1097
+ sandboxId: this.options.sandboxId,
1098
+ runtimeSessionId: sessionId,
1099
+ agentRunId: accepted.agentRunId,
1100
+ workerAttempt: accepted.workerAttempt,
1101
+ error: error instanceof Error ? redactSecretString(error.message) : String(error),
1102
+ });
1103
+ }
1104
+ }
1105
+ turnContextRevision(payload, session) {
1106
+ const raw = payload.context?.runtimeSession;
1107
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
1108
+ const contextRevision = Number(raw.contextRevision ?? 0);
1109
+ if (Number.isInteger(contextRevision) && contextRevision >= 1) {
1110
+ if (contextRevision !== session.contextRevision) {
1111
+ throw new Error(`runtime context revision mismatch: activated ${session.contextRevision}, ` +
1112
+ `received ${contextRevision}`);
1113
+ }
1114
+ return contextRevision;
1115
+ }
1116
+ }
1117
+ return session.contextRevision;
1118
+ }
1119
+ activationInstructions(value) {
1120
+ if (value === undefined || value === null)
1121
+ return [];
1122
+ if (!Array.isArray(value) || value.length > 64) {
1123
+ throw new Error("invalid_activation_instructions");
1124
+ }
1125
+ const instructions = [];
1126
+ let totalBytes = 0;
1127
+ for (const item of value) {
1128
+ if (typeof item !== "string" || item.length < 1) {
1129
+ throw new Error("invalid_activation_instructions");
1130
+ }
1131
+ totalBytes += Buffer.byteLength(item, "utf8");
1132
+ if (totalBytes > 100_000)
1133
+ throw new Error("activation_instructions_too_large");
1134
+ instructions.push(item);
1135
+ }
1136
+ return instructions;
1137
+ }
1138
+ validTurnPayload(payload) {
1139
+ return Boolean(payload &&
1140
+ typeof payload === "object" &&
1141
+ typeof payload.agent_run_id === "string" &&
1142
+ typeof payload.course_run_id === "string" &&
1143
+ payload.runtime &&
1144
+ typeof payload.runtime === "object" &&
1145
+ typeof payload.runtime.id === "string" &&
1146
+ payload.runtime.id.length > 0 &&
1147
+ payload.input &&
1148
+ typeof payload.input === "object" &&
1149
+ !Array.isArray(payload.input) &&
1150
+ payload.context &&
1151
+ typeof payload.context === "object" &&
1152
+ !Array.isArray(payload.context) &&
1153
+ payload.limits &&
1154
+ typeof payload.limits === "object" &&
1155
+ !Array.isArray(payload.limits));
1156
+ }
1157
+ scheduleLifecycle(label, operation) {
1158
+ const fence = this.currentLifecycleFence();
1159
+ const socket = fence.socket;
1160
+ this.lifecycleChain = this.lifecycleChain
1161
+ .then(async () => {
1162
+ if (!this.isCurrentLifecycleFence(fence))
1163
+ return;
1164
+ await operation(fence);
1165
+ })
1166
+ .catch((error) => {
1167
+ this.log.error("Agent Service sandbox lifecycle command failed", {
1168
+ sandboxId: this.options.sandboxId,
1169
+ command: label,
1170
+ error: error instanceof Error ? redactSecretString(error.message) : String(error),
1171
+ });
1172
+ if (this.socket === socket && socket?.readyState === WebSocketClient.OPEN) {
1173
+ socket.close(1011, "lifecycle_command_failed");
1174
+ }
1175
+ });
1176
+ }
1177
+ currentLifecycleFence() {
1178
+ return {
1179
+ sandboxGeneration: this.sandboxGeneration,
1180
+ connectionEpoch: this.connectionEpoch,
1181
+ socket: this.socket,
1182
+ };
1183
+ }
1184
+ isCurrentLifecycleFence(fence) {
1185
+ return this.sandboxGeneration === fence.sandboxGeneration &&
1186
+ this.connectionEpoch === fence.connectionEpoch &&
1187
+ this.socket === fence.socket &&
1188
+ fence.socket?.readyState === WebSocketClient.OPEN;
1189
+ }
1190
+ runIdsForSession(sessionId) {
1191
+ return [...this.turnScopes]
1192
+ .filter(([, scope]) => scope.sessionId === sessionId)
1193
+ .map(([runId]) => runId);
1194
+ }
1195
+ async deactivateSession(sessionId) {
1196
+ const session = this.state.sessions[sessionId];
1197
+ if (!session)
1198
+ return true;
1199
+ const runIds = this.runIdsForSession(sessionId);
1200
+ session.activationId = null;
1201
+ this.activationContexts.delete(sessionId);
1202
+ this.sessionProfiles.delete(sessionId);
1203
+ revokeRuntimeSessionWorkspace(sessionId, this.sandboxGeneration);
1204
+ if (this.activeSessionId === sessionId)
1205
+ this.activeSessionId = null;
1206
+ for (const runId of runIds)
1207
+ this.dispatcher.cancel(runId);
1208
+ this.persist();
1209
+ return this.dispatcher.waitForRuns(runIds, 10_000);
1210
+ }
1211
+ sameTurnScope(left, right) {
1212
+ return left.sessionId === right.sessionId &&
1213
+ left.workerAttempt === right.workerAttempt &&
1214
+ left.activationId === right.activationId;
1215
+ }
1216
+ matchesTurnScope(frame, expected) {
1217
+ return expected !== undefined && this.sameTurnScope(expected, {
1218
+ sessionId: frame.runtime_session_id,
1219
+ workerAttempt: frame.worker_attempt,
1220
+ activationId: frame.activation_id,
1221
+ });
1222
+ }
1223
+ assertTurnScope(frame, expected, label) {
1224
+ if (!this.matchesTurnScope(frame, expected)) {
1225
+ throw new Error(`${label} turn scope mismatch`);
1226
+ }
1227
+ }
1228
+ ackEventOrFile(frame) {
1229
+ const frameId = frame.payload.frame_id;
1230
+ if (typeof frameId !== "string")
1231
+ throw new Error("event.ack has no frame_id");
1232
+ const pendingFile = this.pendingFileCommits.get(frameId);
1233
+ if (pendingFile) {
1234
+ this.assertTurnScope(frame, pendingFile.scope, "file commit ACK");
1235
+ this.pendingFileCommits.delete(frameId);
1236
+ const file = frame.payload.file;
1237
+ if (!file || typeof file !== "object" || Array.isArray(file)) {
1238
+ pendingFile.reject(new Error("file commit ACK has no file record"));
1239
+ }
1240
+ else {
1241
+ pendingFile.resolve(file);
1242
+ }
1243
+ return;
1244
+ }
1245
+ const pending = this.pendingAcks.get(frameId);
1246
+ if (pending)
1247
+ this.assertTurnScope(frame, pending.scope, "event ACK");
1248
+ for (const session of Object.values(this.state.sessions)) {
1249
+ const index = session.spool.findIndex((item) => item.frame_id === frameId);
1250
+ if (index >= 0) {
1251
+ const original = session.spool[index];
1252
+ this.assertTurnScope(frame, {
1253
+ sessionId: original.runtime_session_id,
1254
+ workerAttempt: original.worker_attempt,
1255
+ activationId: original.activation_id,
1256
+ }, "spooled event ACK");
1257
+ session.spool.splice(index, 1);
1258
+ break;
1259
+ }
1260
+ }
1261
+ this.persist();
1262
+ if (pending) {
1263
+ this.pendingAcks.delete(frameId);
1264
+ pending.resolve();
1265
+ }
1266
+ }
1267
+ acceptFileGrant(frame) {
1268
+ const requestFrameId = frame.payload.request_frame_id;
1269
+ if (typeof requestFrameId !== "string")
1270
+ throw new Error("file grant has no request_frame_id");
1271
+ const pending = this.pendingFilePrepares.get(requestFrameId);
1272
+ if (!pending)
1273
+ return;
1274
+ this.assertTurnScope(frame, pending.scope, "file upload grant");
1275
+ this.pendingFilePrepares.delete(requestFrameId);
1276
+ const file = frame.payload.file;
1277
+ const uploadUrl = frame.payload.upload_url;
1278
+ const uploadGrant = frame.payload.upload_grant;
1279
+ if (!file ||
1280
+ typeof file !== "object" ||
1281
+ Array.isArray(file) ||
1282
+ typeof uploadUrl !== "string" ||
1283
+ !uploadUrl ||
1284
+ typeof uploadGrant !== "string" ||
1285
+ !uploadGrant) {
1286
+ pending.reject(new Error("invalid Agent Service file upload grant"));
1287
+ return;
1288
+ }
1289
+ pending.resolve({
1290
+ scope: pending.scope,
1291
+ record: file,
1292
+ uploadUrl,
1293
+ uploadGrant,
1294
+ });
1295
+ }
1296
+ async replaySpool() {
1297
+ for (const session of Object.values(this.state.sessions)) {
1298
+ for (const original of session.spool) {
1299
+ const replay = {
1300
+ ...original,
1301
+ sandbox_generation: this.sandboxGeneration,
1302
+ connection_epoch: this.connectionEpoch,
1303
+ seq: this.nextOutboundSeq(),
1304
+ sent_at: new Date().toISOString(),
1305
+ };
1306
+ Object.assign(original, replay);
1307
+ this.persist();
1308
+ await this.sendFrame(original);
1309
+ }
1310
+ }
1311
+ }
1312
+ async sendHeartbeat() {
1313
+ if (!this.socket ||
1314
+ this.socket.readyState !== WebSocketClient.OPEN ||
1315
+ !this.sandboxGeneration)
1316
+ return;
1317
+ await this.sendControlFrame("sandbox.heartbeat", {
1318
+ spool_frames: this.spoolFrameCount(),
1319
+ });
1320
+ }
1321
+ async sendCommandAck(frame, status, error) {
1322
+ if (frame.sandbox_generation !== this.sandboxGeneration ||
1323
+ frame.connection_epoch !== this.connectionEpoch)
1324
+ return;
1325
+ await this.sendControlFrame("command.ack", {
1326
+ command_frame_id: frame.frame_id,
1327
+ status,
1328
+ ...(error !== undefined ? { error } : {}),
1329
+ });
1330
+ }
1331
+ async sendControlFrame(type, payload) {
1332
+ await this.sendFrame(createSandboxFrame({
1333
+ type,
1334
+ sandboxId: this.options.sandboxId,
1335
+ sandboxGeneration: this.sandboxGeneration,
1336
+ connectionEpoch: this.connectionEpoch,
1337
+ seq: this.nextOutboundSeq(),
1338
+ payload,
1339
+ }));
1340
+ }
1341
+ async sendSessionFrame(type, runtimeSessionId, payload, sourceFrame) {
1342
+ if (sourceFrame &&
1343
+ (sourceFrame.sandbox_generation !== this.sandboxGeneration ||
1344
+ sourceFrame.connection_epoch !== this.connectionEpoch))
1345
+ return;
1346
+ await this.sendFrame(createSandboxFrame({
1347
+ type,
1348
+ sandboxId: this.options.sandboxId,
1349
+ sandboxGeneration: this.sandboxGeneration,
1350
+ connectionEpoch: this.connectionEpoch,
1351
+ seq: this.nextOutboundSeq(),
1352
+ runtimeSessionId,
1353
+ payload,
1354
+ }));
1355
+ }
1356
+ async sendFrame(frame) {
1357
+ const socket = this.socket;
1358
+ if (!socket || socket.readyState !== WebSocketClient.OPEN)
1359
+ return;
1360
+ await new Promise((resolve, reject) => {
1361
+ socket.send(JSON.stringify(frame), (error) => (error ? reject(error) : resolve()));
1362
+ });
1363
+ }
1364
+ nextOutboundSeq() {
1365
+ this.outboundSeq += 1;
1366
+ return this.outboundSeq;
1367
+ }
1368
+ spoolFrameCount() {
1369
+ return Object.values(this.state.sessions)
1370
+ .reduce((sum, session) => sum + session.spool.length, 0);
1371
+ }
1372
+ spoolBytes() {
1373
+ return Object.values(this.state.sessions)
1374
+ .reduce((sum, session) => sum + Buffer.byteLength(JSON.stringify(session.spool), "utf8"), 0);
1375
+ }
1376
+ enforceSpoolLimit() {
1377
+ if (this.spoolFrameCount() > MAX_SPOOL_FRAMES || this.spoolBytes() > MAX_SPOOL_BYTES) {
1378
+ this.dispatcher.cancelAll();
1379
+ throw new Error("Agent Service sandbox event spool exceeded its bounded limit");
1380
+ }
1381
+ }
1382
+ rejectPending(error) {
1383
+ for (const pending of this.pendingAcks.values())
1384
+ pending.reject(error);
1385
+ this.pendingAcks.clear();
1386
+ this.rejectPendingFiles(error);
1387
+ }
1388
+ rejectPendingFiles(error) {
1389
+ for (const pending of this.pendingFilePrepares.values())
1390
+ pending.reject(error);
1391
+ this.pendingFilePrepares.clear();
1392
+ for (const pending of this.pendingFileCommits.values())
1393
+ pending.reject(error);
1394
+ this.pendingFileCommits.clear();
1395
+ this.fileGrants.clear();
1396
+ }
1397
+ persist() {
1398
+ saveState(this.options.sandboxId, this.state);
1399
+ }
1400
+ }