@botlearn-course/daemon 0.0.9 → 0.0.10

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.
package/README.md CHANGED
@@ -38,10 +38,9 @@ npx --yes --package @botlearn-course/daemon@latest botlearn-course-daemon course
38
38
 
39
39
  包内还包含供 BotLearn 托管 E2B Template 使用的内部命令
40
40
  `botlearn-sandbox-supervisor agent-service session`。它不是 BYOA 用户入口:生产环境只允许由
41
- Agent Service 以固定 argv 启动,通过 stdin 接收一次性 bootstrap。E2B daemon/runtime 分别
42
- 降权到 `botlearn-control`/`user` UID;继承 `NoNewPrivs=1`、无法使用 sudo 的平台保留 root control
43
- daemon,由 root-owned 固定 launcher 清空附加组后直接降权为 `user`。两条路径都为 DeepSeek
44
- 设置 `no_new_privs`,runtime 用户本身没有 sudo 权限。托管启动链只执行
41
+ Agent Service 以固定 argv 启动,通过 stdin 接收一次性 bootstrap,并把 daemon/runtime 分别降权
42
+ `botlearn-control`/`user` UID。E2B Template 通过 root-owned 固定 launcher 将 DeepSeek
43
+ 单向降权为 `user` 并设置 `no_new_privs`;runtime 用户本身没有 sudo 权限。托管启动链只执行
45
44
  `/opt` 下的固定 Node、supervisor、daemon、launcher 与 DeepSeek 文件,不信任 E2B 会开放给 runtime
46
45
  写入的 `/usr/local/bin`。
47
46
  `agent-service session --bootstrap-stdin` 同样属于受 supervisor 保护的内部协议,不应直接暴露给
@@ -99,9 +98,9 @@ runtime 凭据。
99
98
  - `auth.json` 是敏感凭据文件(0600),token 只存本机、只发给你 login 时指定的 Course API;
100
99
  日志、transcript、doctor 输出、错误信息在写出前都会经过脱敏(token / api key / 密码等
101
100
  字段与常见 token 形态一律替换为 `[REDACTED]`)。
102
- - 文件扫描只访问 workspace 内路径;包含 `..`、绝对路径或反斜杠的路径会被拒绝。本机
103
- BYOA daemon 上报相对路径、大小、sha256 和脱敏 preview;Agent Service sandbox 还会在
104
- 终态前把完整文件上传到 Course Service 的受保护存储。
101
+ - 文件扫描只访问 workspace 内路径;包含 `..`、绝对路径或反斜杠的路径会被拒绝。daemon
102
+ 只上报相对路径、大小、sha256 和有界脱敏 preview,文件正文始终留在本地或托管 sandbox
103
+ 的持久 workspace,不上传 Course Service 或对象存储。扫描或 metadata 上报失败不影响 run 终态。
105
104
  - 每个 run 在独立 workspace 内执行并有超时上限;runtime 进程在取消/超时后会被终止
106
105
  (SIGTERM → SIGKILL)。
107
106
  - `report_progress` MCP 子进程以 clean environment 启动,不继承 Course 控制字段或模型凭据;
