@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 +4 -2
- package/dist/agent-service-client.d.ts +1 -0
- package/dist/agent-service-client.js +7 -1
- package/dist/course-client.d.ts +1 -1
- package/dist/course-client.js +6 -5
- package/dist/redaction.js +15 -3
- package/dist/run-dispatcher.js +104 -20
- package/dist/runtime-env.d.ts +16 -0
- package/dist/runtime-env.js +52 -0
- package/dist/runtimes/codex.js +8 -8
- package/dist/runtimes/deepseek-tui.d.ts +3 -1
- package/dist/runtimes/deepseek-tui.js +65 -12
- package/dist/runtimes/engine.d.ts +2 -1
- package/dist/runtimes/engine.js +6 -0
- package/dist/runtimes/ndjson-stream.d.ts +3 -6
- package/dist/runtimes/ndjson-stream.js +1 -0
- package/dist/sandbox-supervisor.d.ts +1 -0
- package/dist/sandbox-supervisor.js +17 -2
- package/dist/types.d.ts +14 -0
- package/package.json +1 -1
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
|
|
43
|
-
|
|
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
|
-
|
|
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,
|
package/dist/course-client.d.ts
CHANGED
|
@@ -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)。 */
|
package/dist/course-client.js
CHANGED
|
@@ -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] =
|
|
114
|
-
|
|
115
|
-
|
|
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
|
}
|
package/dist/run-dispatcher.js
CHANGED
|
@@ -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
|
-
|
|
126
|
-
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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"
|
package/dist/runtime-env.d.ts
CHANGED
|
@@ -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
|
+
};
|
package/dist/runtime-env.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/runtimes/codex.js
CHANGED
|
@@ -210,20 +210,20 @@ export class CodexAdapter extends NdjsonStreamAdapter {
|
|
|
210
210
|
return;
|
|
211
211
|
}
|
|
212
212
|
if (obj.type === "turn.completed") {
|
|
213
|
-
// usage 成功与失败的 turn
|
|
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
|
-
|
|
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
|
-
...(
|
|
225
|
-
...(
|
|
226
|
-
...(output !== undefined ? {
|
|
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,
|
|
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(
|
|
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
|
-
...
|
|
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。 */
|
package/dist/runtimes/engine.js
CHANGED
|
@@ -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
|
-
|
|
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