@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,161 +1,100 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file 验证多个 Agent 经 session.prompt 执行 plugin_call 时保持 registry 隔离。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* - 两个 Session 并发进入模型 tool loop,验证异步作用域不会造成 registry 串线。
|
|
7
|
-
* - 第二次模型请求必须收到各自 plugin action 返回的 owner。
|
|
4
|
+
* 两个 CityModel 并发进入原生 LanguageModelV3 tool loop,第二次模型调用必须只
|
|
5
|
+
* 收到各自 Agent plugin action 返回的 owner。
|
|
8
6
|
*/
|
|
9
7
|
|
|
10
8
|
import test from "node:test";
|
|
11
9
|
import assert from "node:assert/strict";
|
|
12
|
-
import http from "node:http";
|
|
13
10
|
import os from "node:os";
|
|
14
11
|
import path from "node:path";
|
|
15
12
|
import fs from "node:fs/promises";
|
|
13
|
+
import { MockLanguageModelV3 } from "ai/test";
|
|
16
14
|
|
|
17
15
|
import { Agent } from "../bin/index.js";
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
createPlugin,
|
|
21
|
-
} from "../bin/plugin/core/PluginActionFactory.js";
|
|
22
|
-
import { CITY_MODEL_INVOKER, CITY_MODEL_KIND } from "@downcity/type";
|
|
16
|
+
import { createAction, createPlugin } from "../bin/plugin/core/PluginActionFactory.js";
|
|
17
|
+
import { CITY_MODEL_KIND } from "@downcity/type";
|
|
23
18
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"cache-control": "no-cache",
|
|
31
|
-
connection: "keep-alive",
|
|
32
|
-
});
|
|
33
|
-
for (const chunk of chunks) {
|
|
34
|
-
const payload = typeof chunk === "string" ? chunk : JSON.stringify(chunk);
|
|
35
|
-
response.write(`data: ${payload}\n\n`);
|
|
36
|
-
}
|
|
37
|
-
response.end();
|
|
19
|
+
/** 构造 AI SDK V3 usage。 */
|
|
20
|
+
function create_usage() {
|
|
21
|
+
return {
|
|
22
|
+
inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 },
|
|
23
|
+
outputTokens: { total: 1, text: 1, reasoning: 0 },
|
|
24
|
+
};
|
|
38
25
|
}
|
|
39
26
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
27
|
+
/** 返回普通文本模型流。 */
|
|
28
|
+
function create_text_stream(text) {
|
|
29
|
+
return {
|
|
30
|
+
stream: new ReadableStream({
|
|
31
|
+
start(controller) {
|
|
32
|
+
controller.enqueue({ type: "stream-start", warnings: [] });
|
|
33
|
+
controller.enqueue({ type: "text-start", id: "text_1" });
|
|
34
|
+
controller.enqueue({ type: "text-delta", id: "text_1", delta: text });
|
|
35
|
+
controller.enqueue({ type: "text-end", id: "text_1" });
|
|
36
|
+
controller.enqueue({
|
|
37
|
+
type: "finish",
|
|
38
|
+
finishReason: { unified: "stop", raw: "stop" },
|
|
39
|
+
usage: create_usage(),
|
|
40
|
+
});
|
|
41
|
+
controller.close();
|
|
42
|
+
},
|
|
43
|
+
}),
|
|
44
|
+
};
|
|
53
45
|
}
|
|
54
46
|
|
|
55
|
-
/**
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
{
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
{
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
},
|
|
86
|
-
"[DONE]",
|
|
87
|
-
]);
|
|
47
|
+
/** 返回一次 plugin_call tool call。 */
|
|
48
|
+
function create_plugin_call_stream(model_id) {
|
|
49
|
+
const tool_input = JSON.stringify({
|
|
50
|
+
plugin: "skill",
|
|
51
|
+
action: "lookup",
|
|
52
|
+
payload: {},
|
|
53
|
+
});
|
|
54
|
+
const call_id = `call_${model_id}`;
|
|
55
|
+
return {
|
|
56
|
+
stream: new ReadableStream({
|
|
57
|
+
start(controller) {
|
|
58
|
+
controller.enqueue({ type: "stream-start", warnings: [] });
|
|
59
|
+
controller.enqueue({ type: "tool-input-start", id: call_id, toolName: "plugin_call" });
|
|
60
|
+
controller.enqueue({ type: "tool-input-delta", id: call_id, delta: tool_input });
|
|
61
|
+
controller.enqueue({ type: "tool-input-end", id: call_id });
|
|
62
|
+
controller.enqueue({
|
|
63
|
+
type: "tool-call",
|
|
64
|
+
toolCallId: call_id,
|
|
65
|
+
toolName: "plugin_call",
|
|
66
|
+
input: tool_input,
|
|
67
|
+
});
|
|
68
|
+
controller.enqueue({
|
|
69
|
+
type: "finish",
|
|
70
|
+
finishReason: { unified: "tool-calls", raw: "tool_calls" },
|
|
71
|
+
usage: create_usage(),
|
|
72
|
+
});
|
|
73
|
+
controller.close();
|
|
74
|
+
},
|
|
75
|
+
}),
|
|
76
|
+
};
|
|
88
77
|
}
|
|
89
78
|
|
|
90
|
-
/**
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
{
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
],
|
|
107
|
-
},
|
|
108
|
-
{
|
|
109
|
-
id: `chatcmpl_${model}_tool`,
|
|
110
|
-
object: "chat.completion.chunk",
|
|
111
|
-
created: 1,
|
|
112
|
-
model,
|
|
113
|
-
choices: [
|
|
114
|
-
{
|
|
115
|
-
index: 0,
|
|
116
|
-
delta: {
|
|
117
|
-
tool_calls: [
|
|
118
|
-
{
|
|
119
|
-
index: 0,
|
|
120
|
-
id: `call_${model}`,
|
|
121
|
-
type: "function",
|
|
122
|
-
function: {
|
|
123
|
-
name: "plugin_call",
|
|
124
|
-
arguments: JSON.stringify({
|
|
125
|
-
plugin: "skill",
|
|
126
|
-
action: "lookup",
|
|
127
|
-
payload: {},
|
|
128
|
-
}),
|
|
129
|
-
},
|
|
130
|
-
},
|
|
131
|
-
],
|
|
132
|
-
},
|
|
133
|
-
finish_reason: null,
|
|
134
|
-
},
|
|
135
|
-
],
|
|
79
|
+
/** 创建原生 LanguageModelV3 CityModel。 */
|
|
80
|
+
function create_test_model(model_id, model_requests) {
|
|
81
|
+
let request_count = 0;
|
|
82
|
+
const language_model = new MockLanguageModelV3({
|
|
83
|
+
modelId: model_id,
|
|
84
|
+
doStream: async (options) => {
|
|
85
|
+
if (!Array.isArray(options.tools) || options.tools.length === 0) {
|
|
86
|
+
return create_text_stream(`Title ${model_id}`);
|
|
87
|
+
}
|
|
88
|
+
request_count += 1;
|
|
89
|
+
const requests = model_requests.get(model_id) ?? [];
|
|
90
|
+
requests.push(options);
|
|
91
|
+
model_requests.set(model_id, requests);
|
|
92
|
+
return request_count === 1
|
|
93
|
+
? create_plugin_call_stream(model_id)
|
|
94
|
+
: create_text_stream("done");
|
|
136
95
|
},
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
object: "chat.completion.chunk",
|
|
140
|
-
created: 1,
|
|
141
|
-
model,
|
|
142
|
-
choices: [
|
|
143
|
-
{
|
|
144
|
-
index: 0,
|
|
145
|
-
delta: {},
|
|
146
|
-
finish_reason: "tool_calls",
|
|
147
|
-
},
|
|
148
|
-
],
|
|
149
|
-
},
|
|
150
|
-
"[DONE]",
|
|
151
|
-
]);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* 创建绑定测试 HTTP 服务的 CityModel。
|
|
156
|
-
*/
|
|
157
|
-
function create_test_model(base_url, model_id) {
|
|
158
|
-
return Object.freeze({
|
|
96
|
+
});
|
|
97
|
+
return Object.assign(language_model, {
|
|
159
98
|
id: model_id,
|
|
160
99
|
name: model_id,
|
|
161
100
|
description: "Multi-agent plugin isolation model",
|
|
@@ -163,19 +102,10 @@ function create_test_model(base_url, model_id) {
|
|
|
163
102
|
tags: [],
|
|
164
103
|
meta: {},
|
|
165
104
|
kind: CITY_MODEL_KIND,
|
|
166
|
-
[CITY_MODEL_INVOKER]: {
|
|
167
|
-
connection: () => ({
|
|
168
|
-
base_url,
|
|
169
|
-
api_key: "test_key",
|
|
170
|
-
model_id,
|
|
171
|
-
}),
|
|
172
|
-
},
|
|
173
105
|
});
|
|
174
106
|
}
|
|
175
107
|
|
|
176
|
-
/**
|
|
177
|
-
* 创建返回固定 owner 的 skill plugin。
|
|
178
|
-
*/
|
|
108
|
+
/** 创建返回固定 owner 的 skill plugin。 */
|
|
179
109
|
function create_owner_plugin(owner, executed_owners) {
|
|
180
110
|
return createPlugin({
|
|
181
111
|
name: "skill",
|
|
@@ -200,63 +130,19 @@ function create_owner_plugin(owner, executed_owners) {
|
|
|
200
130
|
test("multiple session prompts use only their owning Agent plugin registry", async () => {
|
|
201
131
|
const model_requests = new Map();
|
|
202
132
|
const executed_owners = [];
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
if (
|
|
206
|
-
request.method !== "POST" ||
|
|
207
|
-
url.pathname !== "/v1/ai/chat/completions"
|
|
208
|
-
) {
|
|
209
|
-
response.writeHead(404);
|
|
210
|
-
response.end("not found");
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
const body = await read_json_body(request);
|
|
215
|
-
const model = String(body.model || "");
|
|
216
|
-
if (!Array.isArray(body.tools)) {
|
|
217
|
-
write_text_response(response, {
|
|
218
|
-
id: `chatcmpl_${model}_title`,
|
|
219
|
-
model,
|
|
220
|
-
text: `Title ${model}`,
|
|
221
|
-
});
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const requests = model_requests.get(model) || [];
|
|
226
|
-
requests.push(body);
|
|
227
|
-
model_requests.set(model, requests);
|
|
228
|
-
if (requests.length === 1) {
|
|
229
|
-
write_plugin_call_response(response, model);
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
write_text_response(response, {
|
|
233
|
-
id: `chatcmpl_${model}_done`,
|
|
234
|
-
model,
|
|
235
|
-
text: "done",
|
|
236
|
-
});
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
240
|
-
const address = server.address();
|
|
241
|
-
assert.ok(address && typeof address === "object");
|
|
242
|
-
const base_url = `http://127.0.0.1:${String(address.port)}/v1/ai`;
|
|
243
|
-
const root_a = await fs.mkdtemp(
|
|
244
|
-
path.join(os.tmpdir(), "downcity-plugin-isolation-a-"),
|
|
245
|
-
);
|
|
246
|
-
const root_b = await fs.mkdtemp(
|
|
247
|
-
path.join(os.tmpdir(), "downcity-plugin-isolation-b-"),
|
|
248
|
-
);
|
|
133
|
+
const root_a = await fs.mkdtemp(path.join(os.tmpdir(), "downcity-plugin-isolation-a-"));
|
|
134
|
+
const root_b = await fs.mkdtemp(path.join(os.tmpdir(), "downcity-plugin-isolation-b-"));
|
|
249
135
|
const agent_a = new Agent({
|
|
250
136
|
id: "agent_a",
|
|
251
137
|
path: root_a,
|
|
252
138
|
plugins: [create_owner_plugin("agent_a", executed_owners)],
|
|
253
|
-
model: create_test_model(
|
|
139
|
+
model: create_test_model("model_a", model_requests),
|
|
254
140
|
});
|
|
255
141
|
const agent_b = new Agent({
|
|
256
142
|
id: "agent_b",
|
|
257
143
|
path: root_b,
|
|
258
144
|
plugins: [create_owner_plugin("agent_b", executed_owners)],
|
|
259
|
-
model: create_test_model(
|
|
145
|
+
model: create_test_model("model_b", model_requests),
|
|
260
146
|
});
|
|
261
147
|
|
|
262
148
|
try {
|
|
@@ -266,24 +152,17 @@ test("multiple session prompts use only their owning Agent plugin registry", asy
|
|
|
266
152
|
session_a.prompt({ query: "Call your skill plugin" }),
|
|
267
153
|
session_b.prompt({ query: "Call your skill plugin" }),
|
|
268
154
|
]);
|
|
269
|
-
const [result_a, result_b] = await Promise.all([
|
|
270
|
-
turn_a.finished,
|
|
271
|
-
turn_b.finished,
|
|
272
|
-
]);
|
|
155
|
+
const [result_a, result_b] = await Promise.all([turn_a.finished, turn_b.finished]);
|
|
273
156
|
|
|
274
157
|
assert.equal(result_a.success, true);
|
|
275
158
|
assert.equal(result_b.success, true);
|
|
276
159
|
assert.deepEqual([...executed_owners].sort(), ["agent_a", "agent_b"]);
|
|
277
|
-
for (const [
|
|
278
|
-
[
|
|
279
|
-
["model_b", "agent_b"],
|
|
280
|
-
]) {
|
|
281
|
-
const requests = model_requests.get(model) || [];
|
|
160
|
+
for (const [model_id, owner] of [["model_a", "agent_a"], ["model_b", "agent_b"]]) {
|
|
161
|
+
const requests = model_requests.get(model_id) ?? [];
|
|
282
162
|
assert.equal(requests.length, 2);
|
|
283
|
-
assert.match(JSON.stringify(requests[1].
|
|
163
|
+
assert.match(JSON.stringify(requests[1].prompt), new RegExp(owner));
|
|
284
164
|
}
|
|
285
165
|
} finally {
|
|
286
166
|
await Promise.all([agent_a.dispose(), agent_b.dispose()]);
|
|
287
|
-
await new Promise((resolve) => server.close(() => resolve()));
|
|
288
167
|
}
|
|
289
168
|
});
|
|
@@ -15,18 +15,50 @@ import { compose_session_compaction } from "../bin/session/messages/SessionMessa
|
|
|
15
15
|
import { to_executor_history } from "../bin/session/messages/SessionMessageCodec.js";
|
|
16
16
|
import { MockLanguageModelV3 } from "ai/test";
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
/** 可暂停一次 Assistant 草稿写入,用于稳定复现并发写入顺序。 */
|
|
19
|
+
class PausingAssistantMessageStore extends JsonlSessionMessageStore {
|
|
20
|
+
next_assistant_write = null;
|
|
21
|
+
|
|
22
|
+
pause_next_assistant_write() {
|
|
23
|
+
let mark_started;
|
|
24
|
+
let release;
|
|
25
|
+
const started = new Promise((resolve) => {
|
|
26
|
+
mark_started = resolve;
|
|
27
|
+
});
|
|
28
|
+
const wait = new Promise((resolve) => {
|
|
29
|
+
release = resolve;
|
|
30
|
+
});
|
|
31
|
+
this.next_assistant_write = { mark_started, wait };
|
|
32
|
+
return { started, release };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async write_assistant_message(message) {
|
|
36
|
+
const pending = this.next_assistant_write;
|
|
37
|
+
if (pending) {
|
|
38
|
+
this.next_assistant_write = null;
|
|
39
|
+
pending.mark_started();
|
|
40
|
+
await pending.wait;
|
|
41
|
+
}
|
|
42
|
+
await super.write_assistant_message(message);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function create_recorder(
|
|
47
|
+
session_id = "session-recorder-test",
|
|
48
|
+
create_store = (options) => new JsonlSessionMessageStore(options),
|
|
49
|
+
) {
|
|
19
50
|
const root_path = await fs.mkdtemp(path.join(os.tmpdir(), "downcity-session-recorder-"));
|
|
20
51
|
const file_path = path.join(root_path, "active.jsonl");
|
|
21
52
|
const assistant_message_file_path = path.join(root_path, "assistant_message.json");
|
|
22
53
|
const events = [];
|
|
54
|
+
const store = create_store({ session_id, file_path });
|
|
23
55
|
const recorder = new SessionMessages({
|
|
24
56
|
session_id,
|
|
25
|
-
store
|
|
57
|
+
store,
|
|
26
58
|
publish: (mutation) => events.push(mutation),
|
|
27
59
|
});
|
|
28
60
|
await recorder.initialize();
|
|
29
|
-
return { recorder, events, file_path, assistant_message_file_path };
|
|
61
|
+
return { recorder, store, events, file_path, assistant_message_file_path };
|
|
30
62
|
}
|
|
31
63
|
|
|
32
64
|
async function read_jsonl(file_path) {
|
|
@@ -249,6 +281,85 @@ test("Approval Broker 只接受已经准备完整输入的 Tool", async () => {
|
|
|
249
281
|
);
|
|
250
282
|
});
|
|
251
283
|
|
|
284
|
+
test("流式更新与 Approval 共享 Assistant revision 写队列", async () => {
|
|
285
|
+
const session_id = "approval-revision-queue-test";
|
|
286
|
+
const { recorder, store, events } = await create_recorder(
|
|
287
|
+
session_id,
|
|
288
|
+
(options) => new PausingAssistantMessageStore(options),
|
|
289
|
+
);
|
|
290
|
+
const approval_broker = new SessionApprovalBroker({ session_id, messages: recorder });
|
|
291
|
+
const writer = await recorder.open_assistant_message({
|
|
292
|
+
turn_id: "turn-approval-race",
|
|
293
|
+
segment_index: 1,
|
|
294
|
+
});
|
|
295
|
+
await writer.prepare_tool_input({
|
|
296
|
+
tool_call_id: "call-approval-race",
|
|
297
|
+
tool_name: "shell_exec",
|
|
298
|
+
input: {
|
|
299
|
+
cmd: "ls -la ~",
|
|
300
|
+
sandbox: "unrestricted",
|
|
301
|
+
reason: "验证审批写入顺序",
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
await writer.apply_chunk({ type: "text-start", id: "text-race" });
|
|
305
|
+
await writer.apply_chunk({ type: "text-delta", id: "text-race", delta: "准备执行" });
|
|
306
|
+
|
|
307
|
+
const gate = store.pause_next_assistant_write();
|
|
308
|
+
const stream_write = writer.apply_chunk({
|
|
309
|
+
type: "text-delta",
|
|
310
|
+
id: "text-race",
|
|
311
|
+
delta: "命令",
|
|
312
|
+
});
|
|
313
|
+
await gate.started;
|
|
314
|
+
const approval_write = approval_broker.request({
|
|
315
|
+
shell_id: "shell-approval-race",
|
|
316
|
+
tool_call_id: "call-approval-race",
|
|
317
|
+
tool_name: "shell_exec",
|
|
318
|
+
session_id,
|
|
319
|
+
turn_id: "turn-approval-race",
|
|
320
|
+
command: "ls -la ~",
|
|
321
|
+
cwd: "/workspace",
|
|
322
|
+
reason: "验证审批写入顺序",
|
|
323
|
+
operation: "exec",
|
|
324
|
+
timeout_ms: 60_000,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// 让 Approval 路径有机会进入写入;旧实现会在这里基于同一 revision 提交。
|
|
328
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
329
|
+
gate.release();
|
|
330
|
+
const [, approval_handle] = await Promise.all([stream_write, approval_write]);
|
|
331
|
+
|
|
332
|
+
const assistant = recorder.get_message(writer.message_id);
|
|
333
|
+
const tool = assistant.parts.find((part) => part.type === "tool");
|
|
334
|
+
const text_part = assistant.parts.find((part) => part.type === "text");
|
|
335
|
+
assert.equal(assistant.revision, 6);
|
|
336
|
+
assert.equal(tool.state, "approval-required");
|
|
337
|
+
assert.equal(tool.approval.approval_id, approval_handle.approval_id);
|
|
338
|
+
assert.equal(tool.input.cmd, "ls -la ~");
|
|
339
|
+
assert.equal(text_part.text, "准备执行命令");
|
|
340
|
+
assert.deepEqual(
|
|
341
|
+
events
|
|
342
|
+
.filter((event) => event.message_id === writer.message_id)
|
|
343
|
+
.map((event) => event.revision),
|
|
344
|
+
[1, 2, 3, 4, 5, 6],
|
|
345
|
+
);
|
|
346
|
+
assert.equal(
|
|
347
|
+
events.some(
|
|
348
|
+
(event) =>
|
|
349
|
+
event.variant === "part" &&
|
|
350
|
+
event.type === "tool" &&
|
|
351
|
+
event.part.state === "approval-required",
|
|
352
|
+
),
|
|
353
|
+
true,
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
await approval_broker.resolve({
|
|
357
|
+
approval_id: approval_handle.approval_id,
|
|
358
|
+
decision: "denied",
|
|
359
|
+
});
|
|
360
|
+
assert.equal(await approval_handle.decision, "denied");
|
|
361
|
+
});
|
|
362
|
+
|
|
252
363
|
test("不同模型 step 重复使用 Text chunk ID 时仍保持真实 Part 顺序", async () => {
|
|
253
364
|
const { recorder, file_path } = await create_recorder("reused-text-id-order-test");
|
|
254
365
|
const writer = await recorder.open_assistant_message({ turn_id: "turn-1", segment_index: 1 });
|
|
@@ -1,93 +1,80 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @file 验证
|
|
3
|
-
*
|
|
4
|
-
* 关键点(中文)
|
|
5
|
-
* - 测试编译后的 shell 包导出,确保最终 package 产物包含一致行为。
|
|
6
|
-
* - zsh heredoc 会读取 `TMPPREFIX` 创建临时文件,因此必须指向 sandbox tmp。
|
|
2
|
+
* @file 验证 Safe Sandbox 环境变量收敛与临时目录映射。
|
|
7
3
|
*/
|
|
8
4
|
|
|
9
5
|
import test from "node:test";
|
|
10
6
|
import assert from "node:assert/strict";
|
|
11
7
|
import path from "node:path";
|
|
12
8
|
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
resolveSandboxEnvAllowlist,
|
|
17
|
-
} from "@downcity/shell/sandbox/SandboxConfigResolver.js";
|
|
18
|
-
|
|
19
|
-
function createParams() {
|
|
20
|
-
const rootPath = "/tmp/downcity-shell-env/project";
|
|
21
|
-
const sandboxDir = path.join(rootPath, ".downcity", "sandbox");
|
|
22
|
-
const tmpDir = path.join(sandboxDir, "tmp");
|
|
23
|
-
const cacheDir = path.join(sandboxDir, ".cache");
|
|
9
|
+
import { build_macos_sandbox_env } from "@downcity/shell/sandbox/MacOsSeatbelt.js";
|
|
10
|
+
import { build_linux_sandbox_env } from "@downcity/shell/sandbox/LinuxBubblewrap.js";
|
|
11
|
+
import { resolve_sandbox_policy } from "@downcity/shell/sandbox/SandboxPolicy.js";
|
|
24
12
|
|
|
13
|
+
function create_request() {
|
|
14
|
+
const root_path = "/tmp/downcity-shell-env/project";
|
|
15
|
+
const sandbox_dir = path.join(root_path, ".downcity", "sandbox");
|
|
25
16
|
return {
|
|
26
|
-
|
|
27
|
-
|
|
17
|
+
execution_id: "sh_test",
|
|
18
|
+
execution_dir: path.join(root_path, ".downcity", "shell", "sh_test"),
|
|
28
19
|
cmd: "printf hello",
|
|
29
|
-
cwd:
|
|
30
|
-
|
|
20
|
+
cwd: root_path,
|
|
21
|
+
shell_path: "/bin/zsh",
|
|
31
22
|
login: true,
|
|
32
|
-
|
|
23
|
+
base_env: {
|
|
33
24
|
PATH: "/usr/bin:/bin",
|
|
34
25
|
LANG: "C.UTF-8",
|
|
35
26
|
DC_SESSION_ID: "session_test",
|
|
36
27
|
},
|
|
37
|
-
|
|
28
|
+
policy: {
|
|
38
29
|
backend: "macos-seatbelt",
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
30
|
+
root_path,
|
|
31
|
+
sandbox_dir,
|
|
32
|
+
home_dir: sandbox_dir,
|
|
33
|
+
tmp_dir: path.join(sandbox_dir, "tmp"),
|
|
34
|
+
cache_dir: path.join(sandbox_dir, ".cache"),
|
|
35
|
+
env_allowlist: ["PATH", "LANG"],
|
|
36
|
+
read_only_paths: ["/usr", "/bin"],
|
|
37
|
+
read_write_paths: [root_path],
|
|
38
|
+
network_mode: "full",
|
|
39
|
+
fingerprint: "policy_test",
|
|
47
40
|
},
|
|
48
41
|
};
|
|
49
42
|
}
|
|
50
43
|
|
|
51
|
-
function
|
|
52
|
-
assert.equal(env.TMPDIR,
|
|
53
|
-
assert.equal(env.TMP,
|
|
54
|
-
assert.equal(env.TEMP,
|
|
55
|
-
assert.equal(env.TEMPDIR,
|
|
56
|
-
assert.equal(env.TMPPREFIX, path.join(
|
|
57
|
-
assert.equal(env.DC_SANDBOX_TMP,
|
|
44
|
+
function assert_sandbox_tmp_env(env, tmp_dir) {
|
|
45
|
+
assert.equal(env.TMPDIR, tmp_dir);
|
|
46
|
+
assert.equal(env.TMP, tmp_dir);
|
|
47
|
+
assert.equal(env.TEMP, tmp_dir);
|
|
48
|
+
assert.equal(env.TEMPDIR, tmp_dir);
|
|
49
|
+
assert.equal(env.TMPPREFIX, path.join(tmp_dir, "zsh"));
|
|
50
|
+
assert.equal(env.DC_SANDBOX_TMP, tmp_dir);
|
|
58
51
|
}
|
|
59
52
|
|
|
60
|
-
test("macOS
|
|
61
|
-
const
|
|
62
|
-
const env =
|
|
63
|
-
|
|
64
|
-
assertSandboxTmpEnv(env, params.config.tmpDir);
|
|
53
|
+
test("macOS safe env points temporary variables at sandbox tmp", () => {
|
|
54
|
+
const request = create_request();
|
|
55
|
+
const env = build_macos_sandbox_env(request);
|
|
56
|
+
assert_sandbox_tmp_env(env, request.policy.tmp_dir);
|
|
65
57
|
});
|
|
66
58
|
|
|
67
|
-
test("Linux
|
|
68
|
-
const
|
|
69
|
-
const env =
|
|
70
|
-
...
|
|
71
|
-
|
|
72
|
-
...params.config,
|
|
73
|
-
backend: "linux-bubblewrap",
|
|
74
|
-
},
|
|
59
|
+
test("Linux safe env points temporary variables at sandbox tmp", () => {
|
|
60
|
+
const request = create_request();
|
|
61
|
+
const env = build_linux_sandbox_env({
|
|
62
|
+
...request,
|
|
63
|
+
policy: { ...request.policy, backend: "linux-bubblewrap" },
|
|
75
64
|
});
|
|
76
|
-
|
|
77
|
-
assertSandboxTmpEnv(env, params.config.tmpDir);
|
|
65
|
+
assert_sandbox_tmp_env(env, request.policy.tmp_dir);
|
|
78
66
|
});
|
|
79
67
|
|
|
80
|
-
test("safe
|
|
81
|
-
const
|
|
68
|
+
test("safe policy exports explicit agent env keys only", async () => {
|
|
69
|
+
const policy = await resolve_sandbox_policy({
|
|
82
70
|
rootPath: "/tmp/downcity-shell-env/project",
|
|
83
71
|
env: {
|
|
84
72
|
DYNAMIC_ENV_REPRO: "dynamic_value",
|
|
85
73
|
DC_DYNAMIC_REPRO: "dc_value",
|
|
86
74
|
},
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
assert.equal(
|
|
90
|
-
assert.equal(
|
|
91
|
-
assert.equal(
|
|
92
|
-
assert.equal(allowlist.includes("HOST_ONLY_ENV_REPRO"), false);
|
|
75
|
+
}, {});
|
|
76
|
+
assert.equal(policy.env_allowlist.includes("PATH"), true);
|
|
77
|
+
assert.equal(policy.env_allowlist.includes("DYNAMIC_ENV_REPRO"), true);
|
|
78
|
+
assert.equal(policy.env_allowlist.includes("DC_DYNAMIC_REPRO"), true);
|
|
79
|
+
assert.equal(policy.env_allowlist.includes("HOST_ONLY_ENV_REPRO"), false);
|
|
93
80
|
});
|