@botlearn-course/daemon 0.0.10 → 0.0.11

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,9 +38,10 @@ 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,并把 daemon/runtime 分别降权
42
- `botlearn-control`/`user` UID。E2B Template 通过 root-owned 固定 launcher 将 DeepSeek
43
- 单向降权为 `user` 并设置 `no_new_privs`;runtime 用户本身没有 sudo 权限。托管启动链只执行
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 权限。托管启动链只执行
44
45
  `/opt` 下的固定 Node、supervisor、daemon、launcher 与 DeepSeek 文件,不信任 E2B 会开放给 runtime
45
46
  写入的 `/usr/local/bin`。
46
47
  `agent-service session --bootstrap-stdin` 同样属于受 supervisor 保护的内部协议,不应直接暴露给
@@ -14,14 +14,16 @@ 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 the E2B Template.
17
+ * Build the fixed control-to-runtime privilege boundary used by managed sandboxes.
18
18
  *
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.
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.
23
23
  */
24
- export declare function runtimeChildLaunch(binary: string, args: string[], env?: NodeJS.ProcessEnv): {
24
+ export declare function runtimeChildLaunch(binary: string, args: string[], env?: NodeJS.ProcessEnv, options?: {
25
+ currentUid?: number;
26
+ }): {
25
27
  binary: string;
26
28
  args: string[];
27
29
  identity: {
@@ -16,6 +16,7 @@ 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",
19
20
  "BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT",
20
21
  "BOTLEARN_AGENT_SERVICE_PROFILE_ROOT",
21
22
  ];
@@ -27,6 +28,8 @@ const RUNTIME_USER_PATTERN = /^[a-z_][a-z0-9_-]{0,31}$/;
27
28
  const RUNTIME_SUDO_BINARY = "/usr/bin/sudo";
28
29
  const MANAGED_RUNTIME_LAUNCHER = "/opt/botlearn/bin/botlearn-runtime-launcher";
29
30
  const MANAGED_RUNTIME_BINARY = "/opt/deepseek-tui/0.8.39/bin/deepseek";
31
+ const MANAGED_RUNTIME_UID = 1001;
32
+ const MANAGED_RUNTIME_GID = 2000;
30
33
  /** Remove Course control-plane coordinates before any model/runtime child is created. */
31
34
  export function clearAgentServiceControlEnv(env = process.env) {
32
35
  for (const key of AGENT_SERVICE_CONTROL_ENV_KEYS)
@@ -84,16 +87,41 @@ export function runtimeChildIdentity(env = process.env) {
84
87
  return { uid, gid };
85
88
  }
86
89
  /**
87
- * Build the fixed control-to-runtime privilege boundary used by the E2B Template.
90
+ * Build the fixed control-to-runtime privilege boundary used by managed sandboxes.
88
91
  *
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.
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.
93
96
  */
94
- export function runtimeChildLaunch(binary, args, env = process.env) {
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
+ }
95
120
  const runtimeUser = env.BOTLEARN_RUNTIME_USER;
96
121
  if (runtimeUser === undefined) {
122
+ if (launchMode === "sudo") {
123
+ throw new Error("sudo managed runtime launch requires a fixed runtime user");
124
+ }
97
125
  return { binary, args, identity: runtimeChildIdentity(env) };
98
126
  }
99
127
  if (!RUNTIME_USER_PATTERN.test(runtimeUser)) {
@@ -3,6 +3,7 @@ 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";
6
7
  import { runtimeChildEnv, runtimeChildLaunch } from "../runtime-env.js";
7
8
  import { readCommandVersion, resolveCommandOnPath } from "./probe.js";
8
9
  import { adaptDeepseekProgressStarted, cleanupProgressMcpConfig, createDeepseekProgressState, createProgressMcpConfig, deepseekProgressDispositions, isDeepseekProgressCompletion, progressMcpAutoInjectionSupported, progressSystemContext, } from "./progress.js";
@@ -270,7 +271,7 @@ export class DeepseekTuiAdapter {
270
271
  };
271
272
  child.stderr?.setEncoding("utf8");
272
273
  child.stderr?.on("data", (chunk) => {
273
- handle.stderrTail = (handle.stderrTail + chunk).slice(-4096);
274
+ handle.stderrTail = sanitizeRuntimeFailureText(handle.stderrTail + chunk, 4096);
274
275
  });
275
276
  child.on("close", () => {
276
277
  handle.closed = true;
@@ -287,7 +288,7 @@ export class DeepseekTuiAdapter {
287
288
  handle.progressMcpConfig = undefined;
288
289
  });
289
290
  try {
290
- await waitForHealth(baseUrl, this.fetchFn, child, STARTUP_TIMEOUT_MS, signal);
291
+ await waitForHealth(baseUrl, this.fetchFn, handle, STARTUP_TIMEOUT_MS, signal);
291
292
  }
292
293
  catch (error) {
293
294
  shutdownHandle(handle, "startup-failed");
@@ -929,14 +930,18 @@ function shutdownHandle(handle, reason) {
929
930
  }
930
931
  log.debug("deepseek-tui.shutdown", { reason });
931
932
  }
932
- async function waitForHealth(baseUrl, fetchFn, child, timeoutMs, signal) {
933
+ async function waitForHealth(baseUrl, fetchFn, handle, timeoutMs, signal) {
933
934
  const deadline = Date.now() + timeoutMs;
934
935
  let lastError = "";
935
936
  while (Date.now() < deadline) {
936
937
  if (signal.aborted)
937
938
  throw abortReason(signal);
938
- if (child.exitCode !== null) {
939
- throw new Error(`deepseek serve exited with code ${child.exitCode}`);
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}` : ""));
940
945
  }
941
946
  try {
942
947
  const res = await fetchFn(`${baseUrl}/health`, { method: "GET", signal });
@@ -191,7 +191,8 @@ 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();
194
+ const managedRuntime = process.env.BOTLEARN_RUNTIME_USER?.trim()
195
+ || process.env.BOTLEARN_RUNTIME_LAUNCH_MODE?.trim();
195
196
  if (!managedRuntime)
196
197
  return null;
197
198
  root = process.env.BOTLEARN_AGENT_SERVICE_PROFILE_ROOT?.trim();
@@ -1,4 +1,13 @@
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
+ };
2
11
  export declare function acquireSandboxSupervisorLock(sandboxId: string, lockRoot?: string): (() => void) | null;
3
12
  export declare function runSandboxSupervisor(argv: string[]): Promise<number>;
4
13
  export declare function isMainModule(entry?: string): boolean;
@@ -23,6 +23,35 @@ 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
+ }
26
55
  function numericId(flag, user) {
27
56
  const output = execFileSync("/usr/bin/id", [flag, user], {
28
57
  encoding: "utf8",
@@ -112,6 +141,8 @@ export async function runSandboxSupervisor(argv) {
112
141
  const controlUid = numericId("-u", CONTROL_USER);
113
142
  const controlGid = numericId("-g", CONTROL_USER);
114
143
  const runtimeUid = numericId("-u", RUNTIME_USER);
144
+ const noNewPrivileges = noNewPrivilegesEnabled(readFileSync("/proc/self/status", "utf8"));
145
+ const launchPlan = sandboxSupervisorLaunchPlan(noNewPrivileges, controlUid, controlGid);
115
146
  const bootstrap = await readOneShotBootstrap();
116
147
  let child;
117
148
  let releaseLock = null;
@@ -128,25 +159,25 @@ export async function runSandboxSupervisor(argv) {
128
159
  releaseLock = acquireSandboxSupervisorLock(sandboxId);
129
160
  if (releaseLock === null)
130
161
  return 0;
131
- prepareDirectories(controlUid, controlGid);
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
+ }
132
166
  child = spawn(NODE_BINARY, [
133
167
  DAEMON_ENTRY,
134
168
  "agent-service",
135
169
  "session",
136
170
  "--bootstrap-stdin",
137
171
  ], {
138
- uid: controlUid,
139
- gid: controlGid,
172
+ ...launchPlan.daemonIdentity,
140
173
  env: {
141
174
  HOME: "/home/botlearn-control",
142
175
  PATH: MANAGED_PATH,
143
176
  BOTLEARN_DAEMON_HOME: CONTROL_HOME,
144
177
  BOTLEARN_RUNTIME_UID: String(runtimeUid),
145
178
  BOTLEARN_RUNTIME_GID: String(controlGid),
146
- BOTLEARN_RUNTIME_USER: RUNTIME_USER,
147
- BOTLEARN_RUNTIME_GROUP: CONTROL_USER,
148
179
  BOTLEARN_RUNTIME_HOME: "/home/user",
149
- BOTLEARN_RUNTIME_LAUNCHER: RUNTIME_LAUNCHER,
180
+ ...launchPlan.runtimeLaunchEnv,
150
181
  BOTLEARN_DEEPSEEK_TUI_BIN: DEEPSEEK_BINARY,
151
182
  BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT: WORKSPACE,
152
183
  BOTLEARN_AGENT_SERVICE_PROFILE_ROOT: RUNTIME_PROFILE_ROOT,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botlearn-course/daemon",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
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": {