@botlearn-course/daemon 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +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 { runtimeChildEnv } from "../runtime-env.js";
6
+ import { runtimeChildEnv, runtimeChildIdentity } from "../runtime-env.js";
7
7
  import { readCommandVersion, resolveCommandOnPath } from "./probe.js";
8
8
  import { adaptDeepseekProgressStarted, cleanupProgressMcpConfig, createDeepseekProgressState, createProgressMcpConfig, deepseekProgressDispositions, isDeepseekProgressCompletion, progressMcpAutoInjectionSupported, progressSystemContext, } from "./progress.js";
9
9
  import { consoleLogger, wrapEngineAdapter, } from "./engine.js";
@@ -210,7 +210,8 @@ export class DeepseekTuiAdapter {
210
210
  token,
211
211
  ], {
212
212
  cwd: opts.cwd,
213
- env: this.spawnEnv(progressMcpConfig?.path),
213
+ env: this.spawnEnv(opts, progressMcpConfig?.path),
214
+ ...runtimeChildIdentity(),
214
215
  stdio: ["ignore", "pipe", "pipe"],
215
216
  // 自成进程组:解析到的二进制可能是会再 spawn 真实 deepseek-tui
216
217
  // server 的 dispatcher,shutdown 必须对整组发信号而非仅直接子进程。
@@ -264,9 +265,9 @@ export class DeepseekTuiAdapter {
264
265
  * 不设置 DEEPSEEK_RUNTIME_DIR:server 跨 run 池化共享,per-run 目录不成立;
265
266
  * BYOA 直接用用户本机 deepseek 自身的默认状态目录(含已登录凭据)。
266
267
  */
267
- spawnEnv(progressMcpConfigPath) {
268
+ spawnEnv(opts, progressMcpConfigPath) {
268
269
  const env = {
269
- ...runtimeChildEnv(),
270
+ ...runtimeChildEnv(opts.env ?? process.env),
270
271
  FORCE_COLOR: "0",
271
272
  NO_COLOR: "1",
272
273
  };
@@ -636,11 +637,11 @@ function extractDeepseekError(eventName, payload) {
636
637
  stringField(payload?.payload, "message") ??
637
638
  stringField(payload?.payload, "error"));
638
639
  }
639
- if (eventName === "item.failed") {
640
- return (stringField(payload?.payload?.item, "detail") ??
641
- stringField(payload?.payload?.item, "summary") ??
642
- stringField(payload?.payload, "error"));
643
- }
640
+ // An item failure is a tool result inside an otherwise live turn. DeepSeek may use it
641
+ // to correct the arguments, choose another tool, and still complete the turn. Treating
642
+ // its detail as a sticky runtime error would discard that recovery and fail the run
643
+ // even after a later assistant message or successful turn completion. Only provider
644
+ // errors and terminal turn failures are run-level errors here.
644
645
  if (isDeepseekTerminalEvent(eventName, payload)) {
645
646
  const turn = payload?.payload?.turn ?? payload?.turn;
646
647
  const status = stringField(turn, "status");
@@ -37,6 +37,7 @@ export interface EngineRunOptions {
37
37
  systemContext?: string;
38
38
  onBlock?: (block: StreamBlock) => void;
39
39
  onStatus?: (event: RuntimeStatusEvent) => void;
40
+ env?: NodeJS.ProcessEnv;
40
41
  }
41
42
  export interface EngineRunResult {
42
43
  text: string;
@@ -114,9 +114,10 @@ export function wrapEngineAdapter(id, engine, opts) {
114
114
  };
115
115
  const result = await engine.run({
116
116
  text,
117
- sessionId: null,
117
+ sessionId: run.nativeSessionId ?? null,
118
118
  cwd: run.workspaceDir,
119
119
  signal,
120
+ ...(run.runtimeEnv ? { env: run.runtimeEnv } : {}),
120
121
  ...(extraArgs.length > 0 ? { extraArgs } : {}),
121
122
  ...(systemContext !== undefined ? { systemContext } : {}),
122
123
  onBlock: (block) => {
@@ -143,6 +144,7 @@ export function wrapEngineAdapter(id, engine, opts) {
143
144
  if (result.progressDispositions) {
144
145
  await sink.progressDispositions?.(result.progressDispositions);
145
146
  }
147
+ await sink.runtimeSession?.(result.newSessionId);
146
148
  if (result.error) {
147
149
  throw new RuntimeExecutionError(result.error, "runtime_error", result.runtimeFailure);
148
150
  }
@@ -41,7 +41,7 @@ export declare class HermesAgentAdapter extends AcpRuntimeAdapter {
41
41
  * 用户 `~/.hermes` 的 `.env` / config.yaml 里。
42
42
  */
43
43
  protected buildArgs(_opts: EngineRunOptions): string[];
44
- protected spawnEnv(_opts: EngineRunOptions): NodeJS.ProcessEnv;
44
+ protected spawnEnv(opts: EngineRunOptions): NodeJS.ProcessEnv;
45
45
  /** spawn 前把 systemContext 原子写入 `<cwd>/AGENTS.md`(tmp 0600 + rename)。 */
46
46
  protected prepareTurn(opts: EngineRunOptions): void;
47
47
  /**
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { AcpRuntimeAdapter, } from "./acp-stream.js";
4
4
  import { firstExistingPath, readCommandVersion, resolveCommandOnPath, resolveHomePath, } from "./probe.js";
5
5
  import { wrapEngineAdapter, } from "./engine.js";
6
+ import { runtimeChildEnv } from "../runtime-env.js";
6
7
  /**
7
8
  * `hermes-acp` 不在 PATH 上时的已知绝对位置。上游 `scripts/install.sh`
8
9
  * (curl|bash 安装器)把私有 virtualenv 装到 `~/.hermes/hermes-agent/venv/`,
@@ -80,9 +81,9 @@ export class HermesAgentAdapter extends AcpRuntimeAdapter {
80
81
  buildArgs(_opts) {
81
82
  return [];
82
83
  }
83
- spawnEnv(_opts) {
84
+ spawnEnv(opts) {
84
85
  return {
85
- ...process.env,
86
+ ...runtimeChildEnv(opts.env ?? process.env),
86
87
  // 保持 ACP stdout 无 ANSI 码。
87
88
  NO_COLOR: "1",
88
89
  // 危险工具调用走 ACP request_permission。
@@ -46,6 +46,6 @@ export declare abstract class NdjsonStreamAdapter implements EngineAdapter {
46
46
  protected abstract buildArgs(opts: EngineRunOptions): string[];
47
47
  protected abstract handleEvent(obj: unknown, ctx: NdjsonEventCtx): void;
48
48
  /** 覆盖以调整 env(FORCE_COLOR=0、NO_COLOR=1 等)。 */
49
- protected spawnEnv(_opts: EngineRunOptions): NodeJS.ProcessEnv;
49
+ protected spawnEnv(opts: EngineRunOptions): NodeJS.ProcessEnv;
50
50
  run(opts: EngineRunOptions): Promise<EngineRunResult>;
51
51
  }
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { runtimeChildEnv, runtimeChildIdentity } from "../runtime-env.js";
2
3
  import { safeCommand, sanitizeRuntimeFailureText, tailText } from "../redaction.js";
3
4
  import { sliceUtf8Bytes, utf8ByteLength } from "./text-cap.js";
4
5
  import { consoleLogger, } from "./engine.js";
@@ -21,8 +22,8 @@ export class NdjsonStreamAdapter {
21
22
  this.log = logger ?? consoleLogger;
22
23
  }
23
24
  /** 覆盖以调整 env(FORCE_COLOR=0、NO_COLOR=1 等)。 */
24
- spawnEnv(_opts) {
25
- return { ...process.env };
25
+ spawnEnv(opts) {
26
+ return runtimeChildEnv(opts.env ?? process.env);
26
27
  }
27
28
  async run(opts) {
28
29
  if (opts.signal.aborted) {
@@ -43,6 +44,7 @@ export class NdjsonStreamAdapter {
43
44
  const child = spawn(binary, args, {
44
45
  cwd: opts.cwd,
45
46
  env: this.spawnEnv(opts),
47
+ ...runtimeChildIdentity(),
46
48
  stdio: ["ignore", "pipe", "pipe"],
47
49
  });
48
50
  // spawn 是同步的,但若在 spawn 与稍后挂监听之间发生 abort 会被漏掉,
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { runtimeChildEnv, runtimeChildIdentity } from "../runtime-env.js";
2
3
  import { readCommandVersion, resolveCommandOnPath } from "./probe.js";
3
4
  import { sliceUtf8Bytes } from "./text-cap.js";
4
5
  import { consoleLogger, wrapEngineAdapter, } from "./engine.js";
@@ -384,7 +385,8 @@ export class OpenclawAcpAdapter {
384
385
  args.push("--token", gateway.token);
385
386
  const child = this.spawnFn(command, args, {
386
387
  stdio: ["pipe", "pipe", "pipe"],
387
- env: { ...process.env },
388
+ env: runtimeChildEnv(this.env ?? process.env),
389
+ ...runtimeChildIdentity(),
388
390
  });
389
391
  installExitCleanupHook();
390
392
  const handle = {
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export declare function acquireSessionSupervisorLock(runtimeSessionId: string, lockRoot?: string): (() => void) | null;
3
+ export declare function runSandboxSupervisor(argv: string[]): Promise<number>;
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync, spawn } from "node:child_process";
3
+ import { chmodSync, chownSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
4
+ import path from "node:path";
5
+ const MAX_BOOTSTRAP_BYTES = 64 * 1024;
6
+ const CONTROL_USER = "botlearn-control";
7
+ const RUNTIME_USER = "user";
8
+ const CONTROL_HOME = "/home/botlearn-control/.botlearn-course/daemon";
9
+ const WORKSPACE = "/workspace";
10
+ const RUNTIME_PROFILE_ROOT = "/run/botlearn-runtime-profiles";
11
+ const SUPERVISOR_LOCK_ROOT = "/run/botlearn-sandbox-supervisors";
12
+ const DAEMON_BINARY = "/usr/local/bin/botlearn-course-daemon";
13
+ function numericId(flag, user) {
14
+ const output = execFileSync("/usr/bin/id", [flag, user], {
15
+ encoding: "utf8",
16
+ stdio: ["ignore", "pipe", "ignore"],
17
+ }).trim();
18
+ const value = Number(output);
19
+ if (!Number.isInteger(value) || value < 1) {
20
+ throw new Error(`sandbox supervisor could not resolve ${flag} for ${user}`);
21
+ }
22
+ return value;
23
+ }
24
+ async function readOneShotBootstrap() {
25
+ const chunks = [];
26
+ let size = 0;
27
+ for await (const chunk of process.stdin) {
28
+ const bytes = Buffer.from(chunk);
29
+ size += bytes.length;
30
+ if (size > MAX_BOOTSTRAP_BYTES) {
31
+ throw new Error("sandbox supervisor bootstrap exceeds size limit");
32
+ }
33
+ chunks.push(bytes);
34
+ }
35
+ if (size === 0)
36
+ throw new Error("sandbox supervisor bootstrap is empty");
37
+ return Buffer.concat(chunks);
38
+ }
39
+ export function acquireSessionSupervisorLock(runtimeSessionId, lockRoot = SUPERVISOR_LOCK_ROOT) {
40
+ const lockDir = path.join(lockRoot, runtimeSessionId);
41
+ const pidFile = path.join(lockDir, "pid");
42
+ mkdirSync(lockRoot, { recursive: true, mode: 0o700 });
43
+ try {
44
+ mkdirSync(lockDir, { mode: 0o700 });
45
+ }
46
+ catch (error) {
47
+ if (!existsSync(pidFile))
48
+ throw error;
49
+ const existingPid = Number(readFileSync(pidFile, "utf8").trim());
50
+ if (Number.isInteger(existingPid) && existingPid > 1) {
51
+ try {
52
+ process.kill(existingPid, 0);
53
+ return null;
54
+ }
55
+ catch {
56
+ // Stale lock from a supervisor that no longer exists.
57
+ }
58
+ }
59
+ rmSync(lockDir, { recursive: true, force: true });
60
+ mkdirSync(lockDir, { mode: 0o700 });
61
+ }
62
+ writeFileSync(pidFile, `${process.pid}\n`, { mode: 0o600 });
63
+ return () => {
64
+ try {
65
+ if (Number(readFileSync(pidFile, "utf8").trim()) === process.pid) {
66
+ rmSync(lockDir, { recursive: true, force: true });
67
+ }
68
+ }
69
+ catch {
70
+ // A replacement supervisor already owns or removed the lock.
71
+ }
72
+ };
73
+ }
74
+ function prepareDirectories(controlUid, controlGid, runtimeUid, runtimeSessionId, sessionGeneration) {
75
+ mkdirSync(CONTROL_HOME, { recursive: true, mode: 0o700 });
76
+ chownSync("/home/botlearn-control/.botlearn-course", controlUid, controlGid);
77
+ chownSync(CONTROL_HOME, controlUid, controlGid);
78
+ chmodSync(CONTROL_HOME, 0o700);
79
+ mkdirSync(RUNTIME_PROFILE_ROOT, { recursive: true, mode: 0o750 });
80
+ chownSync(RUNTIME_PROFILE_ROOT, controlUid, controlGid);
81
+ chmodSync(RUNTIME_PROFILE_ROOT, 0o750);
82
+ mkdirSync(WORKSPACE, { recursive: true, mode: 0o770 });
83
+ chownSync(WORKSPACE, runtimeUid, controlGid);
84
+ chmodSync(WORKSPACE, 0o770);
85
+ const sessionWorkspace = path.join(WORKSPACE, runtimeSessionId, `generation-${sessionGeneration}`);
86
+ mkdirSync(sessionWorkspace, { recursive: true, mode: 0o770 });
87
+ chownSync(path.join(WORKSPACE, runtimeSessionId), runtimeUid, controlGid);
88
+ chownSync(sessionWorkspace, runtimeUid, controlGid);
89
+ chmodSync(path.join(WORKSPACE, runtimeSessionId), 0o770);
90
+ chmodSync(sessionWorkspace, 0o770);
91
+ }
92
+ export async function runSandboxSupervisor(argv) {
93
+ if (argv.length !== 2 || argv[0] !== "agent-service" || argv[1] !== "session") {
94
+ throw new Error("sandbox supervisor only permits: agent-service session");
95
+ }
96
+ if (typeof process.getuid !== "function" || process.getuid() !== 0) {
97
+ throw new Error("sandbox supervisor must start as root");
98
+ }
99
+ const controlUid = numericId("-u", CONTROL_USER);
100
+ const controlGid = numericId("-g", CONTROL_USER);
101
+ const runtimeUid = numericId("-u", RUNTIME_USER);
102
+ const bootstrap = await readOneShotBootstrap();
103
+ let child;
104
+ let releaseLock = null;
105
+ try {
106
+ const decoded = JSON.parse(bootstrap.toString("utf8"));
107
+ const runtimeSessionId = decoded.runtimeSessionId;
108
+ const sessionGeneration = Number(decoded.sessionGeneration ?? 0);
109
+ if (typeof runtimeSessionId !== "string" ||
110
+ !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(runtimeSessionId) ||
111
+ !Number.isInteger(sessionGeneration) ||
112
+ sessionGeneration < 1) {
113
+ throw new Error("sandbox supervisor bootstrap session scope is invalid");
114
+ }
115
+ releaseLock = acquireSessionSupervisorLock(runtimeSessionId);
116
+ if (releaseLock === null)
117
+ return 0;
118
+ prepareDirectories(controlUid, controlGid, runtimeUid, runtimeSessionId, sessionGeneration);
119
+ child = spawn(DAEMON_BINARY, ["agent-service", "session", "--bootstrap-stdin"], {
120
+ uid: controlUid,
121
+ gid: controlGid,
122
+ env: {
123
+ HOME: "/home/botlearn-control",
124
+ PATH: "/usr/local/bin:/usr/bin:/bin",
125
+ BOTLEARN_DAEMON_HOME: CONTROL_HOME,
126
+ BOTLEARN_RUNTIME_UID: String(runtimeUid),
127
+ BOTLEARN_RUNTIME_GID: String(controlGid),
128
+ BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT: WORKSPACE,
129
+ BOTLEARN_AGENT_SERVICE_PROFILE_ROOT: RUNTIME_PROFILE_ROOT,
130
+ },
131
+ stdio: ["pipe", "inherit", "inherit"],
132
+ });
133
+ if (child.stdin === null) {
134
+ child.kill("SIGKILL");
135
+ throw new Error("sandbox supervisor could not open daemon bootstrap pipe");
136
+ }
137
+ child.stdin.end(bootstrap);
138
+ }
139
+ catch (error) {
140
+ releaseLock?.();
141
+ throw error;
142
+ }
143
+ finally {
144
+ bootstrap.fill(0);
145
+ }
146
+ const forward = (signal) => {
147
+ if (!child.killed)
148
+ child.kill(signal);
149
+ };
150
+ process.once("SIGTERM", () => forward("SIGTERM"));
151
+ process.once("SIGINT", () => forward("SIGINT"));
152
+ try {
153
+ return await new Promise((resolve, reject) => {
154
+ child.once("error", reject);
155
+ child.once("exit", (code, signal) => {
156
+ if (signal)
157
+ resolve(128);
158
+ else
159
+ resolve(code ?? 1);
160
+ });
161
+ });
162
+ }
163
+ finally {
164
+ releaseLock?.();
165
+ }
166
+ }
167
+ if (process.argv[1]?.endsWith("sandbox-supervisor.js")) {
168
+ runSandboxSupervisor(process.argv.slice(2))
169
+ .then((code) => {
170
+ process.exitCode = code;
171
+ })
172
+ .catch((error) => {
173
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
174
+ process.exitCode = 1;
175
+ });
176
+ }
package/dist/types.d.ts CHANGED
@@ -147,12 +147,20 @@ export interface CourseRuntimeSink {
147
147
  file(file: RunFileCandidate): Promise<void>;
148
148
  /** 可选的 run-scoped 内部遥测;不得包含 summary 或 provider raw envelope。 */
149
149
  progressDispositions?(dispositions: RuntimeProgressDispositions): Promise<void>;
150
+ /** Persist the runtime-native thread/session id before a terminal turn event is emitted. */
151
+ runtimeSession?(sessionId: string): Promise<void>;
150
152
  }
151
153
  /** 一次 run 的本地执行上下文:服务器 payload + daemon 本地准备产物。 */
152
154
  export interface RunExecution {
153
155
  payload: RunStartPayload;
154
- /** run 的隔离工作区目录(runtime cwd)。 */
156
+ /** Runtime cwd. Managed persistent sessions reuse one session-scoped workspace. */
155
157
  workspaceDir: string;
158
+ /** Runtime-native thread/session id to resume; null creates the first native session. */
159
+ nativeSessionId?: string | null;
160
+ /** Monotonic Course Service context revision accepted for this turn. */
161
+ contextRevision?: number;
162
+ /** Scoped runtime-only environment (for example a short-lived model proxy grant). */
163
+ runtimeEnv?: NodeJS.ProcessEnv;
156
164
  }
157
165
  export interface CourseRuntime {
158
166
  id: string;
@@ -0,0 +1,43 @@
1
+ import { EventEmitter } from "node:events";
2
+ export type WebSocketRawData = Buffer;
3
+ export interface WebSocketClientOptions {
4
+ headers?: Record<string, string>;
5
+ maxPayload?: number;
6
+ }
7
+ /**
8
+ * Narrow RFC 6455 client for the daemon control plane.
9
+ *
10
+ * It intentionally supports only text/control frames, no extensions, and one configured
11
+ * subprotocol. Keeping this transport on Node built-ins preserves the daemon's zero
12
+ * production-dependency release invariant.
13
+ */
14
+ export declare class WebSocketClient extends EventEmitter {
15
+ private readonly options;
16
+ static readonly CONNECTING = 0;
17
+ static readonly OPEN = 1;
18
+ static readonly CLOSING = 2;
19
+ static readonly CLOSED = 3;
20
+ readonly url: URL;
21
+ readonly requestedProtocol: string;
22
+ readonly maxPayload: number;
23
+ protocol: string;
24
+ readyState: number;
25
+ private socket;
26
+ private handshakeBuffer;
27
+ private frameBuffer;
28
+ private fragmentedOpcode;
29
+ private fragmentedChunks;
30
+ private fragmentedBytes;
31
+ private closeEmitted;
32
+ private closeSent;
33
+ constructor(rawUrl: string, protocol: string, options?: WebSocketClientOptions);
34
+ send(data: string, callback?: (error?: Error) => void): void;
35
+ close(code?: number, reason?: string): void;
36
+ private connect;
37
+ private sendHandshake;
38
+ private onData;
39
+ private consumeFrames;
40
+ private emitFragmentedMessage;
41
+ private failProtocol;
42
+ private emitClose;
43
+ }