@downcity/agent 1.1.259 → 1.1.261

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.
@@ -1,9 +1,5 @@
1
1
  /**
2
- * @file 验证 Linux bubblewrap sandbox 参数生成。
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 { buildLinuxBubblewrapArgs } from "@downcity/shell/sandbox/LinuxBubblewrapSandbox.js";
11
+ import { build_linux_bubblewrap_args } from "@downcity/shell/sandbox/LinuxBubblewrap.js";
16
12
 
17
- async function createSandboxFixture() {
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] === sourcePath &&
79
- args[index + 2] === targetPath
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 bubblewrap args isolate network and overlay writable project paths", async () => {
97
- const fixture = await createSandboxFixture();
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 args = buildLinuxBubblewrapArgs(createParams(fixture, {
124
- config: {
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
- rootPath: fixture.projectRoot,
127
- sandboxDir: fixture.sandboxDir,
128
- homeDir: fixture.sandboxDir,
129
- tmpDir: fixture.tmpDir,
130
- cacheDir: fixture.cacheDir,
131
- envAllowlist: ["PATH"],
132
- writablePaths: [fixture.projectRoot],
133
- networkMode: "full",
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
- login: false,
136
- }));
137
-
138
- assert.equal(hasArg(args, "--unshare-net"), false);
139
- assert.equal(hasOptionPair(args, "--bind", fixture.projectRoot), true);
140
- assert.equal(hasOptionPair(args, "--ro-bind", fixture.projectRoot), false);
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
- fixture.projectRoot,
60
+ project_root,
144
61
  "/bin/sh",
145
- "-c",
62
+ "-lc",
146
63
  "printf hello",
147
64
  ]);
148
65
  } finally {
149
- await fs.rm(fixture.root, { recursive: true, force: true });
66
+ await fs.rm(fixture_root, { recursive: true, force: true });
150
67
  }
151
68
  });
@@ -1,161 +1,100 @@
1
1
  /**
2
2
  * @file 验证多个 Agent 经 session.prompt 执行 plugin_call 时保持 registry 隔离。
3
3
  *
4
- * 关键点(中文)
5
- * - 先创建 Agent A,再创建 Agent B,锁住历史全局 runtime 的覆盖顺序。
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
- createAction,
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
- * 写入一组 OpenAI-compatible SSE chunk。
26
- */
27
- function write_openai_sse(response, chunks) {
28
- response.writeHead(200, {
29
- "content-type": "text/event-stream; charset=utf-8",
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
- * 读取 HTTP JSON 请求体。
42
- */
43
- async function read_json_body(request) {
44
- const raw = await new Promise((resolve, reject) => {
45
- let data = "";
46
- request.on("data", (chunk) => {
47
- data += chunk;
48
- });
49
- request.on("end", () => resolve(data));
50
- request.on("error", reject);
51
- });
52
- return JSON.parse(String(raw || "{}"));
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
- function write_text_response(response, input) {
59
- write_openai_sse(response, [
60
- {
61
- id: input.id,
62
- object: "chat.completion.chunk",
63
- created: 1,
64
- model: input.model,
65
- choices: [
66
- {
67
- index: 0,
68
- delta: { role: "assistant", content: input.text },
69
- finish_reason: null,
70
- },
71
- ],
72
- },
73
- {
74
- id: input.id,
75
- object: "chat.completion.chunk",
76
- created: 1,
77
- model: input.model,
78
- choices: [
79
- {
80
- index: 0,
81
- delta: {},
82
- finish_reason: "stop",
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
- * 返回一次 plugin_call tool call。
92
- */
93
- function write_plugin_call_response(response, model) {
94
- write_openai_sse(response, [
95
- {
96
- id: `chatcmpl_${model}_tool`,
97
- object: "chat.completion.chunk",
98
- created: 1,
99
- model,
100
- choices: [
101
- {
102
- index: 0,
103
- delta: { role: "assistant" },
104
- finish_reason: null,
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
- id: `chatcmpl_${model}_tool`,
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 server = http.createServer(async (request, response) => {
204
- const url = new URL(String(request.url || "/"), "http://127.0.0.1");
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(base_url, "model_a"),
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(base_url, "model_b"),
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 [model, owner] of [
278
- ["model_a", "agent_a"],
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].messages), new RegExp(owner));
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
  });