@downcity/agent 1.1.86 → 1.1.91

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.
Files changed (78) hide show
  1. package/bin/config/AgentInitializer.d.ts.map +1 -1
  2. package/bin/config/AgentInitializer.js +2 -1
  3. package/bin/config/AgentInitializer.js.map +1 -1
  4. package/bin/config/Paths.d.ts +8 -0
  5. package/bin/config/Paths.d.ts.map +1 -1
  6. package/bin/config/Paths.js +10 -0
  7. package/bin/config/Paths.js.map +1 -1
  8. package/bin/executor/Executor.d.ts.map +1 -1
  9. package/bin/executor/Executor.js +3 -0
  10. package/bin/executor/Executor.js.map +1 -1
  11. package/bin/executor/messages/AssistantFileResource.d.ts +31 -0
  12. package/bin/executor/messages/AssistantFileResource.d.ts.map +1 -0
  13. package/bin/executor/messages/AssistantFileResource.js +113 -0
  14. package/bin/executor/messages/AssistantFileResource.js.map +1 -0
  15. package/bin/executor/messages/SessionAttachmentMapper.d.ts +8 -0
  16. package/bin/executor/messages/SessionAttachmentMapper.d.ts.map +1 -1
  17. package/bin/executor/messages/SessionAttachmentMapper.js +54 -0
  18. package/bin/executor/messages/SessionAttachmentMapper.js.map +1 -1
  19. package/bin/executor/messages/SessionMessageCodec.d.ts.map +1 -1
  20. package/bin/executor/messages/SessionMessageCodec.js +5 -3
  21. package/bin/executor/messages/SessionMessageCodec.js.map +1 -1
  22. package/bin/executor/tools/plugin/PluginToolBridge.d.ts.map +1 -1
  23. package/bin/executor/tools/plugin/PluginToolBridge.js +9 -2
  24. package/bin/executor/tools/plugin/PluginToolBridge.js.map +1 -1
  25. package/bin/index.d.ts +1 -1
  26. package/bin/index.d.ts.map +1 -1
  27. package/bin/index.js.map +1 -1
  28. package/bin/plugin/core/ImagePlugin.d.ts +62 -0
  29. package/bin/plugin/core/ImagePlugin.d.ts.map +1 -1
  30. package/bin/plugin/core/ImagePlugin.js +230 -7
  31. package/bin/plugin/core/ImagePlugin.js.map +1 -1
  32. package/bin/sandbox/LinuxBubblewrapSandbox.d.ts +21 -0
  33. package/bin/sandbox/LinuxBubblewrapSandbox.d.ts.map +1 -0
  34. package/bin/sandbox/LinuxBubblewrapSandbox.js +184 -0
  35. package/bin/sandbox/LinuxBubblewrapSandbox.js.map +1 -0
  36. package/bin/sandbox/SandboxConfigResolver.d.ts +5 -0
  37. package/bin/sandbox/SandboxConfigResolver.d.ts.map +1 -1
  38. package/bin/sandbox/SandboxConfigResolver.js +11 -4
  39. package/bin/sandbox/SandboxConfigResolver.js.map +1 -1
  40. package/bin/sandbox/SandboxRunner.d.ts +1 -1
  41. package/bin/sandbox/SandboxRunner.d.ts.map +1 -1
  42. package/bin/sandbox/SandboxRunner.js +11 -3
  43. package/bin/sandbox/SandboxRunner.js.map +1 -1
  44. package/bin/sandbox/types/SandboxRuntime.d.ts +6 -2
  45. package/bin/sandbox/types/SandboxRuntime.d.ts.map +1 -1
  46. package/bin/session/Session.d.ts.map +1 -1
  47. package/bin/session/Session.js +1 -0
  48. package/bin/session/Session.js.map +1 -1
  49. package/bin/session/services/SessionTurnService.d.ts +5 -0
  50. package/bin/session/services/SessionTurnService.d.ts.map +1 -1
  51. package/bin/session/services/SessionTurnService.js +3 -0
  52. package/bin/session/services/SessionTurnService.js.map +1 -1
  53. package/bin/types/executor/SessionRunContext.d.ts +8 -0
  54. package/bin/types/executor/SessionRunContext.d.ts.map +1 -1
  55. package/bin/types/plugin/ImagePlugin.d.ts +79 -2
  56. package/bin/types/plugin/ImagePlugin.d.ts.map +1 -1
  57. package/package.json +2 -2
  58. package/scripts/assistant-file-resource.test.mjs +91 -0
  59. package/scripts/image-plugin-job.test.mjs +155 -0
  60. package/scripts/linux-bubblewrap-sandbox.test.mjs +142 -0
  61. package/src/config/AgentInitializer.ts +2 -0
  62. package/src/config/Paths.ts +11 -0
  63. package/src/executor/Executor.ts +3 -0
  64. package/src/executor/messages/AssistantFileResource.ts +155 -0
  65. package/src/executor/messages/SessionAttachmentMapper.ts +59 -0
  66. package/src/executor/messages/SessionMessageCodec.ts +9 -3
  67. package/src/executor/tools/plugin/PluginToolBridge.ts +13 -2
  68. package/src/index.ts +4 -0
  69. package/src/plugin/core/ImagePlugin.ts +284 -7
  70. package/src/sandbox/LinuxBubblewrapSandbox.ts +229 -0
  71. package/src/sandbox/SandboxConfigResolver.ts +13 -7
  72. package/src/sandbox/SandboxRunner.ts +11 -3
  73. package/src/sandbox/types/SandboxRuntime.ts +7 -2
  74. package/src/session/Session.ts +1 -0
  75. package/src/session/services/SessionTurnService.ts +8 -0
  76. package/src/types/executor/SessionRunContext.ts +9 -0
  77. package/src/types/plugin/ImagePlugin.ts +79 -2
  78. package/tsconfig.tsbuildinfo +1 -1
