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

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
  });
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.1",
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": {