@botlearn-course/daemon 0.0.3 → 0.0.5

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
@@ -39,8 +39,10 @@ npx @botlearn-course/daemon@latest course logout
39
39
  包内还包含供 BotLearn 托管 E2B Template 使用的内部命令
40
40
  `botlearn-sandbox-supervisor agent-service session`。它不是 BYOA 用户入口:生产环境只允许由
41
41
  Agent Service 以固定 argv 启动,通过 stdin 接收一次性 bootstrap,并把 daemon/runtime 分别降权
42
- 到 `botlearn-control`/`user` UID。`agent-service session --bootstrap-stdin` 同样属于受 supervisor
43
- 保护的内部协议,不应直接暴露给课程任务或 workspace shell。
42
+ 到 `botlearn-control`/`user` UID。E2B Template 通过 root-owned 固定 launcher 将 DeepSeek
43
+ 单向降权为 `user` 并设置 `no_new_privs`;runtime 用户本身没有 sudo 权限。
44
+ `agent-service session --bootstrap-stdin` 同样属于受 supervisor 保护的内部协议,不应直接暴露给
45
+ 课程任务或 workspace shell。
44
46
 
45
47
  ## 支持的 runtime
46
48
 
@@ -5,6 +5,7 @@ export declare class AgentServiceRunClient {
5
5
  private readonly agentRunId;
6
6
  private readonly runToken;
7
7
  private readonly workerId;
8
+ private traceId;
8
9
  constructor(baseUrl: string, agentRunId: string, runToken: string, workerId: string);
9
10
  private url;
10
11
  private request;
@@ -8,6 +8,7 @@ export class AgentServiceRunClient {
8
8
  agentRunId;
9
9
  runToken;
10
10
  workerId;
11
+ traceId;
11
12
  constructor(baseUrl, agentRunId, runToken, workerId) {
12
13
  this.baseUrl = baseUrl;
13
14
  this.agentRunId = agentRunId;
@@ -26,6 +27,7 @@ export class AgentServiceRunClient {
26
27
  method,
27
28
  headers: {
28
29
  authorization: `Bearer ${this.runToken}`,
30
+ ...(this.traceId ? { "x-trace-id": this.traceId } : {}),
29
31
  ...(body !== undefined ? { "content-type": "application/json" } : {}),
30
32
  },
31
33
  body: body !== undefined ? JSON.stringify(body) : undefined,
@@ -38,10 +40,13 @@ export class AgentServiceRunClient {
38
40
  return (raw ? JSON.parse(raw) : null);
39
41
  }
40
42
  async getRun() {
41
- return this.request("GET", `/api/v1/agent-service/runs/${this.agentRunId}`);
43
+ const payload = await this.request("GET", `/api/v1/agent-service/runs/${this.agentRunId}`);
44
+ this.traceId = payload.trace_id;
45
+ return payload;
42
46
  }
43
47
  async postEvent(agentRunId, event) {
44
48
  this.assertRunId(agentRunId);
49
+ this.traceId = event.trace_id ?? this.traceId;
45
50
  const sanitized = redactSecretsDeep(event, 8, [this.runToken]);
46
51
  await this.request("POST", `/api/v1/agent-service/runs/${agentRunId}/events`, sanitized);
47
52
  }
@@ -73,6 +78,7 @@ export class AgentServiceRunClient {
73
78
  method: "PUT",
74
79
  headers: {
75
80
  authorization: `Bearer ${this.runToken}`,
81
+ ...(this.traceId ? { "x-trace-id": this.traceId } : {}),
76
82
  "content-type": mimeType ?? "application/octet-stream",
77
83
  },
78
84
  body: data,
@@ -26,7 +26,7 @@ export declare class CourseClient {
26
26
  static fromAuth(auth: DaemonAuth, opts?: Omit<CourseClientOptions, "refreshToken">): CourseClient;
27
27
  setAccessToken(token: string): void;
28
28
  private url;
29
- fetchJson<T>(method: string, p: string, body: unknown, accessToken: string | null): Promise<T>;
29
+ fetchJson<T>(method: string, p: string, body: unknown, accessToken: string | null, traceId?: string): Promise<T>;
30
30
  private refreshAuth;
31
31
  private request;
32
32
  /** 领取下一个分配给本 daemon 的 queued run(无则返回 null)。 */
@@ -51,11 +51,12 @@ export class CourseClient {
51
51
  }
52
52
  return `${base}${p}`;
53
53
  }
54
- async fetchJson(method, p, body, accessToken) {
54
+ async fetchJson(method, p, body, accessToken, traceId) {
55
55
  const res = await fetch(this.url(p), {
56
56
  method,
57
57
  headers: {
58
58
  ...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
59
+ ...(traceId ? { "x-trace-id": traceId } : {}),
59
60
  ...(body !== undefined ? { "content-type": "application/json" } : {}),
60
61
  },
61
62
  body: body !== undefined ? JSON.stringify(body) : undefined,
@@ -82,9 +83,9 @@ export class CourseClient {
82
83
  return false;
83
84
  }
84
85
  }
85
- async request(method, p, body) {
86
+ async request(method, p, body, traceId) {
86
87
  try {
87
- return await this.fetchJson(method, p, body, this.accessToken);
88
+ return await this.fetchJson(method, p, body, this.accessToken, traceId);
88
89
  }
89
90
  catch (err) {
90
91
  if (!(err instanceof CourseClientError) || err.status !== 401)
@@ -92,7 +93,7 @@ export class CourseClient {
92
93
  const refreshed = await this.refreshAuth();
93
94
  if (!refreshed)
94
95
  throw err;
95
- return await this.fetchJson(method, p, body, this.accessToken);
96
+ return await this.fetchJson(method, p, body, this.accessToken, traceId);
96
97
  }
97
98
  }
98
99
  /** 领取下一个分配给本 daemon 的 queued run(无则返回 null)。 */
@@ -102,7 +103,7 @@ export class CourseClient {
102
103
  async postEvent(agentRunId, event) {
103
104
  const credentials = [this.accessToken, this.refreshToken].filter((value) => typeof value === "string");
104
105
  const sanitized = redactSecretsDeep(event, 8, credentials);
105
- await this.request("POST", `/api/v1/daemon/runs/${agentRunId}/events`, sanitized);
106
+ await this.request("POST", `/api/v1/daemon/runs/${agentRunId}/events`, sanitized, event.trace_id);
106
107
  }
107
108
  async postFile(agentRunId, file) {
108
109
  return this.request("POST", `/api/v1/daemon/runs/${agentRunId}/files`, file);
package/dist/redaction.js CHANGED
@@ -13,6 +13,12 @@ const INJECTED_CREDENTIAL_ENV_NAMES = [
13
13
  const MIN_EXACT_SECRET_CHARS = 8;
14
14
  /** key 命中即整值脱敏(深度脱敏用)。 */
15
15
  export const SECRET_KEY_RE = /token|secret|private.?key|api.?key|authorization|password|credential/i;
16
+ const SAFE_NUMERIC_TELEMETRY_KEYS = new Set([
17
+ "input_tokens",
18
+ "cached_input_tokens",
19
+ "output_tokens",
20
+ "total_tokens",
21
+ ]);
16
22
  const SECRET_KEY_NAME_SOURCE = "(?:openai[_-]?api[_-]?key|anthropic[_-]?api[_-]?key|x-api-key|access[_-]?token|refresh[_-]?token|api[_-]?key|apikey|password|secret|token)";
17
23
  const SECRET_FLAG_SOURCE = "--(?:api-key|api_key|apikey|token|access-token|access_token|refresh-token|refresh_token|password|secret)";
18
24
  const QUOTED_JSON_SECRET_PATTERN = new RegExp(`(["'])(${SECRET_KEY_NAME_SOURCE})\\1(\\s*:\\s*)(["'])([^"'\\\\]*(?:\\\\.[^"'\\\\]*)*)\\4`, "gi");
@@ -110,9 +116,15 @@ export function redactSecretsDeep(value, depth = DEFAULT_REDACT_DEPTH, additiona
110
116
  }
111
117
  const out = {};
112
118
  for (const [key, v] of Object.entries(value)) {
113
- out[key] = SECRET_KEY_RE.test(key)
114
- ? REDACTED
115
- : redactSecretsDeep(v, depth - 1, additionalSecrets);
119
+ out[key] =
120
+ SAFE_NUMERIC_TELEMETRY_KEYS.has(key)
121
+ && typeof v === "number"
122
+ && Number.isFinite(v)
123
+ && v >= 0
124
+ ? v
125
+ : SECRET_KEY_RE.test(key)
126
+ ? REDACTED
127
+ : redactSecretsDeep(v, depth - 1, additionalSecrets);
116
128
  }
117
129
  return out;
118
130
  }
@@ -97,6 +97,8 @@ export class RunDispatcher {
97
97
  }
98
98
  async execute(payload) {
99
99
  const runId = payload.agent_run_id;
100
+ const traceId = payload.trace_id ?? runId;
101
+ const runtimeId = payload.runtime.id ?? this.defaultRuntimeId;
100
102
  const controller = new AbortController();
101
103
  this.inflight.set(runId, controller);
102
104
  if (this.pendingCancellations.delete(runId))
@@ -111,6 +113,11 @@ export class RunDispatcher {
111
113
  let profileApplied = false;
112
114
  let toolCalls = 0;
113
115
  let toolLimitExceeded = false;
116
+ let runtimeUsage = {};
117
+ let modelStartedAt;
118
+ let modelFinishedAt;
119
+ let filePersistStartedAt;
120
+ let filePersistFinishedAt;
114
121
  let progressEvents = 0;
115
122
  let lastProgressKey = null;
116
123
  const progressDisposition = {
@@ -121,15 +128,46 @@ export class RunDispatcher {
121
128
  server_rejected: 0,
122
129
  };
123
130
  const startedAt = this.now();
124
- const usage = () => ({
125
- wall_time_ms: Math.max(0, this.now() - startedAt),
126
- tool_calls: toolCalls,
127
- });
131
+ const usage = () => {
132
+ const inputTokens = runtimeUsage.input_tokens;
133
+ const outputTokens = runtimeUsage.output_tokens;
134
+ const totalTokens = runtimeUsage.total_tokens
135
+ ?? (inputTokens !== undefined || outputTokens !== undefined
136
+ ? (inputTokens ?? 0) + (outputTokens ?? 0)
137
+ : undefined);
138
+ const providerReported = inputTokens !== undefined
139
+ || runtimeUsage.cached_input_tokens !== undefined
140
+ || outputTokens !== undefined
141
+ || runtimeUsage.cost_usd !== undefined;
142
+ return {
143
+ schema_version: "agent-run-usage/0.1",
144
+ runtime: runtimeId,
145
+ ...(payload.runtime.model ? { model: payload.runtime.model } : {}),
146
+ usage_source: providerReported ? "mixed" : "worker_measured",
147
+ ...runtimeUsage,
148
+ ...(totalTokens !== undefined ? { total_tokens: totalTokens } : {}),
149
+ tool_calls: toolCalls,
150
+ worker_wall_time_ms: Math.max(0, this.now() - startedAt),
151
+ ...(modelStartedAt !== undefined && modelFinishedAt !== undefined
152
+ ? { model_wall_time_ms: Math.max(0, modelFinishedAt - modelStartedAt) }
153
+ : {}),
154
+ ...(filePersistStartedAt !== undefined && filePersistFinishedAt !== undefined
155
+ ? {
156
+ file_persist_time_ms: Math.max(0, filePersistFinishedAt - filePersistStartedAt),
157
+ }
158
+ : {}),
159
+ };
160
+ };
128
161
  const send = async (event) => {
129
162
  if (serverTerminal)
130
163
  return;
131
164
  seq += 1;
132
- const outgoing = { ...event, seq, event_id: event.event_id ?? randomUUID() };
165
+ const outgoing = {
166
+ ...event,
167
+ trace_id: traceId,
168
+ seq,
169
+ event_id: event.event_id ?? randomUUID(),
170
+ };
133
171
  for (let attempt = 0; attempt < 3; attempt += 1) {
134
172
  try {
135
173
  await this.client.postEvent(runId, outgoing);
@@ -162,8 +200,29 @@ export class RunDispatcher {
162
200
  error: err instanceof Error ? err.message : String(err),
163
201
  });
164
202
  }
203
+ finally {
204
+ const terminalUsage = event.payload?.usage && typeof event.payload.usage === "object"
205
+ ? event.payload.usage
206
+ : usage();
207
+ this.log.info("agent run terminal", {
208
+ ...terminalUsage,
209
+ agentRunId: runId,
210
+ traceId,
211
+ runtime: runtimeId,
212
+ model: payload.runtime.model,
213
+ status: event.type,
214
+ errorType: event.payload?.error_type,
215
+ });
216
+ }
165
217
  };
166
- const runtimeId = payload.runtime.id ?? this.defaultRuntimeId;
218
+ this.log.info("agent run started", {
219
+ agentRunId: runId,
220
+ traceId,
221
+ courseRunId: payload.course_run_id,
222
+ runtime: runtimeId,
223
+ model: payload.runtime.model,
224
+ persistentSession: Boolean(this.persistentSession),
225
+ });
167
226
  try {
168
227
  await send({ type: "run.started" });
169
228
  const runtime = this.runtimes.get(runtimeId);
@@ -214,6 +273,18 @@ export class RunDispatcher {
214
273
  progressDisposition.deduplicated += dispositions.deduplicated;
215
274
  progressDisposition.over_limit += dispositions.over_limit;
216
275
  },
276
+ usage: async (reported) => {
277
+ runtimeUsage = {
278
+ ...runtimeUsage,
279
+ ...reported,
280
+ provider_request_ids: [
281
+ ...new Set([
282
+ ...(runtimeUsage.provider_request_ids ?? []),
283
+ ...(reported.provider_request_ids ?? []),
284
+ ]),
285
+ ].slice(0, 10),
286
+ };
287
+ },
217
288
  block: async (block) => {
218
289
  if (block.kind === "progress") {
219
290
  const normalized = tryNormalizeProgressReport({
@@ -314,19 +385,25 @@ export class RunDispatcher {
314
385
  this.persistentSession?.persistNativeSession(sessionId);
315
386
  },
316
387
  };
317
- await runtime.run({
318
- payload,
319
- workspaceDir,
320
- ...(persistentTurn
321
- ? {
322
- nativeSessionId: persistentTurn.nativeSessionId,
323
- contextRevision: persistentTurn.contextRevision,
324
- ...(persistentTurn.runtimeEnv
325
- ? { runtimeEnv: persistentTurn.runtimeEnv }
326
- : {}),
327
- }
328
- : {}),
329
- }, sink, controller.signal);
388
+ modelStartedAt = this.now();
389
+ try {
390
+ await runtime.run({
391
+ payload,
392
+ workspaceDir,
393
+ ...(persistentTurn
394
+ ? {
395
+ nativeSessionId: persistentTurn.nativeSessionId,
396
+ contextRevision: persistentTurn.contextRevision,
397
+ ...(persistentTurn.runtimeEnv
398
+ ? { runtimeEnv: persistentTurn.runtimeEnv }
399
+ : {}),
400
+ }
401
+ : {}),
402
+ }, sink, controller.signal);
403
+ }
404
+ finally {
405
+ modelFinishedAt = this.now();
406
+ }
330
407
  clearTimeout(timer);
331
408
  if (serverTerminal)
332
409
  return;
@@ -339,7 +416,14 @@ export class RunDispatcher {
339
416
  : DEFAULT_MAX_OUTPUT_CHARS;
340
417
  const output = redactSecretString(truncateText(finalText, maxOutputChars));
341
418
  transcript.writeFinal(output);
342
- const fileReport = await reportFileCandidates(this.client, runId, workspaceDir, this.log, this.scanLimits);
419
+ filePersistStartedAt = this.now();
420
+ let fileReport;
421
+ try {
422
+ fileReport = await reportFileCandidates(this.client, runId, workspaceDir, this.log, this.scanLimits);
423
+ }
424
+ finally {
425
+ filePersistFinishedAt = this.now();
426
+ }
343
427
  if (this.client.uploadFileContent && (fileReport.failed > 0 || fileReport.truncated)) {
344
428
  throw new RuntimeExecutionError(fileReport.truncated
345
429
  ? "workspace file collection was truncated"
@@ -7,3 +7,19 @@ export declare function runtimeChildIdentity(env?: NodeJS.ProcessEnv): {
7
7
  uid?: number;
8
8
  gid?: number;
9
9
  };
10
+ /**
11
+ * Build the fixed control-to-runtime privilege boundary used by the E2B Template.
12
+ *
13
+ * A non-root botlearn-control daemon cannot use spawn({ uid }) directly. The Template
14
+ * therefore grants it one sudoers command: a root-owned launcher that accepts only the
15
+ * immutable DeepSeek dispatcher and sets no_new_privs as the unprivileged runtime user.
16
+ * BYOA processes do not set BOTLEARN_RUNTIME_USER and retain their direct-spawn behavior.
17
+ */
18
+ export declare function runtimeChildLaunch(binary: string, args: string[], env?: NodeJS.ProcessEnv): {
19
+ binary: string;
20
+ args: string[];
21
+ identity: {
22
+ uid?: number;
23
+ gid?: number;
24
+ };
25
+ };
@@ -8,11 +8,19 @@ const AGENT_SERVICE_CONTROL_ENV_KEYS = [
8
8
  "BOTLEARN_AGENT_SERVICE_SESSION_TOKEN",
9
9
  ];
10
10
  const AGENT_SERVICE_SUPERVISOR_ENV_KEYS = [
11
+ "BOTLEARN_DAEMON_HOME",
11
12
  "BOTLEARN_RUNTIME_UID",
12
13
  "BOTLEARN_RUNTIME_GID",
14
+ "BOTLEARN_RUNTIME_USER",
15
+ "BOTLEARN_RUNTIME_GROUP",
16
+ "BOTLEARN_RUNTIME_HOME",
17
+ "BOTLEARN_RUNTIME_LAUNCHER",
13
18
  "BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT",
14
19
  "BOTLEARN_AGENT_SERVICE_PROFILE_ROOT",
15
20
  ];
21
+ const RUNTIME_USER_PATTERN = /^[a-z_][a-z0-9_-]{0,31}$/;
22
+ const RUNTIME_SUDO_BINARY = "/usr/bin/sudo";
23
+ const MANAGED_RUNTIME_LAUNCHER = "/usr/local/bin/botlearn-runtime-launcher";
16
24
  /** Remove Course control-plane coordinates before any model/runtime child is created. */
17
25
  export function clearAgentServiceControlEnv(env = process.env) {
18
26
  for (const key of AGENT_SERVICE_CONTROL_ENV_KEYS)
@@ -21,9 +29,12 @@ export function clearAgentServiceControlEnv(env = process.env) {
21
29
  /** Copy an environment for a runtime child without leaking Agent Service control values. */
22
30
  export function runtimeChildEnv(env = process.env) {
23
31
  const childEnv = { ...env };
32
+ const runtimeHome = childEnv.BOTLEARN_RUNTIME_HOME;
24
33
  clearAgentServiceControlEnv(childEnv);
25
34
  for (const key of AGENT_SERVICE_SUPERVISOR_ENV_KEYS)
26
35
  delete childEnv[key];
36
+ if (runtimeHome)
37
+ childEnv.HOME = runtimeHome;
27
38
  return childEnv;
28
39
  }
29
40
  /** Run model processes as the Template's untrusted runtime UID when configured. */
@@ -38,3 +49,44 @@ export function runtimeChildIdentity(env = process.env) {
38
49
  }
39
50
  return { uid, gid };
40
51
  }
52
+ /**
53
+ * Build the fixed control-to-runtime privilege boundary used by the E2B Template.
54
+ *
55
+ * A non-root botlearn-control daemon cannot use spawn({ uid }) directly. The Template
56
+ * therefore grants it one sudoers command: a root-owned launcher that accepts only the
57
+ * immutable DeepSeek dispatcher and sets no_new_privs as the unprivileged runtime user.
58
+ * BYOA processes do not set BOTLEARN_RUNTIME_USER and retain their direct-spawn behavior.
59
+ */
60
+ export function runtimeChildLaunch(binary, args, env = process.env) {
61
+ const runtimeUser = env.BOTLEARN_RUNTIME_USER;
62
+ if (runtimeUser === undefined) {
63
+ return { binary, args, identity: runtimeChildIdentity(env) };
64
+ }
65
+ if (!RUNTIME_USER_PATTERN.test(runtimeUser)) {
66
+ throw new Error("invalid supervisor-provided runtime user");
67
+ }
68
+ const runtimeGroup = env.BOTLEARN_RUNTIME_GROUP;
69
+ if (runtimeGroup === undefined || !RUNTIME_USER_PATTERN.test(runtimeGroup)) {
70
+ throw new Error("invalid supervisor-provided runtime group");
71
+ }
72
+ if (env.BOTLEARN_RUNTIME_LAUNCHER !== MANAGED_RUNTIME_LAUNCHER) {
73
+ throw new Error("invalid supervisor-provided runtime launcher");
74
+ }
75
+ return {
76
+ binary: RUNTIME_SUDO_BINARY,
77
+ args: [
78
+ "-n",
79
+ "-H",
80
+ "-E",
81
+ "-u",
82
+ runtimeUser,
83
+ "-g",
84
+ runtimeGroup,
85
+ "--",
86
+ MANAGED_RUNTIME_LAUNCHER,
87
+ binary,
88
+ ...args,
89
+ ],
90
+ identity: {},
91
+ };
92
+ }
@@ -210,20 +210,20 @@ export class CodexAdapter extends NdjsonStreamAdapter {
210
210
  return;
211
211
  }
212
212
  if (obj.type === "turn.completed") {
213
- // usage 成功与失败的 turn 都会报。input_tokens 含缓存部分,
214
- // miss = input_tokens - cached_input_tokens。仅本地诊断用。
213
+ // usage 成功与失败的 turn 都会报;input_tokens 已包含缓存部分。
215
214
  const usage = obj.usage;
216
215
  if (usage && typeof usage === "object") {
217
216
  const input = numOrUndefined(usage.input_tokens);
218
217
  const cached = numOrUndefined(usage.cached_input_tokens);
219
218
  const output = numOrUndefined(usage.output_tokens);
220
- const hit = cached;
221
- const miss = input !== undefined ? Math.max(0, input - (cached ?? 0)) : undefined;
222
- if (hit !== undefined || miss !== undefined || output !== undefined) {
219
+ if (input !== undefined || cached !== undefined || output !== undefined) {
223
220
  ctx.state.usage = {
224
- ...(hit !== undefined ? { inputCacheHitTokens: hit } : {}),
225
- ...(miss !== undefined ? { inputCacheMissTokens: miss } : {}),
226
- ...(output !== undefined ? { outputTokens: output } : {}),
221
+ ...(input !== undefined ? { input_tokens: input } : {}),
222
+ ...(cached !== undefined ? { cached_input_tokens: cached } : {}),
223
+ ...(output !== undefined ? { output_tokens: output } : {}),
224
+ ...(input !== undefined || output !== undefined
225
+ ? { total_tokens: (input ?? 0) + (output ?? 0) }
226
+ : {}),
227
227
  };
228
228
  }
229
229
  }
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { type ProbeDeps } from "./probe.js";
3
3
  import { type EngineAdapter, type EngineRunOptions, type EngineRunResult } from "./engine.js";
4
- import type { RuntimeModule, RuntimeProbe } from "../types.js";
4
+ import type { RuntimeModule, RuntimeProbe, RuntimeUsage } from "../types.js";
5
5
  export interface DeepseekAdapterDeps {
6
6
  binary?: string;
7
7
  /** 测试注入:使用现成的兼容 server,不 spawn `deepseek`。 */
@@ -51,4 +51,6 @@ export declare class DeepseekTuiAdapter implements EngineAdapter {
51
51
  }
52
52
  /** 仅测试用:清空进程池。 */
53
53
  export declare function __resetDeepseekTuiPoolForTests(): void;
54
+ /** Accept both OpenAI-compatible and canonical usage fields from terminal SSE frames. */
55
+ export declare function extractDeepseekUsage(payload: any): RuntimeUsage | undefined;
54
56
  export declare const deepseekTuiModule: RuntimeModule;
@@ -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, runtimeChildIdentity } from "../runtime-env.js";
6
+ import { runtimeChildEnv, runtimeChildLaunch } 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";
@@ -144,6 +144,7 @@ export class DeepseekTuiAdapter {
144
144
  ...(runResult.progressDispositions
145
145
  ? { progressDispositions: runResult.progressDispositions }
146
146
  : {}),
147
+ ...(runResult.usage ? { usage: runResult.usage } : {}),
147
148
  ...(error ? { error } : {}),
148
149
  };
149
150
  }
@@ -197,21 +198,24 @@ export class DeepseekTuiAdapter {
197
198
  const progressMcpConfig = this.progressPromptInjectionEnabled
198
199
  ? createProgressMcpConfig()
199
200
  : undefined;
201
+ const binary = this.resolveBinary();
202
+ const args = [
203
+ "serve",
204
+ "--http",
205
+ "--host",
206
+ "127.0.0.1",
207
+ "--port",
208
+ String(port),
209
+ "--auth-token",
210
+ token,
211
+ ];
212
+ const launch = runtimeChildLaunch(binary, args);
200
213
  let child;
201
214
  try {
202
- child = this.spawnFn(this.resolveBinary(), [
203
- "serve",
204
- "--http",
205
- "--host",
206
- "127.0.0.1",
207
- "--port",
208
- String(port),
209
- "--auth-token",
210
- token,
211
- ], {
215
+ child = this.spawnFn(launch.binary, launch.args, {
212
216
  cwd: opts.cwd,
213
217
  env: this.spawnEnv(opts, progressMcpConfig?.path),
214
- ...runtimeChildIdentity(),
218
+ ...launch.identity,
215
219
  stdio: ["ignore", "pipe", "pipe"],
216
220
  // 自成进程组:解析到的二进制可能是会再 spawn 真实 deepseek-tui
217
221
  // server 的 dispatcher,shutdown 必须对整组发信号而非仅直接子进程。
@@ -410,6 +414,7 @@ export class DeepseekTuiAdapter {
410
414
  let seq = 0;
411
415
  let text = "";
412
416
  let errorText = "";
417
+ let usage;
413
418
  let capped = false;
414
419
  const progressState = createDeepseekProgressState();
415
420
  const append = (chunk) => {
@@ -477,6 +482,7 @@ export class DeepseekTuiAdapter {
477
482
  opts.onStatus?.({ kind: "thinking", phase: "updated", label });
478
483
  }
479
484
  else if (isDeepseekTerminalEvent(eventName, payload)) {
485
+ usage = extractDeepseekUsage(payload);
480
486
  opts.onStatus?.({ kind: "thinking", phase: "stopped" });
481
487
  return true;
482
488
  }
@@ -500,6 +506,7 @@ export class DeepseekTuiAdapter {
500
506
  text: text.trim(),
501
507
  ...(errorText ? { error: errorText } : {}),
502
508
  ...(progressDispositions ? { progressDispositions } : {}),
509
+ ...(usage ? { usage } : {}),
503
510
  };
504
511
  }
505
512
  }
@@ -514,6 +521,7 @@ export class DeepseekTuiAdapter {
514
521
  text: text.trim(),
515
522
  ...(errorText ? { error: errorText } : {}),
516
523
  ...(progressDispositions ? { progressDispositions } : {}),
524
+ ...(usage ? { usage } : {}),
517
525
  };
518
526
  };
519
527
  }
@@ -583,6 +591,51 @@ function isDeepseekTerminalEvent(eventName, payload) {
583
591
  embedded === "turn.done" ||
584
592
  embedded === "done");
585
593
  }
594
+ function nonNegativeNumber(value) {
595
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
596
+ ? value
597
+ : undefined;
598
+ }
599
+ /** Accept both OpenAI-compatible and canonical usage fields from terminal SSE frames. */
600
+ export function extractDeepseekUsage(payload) {
601
+ const candidates = [
602
+ payload?.usage,
603
+ payload?.payload?.usage,
604
+ payload?.turn?.usage,
605
+ payload?.payload?.turn?.usage,
606
+ ];
607
+ const raw = candidates.find((value) => value && typeof value === "object");
608
+ if (!raw)
609
+ return undefined;
610
+ const input = nonNegativeNumber(raw.input_tokens ?? raw.prompt_tokens);
611
+ const cached = nonNegativeNumber(raw.cached_input_tokens
612
+ ?? raw.prompt_cache_hit_tokens
613
+ ?? raw.prompt_tokens_details?.cached_tokens);
614
+ const output = nonNegativeNumber(raw.output_tokens ?? raw.completion_tokens);
615
+ const total = nonNegativeNumber(raw.total_tokens)
616
+ ?? (input !== undefined || output !== undefined ? (input ?? 0) + (output ?? 0) : undefined);
617
+ const cost = nonNegativeNumber(raw.cost_usd ?? raw.cost);
618
+ const requestId = stringField(payload, "request_id")
619
+ ?? stringField(payload, "x_request_id")
620
+ ?? stringField(payload?.payload, "request_id")
621
+ ?? stringField(payload?.payload, "x_request_id");
622
+ if (input === undefined
623
+ && cached === undefined
624
+ && output === undefined
625
+ && total === undefined
626
+ && cost === undefined
627
+ && !requestId) {
628
+ return undefined;
629
+ }
630
+ return {
631
+ ...(input !== undefined ? { input_tokens: input } : {}),
632
+ ...(cached !== undefined ? { cached_input_tokens: cached } : {}),
633
+ ...(output !== undefined ? { output_tokens: output } : {}),
634
+ ...(total !== undefined ? { total_tokens: total } : {}),
635
+ ...(cost !== undefined ? { cost_usd: cost } : {}),
636
+ ...(requestId ? { provider_request_ids: [requestId] } : {}),
637
+ };
638
+ }
586
639
  function isToolStarted(eventName, payload) {
587
640
  const itemKind = payload?.payload?.item?.kind ?? payload?.item?.kind;
588
641
  return ((eventName === "item.started" &&
@@ -1,4 +1,4 @@
1
- import { type CourseRuntime, type RuntimeFailureSummary, type RuntimeProgressDispositions } from "../types.js";
1
+ import { type CourseRuntime, type RuntimeFailureSummary, type RuntimeProgressDispositions, type RuntimeUsage } from "../types.js";
2
2
  import type { Logger } from "../log.js";
3
3
  import type { ProgressReport } from "../mcp/report-progress.js";
4
4
  /**
@@ -43,6 +43,7 @@ export interface EngineRunResult {
43
43
  text: string;
44
44
  newSessionId: string;
45
45
  costUsd?: number;
46
+ usage?: RuntimeUsage;
46
47
  /** adapter 自身在 emit 前丢弃的进度计数;不包含 accepted,避免 dispatcher 重复计数。 */
47
48
  progressDispositions?: RuntimeProgressDispositions;
48
49
  /** 非空表示硬失败;由包装层折叠为 RuntimeExecutionError。 */
@@ -144,6 +144,12 @@ export function wrapEngineAdapter(id, engine, opts) {
144
144
  if (result.progressDispositions) {
145
145
  await sink.progressDispositions?.(result.progressDispositions);
146
146
  }
147
+ if (result.usage || result.costUsd !== undefined) {
148
+ await sink.usage?.({
149
+ ...(result.usage ?? {}),
150
+ ...(result.costUsd !== undefined ? { cost_usd: result.costUsd } : {}),
151
+ });
152
+ }
147
153
  await sink.runtimeSession?.(result.newSessionId);
148
154
  if (result.error) {
149
155
  throw new RuntimeExecutionError(result.error, "runtime_error", result.runtimeFailure);
@@ -1,4 +1,5 @@
1
1
  import { type EngineAdapter, type EngineRunOptions, type EngineRunResult, type RuntimeStatusEvent, type StreamBlock } from "./engine.js";
2
+ import type { RuntimeUsage } from "../types.js";
2
3
  import type { Logger } from "../log.js";
3
4
  /** 单轮执行期间穿过事件回调的可变状态;基类据此组装最终 EngineRunResult。 */
4
5
  export interface NdjsonRunState {
@@ -13,12 +14,8 @@ export interface NdjsonRunState {
13
14
  assistantTextCapped: boolean;
14
15
  costUsd?: number;
15
16
  errorText?: string;
16
- /** 部分 CLI 会报告用量(如 Codex turn.completed.usage);仅本地诊断用。 */
17
- usage?: {
18
- inputCacheHitTokens?: number;
19
- inputCacheMissTokens?: number;
20
- outputTokens?: number;
21
- };
17
+ /** 部分 CLI 会报告用量(如 Codex turn.completed.usage)。 */
18
+ usage?: RuntimeUsage;
22
19
  }
23
20
  /** ndjson 分发循环递给子类的逐事件上下文。 */
24
21
  export interface NdjsonEventCtx {
@@ -183,6 +183,7 @@ export class NdjsonStreamAdapter {
183
183
  text,
184
184
  newSessionId: state.newSessionId,
185
185
  ...(state.costUsd !== undefined ? { costUsd: state.costUsd } : {}),
186
+ ...(state.usage ? { usage: state.usage } : {}),
186
187
  ...(state.errorText ? { error: state.errorText } : {}),
187
188
  ...(state.errorText
188
189
  ? {
@@ -1,3 +1,4 @@
1
1
  #!/usr/bin/env node
2
2
  export declare function acquireSessionSupervisorLock(runtimeSessionId: string, lockRoot?: string): (() => void) | null;
3
3
  export declare function runSandboxSupervisor(argv: string[]): Promise<number>;
4
+ export declare function isMainModule(entry?: string): boolean;
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFileSync, spawn } from "node:child_process";
3
- import { chmodSync, chownSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
3
+ import { chmodSync, chownSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync, } from "node:fs";
4
4
  import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
5
6
  const MAX_BOOTSTRAP_BYTES = 64 * 1024;
6
7
  const CONTROL_USER = "botlearn-control";
7
8
  const RUNTIME_USER = "user";
@@ -125,6 +126,10 @@ export async function runSandboxSupervisor(argv) {
125
126
  BOTLEARN_DAEMON_HOME: CONTROL_HOME,
126
127
  BOTLEARN_RUNTIME_UID: String(runtimeUid),
127
128
  BOTLEARN_RUNTIME_GID: String(controlGid),
129
+ BOTLEARN_RUNTIME_USER: RUNTIME_USER,
130
+ BOTLEARN_RUNTIME_GROUP: CONTROL_USER,
131
+ BOTLEARN_RUNTIME_HOME: "/home/user",
132
+ BOTLEARN_RUNTIME_LAUNCHER: "/usr/local/bin/botlearn-runtime-launcher",
128
133
  BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT: WORKSPACE,
129
134
  BOTLEARN_AGENT_SERVICE_PROFILE_ROOT: RUNTIME_PROFILE_ROOT,
130
135
  },
@@ -164,7 +169,17 @@ export async function runSandboxSupervisor(argv) {
164
169
  releaseLock?.();
165
170
  }
166
171
  }
167
- if (process.argv[1]?.endsWith("sandbox-supervisor.js")) {
172
+ export function isMainModule(entry = process.argv[1]) {
173
+ if (!entry)
174
+ return false;
175
+ try {
176
+ return realpathSync(entry) === fileURLToPath(import.meta.url);
177
+ }
178
+ catch {
179
+ return false;
180
+ }
181
+ }
182
+ if (isMainModule()) {
168
183
  runSandboxSupervisor(process.argv.slice(2))
169
184
  .then((code) => {
170
185
  process.exitCode = code;
package/dist/types.d.ts CHANGED
@@ -8,6 +8,7 @@ import type { ProgressStatus } from "./mcp/report-progress.js";
8
8
  /** `GET /daemon/runs/next` 下发的 run.start 载荷(snake_case,与后端 RunStartPayloadOut 一致)。 */
9
9
  export interface RunStartPayload {
10
10
  agent_run_id: string;
11
+ trace_id?: string;
11
12
  course_run_id: string;
12
13
  lesson_id: string | null;
13
14
  task_id: string | null;
@@ -44,6 +45,8 @@ export interface RunStartPayload {
44
45
  export type RunEventType = "run.accepted" | "run.started" | "run.block" | "run.message" | "run.completed" | "run.failed" | "run.cancelled";
45
46
  export interface RunEvent {
46
47
  type: RunEventType;
48
+ /** End-to-end correlation id assigned by Course Service. */
49
+ trace_id?: string;
47
50
  /** Stable across retries; Course Service deduplicates within the current worker attempt. */
48
51
  event_id?: string;
49
52
  /** 1-based 单调递增。后端把 0/缺省视为「未设置」并自行计算,所以客户端 seq 必须从 1 开始。 */
@@ -128,6 +131,15 @@ export interface RuntimeProgressDispositions {
128
131
  deduplicated: number;
129
132
  over_limit: number;
130
133
  }
134
+ /** Content-free model usage; adapters populate only fields actually reported upstream. */
135
+ export interface RuntimeUsage {
136
+ input_tokens?: number;
137
+ cached_input_tokens?: number;
138
+ output_tokens?: number;
139
+ total_tokens?: number;
140
+ cost_usd?: number;
141
+ provider_request_ids?: string[];
142
+ }
131
143
  export interface RuntimeAuthProbe {
132
144
  checked: boolean;
133
145
  ok: boolean;
@@ -147,6 +159,8 @@ export interface CourseRuntimeSink {
147
159
  file(file: RunFileCandidate): Promise<void>;
148
160
  /** 可选的 run-scoped 内部遥测;不得包含 summary 或 provider raw envelope。 */
149
161
  progressDispositions?(dispositions: RuntimeProgressDispositions): Promise<void>;
162
+ /** Provider usage only; must never include prompts, model output, or raw envelopes. */
163
+ usage?(usage: RuntimeUsage): Promise<void>;
150
164
  /** Persist the runtime-native thread/session id before a terminal turn event is emitted. */
151
165
  runtimeSession?(sessionId: string): Promise<void>;
152
166
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botlearn-course/daemon",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
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": {