@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.
- package/bin/config/AgentInitializer.d.ts.map +1 -1
- package/bin/config/AgentInitializer.js +2 -1
- package/bin/config/AgentInitializer.js.map +1 -1
- package/bin/config/Paths.d.ts +8 -0
- package/bin/config/Paths.d.ts.map +1 -1
- package/bin/config/Paths.js +10 -0
- package/bin/config/Paths.js.map +1 -1
- package/bin/executor/Executor.d.ts.map +1 -1
- package/bin/executor/Executor.js +3 -0
- package/bin/executor/Executor.js.map +1 -1
- package/bin/executor/messages/AssistantFileResource.d.ts +31 -0
- package/bin/executor/messages/AssistantFileResource.d.ts.map +1 -0
- package/bin/executor/messages/AssistantFileResource.js +113 -0
- package/bin/executor/messages/AssistantFileResource.js.map +1 -0
- package/bin/executor/messages/SessionAttachmentMapper.d.ts +8 -0
- package/bin/executor/messages/SessionAttachmentMapper.d.ts.map +1 -1
- package/bin/executor/messages/SessionAttachmentMapper.js +54 -0
- package/bin/executor/messages/SessionAttachmentMapper.js.map +1 -1
- package/bin/executor/messages/SessionMessageCodec.d.ts.map +1 -1
- package/bin/executor/messages/SessionMessageCodec.js +5 -3
- package/bin/executor/messages/SessionMessageCodec.js.map +1 -1
- package/bin/executor/tools/plugin/PluginToolBridge.d.ts.map +1 -1
- package/bin/executor/tools/plugin/PluginToolBridge.js +9 -2
- package/bin/executor/tools/plugin/PluginToolBridge.js.map +1 -1
- package/bin/index.d.ts +1 -1
- package/bin/index.d.ts.map +1 -1
- package/bin/index.js.map +1 -1
- package/bin/plugin/core/ImagePlugin.d.ts +62 -0
- package/bin/plugin/core/ImagePlugin.d.ts.map +1 -1
- package/bin/plugin/core/ImagePlugin.js +230 -7
- package/bin/plugin/core/ImagePlugin.js.map +1 -1
- package/bin/sandbox/LinuxBubblewrapSandbox.d.ts +21 -0
- package/bin/sandbox/LinuxBubblewrapSandbox.d.ts.map +1 -0
- package/bin/sandbox/LinuxBubblewrapSandbox.js +184 -0
- package/bin/sandbox/LinuxBubblewrapSandbox.js.map +1 -0
- package/bin/sandbox/SandboxConfigResolver.d.ts +5 -0
- package/bin/sandbox/SandboxConfigResolver.d.ts.map +1 -1
- package/bin/sandbox/SandboxConfigResolver.js +11 -4
- package/bin/sandbox/SandboxConfigResolver.js.map +1 -1
- package/bin/sandbox/SandboxRunner.d.ts +1 -1
- package/bin/sandbox/SandboxRunner.d.ts.map +1 -1
- package/bin/sandbox/SandboxRunner.js +11 -3
- package/bin/sandbox/SandboxRunner.js.map +1 -1
- package/bin/sandbox/types/SandboxRuntime.d.ts +6 -2
- package/bin/sandbox/types/SandboxRuntime.d.ts.map +1 -1
- package/bin/session/Session.d.ts.map +1 -1
- package/bin/session/Session.js +1 -0
- package/bin/session/Session.js.map +1 -1
- package/bin/session/services/SessionTurnService.d.ts +5 -0
- package/bin/session/services/SessionTurnService.d.ts.map +1 -1
- package/bin/session/services/SessionTurnService.js +3 -0
- package/bin/session/services/SessionTurnService.js.map +1 -1
- package/bin/types/executor/SessionRunContext.d.ts +8 -0
- package/bin/types/executor/SessionRunContext.d.ts.map +1 -1
- package/bin/types/plugin/ImagePlugin.d.ts +79 -2
- package/bin/types/plugin/ImagePlugin.d.ts.map +1 -1
- package/package.json +2 -2
- package/scripts/assistant-file-resource.test.mjs +91 -0
- package/scripts/image-plugin-job.test.mjs +155 -0
- package/scripts/linux-bubblewrap-sandbox.test.mjs +142 -0
- package/src/config/AgentInitializer.ts +2 -0
- package/src/config/Paths.ts +11 -0
- package/src/executor/Executor.ts +3 -0
- package/src/executor/messages/AssistantFileResource.ts +155 -0
- package/src/executor/messages/SessionAttachmentMapper.ts +59 -0
- package/src/executor/messages/SessionMessageCodec.ts +9 -3
- package/src/executor/tools/plugin/PluginToolBridge.ts +13 -2
- package/src/index.ts +4 -0
- package/src/plugin/core/ImagePlugin.ts +284 -7
- package/src/sandbox/LinuxBubblewrapSandbox.ts +229 -0
- package/src/sandbox/SandboxConfigResolver.ts +13 -7
- package/src/sandbox/SandboxRunner.ts +11 -3
- package/src/sandbox/types/SandboxRuntime.ts +7 -2
- package/src/session/Session.ts +1 -0
- package/src/session/services/SessionTurnService.ts +8 -0
- package/src/types/executor/SessionRunContext.ts +9 -0
- package/src/types/plugin/ImagePlugin.ts +79 -2
- 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
|
-
|
|
82
|
-
|
|
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 "
|
|
99
|
-
"
|
|
100
|
-
"
|
|
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
|
|
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:
|
|
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
|
-
* -
|
|
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
|
-
|
|
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:
|
|
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:
|
|
376
|
+
backend: SandboxBackend;
|
|
372
377
|
|
|
373
378
|
/**
|
|
374
379
|
* 当前实际采用的网络模式。
|