@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.
@@ -0,0 +1,28 @@
1
+ export declare const AGENT_SERVICE_WS_SCHEMA: "botlearn-agent-sandbox-ws/0.1";
2
+ export declare const AGENT_SERVICE_WS_SUBPROTOCOL: "botlearn-agent-sandbox.v1";
3
+ export type SandboxFrameType = "session.hello" | "session.sync" | "turn.start" | "turn.cancel" | "session.drain" | "session.shutdown" | "event.ack" | "turn.file.upload_grant" | "auth.rotate" | "ping" | "session.ready" | "session.heartbeat" | "command.ack" | "turn.event" | "turn.file.prepare" | "turn.file.committed" | "session.drained" | "pong" | "protocol.error";
4
+ export interface SandboxFrame {
5
+ schema_version: typeof AGENT_SERVICE_WS_SCHEMA;
6
+ type: SandboxFrameType;
7
+ frame_id: string;
8
+ session_id: string;
9
+ session_generation: number;
10
+ connection_epoch: number;
11
+ seq: number;
12
+ sent_at: string;
13
+ agent_run_id: string | null;
14
+ worker_attempt: number | null;
15
+ payload: Record<string, unknown>;
16
+ }
17
+ export declare function parseSandboxFrame(raw: string, maxBytes?: number): SandboxFrame;
18
+ export declare function createSandboxFrame(input: {
19
+ type: SandboxFrameType;
20
+ sessionId: string;
21
+ sessionGeneration: number;
22
+ connectionEpoch: number;
23
+ seq: number;
24
+ payload?: Record<string, unknown>;
25
+ agentRunId?: string;
26
+ workerAttempt?: number;
27
+ frameId?: string;
28
+ }): SandboxFrame;
@@ -0,0 +1,128 @@
1
+ import { randomUUID } from "node:crypto";
2
+ export const AGENT_SERVICE_WS_SCHEMA = "botlearn-agent-sandbox-ws/0.1";
3
+ export const AGENT_SERVICE_WS_SUBPROTOCOL = "botlearn-agent-sandbox.v1";
4
+ const FRAME_TYPES = new Set([
5
+ "session.hello",
6
+ "session.sync",
7
+ "turn.start",
8
+ "turn.cancel",
9
+ "session.drain",
10
+ "session.shutdown",
11
+ "event.ack",
12
+ "turn.file.upload_grant",
13
+ "auth.rotate",
14
+ "ping",
15
+ "session.ready",
16
+ "session.heartbeat",
17
+ "command.ack",
18
+ "turn.event",
19
+ "turn.file.prepare",
20
+ "turn.file.committed",
21
+ "session.drained",
22
+ "pong",
23
+ "protocol.error",
24
+ ]);
25
+ const TURN_TYPES = new Set([
26
+ "turn.start",
27
+ "turn.cancel",
28
+ "event.ack",
29
+ "turn.file.upload_grant",
30
+ "turn.event",
31
+ "turn.file.prepare",
32
+ "turn.file.committed",
33
+ ]);
34
+ const FRAME_KEYS = new Set([
35
+ "schema_version",
36
+ "type",
37
+ "frame_id",
38
+ "session_id",
39
+ "session_generation",
40
+ "connection_epoch",
41
+ "seq",
42
+ "sent_at",
43
+ "agent_run_id",
44
+ "worker_attempt",
45
+ "payload",
46
+ ]);
47
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
48
+ function positiveInteger(value, label) {
49
+ if (!Number.isInteger(value) || value < 1) {
50
+ throw new Error(`${label} must be a positive integer`);
51
+ }
52
+ return value;
53
+ }
54
+ export function parseSandboxFrame(raw, maxBytes = 262_144) {
55
+ if (Buffer.byteLength(raw, "utf8") > maxBytes)
56
+ throw new Error("frame exceeds size limit");
57
+ const decoded = JSON.parse(raw);
58
+ if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) {
59
+ throw new Error("frame must be an object");
60
+ }
61
+ const value = decoded;
62
+ for (const key of Object.keys(value)) {
63
+ if (!FRAME_KEYS.has(key))
64
+ throw new Error(`unknown sandbox WebSocket frame field: ${key}`);
65
+ }
66
+ if (value.schema_version !== AGENT_SERVICE_WS_SCHEMA) {
67
+ throw new Error("unsupported sandbox WebSocket schema");
68
+ }
69
+ if (typeof value.type !== "string" || !FRAME_TYPES.has(value.type)) {
70
+ throw new Error("unknown sandbox WebSocket frame type");
71
+ }
72
+ const type = value.type;
73
+ const agentRunId = typeof value.agent_run_id === "string" ? value.agent_run_id : null;
74
+ const workerAttempt = value.worker_attempt == null
75
+ ? null
76
+ : positiveInteger(value.worker_attempt, "worker_attempt");
77
+ if (TURN_TYPES.has(type) && (!agentRunId || workerAttempt === null)) {
78
+ throw new Error(`${type} requires turn fencing fields`);
79
+ }
80
+ if (!TURN_TYPES.has(type) && (agentRunId !== null || workerAttempt !== null)) {
81
+ throw new Error(`${type} cannot carry turn fencing fields`);
82
+ }
83
+ if (!value.payload || typeof value.payload !== "object" || Array.isArray(value.payload)) {
84
+ throw new Error("frame payload must be an object");
85
+ }
86
+ for (const key of ["frame_id", "session_id", "sent_at"]) {
87
+ if (typeof value[key] !== "string" || !value[key])
88
+ throw new Error(`${key} is required`);
89
+ }
90
+ if (value.frame_id.length > 120)
91
+ throw new Error("frame_id is too long");
92
+ if (!UUID_PATTERN.test(value.session_id))
93
+ throw new Error("session_id must be a UUID");
94
+ if (agentRunId !== null && !UUID_PATTERN.test(agentRunId)) {
95
+ throw new Error("agent_run_id must be a UUID");
96
+ }
97
+ if (Number.isNaN(Date.parse(value.sent_at)))
98
+ throw new Error("sent_at is invalid");
99
+ return {
100
+ schema_version: AGENT_SERVICE_WS_SCHEMA,
101
+ type,
102
+ frame_id: value.frame_id,
103
+ session_id: value.session_id,
104
+ session_generation: positiveInteger(value.session_generation, "session_generation"),
105
+ connection_epoch: positiveInteger(value.connection_epoch, "connection_epoch"),
106
+ seq: positiveInteger(value.seq, "seq"),
107
+ sent_at: value.sent_at,
108
+ agent_run_id: agentRunId,
109
+ worker_attempt: workerAttempt,
110
+ payload: value.payload,
111
+ };
112
+ }
113
+ export function createSandboxFrame(input) {
114
+ const frame = {
115
+ schema_version: AGENT_SERVICE_WS_SCHEMA,
116
+ type: input.type,
117
+ frame_id: input.frameId ?? `frm_${randomUUID().replaceAll("-", "")}`,
118
+ session_id: input.sessionId,
119
+ session_generation: input.sessionGeneration,
120
+ connection_epoch: input.connectionEpoch,
121
+ seq: input.seq,
122
+ sent_at: new Date().toISOString(),
123
+ agent_run_id: input.agentRunId ?? null,
124
+ worker_attempt: input.workerAttempt ?? null,
125
+ payload: input.payload ?? {},
126
+ };
127
+ return parseSandboxFrame(JSON.stringify(frame));
128
+ }
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@ import { hostname } from "node:os";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { authFilePath, clearAuth, machineFingerprint, readAuth, writeAuth } from "./auth-store.js";
7
7
  import { AgentServiceRunClient } from "./agent-service-client.js";
