@downcity/agent 1.1.258 → 1.1.260
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 +18 -26
- package/bin/agent/AgentModel.d.ts +2 -4
- package/bin/agent/AgentModel.d.ts.map +1 -1
- package/bin/agent/AgentModel.js +2 -26
- package/bin/agent/AgentModel.js.map +1 -1
- package/bin/session/SessionMessages.d.ts +16 -0
- package/bin/session/SessionMessages.d.ts.map +1 -1
- package/bin/session/SessionMessages.js +75 -22
- package/bin/session/SessionMessages.js.map +1 -1
- package/package.json +3 -4
- package/scripts/city-model-tool-loop.test.mjs +142 -299
- package/scripts/linux-bubblewrap-sandbox.test.mjs +40 -123
- package/scripts/multi-agent-plugin-isolation.test.mjs +86 -207
- package/scripts/session-messages.test.mjs +114 -3
- package/scripts/shell-sandbox-env.test.mjs +47 -60
- package/src/agent/AgentModel.ts +2 -29
- package/src/executor/README.md +50 -14
- package/src/session/SessionMessages.ts +95 -25
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,309 +1,172 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @file 验证
|
|
2
|
+
* @file 验证 Agent 直接调用实现 LanguageModelV3 的 CityModel 并完成 tool loop。
|
|
3
3
|
*
|
|
4
4
|
* 关键点(中文)
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
8
|
-
* - 新路径不应再调用 `/v1/ai/stream`,避免 UIMessage stream 反向适配丢失 finish 语义。
|
|
5
|
+
* - 测试模型自身已经实现 LanguageModelV3,Agent 不再创建第二个 Provider 模型。
|
|
6
|
+
* - 第一次调用返回 tool-call,Agent 本地执行后把 tool-result 放进第二次调用。
|
|
7
|
+
* - CityModel 的目录信息继续用于上下文窗口和日志,不参与网络连接转换。
|
|
9
8
|
*/
|
|
10
9
|
|
|
11
10
|
import test from "node:test";
|
|
12
11
|
import assert from "node:assert/strict";
|
|
13
|
-
import http from "node:http";
|
|
14
12
|
import os from "node:os";
|
|
15
13
|
import path from "node:path";
|
|
16
14
|
import fs from "node:fs/promises";
|
|
17
|
-
import {
|
|
18
|
-
import { createAction, createPlugin } from "../bin/plugin/core/PluginActionFactory.js";
|
|
19
|
-
import { CITY_MODEL_INVOKER, CITY_MODEL_KIND } from "@downcity/type";
|
|
15
|
+
import { MockLanguageModelV3 } from "ai/test";
|
|
20
16
|
import { tool } from "ai";
|
|
21
17
|
import { z } from "zod";
|
|
22
18
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
res.end();
|
|
19
|
+
import { Agent } from "../bin/index.js";
|
|
20
|
+
import { createAction, createPlugin } from "../bin/plugin/core/PluginActionFactory.js";
|
|
21
|
+
import { CITY_MODEL_KIND } from "@downcity/type";
|
|
22
|
+
|
|
23
|
+
/** 构造 AI SDK V3 usage。 */
|
|
24
|
+
function create_usage() {
|
|
25
|
+
return {
|
|
26
|
+
inputTokens: { total: 5, noCache: 5, cacheRead: 0, cacheWrite: 0 },
|
|
27
|
+
outputTokens: { total: 3, text: 3, reasoning: 0 },
|
|
28
|
+
};
|
|
34
29
|
}
|
|
35
30
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
31
|
+
/** 构造一次 ping tool-call。 */
|
|
32
|
+
function create_tool_call_stream() {
|
|
33
|
+
const input = JSON.stringify({ value: "hello" });
|
|
34
|
+
return {
|
|
35
|
+
stream: new ReadableStream({
|
|
36
|
+
start(controller) {
|
|
37
|
+
controller.enqueue({ type: "stream-start", warnings: [] });
|
|
38
|
+
controller.enqueue({ type: "tool-input-start", id: "call_1", toolName: "ping" });
|
|
39
|
+
controller.enqueue({ type: "tool-input-delta", id: "call_1", delta: input });
|
|
40
|
+
controller.enqueue({ type: "tool-input-end", id: "call_1" });
|
|
41
|
+
controller.enqueue({
|
|
42
|
+
type: "tool-call",
|
|
43
|
+
toolCallId: "call_1",
|
|
44
|
+
toolName: "ping",
|
|
45
|
+
input,
|
|
46
|
+
});
|
|
47
|
+
controller.enqueue({
|
|
48
|
+
type: "finish",
|
|
49
|
+
finishReason: { unified: "tool-calls", raw: "tool_calls" },
|
|
50
|
+
usage: create_usage(),
|
|
51
|
+
});
|
|
52
|
+
controller.close();
|
|
53
|
+
},
|
|
54
|
+
}),
|
|
55
|
+
};
|
|
46
56
|
}
|
|
47
57
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
write_openai_sse(res, [
|
|
68
|
-
{
|
|
69
|
-
id: "chatcmpl_title",
|
|
70
|
-
object: "chat.completion.chunk",
|
|
71
|
-
created: 1,
|
|
72
|
-
model: "mock-model",
|
|
73
|
-
choices: [
|
|
74
|
-
{
|
|
75
|
-
index: 0,
|
|
76
|
-
delta: { role: "assistant", content: "Tool loop" },
|
|
77
|
-
finish_reason: null,
|
|
78
|
-
},
|
|
79
|
-
],
|
|
80
|
-
},
|
|
81
|
-
{
|
|
82
|
-
id: "chatcmpl_title",
|
|
83
|
-
object: "chat.completion.chunk",
|
|
84
|
-
created: 1,
|
|
85
|
-
model: "mock-model",
|
|
86
|
-
choices: [
|
|
87
|
-
{
|
|
88
|
-
index: 0,
|
|
89
|
-
delta: {},
|
|
90
|
-
finish_reason: "stop",
|
|
91
|
-
},
|
|
92
|
-
],
|
|
93
|
-
},
|
|
94
|
-
"[DONE]",
|
|
95
|
-
]);
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
58
|
+
/** 构造普通文本完成流。 */
|
|
59
|
+
function create_text_stream(text) {
|
|
60
|
+
return {
|
|
61
|
+
stream: new ReadableStream({
|
|
62
|
+
start(controller) {
|
|
63
|
+
controller.enqueue({ type: "stream-start", warnings: [] });
|
|
64
|
+
controller.enqueue({ type: "text-start", id: "text_1" });
|
|
65
|
+
controller.enqueue({ type: "text-delta", id: "text_1", delta: text });
|
|
66
|
+
controller.enqueue({ type: "text-end", id: "text_1" });
|
|
67
|
+
controller.enqueue({
|
|
68
|
+
type: "finish",
|
|
69
|
+
finishReason: { unified: "stop", raw: "stop" },
|
|
70
|
+
usage: create_usage(),
|
|
71
|
+
});
|
|
72
|
+
controller.close();
|
|
73
|
+
},
|
|
74
|
+
}),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
98
77
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
choices: [
|
|
108
|
-
{
|
|
109
|
-
index: 0,
|
|
110
|
-
delta: { role: "assistant" },
|
|
111
|
-
finish_reason: null,
|
|
112
|
-
},
|
|
113
|
-
],
|
|
114
|
-
},
|
|
115
|
-
{
|
|
116
|
-
id: "chatcmpl_1",
|
|
117
|
-
object: "chat.completion.chunk",
|
|
118
|
-
created: 1,
|
|
119
|
-
model: "mock-model",
|
|
120
|
-
choices: [
|
|
121
|
-
{
|
|
122
|
-
index: 0,
|
|
123
|
-
delta: {
|
|
124
|
-
tool_calls: [
|
|
125
|
-
{
|
|
126
|
-
index: 0,
|
|
127
|
-
id: "call_1",
|
|
128
|
-
type: "function",
|
|
129
|
-
function: {
|
|
130
|
-
name: "ping",
|
|
131
|
-
arguments: "{\"value\":\"hello\"}",
|
|
132
|
-
},
|
|
133
|
-
},
|
|
134
|
-
],
|
|
135
|
-
},
|
|
136
|
-
finish_reason: null,
|
|
137
|
-
},
|
|
138
|
-
],
|
|
139
|
-
},
|
|
140
|
-
{
|
|
141
|
-
id: "chatcmpl_1",
|
|
142
|
-
object: "chat.completion.chunk",
|
|
143
|
-
created: 1,
|
|
144
|
-
model: "mock-model",
|
|
145
|
-
choices: [
|
|
146
|
-
{
|
|
147
|
-
index: 0,
|
|
148
|
-
delta: {},
|
|
149
|
-
finish_reason: "tool_calls",
|
|
150
|
-
},
|
|
151
|
-
],
|
|
152
|
-
},
|
|
153
|
-
"[DONE]",
|
|
154
|
-
]);
|
|
155
|
-
return;
|
|
78
|
+
/** 给标准 LanguageModelV3 附加 CityModel 目录协议。 */
|
|
79
|
+
function create_city_model(model_requests) {
|
|
80
|
+
let request_count = 0;
|
|
81
|
+
const model = new MockLanguageModelV3({
|
|
82
|
+
modelId: "mock-model",
|
|
83
|
+
doStream: async (options) => {
|
|
84
|
+
if (!Array.isArray(options.tools) || options.tools.length === 0) {
|
|
85
|
+
return create_text_stream("Tool loop");
|
|
156
86
|
}
|
|
87
|
+
request_count += 1;
|
|
88
|
+
model_requests.push(options);
|
|
89
|
+
return request_count === 1
|
|
90
|
+
? create_tool_call_stream()
|
|
91
|
+
: create_text_stream("done");
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
return Object.assign(model, {
|
|
95
|
+
id: "mock-model",
|
|
96
|
+
name: "Mock Model",
|
|
97
|
+
description: "Native CityModel tool loop test",
|
|
98
|
+
modalities: ["text", "stream"],
|
|
99
|
+
tags: [],
|
|
100
|
+
meta: {},
|
|
101
|
+
kind: CITY_MODEL_KIND,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
157
104
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
],
|
|
105
|
+
test("CityModel uses direct LanguageModel path and sends tool result back", async () => {
|
|
106
|
+
const model_requests = [];
|
|
107
|
+
let tool_executed = false;
|
|
108
|
+
const agent_path = await fs.mkdtemp(
|
|
109
|
+
path.join(os.tmpdir(), "downcity-agent-city-model-tool-loop-"),
|
|
110
|
+
);
|
|
111
|
+
const skill_plugin = createPlugin({
|
|
112
|
+
name: "skill",
|
|
113
|
+
title: "Skill",
|
|
114
|
+
description: "Test skill plugin",
|
|
115
|
+
actions: {
|
|
116
|
+
lookup: createAction({
|
|
117
|
+
description: "Lookup a skill",
|
|
118
|
+
execute: async ({ input }) => ({
|
|
119
|
+
success: true,
|
|
120
|
+
data: { name: input.name },
|
|
121
|
+
message: "loaded",
|
|
122
|
+
}),
|
|
123
|
+
}),
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
const agent = new Agent({
|
|
127
|
+
id: "tool_loop_agent",
|
|
128
|
+
path: agent_path,
|
|
129
|
+
plugins: [skill_plugin],
|
|
130
|
+
tools: {
|
|
131
|
+
ping: tool({
|
|
132
|
+
description: "ping tool",
|
|
133
|
+
inputSchema: z.object({ value: z.string() }),
|
|
134
|
+
execute: async ({ value }, options) => {
|
|
135
|
+
tool_executed = true;
|
|
136
|
+
options.experimental_context.session_run_context.pendingAssistantFileParts.push({
|
|
137
|
+
type: "file",
|
|
138
|
+
mediaType: "image/png",
|
|
139
|
+
url: ".downcity/resources/tool-output.png",
|
|
140
|
+
filename: "tool-output.png",
|
|
141
|
+
});
|
|
142
|
+
return { echoed: value };
|
|
197
143
|
},
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
res.writeHead(404);
|
|
204
|
-
res.end("not found");
|
|
144
|
+
}),
|
|
145
|
+
},
|
|
205
146
|
});
|
|
206
147
|
|
|
207
|
-
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
208
|
-
|
|
209
|
-
let agent;
|
|
210
148
|
try {
|
|
211
|
-
const address = server.address();
|
|
212
|
-
assert.ok(address && typeof address === "object");
|
|
213
|
-
const model = Object.freeze({
|
|
214
|
-
id: "mock-model",
|
|
215
|
-
name: "Mock Model",
|
|
216
|
-
description: "mock",
|
|
217
|
-
modalities: ["text", "stream"],
|
|
218
|
-
tags: [],
|
|
219
|
-
meta: {},
|
|
220
|
-
kind: CITY_MODEL_KIND,
|
|
221
|
-
[CITY_MODEL_INVOKER]: {
|
|
222
|
-
connection: () => ({
|
|
223
|
-
base_url: `http://127.0.0.1:${String(address.port)}/v1/ai`,
|
|
224
|
-
api_key: "ub_test",
|
|
225
|
-
model_id: "mock-model",
|
|
226
|
-
}),
|
|
227
|
-
},
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
const agent_path = await fs.mkdtemp(
|
|
231
|
-
path.join(os.tmpdir(), "downcity-agent-city-model-tool-loop-"),
|
|
232
|
-
);
|
|
233
|
-
const skill_plugin = createPlugin({
|
|
234
|
-
name: "skill",
|
|
235
|
-
title: "Skill",
|
|
236
|
-
description: "Test skill plugin",
|
|
237
|
-
actions: {
|
|
238
|
-
lookup: createAction({
|
|
239
|
-
description: "Lookup a skill",
|
|
240
|
-
execute: async ({ input }) => ({
|
|
241
|
-
success: true,
|
|
242
|
-
data: { name: input.name },
|
|
243
|
-
message: "loaded",
|
|
244
|
-
}),
|
|
245
|
-
}),
|
|
246
|
-
},
|
|
247
|
-
});
|
|
248
|
-
agent = new Agent({
|
|
249
|
-
id: "tool_loop_agent",
|
|
250
|
-
path: agent_path,
|
|
251
|
-
plugins: [skill_plugin],
|
|
252
|
-
tools: {
|
|
253
|
-
ping: tool({
|
|
254
|
-
description: "ping tool",
|
|
255
|
-
inputSchema: z.object({
|
|
256
|
-
value: z.string(),
|
|
257
|
-
}),
|
|
258
|
-
execute: async ({ value }, options) => {
|
|
259
|
-
tool_executed = true;
|
|
260
|
-
options.experimental_context.session_run_context.pendingAssistantFileParts.push({
|
|
261
|
-
type: "file",
|
|
262
|
-
mediaType: "image/png",
|
|
263
|
-
url: ".downcity/resources/tool-output.png",
|
|
264
|
-
filename: "tool-output.png",
|
|
265
|
-
});
|
|
266
|
-
return { echoed: value };
|
|
267
|
-
},
|
|
268
|
-
}),
|
|
269
|
-
},
|
|
270
|
-
});
|
|
271
|
-
|
|
272
149
|
const session = await agent.sessions.create();
|
|
273
|
-
await session.set({ model });
|
|
150
|
+
await session.set({ model: create_city_model(model_requests) });
|
|
274
151
|
const turn = await session.prompt({ query: "please use the ping tool" });
|
|
275
152
|
const result = await turn.finished;
|
|
276
153
|
|
|
277
154
|
assert.equal(result.success, true);
|
|
278
155
|
assert.equal(tool_executed, true);
|
|
279
|
-
assert.equal(
|
|
280
|
-
|
|
281
|
-
assert.equal(agent_requests[0]?.model, "mock-model");
|
|
282
|
-
const plugin_call_tool = agent_requests[0]?.tools?.find(
|
|
283
|
-
(item) => item?.type === "function" && item?.function?.name === "plugin_call",
|
|
284
|
-
);
|
|
156
|
+
assert.equal(model_requests.length, 2);
|
|
157
|
+
const plugin_call_tool = model_requests[0].tools.find((item) => item.name === "plugin_call");
|
|
285
158
|
assert.ok(plugin_call_tool);
|
|
286
|
-
|
|
287
|
-
assert.
|
|
288
|
-
assert.
|
|
289
|
-
assert.equal(plugin_call_parameters.additionalProperties, false);
|
|
290
|
-
assert.equal(
|
|
291
|
-
plugin_call_parameters.properties.payload.additionalProperties,
|
|
292
|
-
true,
|
|
293
|
-
);
|
|
159
|
+
assert.equal(plugin_call_tool.inputSchema.type, "object");
|
|
160
|
+
assert.deepEqual(plugin_call_tool.inputSchema.required, ["plugin", "action"]);
|
|
161
|
+
assert.equal(plugin_call_tool.inputSchema.additionalProperties, false);
|
|
294
162
|
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
assert.match(
|
|
300
|
-
assert.match(serialized_second_messages, /call_1/);
|
|
301
|
-
assert.match(serialized_second_messages, /echoed/);
|
|
302
|
-
assert.match(serialized_second_messages, /hello/);
|
|
163
|
+
const serialized_second_prompt = JSON.stringify(model_requests[1].prompt);
|
|
164
|
+
assert.match(serialized_second_prompt, /"role":"tool"/);
|
|
165
|
+
assert.match(serialized_second_prompt, /call_1/);
|
|
166
|
+
assert.match(serialized_second_prompt, /echoed/);
|
|
167
|
+
assert.match(serialized_second_prompt, /hello/);
|
|
303
168
|
|
|
304
|
-
const result_file = result.assistantMessage.parts.find(
|
|
305
|
-
(part) => part.type === "file",
|
|
306
|
-
);
|
|
169
|
+
const result_file = result.assistantMessage.parts.find((part) => part.type === "file");
|
|
307
170
|
assert.deepEqual(result_file, {
|
|
308
171
|
type: "file",
|
|
309
172
|
mediaType: "image/png",
|
|
@@ -311,32 +174,12 @@ test("CityModel uses direct LanguageModel path and sends tool result back", asyn
|
|
|
311
174
|
filename: "tool-output.png",
|
|
312
175
|
});
|
|
313
176
|
const session_messages = await session.messages({ include_internal: true });
|
|
314
|
-
const persisted_assistant = session_messages.items.find(
|
|
315
|
-
|
|
316
|
-
);
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
);
|
|
320
|
-
assert.deepEqual(persisted_file, {
|
|
321
|
-
part_id: persisted_file.part_id,
|
|
322
|
-
sequence: persisted_file.sequence,
|
|
323
|
-
type: "file",
|
|
324
|
-
media_type: "image/png",
|
|
325
|
-
url: ".downcity/resources/tool-output.png",
|
|
326
|
-
filename: "tool-output.png",
|
|
327
|
-
});
|
|
177
|
+
const persisted_assistant = session_messages.items.find((message) => message.type === "assistant");
|
|
178
|
+
const persisted_file = persisted_assistant.parts.find((part) => part.type === "file");
|
|
179
|
+
assert.equal(persisted_file.media_type, "image/png");
|
|
180
|
+
assert.equal(persisted_file.url, ".downcity/resources/tool-output.png");
|
|
181
|
+
assert.equal(persisted_file.filename, "tool-output.png");
|
|
328
182
|
} finally {
|
|
329
|
-
|
|
330
|
-
await agent.dispose();
|
|
331
|
-
}
|
|
332
|
-
await new Promise((resolve, reject) => {
|
|
333
|
-
server.close((error) => {
|
|
334
|
-
if (error) {
|
|
335
|
-
reject(error);
|
|
336
|
-
return;
|
|
337
|
-
}
|
|
338
|
-
resolve();
|
|
339
|
-
});
|
|
340
|
-
});
|
|
183
|
+
await agent.dispose();
|
|
341
184
|
}
|
|
342
185
|
});
|
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @file 验证 Linux
|
|
3
|
-
*
|
|
4
|
-
* 关键点(中文)
|
|
5
|
-
* - 测试编译后的 bin 输出,避免测试文件进入 package 源码导出面。
|
|
6
|
-
* - 不启动真实 `bwrap`,只锁住路径挂载、网络开关与 shell 调用参数。
|
|
2
|
+
* @file 验证 Linux Bubblewrap 参数对统一 Sandbox 策略的映射。
|
|
7
3
|
*/
|
|
8
4
|
|
|
9
5
|
import test from "node:test";
|
|
@@ -12,140 +8,61 @@ import fs from "node:fs/promises";
|
|
|
12
8
|
import os from "node:os";
|
|
13
9
|
import path from "node:path";
|
|
14
10
|
|
|
15
|
-
import {
|
|
11
|
+
import { build_linux_bubblewrap_args } from "@downcity/shell/sandbox/LinuxBubblewrap.js";
|
|
16
12
|
|
|
17
|
-
|
|
18
|
-
const root = await fs.mkdtemp(path.join(os.tmpdir(), "downcity-bwrap-"));
|
|
19
|
-
const projectRoot = path.join(root, "project");
|
|
20
|
-
const writablePath = path.join(projectRoot, ".downcity");
|
|
21
|
-
const shellDir = path.join(writablePath, "shell", "sh_test");
|
|
22
|
-
const sandboxDir = path.join(projectRoot, ".downcity", "sandbox");
|
|
23
|
-
const tmpDir = path.join(sandboxDir, "tmp");
|
|
24
|
-
const cacheDir = path.join(sandboxDir, ".cache");
|
|
25
|
-
|
|
26
|
-
await fs.mkdir(shellDir, { recursive: true });
|
|
27
|
-
await fs.mkdir(tmpDir, { recursive: true });
|
|
28
|
-
await fs.mkdir(cacheDir, { recursive: true });
|
|
29
|
-
|
|
30
|
-
return {
|
|
31
|
-
root,
|
|
32
|
-
projectRoot,
|
|
33
|
-
writablePath,
|
|
34
|
-
shellDir,
|
|
35
|
-
sandboxDir,
|
|
36
|
-
tmpDir,
|
|
37
|
-
cacheDir,
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function createParams(fixture, overrides = {}) {
|
|
42
|
-
return {
|
|
43
|
-
executionId: "sh_test",
|
|
44
|
-
executionDir: fixture.shellDir,
|
|
45
|
-
cmd: "printf hello",
|
|
46
|
-
cwd: fixture.projectRoot,
|
|
47
|
-
actualCwd: fixture.projectRoot,
|
|
48
|
-
shellPath: "/bin/sh",
|
|
49
|
-
login: true,
|
|
50
|
-
baseEnv: {
|
|
51
|
-
PATH: "/usr/bin:/bin",
|
|
52
|
-
LANG: "C.UTF-8",
|
|
53
|
-
DC_SESSION_ID: "session_test",
|
|
54
|
-
},
|
|
55
|
-
config: {
|
|
56
|
-
backend: "linux-bubblewrap",
|
|
57
|
-
rootPath: fixture.projectRoot,
|
|
58
|
-
sandboxDir: fixture.sandboxDir,
|
|
59
|
-
homeDir: fixture.sandboxDir,
|
|
60
|
-
tmpDir: fixture.tmpDir,
|
|
61
|
-
cacheDir: fixture.cacheDir,
|
|
62
|
-
envAllowlist: ["PATH", "LANG"],
|
|
63
|
-
writablePaths: [fixture.projectRoot, fixture.sandboxDir],
|
|
64
|
-
networkMode: "off",
|
|
65
|
-
},
|
|
66
|
-
...overrides,
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function hasArg(args, value) {
|
|
71
|
-
return args.includes(value);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function hasOptionPair(args, option, sourcePath, targetPath = sourcePath) {
|
|
13
|
+
function has_option_pair(args, option, source_path, target_path = source_path) {
|
|
75
14
|
for (let index = 0; index < args.length - 2; index += 1) {
|
|
76
15
|
if (
|
|
77
16
|
args[index] === option &&
|
|
78
|
-
args[index + 1] ===
|
|
79
|
-
args[index + 2] ===
|
|
80
|
-
)
|
|
81
|
-
return true;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
return false;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function hasOptionValue(args, option, value) {
|
|
88
|
-
for (let index = 0; index < args.length - 1; index += 1) {
|
|
89
|
-
if (args[index] === option && args[index + 1] === value) {
|
|
90
|
-
return true;
|
|
91
|
-
}
|
|
17
|
+
args[index + 1] === source_path &&
|
|
18
|
+
args[index + 2] === target_path
|
|
19
|
+
) return true;
|
|
92
20
|
}
|
|
93
21
|
return false;
|
|
94
22
|
}
|
|
95
23
|
|
|
96
|
-
test("Linux
|
|
97
|
-
const
|
|
98
|
-
try {
|
|
99
|
-
const args = buildLinuxBubblewrapArgs(createParams(fixture));
|
|
100
|
-
|
|
101
|
-
assert.equal(hasArg(args, "--die-with-parent"), true);
|
|
102
|
-
assert.equal(hasArg(args, "--unshare-pid"), true);
|
|
103
|
-
assert.equal(hasArg(args, "--unshare-net"), true);
|
|
104
|
-
assert.equal(hasOptionPair(args, "--bind", fixture.projectRoot), true);
|
|
105
|
-
assert.equal(hasOptionPair(args, "--bind", fixture.sandboxDir), false);
|
|
106
|
-
assert.equal(hasOptionPair(args, "--ro-bind", fixture.projectRoot), false);
|
|
107
|
-
assert.equal(hasOptionValue(args, "--dir", fixture.writablePath), false);
|
|
108
|
-
assert.deepEqual(args.slice(-5), [
|
|
109
|
-
"--chdir",
|
|
110
|
-
fixture.projectRoot,
|
|
111
|
-
"/bin/sh",
|
|
112
|
-
"-lc",
|
|
113
|
-
"printf hello",
|
|
114
|
-
]);
|
|
115
|
-
} finally {
|
|
116
|
-
await fs.rm(fixture.root, { recursive: true, force: true });
|
|
117
|
-
}
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
test("Linux bubblewrap args keep root writable when sandbox writablePaths includes root", async () => {
|
|
121
|
-
const fixture = await createSandboxFixture();
|
|
24
|
+
test("Linux maps read paths to ro-bind and workspace to bind", async () => {
|
|
25
|
+
const fixture_root = await fs.mkdtemp(path.join(os.tmpdir(), "downcity-bwrap-"));
|
|
122
26
|
try {
|
|
123
|
-
const
|
|
124
|
-
|
|
27
|
+
const project_root = path.join(fixture_root, "project");
|
|
28
|
+
const tool_root = path.join(fixture_root, "tool");
|
|
29
|
+
await fs.mkdir(project_root, { recursive: true });
|
|
30
|
+
await fs.mkdir(tool_root, { recursive: true });
|
|
31
|
+
const request = {
|
|
32
|
+
execution_id: "sh_test",
|
|
33
|
+
execution_dir: path.join(project_root, ".downcity", "shell", "sh_test"),
|
|
34
|
+
cmd: "printf hello",
|
|
35
|
+
cwd: project_root,
|
|
36
|
+
shell_path: "/bin/sh",
|
|
37
|
+
login: true,
|
|
38
|
+
base_env: { PATH: "/usr/bin:/bin" },
|
|
39
|
+
policy: {
|
|
125
40
|
backend: "linux-bubblewrap",
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
41
|
+
root_path: project_root,
|
|
42
|
+
sandbox_dir: path.join(project_root, ".downcity", "sandbox"),
|
|
43
|
+
home_dir: path.join(project_root, ".downcity", "sandbox"),
|
|
44
|
+
tmp_dir: path.join(project_root, ".downcity", "sandbox", "tmp"),
|
|
45
|
+
cache_dir: path.join(project_root, ".downcity", "sandbox", ".cache"),
|
|
46
|
+
env_allowlist: ["PATH"],
|
|
47
|
+
read_only_paths: [tool_root],
|
|
48
|
+
read_write_paths: [project_root],
|
|
49
|
+
network_mode: "off",
|
|
50
|
+
fingerprint: "policy_test",
|
|
134
51
|
},
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
assert.equal(
|
|
139
|
-
assert.equal(
|
|
140
|
-
assert.equal(
|
|
52
|
+
};
|
|
53
|
+
const args = build_linux_bubblewrap_args(request);
|
|
54
|
+
assert.equal(args.includes("--unshare-net"), true);
|
|
55
|
+
assert.equal(has_option_pair(args, "--ro-bind", tool_root), true);
|
|
56
|
+
assert.equal(has_option_pair(args, "--bind", tool_root), false);
|
|
57
|
+
assert.equal(has_option_pair(args, "--bind", project_root), true);
|
|
141
58
|
assert.deepEqual(args.slice(-5), [
|
|
142
59
|
"--chdir",
|
|
143
|
-
|
|
60
|
+
project_root,
|
|
144
61
|
"/bin/sh",
|
|
145
|
-
"-
|
|
62
|
+
"-lc",
|
|
146
63
|
"printf hello",
|
|
147
64
|
]);
|
|
148
65
|
} finally {
|
|
149
|
-
await fs.rm(
|
|
66
|
+
await fs.rm(fixture_root, { recursive: true, force: true });
|
|
150
67
|
}
|
|
151
68
|
});
|