@botlearn-course/daemon 0.0.7 → 0.0.9

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.
@@ -1,7 +1,35 @@
1
- import { writeFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
+ import { RuntimeExecutionError } from "../types.js";
3
4
  /** message 里回显的任务简报截断长度。 */
4
5
  const BRIEF_PREVIEW_CHARS = 80;
6
+ const RUNTIME_ERROR_SCENARIO = "[[e2e:runtime-error]]";
7
+ const SLOW_RUNTIME_SCENARIO = "[[e2e:slow]]";
8
+ function configuredStepDelayMs(brief) {
9
+ if (brief.includes(SLOW_RUNTIME_SCENARIO))
10
+ return 1_000;
11
+ const configured = Number(process.env.BOTLEARN_FAKE_RUNTIME_STEP_DELAY_MS ?? 0);
12
+ if (!Number.isFinite(configured))
13
+ return 0;
14
+ return Math.max(0, Math.min(5_000, Math.floor(configured)));
15
+ }
16
+ async function pauseBetweenSteps(signal, runtimeId, brief) {
17
+ const delayMs = configuredStepDelayMs(brief);
18
+ throwIfAborted(signal, runtimeId);
19
+ if (delayMs <= 0)
20
+ return;
21
+ await new Promise((resolve, reject) => {
22
+ const timer = setTimeout(() => {
23
+ signal.removeEventListener("abort", onAbort);
24
+ resolve();
25
+ }, delayMs);
26
+ const onAbort = () => {
27
+ clearTimeout(timer);
28
+ reject(new Error(`${runtimeId} runtime aborted`));
29
+ };
30
+ signal.addEventListener("abort", onAbort, { once: true });
31
+ });
32
+ }
5
33
  /**
6
34
  * 确定性假 runtime:不 spawn 任何进程,直接实现 CourseRuntime。
7
35
  * 仅供端到端联调/测试(registry 里 hidden,capabilities 需
@@ -15,18 +43,86 @@ export class FakeRuntime {
15
43
  }
16
44
  async run(run, sink, signal) {
17
45
  const brief = run.payload.input.text ?? "";
18
- const finalText = `已根据任务完成草稿。\n\n任务:${brief.slice(0, BRIEF_PREVIEW_CHARS)}`;
46
+ const draftPath = path.join(run.workspaceDir, "draft.md");
47
+ const priorDraft = existsSync(draftPath) ? readFileSync(draftPath, "utf8") : "";
48
+ const isContinuation = priorDraft.length > 0;
49
+ if (isContinuation && run.contextRevision !== undefined && !run.nativeSessionId) {
50
+ throw new RuntimeExecutionError("E2E FakeRuntime lost its native session between turns", "runtime_unavailable");
51
+ }
52
+ if (!isContinuation && run.contextRevision !== undefined) {
53
+ await sink.runtimeSession?.(`fake-thread-${run.payload.course_run_id}`);
54
+ }
55
+ const draft = isContinuation
56
+ ? [
57
+ "# E2E 草稿",
58
+ "",
59
+ "第一段:这是由真实 Course Daemon 协议生成的初稿内容。",
60
+ "",
61
+ "第二段:本轮读取并延续了上一轮工作区中的 draft.md。",
62
+ ].join("\n")
63
+ : [
64
+ "# E2E 草稿",
65
+ "",
66
+ "第一段:这是由真实 Course Daemon 协议生成的初稿内容。",
67
+ ].join("\n");
68
+ const finalText = isContinuation
69
+ ? `已读取同一工作区的上一轮 draft.md,并补全为标题和两段正文。\n\n${draft}`
70
+ : `已生成包含标题和第一段正文的初稿,请检查后继续完善。\n\n${draft}\n\n任务:${brief.slice(0, BRIEF_PREVIEW_CHARS)}`;
19
71
  const blocks = [
20
- { kind: "status", text: "fake runtime started", raw: { event: "started" } },
21
- { kind: "thinking", text: "正在起草……", raw: { event: "thinking" } },
22
- { kind: "text", text: finalText, raw: { event: "text" } },
72
+ {
73
+ kind: "status",
74
+ text: "deterministic runtime started",
75
+ raw: { event: "started" },
76
+ },
77
+ {
78
+ kind: "thinking",
79
+ phase: "in_progress",
80
+ text: "正在检查任务和工作区。",
81
+ raw: { event: "reasoning" },
82
+ },
83
+ {
84
+ kind: "tool_call",
85
+ name: "write_file",
86
+ text: "draft.md",
87
+ raw: { event: "tool_call" },
88
+ },
89
+ {
90
+ kind: "progress",
91
+ runtime: this.id,
92
+ summary: isContinuation ? "正在补全草稿" : "正在生成初稿",
93
+ status: "in_progress",
94
+ },
23
95
  ];
24
96
  for (const block of blocks) {
25
97
  throwIfAborted(signal, this.id);
26
98
  await sink.block(block);
99
+ await pauseBetweenSteps(signal, this.id, brief);
100
+ }
101
+ if (brief.includes(RUNTIME_ERROR_SCENARIO)) {
102
+ throw new RuntimeExecutionError("E2E deterministic runtime failure");
27
103
  }
28
104
  throwIfAborted(signal, this.id);
29
- writeFileSync(path.join(run.workspaceDir, "draft.md"), finalText, "utf8");
105
+ writeFileSync(draftPath, draft, "utf8");
106
+ await sink.block({
107
+ kind: "tool_result",
108
+ name: "write_file",
109
+ status: "completed",
110
+ text: "draft.md",
111
+ raw: { event: "tool_result" },
112
+ });
113
+ await pauseBetweenSteps(signal, this.id, brief);
114
+ await sink.block({
115
+ kind: "thinking",
116
+ phase: "completed",
117
+ raw: { event: "reasoning" },
118
+ });
119
+ await sink.block({
120
+ kind: "progress",
121
+ runtime: this.id,
122
+ summary: isContinuation ? "草稿已补全" : "初稿已生成",
123
+ status: "completed",
124
+ });
125
+ await sink.block({ kind: "text_delta", text: finalText, raw: { event: "text" } });
30
126
  await sink.message(finalText);
31
127
  }
32
128
  }
@@ -191,7 +191,8 @@ function resolveManagedProgressRoot(explicit) {
191
191
  return null;
192
192
  let root = explicit?.trim();
193
193
  if (root === undefined) {
194
- const managedRuntime = process.env.BOTLEARN_RUNTIME_USER?.trim();
194
+ const managedRuntime = process.env.BOTLEARN_RUNTIME_USER?.trim()
195
+ || process.env.BOTLEARN_RUNTIME_LAUNCH_MODE?.trim();
195
196
  if (!managedRuntime)
196
197
  return null;
197
198
  root = process.env.BOTLEARN_AGENT_SERVICE_PROFILE_ROOT?.trim();
@@ -1,4 +1,13 @@
1
1
  #!/usr/bin/env node
2
- export declare function acquireSessionSupervisorLock(runtimeSessionId: string, lockRoot?: string): (() => void) | null;
2
+ export declare function noNewPrivilegesEnabled(status: string): boolean;
3
+ export declare function sandboxSupervisorLaunchPlan(noNewPrivileges: boolean, controlUid: number, controlGid: number): {
4
+ directoryOwnerUid: number;
5
+ daemonIdentity: {
6
+ uid?: number;
7
+ gid: number;
8
+ };
9
+ runtimeLaunchEnv: Record<string, string>;
10
+ };
11
+ export declare function acquireSandboxSupervisorLock(sandboxId: string, lockRoot?: string): (() => void) | null;
3
12
  export declare function runSandboxSupervisor(argv: string[]): Promise<number>;
4
13
  export declare function isMainModule(entry?: string): boolean;
@@ -23,6 +23,35 @@ const MANAGED_PATH = [
23
23
  // use it for a managed control-plane executable.
24
24
  "/usr/local/bin",
25
25
  ].join(":");
26
+ export function noNewPrivilegesEnabled(status) {
27
+ const match = /^NoNewPrivs:\s*([01])\s*$/mu.exec(status);
28
+ if (!match)
29
+ throw new Error("sandbox supervisor could not read NoNewPrivs state");
30
+ return match[1] === "1";
31
+ }
32
+ export function sandboxSupervisorLaunchPlan(noNewPrivileges, controlUid, controlGid) {
33
+ if (noNewPrivileges) {
34
+ return {
35
+ directoryOwnerUid: 0,
36
+ // Keep the root supervisor's uid so Node can perform a one-way uid/gid drop for
37
+ // the runtime child. The control gid preserves the existing workspace/profile ACLs.
38
+ daemonIdentity: { gid: controlGid },
39
+ runtimeLaunchEnv: {
40
+ BOTLEARN_RUNTIME_LAUNCH_MODE: "direct-uid",
41
+ },
42
+ };
43
+ }
44
+ return {
45
+ directoryOwnerUid: controlUid,
46
+ daemonIdentity: { uid: controlUid, gid: controlGid },
47
+ runtimeLaunchEnv: {
48
+ BOTLEARN_RUNTIME_LAUNCH_MODE: "sudo",
49
+ BOTLEARN_RUNTIME_USER: RUNTIME_USER,
50
+ BOTLEARN_RUNTIME_GROUP: CONTROL_USER,
51
+ BOTLEARN_RUNTIME_LAUNCHER: RUNTIME_LAUNCHER,
52
+ },
53
+ };
54
+ }
26
55
  function numericId(flag, user) {
27
56
  const output = execFileSync("/usr/bin/id", [flag, user], {
28
57
  encoding: "utf8",
@@ -49,8 +78,8 @@ async function readOneShotBootstrap() {
49
78
  throw new Error("sandbox supervisor bootstrap is empty");
50
79
  return Buffer.concat(chunks);
51
80
  }
52
- export function acquireSessionSupervisorLock(runtimeSessionId, lockRoot = SUPERVISOR_LOCK_ROOT) {
53
- const lockDir = path.join(lockRoot, runtimeSessionId);
81
+ export function acquireSandboxSupervisorLock(sandboxId, lockRoot = SUPERVISOR_LOCK_ROOT) {
82
+ const lockDir = path.join(lockRoot, sandboxId);
54
83
  const pidFile = path.join(lockDir, "pid");
55
84
  mkdirSync(lockRoot, { recursive: true, mode: 0o700 });
56
85
  try {
@@ -84,23 +113,23 @@ export function acquireSessionSupervisorLock(runtimeSessionId, lockRoot = SUPERV
84
113
  }
85
114
  };
86
115
  }
87
- function prepareDirectories(controlUid, controlGid, runtimeUid, runtimeSessionId, sessionGeneration) {
116
+ function prepareDirectories(controlUid, controlGid) {
88
117
  mkdirSync(CONTROL_HOME, { recursive: true, mode: 0o700 });
89
118
  chownSync("/home/botlearn-control/.botlearn-course", controlUid, controlGid);
90
119
  chownSync(CONTROL_HOME, controlUid, controlGid);
91
120
  chmodSync(CONTROL_HOME, 0o700);
92
- mkdirSync(RUNTIME_PROFILE_ROOT, { recursive: true, mode: 0o750 });
121
+ mkdirSync(RUNTIME_PROFILE_ROOT, { recursive: true, mode: 0o710 });
93
122
  chownSync(RUNTIME_PROFILE_ROOT, controlUid, controlGid);
94
- chmodSync(RUNTIME_PROFILE_ROOT, 0o750);
95
- mkdirSync(WORKSPACE, { recursive: true, mode: 0o770 });
96
- chownSync(WORKSPACE, runtimeUid, controlGid);
97
- chmodSync(WORKSPACE, 0o770);
98
- const sessionWorkspace = path.join(WORKSPACE, runtimeSessionId, `generation-${sessionGeneration}`);
99
- mkdirSync(sessionWorkspace, { recursive: true, mode: 0o770 });
100
- chownSync(path.join(WORKSPACE, runtimeSessionId), runtimeUid, controlGid);
101
- chownSync(sessionWorkspace, runtimeUid, controlGid);
102
- chmodSync(path.join(WORKSPACE, runtimeSessionId), 0o770);
103
- chmodSync(sessionWorkspace, 0o770);
123
+ // Runtime can traverse to the active run profile path named in its instructions,
124
+ // but cannot enumerate profiles belonging to other activations.
125
+ chmodSync(RUNTIME_PROFILE_ROOT, 0o710);
126
+ // /workspace 只准备根目录;per-session 子目录
127
+ // `<root>/<runtime_session_id>/generation-<sandbox_generation>` daemon
128
+ // (botlearn-control)在 session.open 时创建。根目录只允许 runtime 组穿过,
129
+ // 不允许列举 session id;daemon 仅在 activation 期间开放目标子目录。
130
+ mkdirSync(WORKSPACE, { recursive: true, mode: 0o710 });
131
+ chownSync(WORKSPACE, controlUid, controlGid);
132
+ chmodSync(WORKSPACE, 0o710);
104
133
  }
105
134
  export async function runSandboxSupervisor(argv) {
106
135
  if (argv.length !== 2 || argv[0] !== "agent-service" || argv[1] !== "session") {
@@ -112,41 +141,43 @@ export async function runSandboxSupervisor(argv) {
112
141
  const controlUid = numericId("-u", CONTROL_USER);
113
142
  const controlGid = numericId("-g", CONTROL_USER);
114
143
  const runtimeUid = numericId("-u", RUNTIME_USER);
144
+ const noNewPrivileges = noNewPrivilegesEnabled(readFileSync("/proc/self/status", "utf8"));
145
+ const launchPlan = sandboxSupervisorLaunchPlan(noNewPrivileges, controlUid, controlGid);
115
146
  const bootstrap = await readOneShotBootstrap();
116
147
  let child;
117
148
  let releaseLock = null;
118
149
  try {
119
150
  const decoded = JSON.parse(bootstrap.toString("utf8"));
120
- const runtimeSessionId = decoded.runtimeSessionId;
121
- const sessionGeneration = Number(decoded.sessionGeneration ?? 0);
122
- if (typeof runtimeSessionId !== "string" ||
123
- !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(runtimeSessionId) ||
124
- !Number.isInteger(sessionGeneration) ||
125
- sessionGeneration < 1) {
126
- throw new Error("sandbox supervisor bootstrap session scope is invalid");
151
+ const sandboxId = decoded.sandboxId;
152
+ const sandboxGeneration = Number(decoded.sandboxGeneration ?? 0);
153
+ if (typeof sandboxId !== "string" ||
154
+ !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(sandboxId) ||
155
+ !Number.isInteger(sandboxGeneration) ||
156
+ sandboxGeneration < 1) {
157
+ throw new Error("sandbox supervisor bootstrap sandbox scope is invalid");
127
158
  }
128
- releaseLock = acquireSessionSupervisorLock(runtimeSessionId);
159
+ releaseLock = acquireSandboxSupervisorLock(sandboxId);
129
160
  if (releaseLock === null)
130
161
  return 0;
131
- prepareDirectories(controlUid, controlGid, runtimeUid, runtimeSessionId, sessionGeneration);
162
+ prepareDirectories(launchPlan.directoryOwnerUid, controlGid);
163
+ if (noNewPrivileges) {
164
+ process.stderr.write("sandbox supervisor: NoNewPrivs=1; using root daemon with direct runtime uid drop\n");
165
+ }
132
166
  child = spawn(NODE_BINARY, [
133
167
  DAEMON_ENTRY,
134
168
  "agent-service",
135
169
  "session",
136
170
  "--bootstrap-stdin",
137
171
  ], {
138
- uid: controlUid,
139
- gid: controlGid,
172
+ ...launchPlan.daemonIdentity,
140
173
  env: {
141
174
  HOME: "/home/botlearn-control",
142
175
  PATH: MANAGED_PATH,
143
176
  BOTLEARN_DAEMON_HOME: CONTROL_HOME,
144
177
  BOTLEARN_RUNTIME_UID: String(runtimeUid),
145
178
  BOTLEARN_RUNTIME_GID: String(controlGid),
146
- BOTLEARN_RUNTIME_USER: RUNTIME_USER,
147
- BOTLEARN_RUNTIME_GROUP: CONTROL_USER,
148
179
  BOTLEARN_RUNTIME_HOME: "/home/user",
149
- BOTLEARN_RUNTIME_LAUNCHER: RUNTIME_LAUNCHER,
180
+ ...launchPlan.runtimeLaunchEnv,
150
181
  BOTLEARN_DEEPSEEK_TUI_BIN: DEEPSEEK_BINARY,
151
182
  BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT: WORKSPACE,
152
183
  BOTLEARN_AGENT_SERVICE_PROFILE_ROOT: RUNTIME_PROFILE_ROOT,
@@ -1,4 +1,4 @@
1
- import type { RuntimeBlock } from "./types.js";
1
+ import type { RuntimeBlock, RuntimeFailureSummary } from "./types.js";
2
2
  /**
3
3
  * Transcript writer:块和最终回复追加写入 transcript.jsonl,供本地诊断与回放。
4
4
  * 所有 text/raw 落盘前脱敏;raw 只进本地 transcript,不上 wire。
@@ -8,6 +8,7 @@ export declare class TranscriptWriter {
8
8
  constructor(file: string);
9
9
  writeBlock(block: RuntimeBlock): void;
10
10
  writeFinal(text: string): void;
11
+ writeFailure(failure: Partial<RuntimeFailureSummary>): void;
11
12
  private append;
12
13
  get path(): string;
13
14
  }
@@ -30,6 +30,12 @@ export class TranscriptWriter {
30
30
  writeFinal(text) {
31
31
  this.append({ type: "message", role: "assistant", text: redactSecretString(text) });
32
32
  }
33
+ writeFailure(failure) {
34
+ this.append({
35
+ type: "failure",
36
+ diagnostic: sanitizeRaw(failure),
37
+ });
38
+ }
33
39
  append(record) {
34
40
  appendFileSync(this.file, `${JSON.stringify({ ...record, ts: new Date().toISOString() })}\n`, "utf8");
35
41
  }
package/dist/types.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Course-native 协议与 runtime 契约(spec: docs/specs/lightweight-course-daemon-package.md)。
3
3
  *
4
4
  * 本包不依赖 BotCord Hub/room/owner-chat 语义;wire 类型与后端
5
- * `backend/botlearn_course/schemas.py` 的 daemon 契约严格对齐。
5
+ * `services/course-api/botlearn_course/schemas.py` 的 daemon 契约严格对齐。
6
6
  */
7
7
  import type { ProgressStatus } from "./mcp/report-progress.js";
8
8
  /** `GET /daemon/runs/next` 下发的 run.start 载荷(snake_case,与后端 RunStartPayloadOut 一致)。 */
@@ -196,7 +196,10 @@ export declare class RuntimeExecutionError extends Error {
196
196
  readonly failure?: Partial<RuntimeFailureSummary> | undefined;
197
197
  constructor(message: string, errorType?: "runtime_error" | "runtime_unavailable" | "timeout", failure?: Partial<RuntimeFailureSummary> | undefined);
198
198
  }
199
- /** 本地诊断用的失败摘要(脱敏后可入日志/transcript,不上报 wire)。 */
199
+ /**
200
+ * 本地诊断用的失败摘要。完整结构只进脱敏日志/transcript;wire 仅允许
201
+ * run-dispatcher 构造的 failure_diagnostic 白名单子集。
202
+ */
200
203
  export interface RuntimeFailureSummary {
201
204
  agent_run_id: string;
202
205
  runtime: string;
package/dist/types.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Course-native 协议与 runtime 契约(spec: docs/specs/lightweight-course-daemon-package.md)。
3
3
  *
4
4
  * 本包不依赖 BotCord Hub/room/owner-chat 语义;wire 类型与后端
5
- * `backend/botlearn_course/schemas.py` 的 daemon 契约严格对齐。
5
+ * `services/course-api/botlearn_course/schemas.py` 的 daemon 契约严格对齐。
6
6
  */
7
7
  /** runtime 执行失败(dispatcher 折叠为 run.failed)。 */
8
8
  export class RuntimeExecutionError extends Error {
@@ -18,14 +18,37 @@ export declare function runWorkspaceDir(agentRunId: string): string;
18
18
  export declare function transcriptPath(agentRunId: string): string;
19
19
  export declare function runtimeProfileDir(agentRunId: string): string;
20
20
  export declare function runtimeProfileRunRootDir(agentRunId: string): string;
21
- export declare function runtimeSessionRootDir(runtimeSessionId: string, sessionGeneration: number): string;
22
- export declare function runtimeSessionWorkspaceDir(runtimeSessionId: string, sessionGeneration: number): string;
23
- export declare function runtimeSessionTranscriptPath(runtimeSessionId: string, sessionGeneration: number, agentRunId: string): string;
21
+ export declare function runtimeSessionRootDir(runtimeSessionId: string, sandboxGeneration: number): string;
22
+ /**
23
+ * per-session workspace(ADR-015 §7):managed sandbox 下为
24
+ * `<BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT>/<runtime_session_id>/generation-<sandbox_generation>`,
25
+ * 由 daemon 在 session.open 时创建。
26
+ */
27
+ export declare function runtimeSessionWorkspaceDir(runtimeSessionId: string, sandboxGeneration: number): string;
28
+ export declare function runtimeSessionTranscriptPath(runtimeSessionId: string, sandboxGeneration: number, agentRunId: string): string;
24
29
  export declare function ensureRunWorkspace(agentRunId: string): {
25
30
  rootDir: string;
26
31
  workspaceDir: string;
27
32
  };
28
- export declare function ensureRuntimeSessionWorkspace(runtimeSessionId: string, sessionGeneration: number, agentRunId: string): {
33
+ /**
34
+ * session.open 时创建 per-session 目录。managed workspace 默认保持 0700,只有当前
35
+ * activation 会通过 exposeRuntimeSessionWorkspace() 临时开放给 runtime 组。
36
+ */
37
+ export declare function ensureRuntimeSessionDirectories(runtimeSessionId: string, sandboxGeneration: number): {
38
+ rootDir: string;
39
+ workspaceDir: string;
40
+ };
41
+ /**
42
+ * 暴露当前 activation 的 managed workspace。sandbox 根目录由 supervisor 设为
43
+ * 0710,因此 runtime 只能穿过根目录,不能列举其他 session id;未激活 session
44
+ * 的父目录与 workspace 始终为 0700。
45
+ */
46
+ export declare function exposeRuntimeSessionWorkspace(runtimeSessionId: string, sandboxGeneration: number): void;
47
+ /** Revoke a managed workspace without deleting the session's durable local materialization. */
48
+ export declare function revokeRuntimeSessionWorkspace(runtimeSessionId: string, sandboxGeneration: number): void;
49
+ /** session.close 时删除 workspace 与本地 session 物化状态(transcripts 等)。幂等。 */
50
+ export declare function removeRuntimeSessionWorkspace(runtimeSessionId: string, sandboxGeneration: number): void;
51
+ export declare function ensureRuntimeSessionWorkspace(runtimeSessionId: string, sandboxGeneration: number, agentRunId: string): {
29
52
  rootDir: string;
30
53
  workspaceDir: string;
31
54
  transcriptFile: string;
package/dist/workspace.js CHANGED
@@ -1,4 +1,4 @@
1
- import { chmodSync, existsSync, mkdirSync } from "node:fs";
1
+ import { chmodSync, mkdirSync, rmSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { daemonHome } from "./auth-store.js";
4
4
  /**
@@ -42,34 +42,51 @@ export function runtimeProfileDir(agentRunId) {
42
42
  export function runtimeProfileRunRootDir(agentRunId) {
43
43
  return path.dirname(runtimeProfileDir(agentRunId));
44
44
  }
45
- export function runtimeSessionRootDir(runtimeSessionId, sessionGeneration) {
45
+ export function runtimeSessionRootDir(runtimeSessionId, sandboxGeneration) {
46
46
  assertSafeId(runtimeSessionId, "runtime_session_id");
47
- if (!Number.isInteger(sessionGeneration) || sessionGeneration < 1) {
48
- throw new Error("unsafe session_generation: expected a positive integer");
47
+ if (!Number.isInteger(sandboxGeneration) || sandboxGeneration < 1) {
48
+ throw new Error("unsafe sandbox_generation: expected a positive integer");
49
49
  }
50
- return path.join(daemonHome(), "agent-service-sessions", runtimeSessionId, `generation-${sessionGeneration}`);
50
+ return path.join(daemonHome(), "agent-service-sessions", runtimeSessionId, `generation-${sandboxGeneration}`);
51
51
  }
52
- export function runtimeSessionWorkspaceDir(runtimeSessionId, sessionGeneration) {
52
+ /**
53
+ * per-session workspace(ADR-015 §7):managed sandbox 下为
54
+ * `<BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT>/<runtime_session_id>/generation-<sandbox_generation>`,
55
+ * 由 daemon 在 session.open 时创建。
56
+ */
57
+ export function runtimeSessionWorkspaceDir(runtimeSessionId, sandboxGeneration) {
58
+ assertSafeId(runtimeSessionId, "runtime_session_id");
59
+ if (!Number.isInteger(sandboxGeneration) || sandboxGeneration < 1) {
60
+ throw new Error("unsafe sandbox_generation: expected a positive integer");
61
+ }
53
62
  const managedRoot = process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT?.trim();
54
63
  if (managedRoot) {
55
- return path.join(managedRoot, runtimeSessionId, `generation-${sessionGeneration}`);
64
+ return path.join(managedRoot, runtimeSessionId, `generation-${sandboxGeneration}`);
56
65
  }
57
- return path.join(runtimeSessionRootDir(runtimeSessionId, sessionGeneration), "workspace");
66
+ return path.join(runtimeSessionRootDir(runtimeSessionId, sandboxGeneration), "workspace");
67
+ }
68
+ function managedRuntimeSessionParentDir(runtimeSessionId) {
69
+ assertSafeId(runtimeSessionId, "runtime_session_id");
70
+ const managedRoot = process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT?.trim();
71
+ return managedRoot ? path.join(managedRoot, runtimeSessionId) : null;
58
72
  }
59
- export function runtimeSessionTranscriptPath(runtimeSessionId, sessionGeneration, agentRunId) {
73
+ export function runtimeSessionTranscriptPath(runtimeSessionId, sandboxGeneration, agentRunId) {
60
74
  assertSafeId(agentRunId, "agent_run_id");
61
- return path.join(runtimeSessionRootDir(runtimeSessionId, sessionGeneration), "transcripts", `${agentRunId}.jsonl`);
75
+ return path.join(runtimeSessionRootDir(runtimeSessionId, sandboxGeneration), "transcripts", `${agentRunId}.jsonl`);
62
76
  }
63
77
  // recursive mkdir 只对新建目录生效 mode,已存在目录需 best-effort 收紧。
64
- function mkdirTolerant(dir) {
65
- mkdirSync(dir, { recursive: true, mode: 0o700 });
78
+ function mkdirTolerant(dir, mode = 0o700) {
79
+ mkdirSync(dir, { recursive: true, mode });
66
80
  try {
67
- chmodSync(dir, 0o700);
81
+ chmodSync(dir, mode);
68
82
  }
69
83
  catch {
70
84
  // Windows 等不支持 chmod 时忽略。
71
85
  }
72
86
  }
87
+ function isMissingPathError(error) {
88
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
89
+ }
73
90
  export function ensureRunWorkspace(agentRunId) {
74
91
  const rootDir = runRootDir(agentRunId);
75
92
  const workspaceDir = runWorkspaceDir(agentRunId);
@@ -77,19 +94,75 @@ export function ensureRunWorkspace(agentRunId) {
77
94
  mkdirTolerant(workspaceDir);
78
95
  return { rootDir, workspaceDir };
79
96
  }
80
- export function ensureRuntimeSessionWorkspace(runtimeSessionId, sessionGeneration, agentRunId) {
81
- const rootDir = runtimeSessionRootDir(runtimeSessionId, sessionGeneration);
82
- const workspaceDir = runtimeSessionWorkspaceDir(runtimeSessionId, sessionGeneration);
83
- const transcriptFile = runtimeSessionTranscriptPath(runtimeSessionId, sessionGeneration, agentRunId);
97
+ /**
98
+ * session.open 时创建 per-session 目录。managed workspace 默认保持 0700,只有当前
99
+ * activation 会通过 exposeRuntimeSessionWorkspace() 临时开放给 runtime 组。
100
+ */
101
+ export function ensureRuntimeSessionDirectories(runtimeSessionId, sandboxGeneration) {
102
+ const rootDir = runtimeSessionRootDir(runtimeSessionId, sandboxGeneration);
103
+ const workspaceDir = runtimeSessionWorkspaceDir(runtimeSessionId, sandboxGeneration);
84
104
  mkdirTolerant(rootDir);
85
- if (process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT?.trim()) {
86
- if (!existsSync(workspaceDir)) {
87
- throw new Error("managed runtime session workspace was not prepared by the supervisor");
88
- }
105
+ const managedParent = managedRuntimeSessionParentDir(runtimeSessionId);
106
+ if (managedParent)
107
+ mkdirTolerant(managedParent, 0o700);
108
+ mkdirTolerant(workspaceDir, 0o700);
109
+ return { rootDir, workspaceDir };
110
+ }
111
+ /**
112
+ * 暴露当前 activation 的 managed workspace。sandbox 根目录由 supervisor 设为
113
+ * 0710,因此 runtime 只能穿过根目录,不能列举其他 session id;未激活 session
114
+ * 的父目录与 workspace 始终为 0700。
115
+ */
116
+ export function exposeRuntimeSessionWorkspace(runtimeSessionId, sandboxGeneration) {
117
+ const managedParent = managedRuntimeSessionParentDir(runtimeSessionId);
118
+ if (!managedParent)
119
+ return;
120
+ const { workspaceDir } = ensureRuntimeSessionDirectories(runtimeSessionId, sandboxGeneration);
121
+ // Parent is traverse-only for the runtime group: allowing group write here would let
122
+ // the runtime replace the generation directory with a symlink before revoke/cleanup.
123
+ chmodSync(managedParent, 0o710);
124
+ chmodSync(workspaceDir, 0o770);
125
+ }
126
+ /** Revoke a managed workspace without deleting the session's durable local materialization. */
127
+ export function revokeRuntimeSessionWorkspace(runtimeSessionId, sandboxGeneration) {
128
+ const managedParent = managedRuntimeSessionParentDir(runtimeSessionId);
129
+ if (!managedParent)
130
+ return;
131
+ const workspaceDir = runtimeSessionWorkspaceDir(runtimeSessionId, sandboxGeneration);
132
+ // Revoke the leaf before the parent so a runtime loses access as early as possible.
133
+ try {
134
+ chmodSync(workspaceDir, 0o700);
135
+ }
136
+ catch (error) {
137
+ // session.close / generation cleanup may already have removed it. Permission and I/O
138
+ // failures must remain visible: proceeding would leave an inactive workspace exposed.
139
+ if (!isMissingPathError(error))
140
+ throw error;
141
+ }
142
+ try {
143
+ chmodSync(managedParent, 0o700);
144
+ }
145
+ catch (error) {
146
+ if (!isMissingPathError(error))
147
+ throw error;
89
148
  }
90
- else {
91
- mkdirTolerant(workspaceDir);
149
+ }
150
+ /** session.close 时删除 workspace 与本地 session 物化状态(transcripts 等)。幂等。 */
151
+ export function removeRuntimeSessionWorkspace(runtimeSessionId, sandboxGeneration) {
152
+ // 校验路径段安全后按 session 整体删除(含全部 generation 与 transcripts)。
153
+ runtimeSessionRootDir(runtimeSessionId, sandboxGeneration);
154
+ rmSync(path.join(daemonHome(), "agent-service-sessions", runtimeSessionId), {
155
+ recursive: true,
156
+ force: true,
157
+ });
158
+ const managedRoot = process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT?.trim();
159
+ if (managedRoot) {
160
+ rmSync(path.join(managedRoot, runtimeSessionId), { recursive: true, force: true });
92
161
  }
162
+ }
163
+ export function ensureRuntimeSessionWorkspace(runtimeSessionId, sandboxGeneration, agentRunId) {
164
+ const { rootDir, workspaceDir } = ensureRuntimeSessionDirectories(runtimeSessionId, sandboxGeneration);
165
+ const transcriptFile = runtimeSessionTranscriptPath(runtimeSessionId, sandboxGeneration, agentRunId);
93
166
  mkdirTolerant(path.dirname(transcriptFile));
94
167
  return { rootDir, workspaceDir, transcriptFile };
95
168
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botlearn-course/daemon",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
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": {
@@ -1,67 +0,0 @@
1
- import { type Logger } from "./log.js";
2
- import { type PersistentSessionExecution, type PreparedPersistentTurn, type RunReportingClient } from "./run-dispatcher.js";
3
- import type { CourseRuntime, CourseRuntimeProfile, RunEvent, RunFileCandidate, RunFileRecord, RunStartPayload } from "./types.js";
4
- export interface AgentServiceSessionOptions {
5
- wsUrl: string;
6
- sessionId: string;
7
- sessionToken: string;
8
- runtimes: Map<string, CourseRuntime>;
9
- daemonVersion: string;
10
- runtimeEnv?: Record<string, string>;
11
- log?: Logger;
12
- random?: () => number;
13
- sleep?: (ms: number) => Promise<void>;
14
- }
15
- /** Long-running daemon client for one persistent managed sandbox session. */
16
- export declare class AgentServiceSessionClient implements RunReportingClient, PersistentSessionExecution {
17
- private readonly options;
18
- private readonly log;
19
- private readonly random;
20
- private readonly sleep;
21
- private readonly state;
22
- private readonly dispatcher;
23
- private readonly attempts;
24
- private readonly runtimeProfiles;
25
- private readonly pendingAcks;
26
- private readonly pendingFilePrepares;
27
- private readonly pendingFileCommits;
28
- private readonly fileGrants;
29
- private readonly inflightCommands;
30
- private socket;
31
- private sessionGeneration;
32
- private connectionEpoch;
33
- private inboundSeq;
34
- private heartbeatMs;
35
- private staleMs;
36
- private stopped;
37
- private permanentFailure;
38
- constructor(options: AgentServiceSessionOptions);
39
- prepareTurn(payload: RunStartPayload): PreparedPersistentTurn;
40
- persistNativeSession(sessionId: string): void;
41
- run(): Promise<void>;
42
- stop(): void;
43
- postEvent(agentRunId: string, event: RunEvent): Promise<void>;
44
- postFile(agentRunId: string, file: RunFileCandidate): Promise<RunFileRecord>;
45
- uploadFileContent(agentRunId: string, fileId: string, absPath: string, mimeType?: string): Promise<RunFileRecord>;
46
- getRunRuntimeProfile(agentRunId: string): Promise<CourseRuntimeProfile>;
47
- private connectOnce;
48
- private handleHello;
49
- private assertServerFrame;
50
- private handleServerFrame;
51
- private applyDesiredState;
52
- private reconcileInterruptedCommand;
53
- private runtimeActivation;
54
- private ackEventOrFile;
55
- private acceptFileGrant;
56
- private replaySpool;
57
- private sendHeartbeat;
58
- private sendCommandAck;
59
- private sendControlFrame;
60
- private sendFrame;
61
- private nextOutboundSeq;
62
- private spoolBytes;
63
- private enforceSpoolLimit;
64
- private rejectPending;
65
- private rejectPendingFiles;
66
- private persist;
67
- }