@botlearn-course/daemon 0.0.16 → 0.0.17-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -102,6 +102,9 @@ runtime 凭据。
102
102
  - 文件扫描只访问 workspace 内路径;包含 `..`、绝对路径或反斜杠的路径会被拒绝。daemon
103
103
  只上报相对路径、大小、sha256 和有界脱敏 preview,文件正文始终留在本地或托管 sandbox
104
104
  的持久 workspace,不上传 Course Service 或对象存储。扫描或 metadata 上报失败不影响 run 终态。
105
+ - 托管 Practice Agent 的学员输入附件由 Course Service 为当前 activation 签发单对象、短时效的
106
+ 存储源站 URL;daemon 直接下载并复核 grant 集合、大小、SHA-256、MIME 与图片签名。URL 只留在
107
+ control process 内存,不进入 runtime env、prompt、workspace state、transcript、事件或日志。
105
108
  - 每个 run 在独立 workspace 内执行并有超时上限;runtime 进程在取消/超时后会被终止
106
109
  (SIGTERM → SIGKILL)。
107
110
  - `report_progress` MCP 子进程以 clean environment 启动,不继承 Course 控制字段或模型凭据;
@@ -15,6 +15,7 @@ const MAX_SPOOL_FRAMES = 1024;
15
15
  const MAX_SPOOL_BYTES = 8 * 1024 * 1024;
16
16
  const MAX_EVENT_ACK_WINDOW = 64;
17
17
  const LEGACY_EVENT_ACK_WINDOW = 1;
18
+ const INPUT_ATTACHMENT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
18
19
  /** 出站 seq 基址:seq = connection_epoch * SEQ_EPOCH_BASE + n,跨重连单调(合同 §1.1)。 */
19
20
  const SEQ_EPOCH_BASE = 1_000_000_000;
20
21
  const RUNTIME_LOG_WINDOW_MS = 60_000;
@@ -88,26 +89,44 @@ function inputAttachmentGrant(value) {
88
89
  throw new Error("invalid_input_attachment_grant");
89
90
  }
90
91
  const candidate = value;
91
- const baseUrl = typeof candidate.base_url === "string" ? candidate.base_url.trim() : "";
92
- const token = typeof candidate.token === "string" ? candidate.token.trim() : "";
93
- if (!baseUrl || baseUrl.length > 2048 || !token || token.length > 8192) {
92
+ if (!Array.isArray(candidate.downloads) ||
93
+ candidate.downloads.length < 1 ||
94
+ candidate.downloads.length > 5) {
94
95
  throw new Error("invalid_input_attachment_grant");
95
96
  }
96
- let parsed;
97
- try {
98
- parsed = new URL(baseUrl);
99
- }
100
- catch {
101
- throw new Error("invalid_input_attachment_grant");
102
- }
103
- if (!["http:", "https:"].includes(parsed.protocol) ||
104
- parsed.username ||
105
- parsed.password ||
106
- parsed.search ||
107
- parsed.hash) {
97
+ const downloads = candidate.downloads.map((value) => {
98
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
99
+ throw new Error("invalid_input_attachment_grant");
100
+ }
101
+ const raw = value;
102
+ const attachmentId = typeof raw.attachment_id === "string"
103
+ ? raw.attachment_id.trim()
104
+ : "";
105
+ const downloadUrl = typeof raw.download_url === "string"
106
+ ? raw.download_url.trim()
107
+ : "";
108
+ let parsed;
109
+ try {
110
+ parsed = new URL(downloadUrl);
111
+ }
112
+ catch {
113
+ throw new Error("invalid_input_attachment_grant");
114
+ }
115
+ if (!INPUT_ATTACHMENT_ID.test(attachmentId) ||
116
+ !downloadUrl ||
117
+ downloadUrl.length > 4096 ||
118
+ !["http:", "https:"].includes(parsed.protocol) ||
119
+ parsed.username ||
120
+ parsed.password ||
121
+ parsed.hash) {
122
+ throw new Error("invalid_input_attachment_grant");
123
+ }
124
+ return { attachmentId, downloadUrl };
125
+ });
126
+ if (new Set(downloads.map((item) => item.attachmentId)).size !== downloads.length) {
108
127
  throw new Error("invalid_input_attachment_grant");
109
128
  }
110
- return { baseUrl: parsed.toString().replace(/\/$/, ""), token };
129
+ return { downloads };
111
130
  }
112
131
  function runtimeSkillGrantKey(grants) {
113
132
  return JSON.stringify(grants);
@@ -1,6 +1,8 @@
1
1
  import type { MaterializedInputAttachment, RunInputAttachment } from "./types.js";
2
2
  export interface InputAttachmentGrant {
3
- baseUrl: string;
4
- token: string;
3
+ downloads: Array<{
4
+ attachmentId: string;
5
+ downloadUrl: string;
6
+ }>;
5
7
  }
6
8
  export declare function materializeInputAttachments(workspaceDir: string, attachments: RunInputAttachment[], grant: InputAttachmentGrant, signal: AbortSignal): Promise<MaterializedInputAttachment[]>;
@@ -58,26 +58,39 @@ function validateAttachment(attachment) {
58
58
  }
59
59
  return extension;
60
60
  }
