@botlearn-course/daemon 0.0.7 → 0.0.8

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.
@@ -111,6 +111,7 @@ export class DeepseekTuiAdapter {
111
111
  let handle;
112
112
  let countedInFlight = false;
113
113
  let releaseTurn;
114
+ const managedActivationId = this.managedActivationId(opts);
114
115
  try {
115
116
  // The local server has a process-level kill fallback when turn-scoped interrupt
116
117
  // fails. Serialize turns so cancelling one run can never terminate another run.
@@ -126,7 +127,10 @@ export class DeepseekTuiAdapter {
126
127
  if (handle.idleTimer)
127
128
  clearTimeout(handle.idleTimer);
128
129
  const headers = authHeaders(handle.token);
129
- let threadId = opts.sessionId?.trim() || "";
130
+ // Agent Service model credentials are activation-scoped. The local DeepSeek
131
+ // server reads them only at process startup, so its native thread cache cannot
132
+ // safely cross activations; durable Course context rebuilds the new thread.
133
+ let threadId = managedActivationId ? "" : (opts.sessionId?.trim() || "");
130
134
  if (threadId && !isValidThreadId(threadId)) {
131
135
  return {
132
136
  text: "",
@@ -152,7 +156,7 @@ export class DeepseekTuiAdapter {
152
156
  const error = runResult.error ?? (text === "" ? emptyCompletionError(handle.stderrTail) : undefined);
153
157
  return {
154
158
  text,
155
- newSessionId: threadId,
159
+ newSessionId: managedActivationId ? "" : threadId,
156
160
  ...(runResult.progressDispositions
157
161
  ? { progressDispositions: runResult.progressDispositions }
158
162
  : {}),
@@ -166,7 +170,7 @@ export class DeepseekTuiAdapter {
166
170
  const staleSession = Boolean(opts.sessionId) && isMissingThreadHttpError(err);
167
171
  return {
168
172
  text: "",
169
- newSessionId: staleSession ? "" : (opts.sessionId ?? ""),
173
+ newSessionId: managedActivationId || staleSession ? "" : (opts.sessionId ?? ""),
170
174
  error: `deepseek-tui: ${message}`,
171
175
  };
172
176
  }
@@ -174,8 +178,14 @@ export class DeepseekTuiAdapter {
174
178
  opts.signal.removeEventListener("abort", onAbort);
175
179
  if (handle && countedInFlight) {
176
180
  handle.inFlight = Math.max(0, handle.inFlight - 1);
177
- if (!this.explicitServerUrl)
181
+ if (managedActivationId && !this.explicitServerUrl && handle.inFlight === 0) {
182
+ if (PROCESS_POOL.get(POOL_KEY) === handle)
183
+ PROCESS_POOL.delete(POOL_KEY);
184
+ shutdownHandle(handle, "managed-activation-finished");
185
+ }
186
+ else if (!this.explicitServerUrl) {
178
187
  resetIdle(handle, POOL_KEY);
188
+ }
179
189
  }
180
190
  releaseTurn?.();
181
191
  }
@@ -194,14 +204,23 @@ export class DeepseekTuiAdapter {
194
204
  child: nullChild(),
195
205
  baseUrl: trimTrailingSlash(this.explicitServerUrl),
196
206
  token: this.explicitAuthToken ?? "",
207
+ managedActivationId: null,
197
208
  closed: false,
198
209
  inFlight: 0,
199
210
  stderrTail: "",
200
211
  };
201
212
  }
213
+ const managedActivationId = this.managedActivationId(opts);
202
214
  const existing = PROCESS_POOL.get(POOL_KEY);
203
- if (existing && !existing.closed)
215
+ if (existing
216
+ && !existing.closed
217
+ && existing.managedActivationId === managedActivationId) {
204
218
  return existing;
219
+ }
220
+ if (existing) {
221
+ PROCESS_POOL.delete(POOL_KEY);
222
+ shutdownHandle(existing, "activation-scope-changed");
223
+ }
205
224
  const port = await findFreePort();
206
225
  if (signal.aborted)
207
226
  throw abortReason(signal);
@@ -243,6 +262,7 @@ export class DeepseekTuiAdapter {
243
262
  child,
244
263
  baseUrl,
245
264
  token,
265
+ managedActivationId,
246
266
  closed: false,
247
267
  inFlight: 0,
248
268
  stderrTail: "",
@@ -278,8 +298,8 @@ export class DeepseekTuiAdapter {
278
298
  return handle;
279
299
  }
280
300
  /**
281
- * 不设置 DEEPSEEK_RUNTIME_DIR:server run 池化共享,per-run 目录不成立;
282
- * BYOA 直接用用户本机 deepseek 自身的默认状态目录(含已登录凭据)。
301
+ * 不设置 DEEPSEEK_RUNTIME_DIR:BYOA server 可跨 run 池化,直接使用用户本机
302
+ * deepseek 默认状态目录(含已登录凭据);Agent Service server 按 activation 回收。
283
303
  */
284
304
  spawnEnv(opts, progressMcpConfigPath) {
285
305
  const env = {
@@ -291,6 +311,12 @@ export class DeepseekTuiAdapter {
291
311
  env.DEEPSEEK_MCP_CONFIG = progressMcpConfigPath;
292
312
  return env;
293
313
  }
314
+ managedActivationId(opts) {
315
+ if (this.explicitServerUrl)
316
+ return null;
317
+ const value = opts.env?.BOTLEARN_AGENT_SERVICE_ACTIVATION_ID?.trim();
318
+ return value || null;
319
+ }
294
320
  async createThread(baseUrl, headers, opts, signal) {
295
321
  const body = {
296
322
  workspace: opts.cwd,
@@ -90,7 +90,10 @@ export function wrapEngineAdapter(id, engine, opts) {
90
90
  const payload = run.payload;
91
91
  // DeepSeek persists and replays the native thread history. The durable Course Service
92
92
  // conversation is recovery/bootstrap data, not a second history to inject on every turn.
93
- const resumesDeepseekThread = id === "deepseek-tui" && Boolean(run.nativeSessionId?.trim());
93
+ // Agent Service activations intentionally start a fresh local server because their model
94
+ // credential expires with the turn, so a cached thread id cannot suppress durable context.
95
+ const managedActivation = Boolean(run.runtimeEnv?.BOTLEARN_AGENT_SERVICE_ACTIVATION_ID?.trim());
96
+ const resumesDeepseekThread = id === "deepseek-tui" && !managedActivation && Boolean(run.nativeSessionId?.trim());
94
97
  const text = resumesDeepseekThread
95
98
  ? (payload.input.text ?? "")
96
99
  : renderConversationInput(payload);
@@ -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
  }
@@ -1,4 +1,4 @@
1
1
  #!/usr/bin/env node
2
- export declare function acquireSessionSupervisorLock(runtimeSessionId: string, lockRoot?: string): (() => void) | null;
2
+ export declare function acquireSandboxSupervisorLock(sandboxId: string, lockRoot?: string): (() => void) | null;
3
3
  export declare function runSandboxSupervisor(argv: string[]): Promise<number>;
4
4
  export declare function isMainModule(entry?: string): boolean;
@@ -49,8 +49,8 @@ async function readOneShotBootstrap() {
49
49
  throw new Error("sandbox supervisor bootstrap is empty");
50
50
  return Buffer.concat(chunks);
51
51
  }
52
- export function acquireSessionSupervisorLock(runtimeSessionId, lockRoot = SUPERVISOR_LOCK_ROOT) {
53
- const lockDir = path.join(lockRoot, runtimeSessionId);
52
+ export function acquireSandboxSupervisorLock(sandboxId, lockRoot = SUPERVISOR_LOCK_ROOT) {
53
+ const lockDir = path.join(lockRoot, sandboxId);
54
54
  const pidFile = path.join(lockDir, "pid");
55
55
  mkdirSync(lockRoot, { recursive: true, mode: 0o700 });
56
56
  try {
@@ -84,23 +84,23 @@ export function acquireSessionSupervisorLock(runtimeSessionId, lockRoot = SUPERV
84
84
  }
85
85
  };
86
86
  }
87
- function prepareDirectories(controlUid, controlGid, runtimeUid, runtimeSessionId, sessionGeneration) {
87
+ function prepareDirectories(controlUid, controlGid) {
88
88
  mkdirSync(CONTROL_HOME, { recursive: true, mode: 0o700 });
89
89
  chownSync("/home/botlearn-control/.botlearn-course", controlUid, controlGid);
90
90
  chownSync(CONTROL_HOME, controlUid, controlGid);
91
91
  chmodSync(CONTROL_HOME, 0o700);
92
- mkdirSync(RUNTIME_PROFILE_ROOT, { recursive: true, mode: 0o750 });
92
+ mkdirSync(RUNTIME_PROFILE_ROOT, { recursive: true, mode: 0o710 });
93
93
  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);
94
+ // Runtime can traverse to the active run profile path named in its instructions,
95
+ // but cannot enumerate profiles belonging to other activations.
96
+ chmodSync(RUNTIME_PROFILE_ROOT, 0o710);
97
+ // /workspace 只准备根目录;per-session 子目录
98
+ // `<root>/<runtime_session_id>/generation-<sandbox_generation>` daemon
99
+ // (botlearn-control)在 session.open 时创建。根目录只允许 runtime 组穿过,
100
+ // 不允许列举 session id;daemon 仅在 activation 期间开放目标子目录。
101
+ mkdirSync(WORKSPACE, { recursive: true, mode: 0o710 });
102
+ chownSync(WORKSPACE, controlUid, controlGid);
103
+ chmodSync(WORKSPACE, 0o710);
104
104
  }
105
105
  export async function runSandboxSupervisor(argv) {
106
106
  if (argv.length !== 2 || argv[0] !== "agent-service" || argv[1] !== "session") {
@@ -117,18 +117,18 @@ export async function runSandboxSupervisor(argv) {
117
117
  let releaseLock = null;
118
118
  try {
119
119
  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");
120
+ const sandboxId = decoded.sandboxId;
121
+ const sandboxGeneration = Number(decoded.sandboxGeneration ?? 0);
122
+ if (typeof sandboxId !== "string" ||
123
+ !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(sandboxId) ||
124
+ !Number.isInteger(sandboxGeneration) ||
125
+ sandboxGeneration < 1) {
126
+ throw new Error("sandbox supervisor bootstrap sandbox scope is invalid");
127
127
  }
128
- releaseLock = acquireSessionSupervisorLock(runtimeSessionId);
128
+ releaseLock = acquireSandboxSupervisorLock(sandboxId);
129
129
  if (releaseLock === null)
130
130
  return 0;
131
- prepareDirectories(controlUid, controlGid, runtimeUid, runtimeSessionId, sessionGeneration);
131
+ prepareDirectories(controlUid, controlGid);
132
132
  child = spawn(NODE_BINARY, [
133
133
  DAEMON_ENTRY,
134
134
  "agent-service",
@@ -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.8",
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
- }