@@ -7,10 +7,14 @@
7
7
  * - action 返回 AI SDK UIMessage,后续由 plugin tool bridge 抽取 file parts 写回 assistant 消息。
8
8
  */
9
9
 
10
+ import crypto from "node:crypto";
10
11
  import type { AgentContext } from "@/types/runtime/agent/AgentContext.js";
11
12
  import type { JsonObject, JsonValue } from "@/types/common/Json.js";
12
13
  import type {
13
14
  ImagePluginInput,
15
+ ImagePluginJobCreateResult,
16
+ ImagePluginJobResult,
17
+ ImagePluginJobStatusResult,
14
18
  ImagePluginOptions,
15
19
  ImagePluginResult,
16
20
  } from "@/types/plugin/ImagePlugin.js";
@@ -20,6 +24,39 @@ const DEFAULT_IMAGE_PLUGIN_NAME = "image";
20
24
  const DEFAULT_IMAGE_PLUGIN_TITLE = "Image";
21
25
  const DEFAULT_IMAGE_PLUGIN_DESCRIPTION =
22
26
  "Generate images and return them as assistant file parts.";
27
+ const DEFAULT_WAIT_TIMEOUT_MS = 60_000;
28
+ const DEFAULT_POLL_INTERVAL_MS = 3_000;
29
+
30
+ type LocalImageJobRecord = {
31
+ /**
32
+ * 图片任务唯一 ID。
33
+ */
34
+ job_id: string;
35
+ /**
36
+ * 当前任务状态。
37
+ */
38
+ status: "queued" | "running" | "succeeded" | "failed";
39
+ /**
40
+ * 成功时的图片结果。
41
+ */
42
+ result?: ImagePluginResult;
43
+ /**
44
+ * 失败时的错误信息。
45
+ */
46
+ error?: string;
47
+ /**
48
+ * 人类可读状态说明。
49
+ */
50
+ message?: string;
51
+ /**
52
+ * 任务创建时间。
53
+ */
54
+ created_at: string;
55
+ /**
56
+ * 任务更新时间。
57
+ */
58
+ updated_at: string;
59
+ };
23
60
 
24
61
  /**
25
62
  * 判断值是否为普通对象。
@@ -40,6 +77,15 @@ function normalize_image_payload(payload: JsonValue | undefined): ImagePluginInp
40
77
  return { ...record } as ImagePluginInput;
41
78
  }
42
79
 
80
+ function normalize_job_id_payload(payload: JsonValue | undefined): { job_id: string } {
81
+ const record = to_record(payload ?? {});
82
+ const job_id = String(record?.job_id || "").trim();
83
+ if (!job_id) {
84
+ throw new TypeError("ImagePlugin job action requires job_id");
85
+ }
86
+ return { job_id };
87
+ }
88
+
43
89
  /**
44
90
  * 校验 image 函数返回的 UIMessage。
45
91
  */