61
- function validateGrant(grant) {
62
- if (!grant.token || grant.token.length > 8192) {
63
- throw new Error("invalid input attachment download token");
61
+ function validateDownloadUrl(value) {
62
+ if (!value || value.length > 4096) {
63
+ throw new Error("invalid input attachment download URL");
64
64
  }
65
- let base;
65
+ let parsed;
66
66
  try {
67
- base = new URL(grant.baseUrl.endsWith("/") ? grant.baseUrl : `${grant.baseUrl}/`);
67
+ parsed = new URL(value);
68
68
  }
69
69
  catch {
70
70
  throw new Error("invalid input attachment download URL");
71
71
  }
72
- if (!["http:", "https:"].includes(base.protocol) ||
73
- base.username ||
74
- base.password ||
75
- base.search ||
76
- base.hash ||
77
- base.toString().length > 2048) {
72
+ if (!["http:", "https:"].includes(parsed.protocol) ||
73
+ parsed.username ||
74
+ parsed.password ||
75
+ parsed.hash) {
78
76
  throw new Error("invalid input attachment download URL");
79
77
  }
80
- return base;
78
+ return value;
79
+ }
80
+ function validateGrant(grant) {
81
+ if (!Array.isArray(grant.downloads) ||
82
+ grant.downloads.length < 1 ||
83
+ grant.downloads.length > MAX_ATTACHMENTS) {
84
+ throw new Error("invalid input attachment download grant");
85
+ }
86
+ const downloads = new Map();
87
+ for (const item of grant.downloads) {
88
+ if (!ATTACHMENT_ID.test(item.attachmentId) || downloads.has(item.attachmentId)) {
89
+ throw new Error("invalid input attachment download grant");
90
+ }
91
+ downloads.set(item.attachmentId, validateDownloadUrl(item.downloadUrl));
92
+ }
93
+ return downloads;
81
94
  }
82
95
  function hasExpectedImageSignature(contentType, bytes) {
83
96
  switch (contentType) {
@@ -177,7 +190,12 @@ export async function materializeInputAttachments(workspaceDir, attachments, gra
177
190
  if (new Set(attachments.map((item) => item.attachment_id)).size !== attachments.length) {
178
191
  throw new Error("duplicate input attachment id");
179
192
  }
180
- const baseUrl = validateGrant(grant);
193
+ const extensions = new Map(attachments.map((attachment) => [attachment.attachment_id, validateAttachment(attachment)]));
194
+ const downloadUrls = validateGrant(grant);
195
+ if (downloadUrls.size !== attachments.length ||
196
+ attachments.some((attachment) => !downloadUrls.has(attachment.attachment_id))) {
197
+ throw new Error("input attachment download grant does not match run attachments");
198
+ }
181
199
  const inputDir = path.resolve(workspaceDir, ".botlearn", "input-attachments");
182
200
  const workspaceRoot = path.resolve(workspaceDir);
183
201
  if (!inputDir.startsWith(`${workspaceRoot}${path.sep}`)) {
@@ -188,7 +206,7 @@ export async function materializeInputAttachments(workspaceDir, attachments, gra
188
206
  await chmod(inputDir, 0o770);
189
207
  const results = [];
190
208
  for (const attachment of attachments) {
191
- const extension = validateAttachment(attachment);
209
+ const extension = extensions.get(attachment.attachment_id);
192
210
  const filename = `${attachment.attachment_id}${extension}`;
193
211
  const filePath = path.join(inputDir, filename);
194
212
  const relativePath = path.posix.join(".botlearn", "input-attachments", filename);
@@ -196,9 +214,9 @@ export async function materializeInputAttachments(workspaceDir, attachments, gra
196
214
  await rm(filePath, { force: true });
197
215
  const partPath = `${filePath}.part`;
198
216
  await rm(partPath, { force: true });
199
- const downloadUrl = new URL(encodeURIComponent(attachment.attachment_id), baseUrl);
217
+ const downloadUrl = downloadUrls.get(attachment.attachment_id);
200
218
  const response = await fetch(downloadUrl, {
201
- headers: { authorization: `Bearer ${grant.token}` },
219
+ headers: { "accept-encoding": "identity" },
202
220
  redirect: "error",
203
221
  signal,
204
222
  });
@@ -18,6 +18,10 @@ const DEFAULT_TIMEOUT_SECONDS = 900;
18
18
  const MIN_TIMEOUT_SECONDS = 30;
19
19
  const MAX_TIMEOUT_SECONDS = 7200;
20
20
  const DEFAULT_MAX_OUTPUT_CHARS = 100_000;
21
+ // Attachment size is bounded at 10 MiB in input-attachments.ts. Keep one universal
22
+ // materialization budget that also works for supported cross-cloud object stores; this is
23
+ // a daemon compatibility invariant, not an environment-specific tuning knob.
24
+ const INPUT_ATTACHMENT_DOWNLOAD_TIMEOUT_MS = 5 * 60_000;
21
25
  // wire 上单块文本上限;完整文本在本地 transcript。
22
26
  const BLOCK_TEXT_MAX_CHARS = 4000;
23
27
  const CONTENT_FLUSH_MAX_CHARS = 512;
@@ -449,7 +453,7 @@ export class RunDispatcher {
449
453
  const attachmentController = new AbortController();
450
454
  const abortAttachmentDownload = () => attachmentController.abort();
451
455
  controller.signal.addEventListener("abort", abortAttachmentDownload, { once: true });
452
- const attachmentTimer = setTimeout(abortAttachmentDownload, 60_000);
456
+ const attachmentTimer = setTimeout(abortAttachmentDownload, INPUT_ATTACHMENT_DOWNLOAD_TIMEOUT_MS);
453
457
  try {
454
458
  inputAttachments = await materializeInputAttachments(workspaceDir, requestedAttachments, persistentTurn.inputAttachmentGrant, attachmentController.signal);
455
459
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botlearn-course/daemon",
3
- "version": "0.0.16",
3
+ "version": "0.0.17-beta.2",
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": {