@@ -12,7 +12,6 @@ export declare class AgentServiceRunClient {
12
12
  getRun(): Promise<RunStartPayload>;
13
13
  postEvent(agentRunId: string, event: RunEvent): Promise<void>;
14
14
  postFile(agentRunId: string, file: RunFileCandidate): Promise<RunFileRecord>;
15
- uploadFileContent(agentRunId: string, fileId: string, absPath: string, mimeType?: string): Promise<RunFileRecord>;
16
15
  getRunRuntimeProfile(agentRunId: string): Promise<CourseRuntimeProfile>;
17
16
  heartbeat(): Promise<void>;
18
17
  private assertRunId;
@@ -1,6 +1,5 @@
1
1
  import { CourseClientError } from "./course-client.js";
2
- import { readFile } from "node:fs/promises";
3
- import { assertNoInjectedCredentials, redactSecretString, redactSecretsDeep, } from "./redaction.js";
2
+ import { redactSecretString, redactSecretsDeep, } from "./redaction.js";
4
3
  const ERROR_BODY_MAX_CHARS = 500;
5
4
  /** Run-scoped client used only inside an Agent Service sandbox. */
6
5
  export class AgentServiceRunClient {
@@ -66,40 +65,6 @@ export class AgentServiceRunClient {
66
65
  }
67
66
  throw new Error("unreachable");
68
67
  }
69
- async uploadFileContent(agentRunId, fileId, absPath, mimeType) {
70
- this.assertRunId(agentRunId);
71
- const data = await readFile(absPath);
72
- assertNoInjectedCredentials(data, [this.runToken]);
73
- const path = `/course/v1/agent-service/runs/${agentRunId}/files/${fileId}/content`;
74
- for (let attempt = 0; attempt < 3; attempt += 1) {
75
- let response;
76
- try {
77
- response = await fetch(this.url(path), {
78
- method: "PUT",
79
- headers: {
80
- authorization: `Bearer ${this.runToken}`,
81
- ...(this.traceId ? { "x-trace-id": this.traceId } : {}),
82
- "content-type": mimeType ?? "application/octet-stream",
83
- },
84
- body: data,
85
- });
86
- }
87
- catch (err) {
88
- if (attempt === 2)
89
- throw err;
90
- await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1)));
91
- continue;
92
- }
93
- if (response.ok)
94
- return (await response.json());
95
- const text = await response.text().catch(() => "");
96
- const error = new CourseClientError(response.status, `PUT ${path} -> ${response.status} ${redactSecretString(text.slice(0, ERROR_BODY_MAX_CHARS))}`);
97
- if (attempt === 2 || (response.status !== 429 && response.status < 500))
98
- throw error;
99
- await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1)));
100
- }
101
- throw new Error("unreachable");
102
- }
103
68
  async getRunRuntimeProfile(agentRunId) {
104
69
  this.assertRunId(agentRunId);
105
70
  return this.request("GET", `/course/v1/agent-service/runs/${agentRunId}/runtime-profile`);
@@ -34,9 +34,7 @@ export declare class AgentServiceSandboxClient implements RunReportingClient, Pe
34
34
  /** activation-scoped 模型短凭据;只保存在内存中,绝不进入 state.json。 */
35
35
  private readonly activationContexts;
36
36
  private readonly pendingAcks;
37
- private readonly pendingFilePrepares;
38
- private readonly pendingFileCommits;
39
- private readonly fileGrants;
37
+ private readonly pendingFileReports;
40
38
  private readonly inflightCommands;
41
39
  private socket;
42
40
  private sandboxGeneration;
@@ -61,7 +59,6 @@ export declare class AgentServiceSandboxClient implements RunReportingClient, Pe
61
59
  stop(): void;
62
60
  postEvent(agentRunId: string, event: RunEvent): Promise<void>;
63
61
  postFile(agentRunId: string, file: RunFileCandidate): Promise<RunFileRecord>;
64
- uploadFileContent(agentRunId: string, fileId: string, absPath: string, mimeType?: string): Promise<RunFileRecord>;
65
62
  getRunRuntimeProfile(agentRunId: string): Promise<CourseRuntimeProfile>;
66
63
  private connectOnce;
67
64
  private handleHello;
@@ -91,7 +88,6 @@ export declare class AgentServiceSandboxClient implements RunReportingClient, Pe
91
88
  private matchesTurnScope;
92
89
  private assertTurnScope;
93
90
  private ackEventOrFile;
94
- private acceptFileGrant;
95
91
  private replaySpool;
96
92
  private sendHeartbeat;
97
93
  private sendCommandAck;
@@ -1,12 +1,11 @@
1
1
  import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
2
- import { readFile } from "node:fs/promises";
3
2
  import path from "node:path";
4
3
  import { ensureDaemonHome } from "./auth-store.js";
5
4
  import { AGENT_SERVICE_WS_SCHEMA, AGENT_SERVICE_WS_SUBPROTOCOL, createSandboxFrame, parseSandboxFrame, UnsupportedSandboxProtocolError, } from "./agent-service-ws-protocol.js";
6
5
  import { log as defaultLog } from "./log.js";
7
6
  import { availableRunCapabilities } from "./runtime-capabilities.js";
8
7
  import { activationRuntimeEnv, runtimeChildEnv } from "./runtime-env.js";
9
- import { assertNoInjectedCredentials, redactSecretString } from "./redaction.js";
8
+ import { redactSecretString } from "./redaction.js";
10
9
  import { RunDispatcher, } from "./run-dispatcher.js";
11
10
  import { ensureRuntimeSessionDirectories, ensureRuntimeSessionWorkspace, exposeRuntimeSessionWorkspace, removeRuntimeSessionWorkspace, revokeRuntimeSessionWorkspace, } from "./workspace.js";
12
11
  import { WebSocketClient, } from "./websocket-client.js";
@@ -226,9 +225,7 @@ export class AgentServiceSandboxClient {
226
225
  /** activation-scoped 模型短凭据;只保存在内存中,绝不进入 state.json。 */
227
226
  activationContexts = new Map();
228
227
  pendingAcks = new Map();
229
- pendingFilePrepares = new Map();
230
- pendingFileCommits = new Map();
231
- fileGrants = new Map();
228
+ pendingFileReports = new Map();
232
229
  inflightCommands = new Set();
233
230
  socket = null;
234
231
  sandboxGeneration = 0;
@@ -332,10 +329,6 @@ export class AgentServiceSandboxClient {
332
329
  this.turnScopes.delete(payload.agent_run_id);
333
330
  if (this.currentTurnSessionId === scope.sessionId)
334
331
  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
332
  this.persist();
340
333
  }
341
334
  completePendingActivationCleanup(sessionId) {
@@ -369,12 +362,6 @@ export class AgentServiceSandboxClient {
369
362
  this.activeSessionId = null;
370
363
  if (this.currentTurnSessionId === sessionId)
371
364
  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
365
  this.persist();
379
366
  }
380
367
  recoverPendingActivationCleanups() {
@@ -483,7 +470,7 @@ export class AgentServiceSandboxClient {
483
470
  throw new Error("Agent Service sandbox file requires size_bytes and sha256");
484
471
  }
485
472
  const frame = createSandboxFrame({
486
- type: "turn.file.prepare",
473
+ type: "turn.file.report",
487
474
  sandboxId: this.options.sandboxId,
488
475
  sandboxGeneration: this.sandboxGeneration,
489
476
  connectionEpoch: this.connectionEpoch,
@@ -494,73 +481,15 @@ export class AgentServiceSandboxClient {
494
481
  activationId: scope.activationId,
495
482
  payload: file,
496
483
  });
497
- const prepared = new Promise((resolve, reject) => {
498
- this.pendingFilePrepares.set(frame.frame_id, {
484
+ const reported = new Promise((resolve, reject) => {
485
+ this.pendingFileReports.set(frame.frame_id, {
499
486
  scope: { ...scope },
500
487
  resolve,
501
488
  reject,
502
489
  });
503
490
  });
504
491
  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
- }
492
+ return await reported;
564
493
  }
565
494
  async getRunRuntimeProfile(agentRunId) {
566
495
  const embedded = this.runProfiles.get(agentRunId);
@@ -724,9 +653,6 @@ export class AgentServiceSandboxClient {
724
653
  case "event.ack":
725
654
  this.ackEventOrFile(frame);
726
655
  return;
727
- case "turn.file.upload_grant":
728
- this.acceptFileGrant(frame);
729
- return;
730
656
  case "ping":
731
657
  await this.sendControlFrame("pong", { ping_frame_id: frame.frame_id });
732
658
  return;
@@ -1229,13 +1155,13 @@ export class AgentServiceSandboxClient {
1229
1155
  const frameId = frame.payload.frame_id;
1230
1156
  if (typeof frameId !== "string")
1231
1157
  throw new Error("event.ack has no frame_id");
1232
- const pendingFile = this.pendingFileCommits.get(frameId);
1158
+ const pendingFile = this.pendingFileReports.get(frameId);
1233
1159
  if (pendingFile) {
1234
- this.assertTurnScope(frame, pendingFile.scope, "file commit ACK");
1235
- this.pendingFileCommits.delete(frameId);
1160
+ this.assertTurnScope(frame, pendingFile.scope, "file report ACK");
1161
+ this.pendingFileReports.delete(frameId);
1236
1162
  const file = frame.payload.file;
1237
1163
  if (!file || typeof file !== "object" || Array.isArray(file)) {
1238
- pendingFile.reject(new Error("file commit ACK has no file record"));
1164
+ pendingFile.reject(new Error("file report ACK has no file record"));
1239
1165
  }
1240
1166
  else {
1241
1167
  pendingFile.resolve(file);
@@ -1264,35 +1190,6 @@ export class AgentServiceSandboxClient {
1264
1190
  pending.resolve();
1265
1191
  }
1266
1192
  }
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
1193
  async replaySpool() {
1297
1194
  for (const session of Object.values(this.state.sessions)) {
1298
1195
  for (const original of session.spool) {
@@ -1386,13 +1283,9 @@ export class AgentServiceSandboxClient {
1386
1283
  this.rejectPendingFiles(error);
1387
1284
  }
1388
1285
  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())
1286
+ for (const pending of this.pendingFileReports.values())
1393
1287
  pending.reject(error);
1394
- this.pendingFileCommits.clear();
1395
- this.fileGrants.clear();
1288
+ this.pendingFileReports.clear();
1396
1289
  }
1397
1290
  persist() {
1398
1291
  saveState(this.options.sandboxId, this.state);
@@ -1,6 +1,6 @@
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" | "turn.file.upload_grant" | "auth.rotate" | "ping" | "sandbox.ready" | "sandbox.heartbeat" | "command.ack" | "session.opened" | "session.closed" | "turn.event" | "turn.file.prepare" | "turn.file.committed" | "sandbox.drained" | "pong" | "protocol.error";
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" | "pong" | "protocol.error";
4
4
  export declare class UnsupportedSandboxProtocolError extends Error {
5
5
  readonly schemaVersion: unknown;
6
6
  constructor(schemaVersion: unknown);
@@ -12,7 +12,6 @@ const FRAME_TYPES = new Set([
12
12
  "sandbox.drain",
13
13
  "sandbox.shutdown",
14
14
  "event.ack",
15
- "turn.file.upload_grant",
16
15
  "auth.rotate",
17
16
  "ping",
18
17
  "sandbox.ready",
@@ -21,8 +20,7 @@ const FRAME_TYPES = new Set([
21
20
  "session.opened",
22
21
  "session.closed",
23
22
  "turn.event",
24
- "turn.file.prepare",
25
- "turn.file.committed",
23
+ "turn.file.report",
26
24
  "sandbox.drained",
27
25
  "pong",
28
26
  "protocol.error",
@@ -40,10 +38,8 @@ const TURN_TYPES = new Set([
40
38
  "turn.start",
41
39
  "turn.cancel",
42
40
  "event.ack",
43
- "turn.file.upload_grant",
44
41
  "turn.event",
45
- "turn.file.prepare",
46
- "turn.file.committed",
42
+ "turn.file.report",
47
43
  ]);
48
44
  const FRAME_KEYS = new Set([
49
45
  "schema_version",
@@ -1,21 +1,20 @@
1
1
  import type { Logger } from "./log.js";
2
- import type { RunFileCandidate, RunFileRecord } from "./types.js";
2
+ import type { RunFileCandidate } from "./types.js";
3
3
  export interface ScanLimits {
4
4
  maxFiles?: number;
5
5
  maxFileBytes?: number;
6
6
  maxPreviewChars?: number;
7
7
  maxDepth?: number;
8
+ maxReportMs?: number;
8
9
  }
9
10
  export interface ScannedFile extends RunFileCandidate {
10
11
  absPath: string;
11
12
  }
12
13
  export interface FileReportingClient {
13
- postFile(agentRunId: string, file: RunFileCandidate): Promise<RunFileRecord | void>;
14
- uploadFileContent?(agentRunId: string, fileId: string, absPath: string, mimeType?: string): Promise<RunFileRecord>;
14
+ postFile(agentRunId: string, file: RunFileCandidate): Promise<unknown>;
15
15
  }
16
16
  export interface FileReportResult {
17
17
  reported: number;
18
- uploaded: number;
19
18
  failed: number;
20
19
  truncated: boolean;
21
20
  }
@@ -9,6 +9,7 @@ const DEFAULT_MAX_FILE_BYTES = 50 * 1024 * 1024;
9
9
  // 与后端 MAX_PREVIEW_CHARS 一致。
10
10
  const DEFAULT_MAX_PREVIEW_CHARS = 4000;
11
11
  const DEFAULT_MAX_DEPTH = 8;
12
+ const DEFAULT_MAX_REPORT_MS = 3_000;
12
13
  // 文本启发式:前 4KiB 不含 NUL 字节即视为文本,可取 preview。
13
14
  const TEXT_SNIFF_BYTES = 4096;
14
15
  // 与后端 _is_safe_relative_path 同规:非空、非绝对、无反斜杠、无 .. 段、无空段。
@@ -147,18 +148,24 @@ export async function reportFileCandidates(client, agentRunId, workspaceDir, log
147
148
  log.warn("workspace file scan truncated", { agentRunId, reported: files.length });
148
149
  }
149
150
  let reported = 0;
150
- let uploaded = 0;
151
151
  let failed = 0;
152
- for (const { absPath, ...candidate } of files) {
152
+ const reportDeadline = Date.now() + (limits?.maxReportMs ?? DEFAULT_MAX_REPORT_MS);
153
+ for (const { absPath: _absPath, ...candidate } of files) {
154
+ const remainingMs = reportDeadline - Date.now();
155
+ if (remainingMs <= 0) {
156
+ failed += files.length - reported - failed;
157
+ log.warn("workspace file metadata report timed out", { agentRunId, reported });
158
+ break;
159
+ }
160
+ let timeout;
153
161
  try {
154
- const record = await client.postFile(agentRunId, candidate);
162
+ await Promise.race([
163
+ client.postFile(agentRunId, candidate),
164
+ new Promise((_resolve, reject) => {
165
+ timeout = setTimeout(() => reject(new Error("workspace file metadata report timed out")), remainingMs);
166
+ }),
167
+ ]);
155
168
  reported += 1;
156
- if (client.uploadFileContent) {
157
- if (!record)
158
- throw new Error("file candidate response is missing its id");
159
- await client.uploadFileContent(agentRunId, record.id, absPath, candidate.mime_type);
160
- uploaded += 1;
161
- }
162
169
  }
163
170
  catch (err) {
164
171
  failed += 1;
@@ -168,6 +175,10 @@ export async function reportFileCandidates(client, agentRunId, workspaceDir, log
168
175
  error: err instanceof Error ? err.message : String(err),
169
176
  });
170
177
  }
178
+ finally {
179
+ if (timeout !== undefined)
180
+ clearTimeout(timeout);
181
+ }
171
182
  }
172
- return { reported, uploaded, failed, truncated };
183
+ return { reported, failed, truncated };
173
184
  }
@@ -1,6 +1,6 @@
1
1
  import { type ScanLimits } from "./file-candidates.js";
2
2
  import { type Logger } from "./log.js";
3
- import { type CourseRuntimeProfile, type CourseRuntime, type RunEvent, type RunFileRecord, type RunStartPayload } from "./types.js";
3
+ import { type CourseRuntimeProfile, type CourseRuntime, type RunEvent, type RunStartPayload } from "./types.js";
4
4
  export interface RunDispatcherOptions {
5
5
  defaultRuntimeId?: string;
6
6
  log?: Logger;
@@ -22,8 +22,7 @@ export interface PersistentSessionExecution {
22
22
  }
23
23
  export interface RunReportingClient {
24
24
  postEvent(agentRunId: string, event: RunEvent): Promise<void>;
25
- postFile(agentRunId: string, file: import("./types.js").RunFileCandidate): Promise<RunFileRecord | void>;
26
- uploadFileContent?(agentRunId: string, fileId: string, absPath: string, mimeType?: string): Promise<RunFileRecord>;
25
+ postFile(agentRunId: string, file: import("./types.js").RunFileCandidate): Promise<unknown>;
27
26
  getRunRuntimeProfile?(agentRunId: string): Promise<CourseRuntimeProfile>;
28
27
  }
29
28
  /**
@@ -183,8 +183,8 @@ export class RunDispatcher {
183
183
  let runtimeUsage = {};
184
184
  let modelStartedAt;
185
185
  let modelFinishedAt;
186
- let filePersistStartedAt;
187
- let filePersistFinishedAt;
186
+ let fileReportStartedAt;
187
+ let fileReportFinishedAt;
188
188
  let progressEvents = 0;
189
189
  let transcript;
190
190
  let lastProgressKey = null;
@@ -219,9 +219,9 @@ export class RunDispatcher {
219
219
  ...(modelStartedAt !== undefined && modelFinishedAt !== undefined
220
220
  ? { model_wall_time_ms: Math.max(0, modelFinishedAt - modelStartedAt) }
221
221
  : {}),
222
- ...(filePersistStartedAt !== undefined && filePersistFinishedAt !== undefined
222
+ ...(fileReportStartedAt !== undefined && fileReportFinishedAt !== undefined
223
223
  ? {
224
- file_persist_time_ms: Math.max(0, filePersistFinishedAt - filePersistStartedAt),
224
+ file_report_time_ms: Math.max(0, fileReportFinishedAt - fileReportStartedAt),
225
225
  }
226
226
  : {}),
227
227
  };
@@ -658,18 +658,12 @@ export class RunDispatcher {
658
658
  : DEFAULT_MAX_OUTPUT_CHARS;
659
659
  const output = redactSecretString(truncateText(finalText, maxOutputChars));
660
660
  activeTranscript.writeFinal(output);
661
- filePersistStartedAt = this.now();
662
- let fileReport;
661
+ fileReportStartedAt = this.now();
663
662
  try {
664
- fileReport = await reportFileCandidates(this.client, runId, workspaceDir, this.log, this.scanLimits);
663
+ await reportFileCandidates(this.client, runId, workspaceDir, this.log, this.scanLimits);
665
664
  }
666
665
  finally {
667
- filePersistFinishedAt = this.now();
668
- }
669
- if (this.client.uploadFileContent && (fileReport.failed > 0 || fileReport.truncated)) {
670
- throw new RuntimeExecutionError(fileReport.truncated
671
- ? "workspace file collection was truncated"
672
- : `failed to persist ${fileReport.failed} workspace file(s)`);
666
+ fileReportFinishedAt = this.now();
673
667
  }
674
668
  await send({ type: "run.message", role: "assistant", text: output });
675
669
  await sendTerminal({
@@ -14,16 +14,14 @@ export declare function runtimeChildIdentity(env?: NodeJS.ProcessEnv): {
14
14
  gid?: number;
15
15
  };
16
16
  /**
17
- * Build the fixed control-to-runtime privilege boundary used by managed sandboxes.
17
+ * Build the fixed control-to-runtime privilege boundary used by the E2B Template.
18
18
  *
19
- * E2B runs the daemon as botlearn-control and grants it one fixed sudoers command.
20
- * Platforms that inherit no_new_privs cannot use sudo, so their root supervisor daemon
21
- * invokes the same immutable launcher directly; the launcher clears supplementary groups
22
- * and performs the one-way uid/gid drop. BYOA retains direct-spawn behavior.
19
+ * A non-root botlearn-control daemon cannot use spawn({ uid }) directly. The Template
20
+ * therefore grants it one sudoers command: a root-owned launcher that accepts only the
21
+ * immutable DeepSeek dispatcher and sets no_new_privs as the unprivileged runtime user.
22
+ * BYOA processes do not set BOTLEARN_RUNTIME_USER and retain their direct-spawn behavior.
23
23
  */
24
- export declare function runtimeChildLaunch(binary: string, args: string[], env?: NodeJS.ProcessEnv, options?: {
25
- currentUid?: number;
26
- }): {
24
+ export declare function runtimeChildLaunch(binary: string, args: string[], env?: NodeJS.ProcessEnv): {
27
25
  binary: string;
28
26
  args: string[];
29
27
  identity: {
@@ -16,7 +16,6 @@ const AGENT_SERVICE_SUPERVISOR_ENV_KEYS = [
16
16
  "BOTLEARN_RUNTIME_GROUP",
17
17
  "BOTLEARN_RUNTIME_HOME",
18
18
  "BOTLEARN_RUNTIME_LAUNCHER",
19
- "BOTLEARN_RUNTIME_LAUNCH_MODE",
20
19
  "BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT",
21
20
  "BOTLEARN_AGENT_SERVICE_PROFILE_ROOT",
22
21
  ];
@@ -28,8 +27,6 @@ const RUNTIME_USER_PATTERN = /^[a-z_][a-z0-9_-]{0,31}$/;
28
27
  const RUNTIME_SUDO_BINARY = "/usr/bin/sudo";
29
28
  const MANAGED_RUNTIME_LAUNCHER = "/opt/botlearn/bin/botlearn-runtime-launcher";
30
29
  const MANAGED_RUNTIME_BINARY = "/opt/deepseek-tui/0.8.39/bin/deepseek";
31
- const MANAGED_RUNTIME_UID = 1001;
32
- const MANAGED_RUNTIME_GID = 2000;
33
30
  /** Remove Course control-plane coordinates before any model/runtime child is created. */
34
31
  export function clearAgentServiceControlEnv(env = process.env) {
35
32
  for (const key of AGENT_SERVICE_CONTROL_ENV_KEYS)
@@ -87,41 +84,16 @@ export function runtimeChildIdentity(env = process.env) {
87
84
  return { uid, gid };
88
85
  }
89
86
  /**
90
- * Build the fixed control-to-runtime privilege boundary used by managed sandboxes.
87
+ * Build the fixed control-to-runtime privilege boundary used by the E2B Template.
91
88
  *
92
- * E2B runs the daemon as botlearn-control and grants it one fixed sudoers command.
93
- * Platforms that inherit no_new_privs cannot use sudo, so their root supervisor daemon
94
- * invokes the same immutable launcher directly; the launcher clears supplementary groups
95
- * and performs the one-way uid/gid drop. BYOA retains direct-spawn behavior.
89
+ * A non-root botlearn-control daemon cannot use spawn({ uid }) directly. The Template
90
+ * therefore grants it one sudoers command: a root-owned launcher that accepts only the
91
+ * immutable DeepSeek dispatcher and sets no_new_privs as the unprivileged runtime user.
92
+ * BYOA processes do not set BOTLEARN_RUNTIME_USER and retain their direct-spawn behavior.
96
93
  */
97
- export function runtimeChildLaunch(binary, args, env = process.env, options = {}) {
98
- const launchMode = env.BOTLEARN_RUNTIME_LAUNCH_MODE?.trim();
99
- if (launchMode === "direct-uid") {
100
- const currentUid = options.currentUid ?? process.getuid?.();
101
- if (currentUid !== 0) {
102
- throw new Error("direct managed runtime launch requires a root supervisor daemon");
103
- }
104
- if (binary !== MANAGED_RUNTIME_BINARY) {
105
- throw new Error("invalid supervisor-provided runtime binary");
106
- }
107
- const identity = runtimeChildIdentity(env);
108
- if (identity.uid !== MANAGED_RUNTIME_UID || identity.gid !== MANAGED_RUNTIME_GID) {
109
- throw new Error("direct managed runtime launch requires a fixed runtime uid and gid");
110
- }
111
- return {
112
- binary: MANAGED_RUNTIME_LAUNCHER,
113
- args: [binary, ...args],
114
- identity: {},
115
- };
116
- }
117
- if (launchMode !== undefined && launchMode !== "sudo") {
118
- throw new Error("invalid supervisor-provided runtime launch mode");
119
- }
94
+ export function runtimeChildLaunch(binary, args, env = process.env) {
120
95
  const runtimeUser = env.BOTLEARN_RUNTIME_USER;
121
96
  if (runtimeUser === undefined) {
122
- if (launchMode === "sudo") {
123
- throw new Error("sudo managed runtime launch requires a fixed runtime user");
124
- }
125
97
  return { binary, args, identity: runtimeChildIdentity(env) };
126
98
  }
127
99
  if (!RUNTIME_USER_PATTERN.test(runtimeUser)) {
@@ -3,7 +3,6 @@ import { existsSync, realpathSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import net from "node:net";
5
5
  import { MAX_PROGRESS_EVENTS_PER_ATTEMPT } from "../mcp/report-progress.js";
6
- import { sanitizeRuntimeFailureText } from "../redaction.js";
7
6
  import { runtimeChildEnv, runtimeChildLaunch } from "../runtime-env.js";
8
7
  import { readCommandVersion, resolveCommandOnPath } from "./probe.js";
9
8
  import { adaptDeepseekProgressStarted, cleanupProgressMcpConfig, createDeepseekProgressState, createProgressMcpConfig, deepseekProgressDispositions, isDeepseekProgressCompletion, progressMcpAutoInjectionSupported, progressSystemContext, } from "./progress.js";
@@ -271,7 +270,7 @@ export class DeepseekTuiAdapter {
271
270
  };
272
271
  child.stderr?.setEncoding("utf8");
273
272
  child.stderr?.on("data", (chunk) => {
274
- handle.stderrTail = sanitizeRuntimeFailureText(handle.stderrTail + chunk, 4096);
273
+ handle.stderrTail = (handle.stderrTail + chunk).slice(-4096);
275
274
  });
276
275
  child.on("close", () => {
277
276
  handle.closed = true;
@@ -288,7 +287,7 @@ export class DeepseekTuiAdapter {
288
287
  handle.progressMcpConfig = undefined;
289
288
  });
290
289
  try {
291
- await waitForHealth(baseUrl, this.fetchFn, handle, STARTUP_TIMEOUT_MS, signal);
290
+ await waitForHealth(baseUrl, this.fetchFn, child, STARTUP_TIMEOUT_MS, signal);
292
291
  }
293
292
  catch (error) {
294
293
  shutdownHandle(handle, "startup-failed");
@@ -930,18 +929,14 @@ function shutdownHandle(handle, reason) {
930
929
  }
931
930
  log.debug("deepseek-tui.shutdown", { reason });
932
931
  }
933
- async function waitForHealth(baseUrl, fetchFn, handle, timeoutMs, signal) {
932
+ async function waitForHealth(baseUrl, fetchFn, child, timeoutMs, signal) {
934
933
  const deadline = Date.now() + timeoutMs;
935
934
  let lastError = "";
936
935
  while (Date.now() < deadline) {
937
936
  if (signal.aborted)
938
937
  throw abortReason(signal);
939
- if (handle.child.exitCode !== null) {
940
- const detail = sanitizeRuntimeFailureText(handle.stderrTail, 1024)
941
- .trim()
942
- .replace(/\s+/gu, " ");
943
- throw new Error(`deepseek serve exited with code ${handle.child.exitCode}`
944
- + (detail ? `: ${detail}` : ""));
938
+ if (child.exitCode !== null) {
939
+ throw new Error(`deepseek serve exited with code ${child.exitCode}`);
945
940
  }
946
941
  try {
947
942
  const res = await fetchFn(`${baseUrl}/health`, { method: "GET", signal });
@@ -191,8 +191,7 @@ function resolveManagedProgressRoot(explicit) {
191
191
  return null;
192
192
  let root = explicit?.trim();
193
193
  if (root === undefined) {
194
- const managedRuntime = process.env.BOTLEARN_RUNTIME_USER?.trim()
195
- || process.env.BOTLEARN_RUNTIME_LAUNCH_MODE?.trim();
194
+ const managedRuntime = process.env.BOTLEARN_RUNTIME_USER?.trim();
196
195
  if (!managedRuntime)
197
196
  return null;
198
197
  root = process.env.BOTLEARN_AGENT_SERVICE_PROFILE_ROOT?.trim();
@@ -1,13 +1,4 @@
1
1
  #!/usr/bin/env node
2
- export declare function noNewPrivilegesEnabled(status: string): boolean;
3
- export declare function sandboxSupervisorLaunchPlan(noNewPrivileges: boolean, controlUid: number, controlGid: number): {
4
- directoryOwnerUid: number;
5
- daemonIdentity: {
6
- uid?: number;
7
- gid: number;
8
- };
9
- runtimeLaunchEnv: Record<string, string>;
10
- };
11
2
  export declare function acquireSandboxSupervisorLock(sandboxId: string, lockRoot?: string): (() => void) | null;
12
3
  export declare function runSandboxSupervisor(argv: string[]): Promise<number>;
13
4
  export declare function isMainModule(entry?: string): boolean;
@@ -23,35 +23,6 @@ const MANAGED_PATH = [
23
23
  // use it for a managed control-plane executable.
24
24
  "/usr/local/bin",
25
25
  ].join(":");
26
- export function noNewPrivilegesEnabled(status) {
27
- const match = /^NoNewPrivs:\s*([01])\s*$/mu.exec(status);
28
- if (!match)
29
- throw new Error("sandbox supervisor could not read NoNewPrivs state");
30
- return match[1] === "1";
31
- }
32
- export function sandboxSupervisorLaunchPlan(noNewPrivileges, controlUid, controlGid) {
33
- if (noNewPrivileges) {
34
- return {
35
- directoryOwnerUid: 0,
36
- // Keep the root supervisor's uid so Node can perform a one-way uid/gid drop for
37
- // the runtime child. The control gid preserves the existing workspace/profile ACLs.
38
- daemonIdentity: { gid: controlGid },
39
- runtimeLaunchEnv: {
40
- BOTLEARN_RUNTIME_LAUNCH_MODE: "direct-uid",
41
- },
42
- };
43
- }
44
- return {
45
- directoryOwnerUid: controlUid,
46
- daemonIdentity: { uid: controlUid, gid: controlGid },
47
- runtimeLaunchEnv: {
48
- BOTLEARN_RUNTIME_LAUNCH_MODE: "sudo",
49
- BOTLEARN_RUNTIME_USER: RUNTIME_USER,
50
- BOTLEARN_RUNTIME_GROUP: CONTROL_USER,
51
- BOTLEARN_RUNTIME_LAUNCHER: RUNTIME_LAUNCHER,
52
- },
53
- };
54
- }
55
26
  function numericId(flag, user) {
56
27
  const output = execFileSync("/usr/bin/id", [flag, user], {
57
28
  encoding: "utf8",
@@ -141,8 +112,6 @@ export async function runSandboxSupervisor(argv) {
141
112
  const controlUid = numericId("-u", CONTROL_USER);
142
113
  const controlGid = numericId("-g", CONTROL_USER);
143
114
  const runtimeUid = numericId("-u", RUNTIME_USER);
144
- const noNewPrivileges = noNewPrivilegesEnabled(readFileSync("/proc/self/status", "utf8"));
145
- const launchPlan = sandboxSupervisorLaunchPlan(noNewPrivileges, controlUid, controlGid);
146
115
  const bootstrap = await readOneShotBootstrap();
147
116
  let child;
148
117
  let releaseLock = null;
@@ -159,25 +128,25 @@ export async function runSandboxSupervisor(argv) {
159
128
  releaseLock = acquireSandboxSupervisorLock(sandboxId);
160
129
  if (releaseLock === null)
161
130
  return 0;
162
- prepareDirectories(launchPlan.directoryOwnerUid, controlGid);
163
- if (noNewPrivileges) {
164
- process.stderr.write("sandbox supervisor: NoNewPrivs=1; using root daemon with direct runtime uid drop\n");
165
- }
131
+ prepareDirectories(controlUid, controlGid);
166
132
  child = spawn(NODE_BINARY, [
167
133
  DAEMON_ENTRY,
168
134
  "agent-service",
169
135
  "session",
170
136
  "--bootstrap-stdin",
171
137
  ], {
172
- ...launchPlan.daemonIdentity,
138
+ uid: controlUid,
139
+ gid: controlGid,
173
140
  env: {
174
141
  HOME: "/home/botlearn-control",
175
142
  PATH: MANAGED_PATH,
176
143
  BOTLEARN_DAEMON_HOME: CONTROL_HOME,
177
144
  BOTLEARN_RUNTIME_UID: String(runtimeUid),
178
145
  BOTLEARN_RUNTIME_GID: String(controlGid),
146
+ BOTLEARN_RUNTIME_USER: RUNTIME_USER,
147
+ BOTLEARN_RUNTIME_GROUP: CONTROL_USER,
179
148
  BOTLEARN_RUNTIME_HOME: "/home/user",
180
- ...launchPlan.runtimeLaunchEnv,
149
+ BOTLEARN_RUNTIME_LAUNCHER: RUNTIME_LAUNCHER,
181
150
  BOTLEARN_DEEPSEEK_TUI_BIN: DEEPSEEK_BINARY,
182
151
  BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT: WORKSPACE,
183
152
  BOTLEARN_AGENT_SERVICE_PROFILE_ROOT: RUNTIME_PROFILE_ROOT,
package/dist/types.d.ts CHANGED
@@ -58,7 +58,7 @@ export interface RunEvent {
58
58
  }
59
59
  /** `POST /daemon/runs/{id}/files` 的文件候选(与后端 DaemonRunFileIn 一致)。 */
60
60
  export interface RunFileCandidate {
61
- event?: "created" | "modified" | "deleted" | "upload_completed" | "upload_failed";
61
+ event?: "created" | "modified" | "deleted";
62
62
  /** workspace 相对路径,正斜杠;不得包含 `..`、绝对路径或反斜杠(后端 400)。 */
63
63
  path: string;
64
64
  name?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botlearn-course/daemon",
3
- "version": "0.0.9",
3
+ "version": "0.0.10",
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": {