@@ -51,6 +97,25 @@ function normalize_image_result(result: ImagePluginResult): ImagePluginResult {
51
97
  return result;
52
98
  }
53
99
 
100
+ /**
101
+ * 归一化任务状态查询结果,确保 status action 不携带图片结果。
102
+ */
103
+ function normalize_job_status_result(
104
+ result: ImagePluginJobStatusResult,
105
+ ): ImagePluginJobStatusResult {
106
+ return {
107
+ job_id: result.job_id,
108
+ status: result.status,
109
+ ...(result.message ? { message: result.message } : {}),
110
+ ...(result.error ? { error: result.error } : {}),
111
+ ...(typeof result.poll_after_ms === "number"
112
+ ? { poll_after_ms: result.poll_after_ms }
113
+ : {}),
114
+ ...(result.created_at ? { created_at: result.created_at } : {}),
115
+ ...(result.updated_at ? { updated_at: result.updated_at } : {}),
116
+ };
117
+ }
118
+
54
119
  /**
55
120
  * Agent 图片生成插件。
56
121
  */
@@ -71,6 +136,12 @@ export class ImagePlugin extends BasePlugin {
71
136
  readonly description: string;
72
137
 
73
138
  private readonly image: ImagePluginOptions["image"];
139
+ private readonly create_job?: ImagePluginOptions["create"];
140
+ private readonly read_job_status?: ImagePluginOptions["status"];
141
+ private readonly read_job_result?: ImagePluginOptions["result"];
142
+ private readonly wait_timeout_ms: number;
143
+ private readonly poll_interval_ms: number;
144
+ private readonly local_jobs = new Map<string, LocalImageJobRecord>();
74
145
 
75
146
  constructor(options: ImagePluginOptions) {
76
147
  super();
@@ -78,8 +149,23 @@ export class ImagePlugin extends BasePlugin {
78
149
  if (!name) {
79
150
  throw new Error("ImagePlugin requires a non-empty name");
80
151
  }
81
- if (typeof options.image !== "function") {
82
- throw new Error("ImagePlugin requires an image(input) function");
152
+ const has_custom_job_api = Boolean(
153
+ options.create || options.status || options.result,
154
+ );
155
+ if (
156
+ has_custom_job_api &&
157
+ (typeof options.create !== "function" ||
158
+ typeof options.status !== "function" ||
159
+ typeof options.result !== "function")
160
+ ) {
161
+ throw new Error(
162
+ "ImagePlugin custom job API requires create, status, and result functions",
163
+ );
164
+ }
165
+ if (!has_custom_job_api && typeof options.image !== "function") {
166
+ throw new Error(
167
+ "ImagePlugin requires either image(input) or create/status/result functions",
168
+ );
83
169
  }
84
170
  this.name = name;
85
171
  this.title = String(options.title || DEFAULT_IMAGE_PLUGIN_TITLE).trim();
@@ -87,6 +173,17 @@ export class ImagePlugin extends BasePlugin {
87
173
  options.description || DEFAULT_IMAGE_PLUGIN_DESCRIPTION,
88
174
  ).trim();
89
175
  this.image = options.image;
176
+ this.create_job = options.create;
177
+ this.read_job_status = options.status;
178
+ this.read_job_result = options.result;
179
+ this.wait_timeout_ms =
180
+ typeof options.wait_timeout_ms === "number" && options.wait_timeout_ms > 0
181
+ ? options.wait_timeout_ms
182
+ : DEFAULT_WAIT_TIMEOUT_MS;
183
+ this.poll_interval_ms =
184
+ typeof options.poll_interval_ms === "number" && options.poll_interval_ms > 0
185
+ ? options.poll_interval_ms
186
+ : DEFAULT_POLL_INTERVAL_MS;
90
187
  }
91
188
 
92
189
  /**
@@ -94,22 +191,202 @@ export class ImagePlugin extends BasePlugin {
94
191
  */
95
192
  system(_context: AgentContext): string {
96
193
  return [
97
- "Image generation is available through the plugin_call tool.",
98
- `Call plugin "${this.name}" action "generate" when the user asks to create, render, draw, or edit an image.`,
99
- "Pass a JSON payload with prompt, optional size/aspect_ratio/quality/n, and optional provider_options.",
100
- "The generated image files will be attached to the final assistant message automatically.",
194
+ "Image generation is available through the plugin_call tool as an observable job workflow.",
195
+ `Call plugin "${this.name}" action "create" when the user asks to create, render, draw, or edit an image.`,
196
+ `Then call plugin "${this.name}" action "status" with { job_id } to inspect progress.`,
197
+ `When status is succeeded, call plugin "${this.name}" action "result" with { job_id } to attach the generated files.`,
198
+ "Use action \"generate\" only as a compatibility shortcut when you explicitly need to wait for completion.",
199
+ "Pass a JSON payload with prompt, optional size/aspect_ratio/quality/n, and optional provider_options to create/generate.",
101
200
  ].join("\n");
102
201
  }
103
202
 
203
+ private create_local_job(input: ImagePluginInput): ImagePluginJobCreateResult {
204
+ if (typeof this.image !== "function") {
205
+ throw new Error("ImagePlugin local image job requires image(input)");
206
+ }
207
+ const now = new Date().toISOString();
208
+ const job_id = `img_${crypto.randomUUID()}`;
209
+ const record: LocalImageJobRecord = {
210
+ job_id,
211
+ status: "running",
212
+ message: "image job is running",
213
+ created_at: now,
214
+ updated_at: now,
215
+ };
216
+ this.local_jobs.set(job_id, record);
217
+
218
+ void this.run_local_job(record, input, this.image);
219
+
220
+ return {
221
+ job_id,
222
+ status: record.status,
223
+ message: record.message,
224
+ poll_after_ms: this.poll_interval_ms,
225
+ created_at: record.created_at,
226
+ updated_at: record.updated_at,
227
+ };
228
+ }
229
+
230
+ private async run_local_job(
231
+ record: LocalImageJobRecord,
232
+ input: ImagePluginInput,
233
+ image: NonNullable<ImagePluginOptions["image"]>,
234
+ ): Promise<void> {
235
+ try {
236
+ const message = normalize_image_result(await image(input));
237
+ record.status = "succeeded";
238
+ record.result = message;
239
+ record.message = "image job succeeded";
240
+ record.updated_at = new Date().toISOString();
241
+ } catch (error) {
242
+ record.status = "failed";
243
+ record.error = String(error);
244
+ record.message = "image job failed";
245
+ record.updated_at = new Date().toISOString();
246
+ }
247
+ }
248
+
249
+ private read_local_job(job_id: string): LocalImageJobRecord {
250
+ const record = this.local_jobs.get(job_id);
251
+ if (!record) {
252
+ throw new Error(`Unknown image job: ${job_id}`);
253
+ }
254
+ return record;
255
+ }
256
+
257
+ private serialize_local_status(record: LocalImageJobRecord): ImagePluginJobStatusResult {
258
+ return {
259
+ job_id: record.job_id,
260
+ status: record.status,
261
+ ...(record.message ? { message: record.message } : {}),
262
+ ...(record.error ? { error: record.error } : {}),
263
+ ...(record.status === "running" || record.status === "queued"
264
+ ? { poll_after_ms: this.poll_interval_ms }
265
+ : {}),
266
+ created_at: record.created_at,
267
+ updated_at: record.updated_at,
268
+ };
269
+ }
270
+
271
+ private serialize_local_result(record: LocalImageJobRecord): ImagePluginJobResult {
272
+ return {
273
+ job_id: record.job_id,
274
+ status: record.status,
275
+ ...(record.result ? { result: record.result } : {}),
276
+ ...(record.error ? { error: record.error } : {}),
277
+ ...(record.message ? { message: record.message } : {}),
278
+ created_at: record.created_at,
279
+ updated_at: record.updated_at,
280
+ };
281
+ }
282
+
283
+ private async wait_for_job(job_id: string): Promise<ImagePluginResult> {
284
+ const deadline = Date.now() + this.wait_timeout_ms;
285
+ while (Date.now() <= deadline) {
286
+ const result = this.read_job_result
287
+ ? await this.read_job_result({ job_id })
288
+ : this.serialize_local_result(this.read_local_job(job_id));
289
+ if (result.status === "succeeded" && result.result) {
290
+ return normalize_image_result(result.result);
291
+ }
292
+ if (result.status === "failed") {
293
+ throw new Error(result.error || result.message || "image job failed");
294
+ }
295
+ await new Promise((resolve) => setTimeout(resolve, this.poll_interval_ms));
296
+ }
297
+ throw new Error(`image job timed out: ${job_id}`);
298
+ }
299
+
104
300
  /**
105
301
  * 显式 action 集合。
106
302
  */
107
303
  readonly actions = {
304
+ create: {
305
+ execute: async ({ payload }: { payload: JsonValue }) => {
306
+ try {
307
+ const input = normalize_image_payload(payload);
308
+ const result = this.create_job
309
+ ? await this.create_job(input)
310
+ : this.create_local_job(input);
311
+ return {
312
+ success: true,
313
+ data: result as unknown as JsonObject,
314
+ message: result.message || "image job created",
315
+ };
316
+ } catch (error) {
317
+ return {
318
+ success: false,
319
+ error: String(error),
320
+ message: String(error),
321
+ };
322
+ }
323
+ },
324
+ },
325
+ status: {
326
+ execute: async ({ payload }: { payload: JsonValue }) => {
327
+ try {
328
+ const input = normalize_job_id_payload(payload);
329
+ const result = this.read_job_status
330
+ ? normalize_job_status_result(await this.read_job_status(input))
331
+ : this.serialize_local_status(this.read_local_job(input.job_id));
332
+ return {
333
+ success: true,
334
+ data: result as unknown as JsonObject,
335
+ message: result.message || `image job ${result.status}`,
336
+ };
337
+ } catch (error) {
338
+ return {
339
+ success: false,
340
+ error: String(error),
341
+ message: String(error),
342
+ };
343
+ }
344
+ },
345
+ },
346
+ result: {
347
+ execute: async ({ payload }: { payload: JsonValue }) => {
348
+ try {
349
+ const input = normalize_job_id_payload(payload);
350
+ const result = this.read_job_result
351
+ ? await this.read_job_result(input)
352
+ : this.serialize_local_result(this.read_local_job(input.job_id));
353
+ if (result.status === "succeeded" && result.result) {
354
+ return {
355
+ success: true,
356
+ data: result.result as unknown as JsonObject,
357
+ message: result.message || "image job succeeded",
358
+ };
359
+ }
360
+ if (result.status === "failed") {
361
+ return {
362
+ success: false,
363
+ data: result as unknown as JsonObject,
364
+ error: result.error || result.message || "image job failed",
365
+ message: result.message || "image job failed",
366
+ };
367
+ }
368
+ return {
369
+ success: true,
370
+ data: result as unknown as JsonObject,
371
+ message: result.message || `image job ${result.status}`,
372
+ };
373
+ } catch (error) {
374
+ return {
375
+ success: false,
376
+ error: String(error),
377
+ message: String(error),
378
+ };
379
+ }
380
+ },
381
+ },
108
382
  generate: {
109
383
  execute: async ({ payload }: { payload: JsonValue }) => {
110
384
  try {
111
385
  const input = normalize_image_payload(payload);
112
- const message = normalize_image_result(await this.image(input));
386
+ const job = this.create_job
387
+ ? await this.create_job(input)
388
+ : this.create_local_job(input);
389
+ const message = await this.wait_for_job(job.job_id);
113
390
  return {
114
391
  success: true,
115
392
  data: message as unknown as JsonObject,
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Linux Bubblewrap sandbox backend。
3
+ *
4
+ * 关键点(中文)
5
+ * - 基于 `bwrap` 提供 Linux 本机 shell sandbox。
6
+ * - 继续保持“shell 命令必须进入 sandbox”的安全语义,不提供宿主机裸跑回退。
7
+ * - 边界与 macOS backend 对齐:路径、环境变量、网络、隔离 HOME/TMPDIR。
8
+ */
9
+
10
+ import { spawn } from "node:child_process";
11
+ import path from "node:path";
12
+ import fs from "fs-extra";
13
+ import type {
14
+ SandboxSpawnParams,
15
+ SandboxSpawnResult,
16
+ } from "@/sandbox/types/SandboxRuntime.js";
17
+
18
+ const DEFAULT_PATH_VALUE =
19
+ "/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin";
20
+
21
+ function dedupeExistingPaths(values: string[]): string[] {
22
+ const seen = new Set<string>();
23
+ const result: string[] = [];
24
+ for (const value of values) {
25
+ const normalized = path.resolve(String(value || "").trim());
26
+ if (!normalized || seen.has(normalized)) continue;
27
+ if (!fs.existsSync(normalized)) continue;
28
+ seen.add(normalized);
29
+ result.push(normalized);
30
+ }
31
+ return result;
32
+ }
33
+
34
+ function buildReadablePaths(params: {
35
+ rootPath: string;
36
+ shellPath: string;
37
+ shellHomeDir: string;
38
+ shellTmpDir: string;
39
+ }): string[] {
40
+ return dedupeExistingPaths([
41
+ "/usr",
42
+ "/bin",
43
+ "/sbin",
44
+ "/lib",
45
+ "/lib64",
46
+ "/etc",
47
+ params.rootPath,
48
+ params.shellHomeDir,
49
+ params.shellTmpDir,
50
+ path.dirname(params.shellPath),
51
+ ]);
52
+ }
53
+
54
+ function buildWritablePaths(params: SandboxSpawnParams & {
55
+ shellHomeDir: string;
56
+ shellTmpDir: string;
57
+ }): string[] {
58
+ return dedupeExistingPaths([
59
+ ...params.config.writablePaths,
60
+ params.shellDir,
61
+ params.shellHomeDir,
62
+ params.shellTmpDir,
63
+ ]);
64
+ }
65
+
66
+ function isPathCoveredBy(paths: string[], targetPath: string): boolean {
67
+ const normalizedTarget = path.resolve(targetPath);
68
+ return paths.some((value) => {
69
+ const normalizedValue = path.resolve(value);
70
+ if (normalizedValue === normalizedTarget) return true;
71
+ const relative = path.relative(normalizedValue, normalizedTarget);
72
+ return Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative);
73
+ });
74
+ }
75
+
76
+ function buildSandboxEnv(params: SandboxSpawnParams & {
77
+ shellHomeDir: string;
78
+ shellTmpDir: string;
79
+ }): NodeJS.ProcessEnv {
80
+ const env: NodeJS.ProcessEnv = {};
81
+ for (const key of params.config.envAllowlist) {
82
+ const value = params.baseEnv[key];
83
+ if (typeof value !== "string" || !value.trim()) continue;
84
+ env[key] = value;
85
+ }
86
+
87
+ for (const [key, value] of Object.entries(params.baseEnv)) {
88
+ if (!key.startsWith("DC_")) continue;
89
+ if (typeof value !== "string" || !value.trim()) continue;
90
+ env[key] = value;
91
+ }
92
+
93
+ env.PATH = String(env.PATH || params.baseEnv.PATH || DEFAULT_PATH_VALUE);
94
+ env.HOME = params.shellHomeDir;
95
+ env.TMPDIR = params.shellTmpDir;
96
+ env.SHELL = params.shellPath;
97
+
98
+ return env;
99
+ }
100
+
101
+ function addReadOnlyBind(args: string[], sourcePath: string): void {
102
+ args.push("--ro-bind", sourcePath, sourcePath);
103
+ }
104
+
105
+ function addWritableBind(args: string[], sourcePath: string): void {
106
+ args.push("--bind", sourcePath, sourcePath);
107
+ }
108
+
109
+ function addParentDirs(args: string[], targetPath: string, createdDirs: Set<string>): void {
110
+ const parts = path.resolve(targetPath).split(path.sep).filter(Boolean);
111
+ let current = "";
112
+ for (let index = 0; index < parts.length - 1; index += 1) {
113
+ current = `${current}/${parts[index]}`;
114
+ if (createdDirs.has(current)) continue;
115
+ createdDirs.add(current);
116
+ args.push("--dir", current);
117
+ }
118
+ }
119
+
120
+ export function buildLinuxBubblewrapArgs(params: SandboxSpawnParams & {
121
+ actualCwd: string;
122
+ shellHomeDir: string;
123
+ shellTmpDir: string;
124
+ }): string[] {
125
+ const readablePaths = buildReadablePaths({
126
+ rootPath: params.config.rootPath,
127
+ shellPath: params.shellPath,
128
+ shellHomeDir: params.shellHomeDir,
129
+ shellTmpDir: params.shellTmpDir,
130
+ });
131
+ const writablePaths = buildWritablePaths({
132
+ ...params,
133
+ shellHomeDir: params.shellHomeDir,
134
+ shellTmpDir: params.shellTmpDir,
135
+ });
136
+ const writableSet = new Set(writablePaths);
137
+ const createdDirs = new Set<string>();
138
+ const mountedPaths: string[] = [];
139
+ const args = [
140
+ "--die-with-parent",
141
+ "--unshare-pid",
142
+ "--proc",
143
+ "/proc",
144
+ "--dev",
145
+ "/dev",
146
+ ];
147
+
148
+ if (params.config.networkMode === "off") {
149
+ args.push("--unshare-net");
150
+ }
151
+
152
+ for (const readablePath of readablePaths) {
153
+ if (writableSet.has(readablePath)) continue;
154
+ if (!isPathCoveredBy(mountedPaths, readablePath)) {
155
+ addParentDirs(args, readablePath, createdDirs);
156
+ }
157
+ addReadOnlyBind(args, readablePath);
158
+ mountedPaths.push(readablePath);
159
+ }
160
+
161
+ for (const writablePath of writablePaths) {
162
+ if (!isPathCoveredBy(mountedPaths, writablePath)) {
163
+ addParentDirs(args, writablePath, createdDirs);
164
+ }
165
+ addWritableBind(args, writablePath);
166
+ mountedPaths.push(writablePath);
167
+ }
168
+
169
+ if (
170
+ !isPathCoveredBy(readablePaths, params.actualCwd) &&
171
+ !isPathCoveredBy(writablePaths, params.actualCwd)
172
+ ) {
173
+ if (!isPathCoveredBy(mountedPaths, params.actualCwd)) {
174
+ addParentDirs(args, params.actualCwd, createdDirs);
175
+ }
176
+ addReadOnlyBind(args, params.actualCwd);
177
+ }
178
+
179
+ args.push(
180
+ "--chdir",
181
+ params.actualCwd,
182
+ params.shellPath,
183
+ params.login ? "-lc" : "-c",
184
+ params.cmd,
185
+ );
186
+ return args;
187
+ }
188
+
189
+ /**
190
+ * 在 Linux bubblewrap sandbox 中启动 shell 子进程。
191
+ */
192
+ export async function spawnLinuxBubblewrapSandbox(
193
+ params: SandboxSpawnParams & { actualCwd: string },
194
+ ): Promise<SandboxSpawnResult> {
195
+ const sandboxRootDir = path.join(params.shellDir, "sandbox");
196
+ const shellHomeDir = path.join(sandboxRootDir, "home");
197
+ const shellTmpDir = path.join(sandboxRootDir, "tmp");
198
+
199
+ await fs.ensureDir(shellHomeDir);
200
+ await fs.ensureDir(shellTmpDir);
201
+ for (const writablePath of params.config.writablePaths) {
202
+ await fs.ensureDir(writablePath);
203
+ }
204
+
205
+ const child = spawn("bwrap", buildLinuxBubblewrapArgs({
206
+ ...params,
207
+ shellHomeDir,
208
+ shellTmpDir,
209
+ }), {
210
+ cwd: params.actualCwd,
211
+ stdio: "pipe",
212
+ env: buildSandboxEnv({
213
+ ...params,
214
+ shellHomeDir,
215
+ shellTmpDir,
216
+ }),
217
+ });
218
+
219
+ child.stdout.setEncoding("utf8");
220
+ child.stderr.setEncoding("utf8");
221
+
222
+ return {
223
+ child,
224
+ cwd: params.actualCwd,
225
+ sandboxed: true,
226
+ backend: "linux-bubblewrap",
227
+ networkMode: params.config.networkMode,
228
+ };
229
+ }
@@ -9,6 +9,7 @@
9
9
 
10
10
  import path from "node:path";
11
11
  import type { AgentContext } from "@/types/runtime/agent/AgentContext.js";
12
+ import type { SandboxBackend } from "@/sandbox/types/SandboxRuntime.js";
12
13
  import type { ResolvedSandboxConfig } from "@/sandbox/types/SandboxRuntime.js";
13
14
 
14
15
  const DEFAULT_ENV_ALLOWLIST = [
@@ -84,6 +85,17 @@ function normalizeWritablePaths(params: {
84
85
  return result;
85
86
  }
86
87
 
88
+ /**
89
+ * 根据宿主平台解析当前 sandbox backend。
90
+ */
91
+ export function resolveSandboxBackend(): SandboxBackend {
92
+ if (process.platform === "darwin") return "macos-seatbelt";
93
+ if (process.platform === "linux") return "linux-bubblewrap";
94
+ throw new Error(
95
+ `sandbox backend is required for shell execution, but current platform is unsupported: ${process.platform}`,
96
+ );
97
+ }
98
+
87
99
  /**
88
100
  * 解析当前请求最终使用的 sandbox 配置。
89
101
  */
@@ -91,14 +103,8 @@ export function resolveSandboxConfig(context: AgentContext): ResolvedSandboxConf
91
103
  const rootPath = path.resolve(context.rootPath);
92
104
  const projectConfig = context.config?.sandbox;
93
105
 
94
- if (process.platform !== "darwin") {
95
- throw new Error(
96
- `sandbox backend is required for shell execution, but current platform is unsupported: ${process.platform}`,
97
- );
98
- }
99
-
100
106
  return {
101
- backend: "macos-seatbelt",
107
+ backend: resolveSandboxBackend(),
102
108
  rootPath,
103
109
  envAllowlist: normalizeEnvAllowlist(projectConfig?.envAllowlist),
104
110
  writablePaths: normalizeWritablePaths({
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * 关键点(中文)
5
5
  * - 这里不实现完整的 session/read/write 协议,只负责 shell 子进程创建时统一进入 sandbox backend。
6
- * - 当前版本只接入 macOS seatbelt backend。
6
+ * - 当前版本接入 macOS seatbelt 与 Linux bubblewrap backend。
7
7
  * - shell 命令不再允许回退到宿主机普通子进程执行。
8
8
  */
9
9
 
@@ -11,6 +11,7 @@ import type { AgentContext } from "@/types/runtime/agent/AgentContext.js";
11
11
  import type { SandboxSpawnResult } from "@/sandbox/types/SandboxRuntime.js";
12
12
  import { resolveSandboxConfig, resolveSandboxCwd } from "@/sandbox/SandboxConfigResolver.js";
13
13
  import { spawnMacOsSeatbeltSandbox } from "@/sandbox/MacOsSeatbeltSandbox.js";
14
+ import { spawnLinuxBubblewrapSandbox } from "@/sandbox/LinuxBubblewrapSandbox.js";
14
15
 
15
16
  /**
16
17
  * 启动 shell 子进程。
@@ -31,7 +32,7 @@ export async function spawnShellProcess(params: {
31
32
  requestedCwd: params.cwd,
32
33
  context: params.context,
33
34
  });
34
- return spawnMacOsSeatbeltSandbox({
35
+ const spawnParams = {
35
36
  shellId: params.shellId,
36
37
  shellDir: params.shellDir,
37
38
  cmd: params.cmd,
@@ -41,7 +42,14 @@ export async function spawnShellProcess(params: {
41
42
  baseEnv: params.baseEnv,
42
43
  config,
43
44
  actualCwd,
44
- });
45
+ };
46
+ if (config.backend === "macos-seatbelt") {
47
+ return spawnMacOsSeatbeltSandbox(spawnParams);
48
+ }
49
+ if (config.backend === "linux-bubblewrap") {
50
+ return spawnLinuxBubblewrapSandbox(spawnParams);
51
+ }
52
+ throw new Error(`unsupported sandbox backend: ${config.backend}`);
45
53
  }
46
54
 
47
55
  /**
@@ -11,6 +11,11 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process";
11
11
  import type { SandboxConfig } from "@/sandbox/types/Sandbox.js";
12
12
  import type { SandboxNetworkMode } from "@/sandbox/types/Sandbox.js";
13
13
 
14
+ /**
15
+ * 当前内置支持的 sandbox backend。
16
+ */
17
+ export type SandboxBackend = "macos-seatbelt" | "linux-bubblewrap";
18
+
14
19
  /**
15
20
  * sandbox 会话状态。
16
21
  *
@@ -298,7 +303,7 @@ export interface ResolvedSandboxConfig extends SandboxConfig {
298
303
  /**
299
304
  * 当前运行时选中的 backend。
300
305
  */
301
- backend: "macos-seatbelt";
306
+ backend: SandboxBackend;
302
307
  }
303
308
 
304
309
  /**
@@ -368,7 +373,7 @@ export interface SandboxSpawnResult {
368
373
  /**
369
374
  * 当前使用的 backend 名称。
370
375
  */
371
- backend: "macos-seatbelt";
376
+ backend: SandboxBackend;
372
377
 
373
378
  /**
374
379
  * 当前实际采用的网络模式。