8
+ import { AgentServiceSessionClient } from "./agent-service-session.js";
8
9
  import { CourseClient, isAuthFailure, loginCourseDaemon } from "./course-client.js";
9
10
  import { runCourseDoctor } from "./doctor.js";
10
11
  import { log } from "./log.js";
@@ -14,6 +15,17 @@ import { RunDispatcher } from "./run-dispatcher.js";
14
15
  import { clearAgentServiceControlEnv } from "./runtime-env.js";
15
16
  import { RUNTIME_MODULES, detectAvailableRuntimeIds, fakeRuntimeEnabled, } from "./runtimes/index.js";
16
17
  const pkg = createRequire(import.meta.url)("../package.json");
18
+ const SESSION_BOOTSTRAP_MAX_BYTES = 64 * 1024;
19
+ const SESSION_RUNTIME_ENV_KEYS = new Set([
20
+ "OPENAI_API_KEY",
21
+ "OPENAI_BASE_URL",
22
+ "ANTHROPIC_API_KEY",
23
+ "ANTHROPIC_BASE_URL",
24
+ "DEEPSEEK_API_KEY",
25
+ "DEEPSEEK_BASE_URL",
26
+ "GEMINI_API_KEY",
27
+ "GEMINI_BASE_URL",
28
+ ]);
17
29
  const HELP = `botlearn-course-daemon ${pkg.version} — run BotLearn Course tasks on your own machine (BYOA)
18
30
 
19
31
  Usage:
@@ -22,6 +34,7 @@ Usage:
22
34
  botlearn-course-daemon course logout
23
35
  botlearn-course-daemon course doctor
24
36
  botlearn-course-daemon agent-service run
37
+ botlearn-course-daemon agent-service session
25
38
  botlearn-course-daemon --help | --version
26
39
 
27
40
  Commands:
@@ -42,11 +55,14 @@ Agent Service sandbox environment:
42
55
  BOTLEARN_COURSE_API_URL
43
56
  BOTLEARN_AGENT_SERVICE_RUN_ID
44
57
  BOTLEARN_AGENT_SERVICE_RUN_TOKEN
45
- BOTLEARN_AGENT_SERVICE_WORKER_ID`;
58
+ BOTLEARN_AGENT_SERVICE_WORKER_ID
59
+ BOTLEARN_AGENT_SERVICE_WS_URL
60
+ BOTLEARN_AGENT_SERVICE_RUNTIME_SESSION_ID
61
+ BOTLEARN_AGENT_SERVICE_SESSION_TOKEN`;
46
62
  // ---------------------------------------------------------------
47
63
  // flag parser(极简:--k v / --k=v / 布尔开关)
48
64
  // ---------------------------------------------------------------
49
- const BOOLEAN_FLAGS = new Set(["once", "help", "version"]);
65
+ const BOOLEAN_FLAGS = new Set(["once", "help", "version", "bootstrap-stdin"]);
50
66
  export { clearAgentServiceControlEnv } from "./runtime-env.js";
51
67
  export function parseCliArgs(argv) {
52
68
  const positional = [];
@@ -288,6 +304,97 @@ async function cmdAgentServiceRun() {
288
304
  }
289
305
  return 0;
290
306
  }
307
+ async function readSessionBootstrapFromStdin() {
308
+ const chunks = [];
309
+ let size = 0;
310
+ for await (const chunk of process.stdin) {
311
+ const bytes = Buffer.from(chunk);
312
+ size += bytes.length;
313
+ if (size > SESSION_BOOTSTRAP_MAX_BYTES) {
314
+ throw new Error("Agent Service session bootstrap exceeds size limit");
315
+ }
316
+ chunks.push(bytes);
317
+ }
318
+ const raw = Buffer.concat(chunks);
319
+ try {
320
+ const parsed = JSON.parse(raw.toString("utf8"));
321
+ if (parsed.schemaVersion !== "agent-service-session-bootstrap/0.1") {
322
+ throw new Error("Agent Service session bootstrap schema is unsupported");
323
+ }
324
+ const runtimeEnv = {};
325
+ if (parsed.runtimeEnv !== undefined) {
326
+ if (!parsed.runtimeEnv ||
327
+ typeof parsed.runtimeEnv !== "object" ||
328
+ Array.isArray(parsed.runtimeEnv)) {
329
+ throw new Error("Agent Service session bootstrap runtimeEnv is invalid");
330
+ }
331
+ for (const [key, value] of Object.entries(parsed.runtimeEnv)) {
332
+ if (!SESSION_RUNTIME_ENV_KEYS.has(key) ||
333
+ typeof value !== "string" ||
334
+ value.length < 1 ||
335
+ value.length > 8192) {
336
+ throw new Error(`Agent Service session bootstrap runtime env is forbidden: ${key}`);
337
+ }
338
+ runtimeEnv[key] = value;
339
+ }
340
+ }
341
+ const wsUrl = parsed.wsUrl;
342
+ const runtimeSessionId = parsed.runtimeSessionId;
343
+ const sessionToken = parsed.sessionToken;
344
+ if (typeof wsUrl !== "string" ||
345
+ !/^wss?:\/\//.test(wsUrl) ||
346
+ typeof runtimeSessionId !== "string" ||
347
+ !runtimeSessionId ||
348
+ typeof sessionToken !== "string" ||
349
+ !sessionToken) {
350
+ throw new Error("Agent Service session bootstrap is incomplete");
351
+ }
352
+ return {
353
+ wsUrl,
354
+ runtimeSessionId,
355
+ sessionToken,
356
+ ...(Object.keys(runtimeEnv).length > 0 ? { runtimeEnv } : {}),
357
+ };
358
+ }
359
+ finally {
360
+ raw.fill(0);
361
+ for (const chunk of chunks)
362
+ chunk.fill(0);
363
+ }
364
+ }
365
+ async function cmdAgentServiceSession(args) {
366
+ const bootstrap = args.flags["bootstrap-stdin"] === true
367
+ ? await readSessionBootstrapFromStdin()
368
+ : {
369
+ wsUrl: process.env.BOTLEARN_AGENT_SERVICE_WS_URL,
370
+ runtimeSessionId: process.env.BOTLEARN_AGENT_SERVICE_RUNTIME_SESSION_ID,
371
+ sessionToken: process.env.BOTLEARN_AGENT_SERVICE_SESSION_TOKEN,
372
+ runtimeEnv: undefined,
373
+ };
374
+ const { wsUrl, runtimeSessionId, sessionToken } = bootstrap;
375
+ if (!wsUrl || !runtimeSessionId || !sessionToken) {
376
+ console.error("Agent Service sandbox session environment is incomplete");
377
+ return 1;
378
+ }
379
+ augmentProcessPath();
380
+ const runtimes = new Map();
381
+ for (const mod of RUNTIME_MODULES) {
382
+ if (mod.hidden && !fakeRuntimeEnabled())
383
+ continue;
384
+ runtimes.set(mod.id, mod.create());
385
+ }
386
+ const client = new AgentServiceSessionClient({
387
+ wsUrl,
388
+ sessionId: runtimeSessionId,
389
+ sessionToken,
390
+ ...(bootstrap.runtimeEnv ? { runtimeEnv: bootstrap.runtimeEnv } : {}),
391
+ runtimes,
392
+ daemonVersion: pkg.version,
393
+ });
394
+ clearAgentServiceControlEnv();
395
+ await client.run();
396
+ return 0;
397
+ }
291
398
  // ---------------------------------------------------------------
292
399
  // 入口
293
400
  // ---------------------------------------------------------------
@@ -320,6 +427,9 @@ export async function runCli(argv) {
320
427
  if (command === "agent-service" && sub === "run") {
321
428
  return cmdAgentServiceRun();
322
429
  }
430
+ if (command === "agent-service" && sub === "session") {
431
+ return cmdAgentServiceSession(args);
432
+ }
323
433
  console.error(HELP);
324
434
  return 1;
325
435
  }
package/dist/index.d.ts CHANGED
@@ -6,6 +6,8 @@ export * from "./types.js";
6
6
  export * from "./auth-store.js";
7
7
  export * from "./course-client.js";
8
8
  export * from "./agent-service-client.js";
9
+ export * from "./agent-service-session.js";
10
+ export * from "./agent-service-ws-protocol.js";
9
11
  export * from "./run-dispatcher.js";
10
12
  export * from "./run-queue.js";
11
13
  export * from "./workspace.js";
package/dist/index.js CHANGED
@@ -6,6 +6,8 @@ export * from "./types.js";
6
6
  export * from "./auth-store.js";
7
7
  export * from "./course-client.js";
8
8
  export * from "./agent-service-client.js";
9
+ export * from "./agent-service-session.js";
10
+ export * from "./agent-service-ws-protocol.js";
9
11
  export * from "./run-dispatcher.js";
10
12
  export * from "./run-queue.js";
11
13
  export * from "./workspace.js";
@@ -6,6 +6,18 @@ export interface RunDispatcherOptions {
6
6
  log?: Logger;
7
7
  scanLimits?: ScanLimits;
8
8
  now?: () => number;
9
+ persistentSession?: PersistentSessionExecution;
10
+ }
11
+ export interface PreparedPersistentTurn {
12
+ workspaceDir: string;
13
+ transcriptFile: string;
14
+ nativeSessionId: string | null;
15
+ contextRevision: number;
16
+ runtimeEnv?: NodeJS.ProcessEnv;
17
+ }
18
+ export interface PersistentSessionExecution {
19
+ prepareTurn(payload: RunStartPayload): PreparedPersistentTurn;
20
+ persistNativeSession(sessionId: string): void;
9
21
  }
10
22
  export interface RunReportingClient {
11
23
  postEvent(agentRunId: string, event: RunEvent): Promise<void>;
@@ -27,10 +39,13 @@ export declare class RunDispatcher {
27
39
  private readonly runtimes;
28
40
  private readonly queue;
29
41
  private readonly inflight;
42
+ private readonly scheduledRunIds;
43
+ private readonly pendingCancellations;
30
44
  private readonly defaultRuntimeId;
31
45
  private readonly log;
32
46
  private readonly scanLimits?;
33
47
  private readonly now;
48
+ private readonly persistentSession?;
34
49
  constructor(client: RunReportingClient, runtimes: Map<string, CourseRuntime>, opts?: RunDispatcherOptions);
35
50
  /** 排队执行一个 run。返回的 Promise 不 reject(失败已归一化回报为 run.failed)。 */
36
51
  dispatch(payload: RunStartPayload): Promise<void>;
@@ -36,10 +36,13 @@ export class RunDispatcher {
36
36
  runtimes;
37
37
  queue = new RunQueue();
38
38
  inflight = new Map();
39
+ scheduledRunIds = new Set();
40
+ pendingCancellations = new Set();
39
41
  defaultRuntimeId;
40
42
  log;
41
43
  scanLimits;
42
44
  now;
45
+ persistentSession;
43
46
  constructor(client, runtimes, opts = {}) {
44
47
  this.client = client;
45
48
  this.runtimes = runtimes;
@@ -47,22 +50,37 @@ export class RunDispatcher {
47
50
  this.log = opts.log ?? defaultLog;
48
51
  this.scanLimits = opts.scanLimits;
49
52
  this.now = opts.now ?? Date.now;
53
+ this.persistentSession = opts.persistentSession;
50
54
  }
51
55
  /** 排队执行一个 run。返回的 Promise 不 reject(失败已归一化回报为 run.failed)。 */
52
56
  dispatch(payload) {
53
57
  const key = payload.agent_instance_id ?? payload.agent_run_id;
54
- return this.queue.enqueue(key, () => this.execute(payload));
58
+ this.scheduledRunIds.add(payload.agent_run_id);
59
+ return this.queue
60
+ .enqueue(key, () => this.execute(payload))
61
+ .finally(() => {
62
+ this.scheduledRunIds.delete(payload.agent_run_id);
63
+ this.pendingCancellations.delete(payload.agent_run_id);
64
+ });
55
65
  }
56
66
  cancel(agentRunId) {
57
67
  const controller = this.inflight.get(agentRunId);
58
- if (!controller)
68
+ if (controller) {
69
+ controller.abort();
70
+ return true;
71
+ }
72
+ if (!this.scheduledRunIds.has(agentRunId))
59
73
  return false;
60
- controller.abort();
74
+ // dispatch() queues execution on a microtask. A durable cancel may arrive after the
75
+ // command ACK but before execute() installs its AbortController.
76
+ this.pendingCancellations.add(agentRunId);
61
77
  return true;
62
78
  }
63
79
  cancelAll() {
64
80
  for (const controller of this.inflight.values())
65
81
  controller.abort();
82
+ for (const runId of this.scheduledRunIds)
83
+ this.pendingCancellations.add(runId);
66
84
  }
67
85
  get activeCount() {
68
86
  return this.inflight.size;
@@ -81,6 +99,8 @@ export class RunDispatcher {
81
99
  const runId = payload.agent_run_id;
82
100
  const controller = new AbortController();
83
101
  this.inflight.set(runId, controller);
102
+ if (this.pendingCancellations.delete(runId))
103
+ controller.abort();
84
104
  let seq = 0;
85
105
  // 服务端已把本 run 判为终态(409):停止一切后续上报。
86
106
  let serverTerminal = false;
@@ -173,12 +193,13 @@ export class RunDispatcher {
173
193
  ...runtimeProfileInstructions(payload, applied),
174
194
  ];
175
195
  }
176
- const { workspaceDir } = ensureRunWorkspace(runId);
196
+ const persistentTurn = this.persistentSession?.prepareTurn(payload);
197
+ const { workspaceDir } = persistentTurn ?? ensureRunWorkspace(runId);
177
198
  const missingCapabilities = missingRunCapabilities(payload, workspaceDir);
178
199
  if (missingCapabilities.length > 0) {
179
200
  throw new RuntimeExecutionError(`runtime is missing required capabilities: ${missingCapabilities.join(", ")}`, "runtime_unavailable");
180
201
  }
181
- const transcript = new TranscriptWriter(transcriptPath(runId));
202
+ const transcript = new TranscriptWriter(persistentTurn?.transcriptFile ?? transcriptPath(runId));
182
203
  const timeoutSeconds = clampTimeoutSeconds(payload.limits.timeout_seconds);
183
204
  timer = setTimeout(() => {
184
205
  timedOut = true;
@@ -289,8 +310,23 @@ export class RunDispatcher {
289
310
  file: async (file) => {
290
311
  await this.client.postFile(runId, file);
291
312
  },
313
+ runtimeSession: async (sessionId) => {
314
+ this.persistentSession?.persistNativeSession(sessionId);
315
+ },
292
316
  };
293
- await runtime.run({ payload, workspaceDir }, sink, controller.signal);
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);
294
330
  clearTimeout(timer);
295
331
  if (serverTerminal)
296
332
  return;
@@ -2,3 +2,8 @@
2
2
  export declare function clearAgentServiceControlEnv(env?: NodeJS.ProcessEnv): void;
3
3
  /** Copy an environment for a runtime child without leaking Agent Service control values. */
4
4
  export declare function runtimeChildEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
5
+ /** Run model processes as the Template's untrusted runtime UID when configured. */
6
+ export declare function runtimeChildIdentity(env?: NodeJS.ProcessEnv): {
7
+ uid?: number;
8
+ gid?: number;
9
+ };
@@ -3,6 +3,15 @@ const AGENT_SERVICE_CONTROL_ENV_KEYS = [
3
3
  "BOTLEARN_AGENT_SERVICE_RUN_ID",
4
4
  "BOTLEARN_AGENT_SERVICE_RUN_TOKEN",
5
5
  "BOTLEARN_AGENT_SERVICE_WORKER_ID",
6
+ "BOTLEARN_AGENT_SERVICE_WS_URL",
7
+ "BOTLEARN_AGENT_SERVICE_RUNTIME_SESSION_ID",
8
+ "BOTLEARN_AGENT_SERVICE_SESSION_TOKEN",
9
+ ];
10
+ const AGENT_SERVICE_SUPERVISOR_ENV_KEYS = [
11
+ "BOTLEARN_RUNTIME_UID",
12
+ "BOTLEARN_RUNTIME_GID",
13
+ "BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT",
14
+ "BOTLEARN_AGENT_SERVICE_PROFILE_ROOT",
6
15
  ];
7
16
  /** Remove Course control-plane coordinates before any model/runtime child is created. */
8
17
  export function clearAgentServiceControlEnv(env = process.env) {
@@ -13,5 +22,19 @@ export function clearAgentServiceControlEnv(env = process.env) {
13
22
  export function runtimeChildEnv(env = process.env) {
14
23
  const childEnv = { ...env };
15
24
  clearAgentServiceControlEnv(childEnv);
25
+ for (const key of AGENT_SERVICE_SUPERVISOR_ENV_KEYS)
26
+ delete childEnv[key];
16
27
  return childEnv;
17
28
  }
29
+ /** Run model processes as the Template's untrusted runtime UID when configured. */
30
+ export function runtimeChildIdentity(env = process.env) {
31
+ const uid = Number(env.BOTLEARN_RUNTIME_UID);
32
+ const gid = Number(env.BOTLEARN_RUNTIME_GID);
33
+ if (!Number.isInteger(uid) ||
34
+ uid < 1 ||
35
+ !Number.isInteger(gid) ||
36
+ gid < 1) {
37
+ return {};
38
+ }
39
+ return { uid, gid };
40
+ }
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { chmodSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
3
  import path from "node:path";
4
- import { runtimeProfileDir, runRootDir } from "./workspace.js";
4
+ import { runtimeProfileDir, runtimeProfileRunRootDir } from "./workspace.js";
5
5
  const SAFE_ASSET_ID = /^[a-z0-9][a-z0-9._-]{0,159}$/;
6
6
  const SHA256 = /^sha256:[0-9a-f]{64}$/;
7
7
  const MAX_SKILL_PACKAGES = 32;
@@ -20,13 +20,19 @@ export function applyRunRuntimeProfile(payload, raw) {
20
20
  if (payload.context.profileHash !== profile.profileHash) {
21
21
  throw new RuntimeProfileApplyError("run.start profileHash does not match runtime profile");
22
22
  }
23
- const runDir = runRootDir(payload.agent_run_id);
23
+ const runDir = runtimeProfileRunRootDir(payload.agent_run_id);
24
24
  const target = runtimeProfileDir(payload.agent_run_id);
25
- mkdirSync(runDir, { recursive: true, mode: 0o700 });
25
+ const managedReadView = Boolean(process.env.BOTLEARN_AGENT_SERVICE_PROFILE_ROOT?.trim());
26
+ const directoryMode = managedReadView ? 0o750 : 0o700;
27
+ const fileMode = managedReadView ? 0o640 : 0o600;
28
+ mkdirSync(runDir, { recursive: true, mode: directoryMode });
29
+ chmodSync(runDir, directoryMode);
26
30
  const staging = mkdtempSync(path.join(runDir, ".runtime-profile-"));
31
+ chmodSync(staging, directoryMode);
27
32
  try {
28
33
  const skillsRoot = path.join(staging, "skills");
29
- mkdirSync(skillsRoot, { recursive: true, mode: 0o700 });
34
+ mkdirSync(skillsRoot, { recursive: true, mode: directoryMode });
35
+ chmodSync(skillsRoot, directoryMode);
30
36
  const skillRefs = [];
31
37
  const skillIds = new Set();
32
38
  for (const skill of profile.skillPackages) {
@@ -34,12 +40,14 @@ export function applyRunRuntimeProfile(payload, raw) {
34
40
  throw new RuntimeProfileApplyError(`duplicate runtime Skill id: ${skill.id}`);
35
41
  }
36
42
  skillIds.add(skill.id);
37
- installSkillPackage(skillsRoot, skill);
43
+ installSkillPackage(skillsRoot, skill, { directoryMode, fileMode });
38
44
  skillRefs.push(`${skill.id}@${skill.version}`);
39
45
  }
40
46
  const promptPackPath = path.join(staging, "prompt-pack.md");
41
- writeFileSync(promptPackPath, profile.promptPack.systemInstructions, { mode: 0o600 });
42
- writeFileSync(path.join(staging, "profile.json"), `${JSON.stringify({
47
+ writeFileSync(promptPackPath, profile.promptPack.systemInstructions, { mode: fileMode });
48
+ chmodSync(promptPackPath, fileMode);
49
+ const manifestPath = path.join(staging, "profile.json");
50
+ writeFileSync(manifestPath, `${JSON.stringify({
43
51
  schemaVersion: profile.schemaVersion,
44
52
  profileId: profile.profileId,
45
53
  profileHash: profile.profileHash,
@@ -51,7 +59,8 @@ export function applyRunRuntimeProfile(payload, raw) {
51
59
  },
52
60
  skillRefs,
53
61
  requiredCapabilities: profile.requiredCapabilities,
54
- }, null, 2)}\n`, { mode: 0o600 });
62
+ }, null, 2)}\n`, { mode: fileMode });
63
+ chmodSync(manifestPath, fileMode);
55
64
  rmSync(target, { recursive: true, force: true });
56
65
  renameSync(staging, target);
57
66
  return {
@@ -94,7 +103,7 @@ export function runtimeProfileInstructions(payload, applied) {
94
103
  : []),
95
104
  ];
96
105
  }
97
- function installSkillPackage(skillsRoot, skill) {
106
+ function installSkillPackage(skillsRoot, skill, modes) {
98
107
  assertSafeAssetId(skill.id, "Skill id");
99
108
  if (!SHA256.test(skill.digest)) {
100
109
  throw new RuntimeProfileApplyError(`Skill ${skill.id} has an invalid digest`);
@@ -104,11 +113,14 @@ function installSkillPackage(skillsRoot, skill) {
104
113
  throw new RuntimeProfileApplyError(`Skill ${skill.id}@${skill.version} digest mismatch`);
105
114
  }
106
115
  const skillDir = path.join(skillsRoot, skill.id);
107
- mkdirSync(skillDir, { recursive: true, mode: 0o700 });
116
+ mkdirSync(skillDir, { recursive: true, mode: modes.directoryMode });
117
+ chmodSync(skillDir, modes.directoryMode);
108
118
  for (const entry of entries) {
109
119
  const destination = path.join(skillDir, entry.path);
110
- mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
111
- writeFileSync(destination, entry.content, { mode: 0o600 });
120
+ mkdirSync(path.dirname(destination), { recursive: true, mode: modes.directoryMode });
121
+ chmodSync(path.dirname(destination), modes.directoryMode);
122
+ writeFileSync(destination, entry.content, { mode: modes.fileMode });
123
+ chmodSync(destination, modes.fileMode);
112
124
  }
113
125
  }
114
126
  function archiveEntries(skill) {
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { runtimeChildIdentity } from "../runtime-env.js";
2
3
  import { sanitizeRuntimeFailureText } from "../redaction.js";
3
4
  import { sliceUtf8Bytes, utf8ByteLength } from "./text-cap.js";
4
5
  import { consoleLogger, } from "./engine.js";
@@ -206,6 +207,7 @@ export class AcpRuntimeAdapter {
206
207
  const child = spawn(binary, args, {
207
208
  cwd: opts.cwd,
208
209
  env: this.spawnEnv(opts),
210
+ ...runtimeChildIdentity(),
209
211
  stdio: ["pipe", "pipe", "pipe"],
210
212
  });
211
213
  let killTimer = null;
@@ -38,7 +38,7 @@ export declare class CodexAdapter extends NdjsonStreamAdapter {
38
38
  * `--` 隔开 flags 与 positionals,防止以 `-` 开头的 prompt 被解析成选项。
39
39
  */
40
40
  protected buildArgs(opts: EngineRunOptions): string[];
41
- protected spawnEnv(_opts: EngineRunOptions): NodeJS.ProcessEnv;
41
+ protected spawnEnv(opts: EngineRunOptions): NodeJS.ProcessEnv;
42
42
  protected handleEvent(raw: unknown, ctx: NdjsonEventCtx): void;
43
43
  }
44
44
  export declare const codexModule: RuntimeModule;
@@ -181,9 +181,9 @@ export class CodexAdapter extends NdjsonStreamAdapter {
181
181
  }
182
182
  return ["exec", ...tail, "--", prompt];
183
183
  }
184
- spawnEnv(_opts) {
184
+ spawnEnv(opts) {
185
185
  return {
186
- ...process.env,
186
+ ...super.spawnEnv(opts),
187
187
  // 保证 JSONL 输出不混入 ANSI 转义。
188
188
  FORCE_COLOR: "0",
189
189
  NO_COLOR: "1",