@downcity/plugins 1.0.61 → 1.0.66

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.
@@ -0,0 +1,335 @@
1
+ /**
2
+ * @file 验证 shell unrestricted sandbox 审批运行时。
3
+ *
4
+ * 关键点(中文)
5
+ * - 测试编译后的 bin 输出,避免测试文件进入 package 源码导出面。
6
+ * - 直接驱动 shell runtime state,模拟 UI/Console 收到 approval_id 后批准或拒绝。
7
+ */
8
+
9
+ import test from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import fs from "node:fs/promises";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+
15
+ import {
16
+ approveShellApproval,
17
+ closeAllShellSessions,
18
+ createShellPluginState,
19
+ denyShellApproval,
20
+ execShellCommand,
21
+ readShellSession,
22
+ startShellSession,
23
+ writeShellSession,
24
+ } from "../bin/shell/runtime/ShellActionRuntime.js";
25
+
26
+ async function create_fixture() {
27
+ const root_path = await fs.mkdtemp(path.join(os.tmpdir(), "downcity-unrestricted-"));
28
+ const events = [];
29
+ const context = {
30
+ rootPath: root_path,
31
+ env: {},
32
+ config: { id: "test-agent" },
33
+ paths: {
34
+ getDowncityChannelMetaPath: () => path.join(root_path, ".downcity", "channel", "meta.json"),
35
+ },
36
+ session: {
37
+ get: () => ({
38
+ publishEvent: (event) => {
39
+ events.push(event);
40
+ },
41
+ }),
42
+ },
43
+ };
44
+ return {
45
+ root_path,
46
+ events,
47
+ context,
48
+ };
49
+ }
50
+
51
+ async function wait_for_approval(state) {
52
+ const started_at = Date.now();
53
+ while (Date.now() - started_at < 2000) {
54
+ const approval = Array.from(state.approvals.values())[0];
55
+ if (approval) return approval;
56
+ await new Promise((resolve) => setTimeout(resolve, 10));
57
+ }
58
+ throw new Error("approval request was not created");
59
+ }
60
+
61
+ test("shell_start unrestricted requires reason", async () => {
62
+ const fixture = await create_fixture();
63
+ const state = createShellPluginState({ defaultApprovalTimeoutMs: 500 });
64
+ try {
65
+ await assert.rejects(
66
+ startShellSession(state, fixture.context, {
67
+ cmd: "printf should-not-run",
68
+ cwd: fixture.root_path,
69
+ shell: "/bin/sh",
70
+ login: false,
71
+ sandbox: "unrestricted",
72
+ }),
73
+ /requires a non-empty reason/,
74
+ );
75
+ } finally {
76
+ await closeAllShellSessions(state, true);
77
+ await fs.rm(fixture.root_path, { recursive: true, force: true });
78
+ }
79
+ });
80
+
81
+ test("shell_start unrestricted denied returns denied tool result without execution", async () => {
82
+ const fixture = await create_fixture();
83
+ const state = createShellPluginState({
84
+ defaultApprovalTimeoutMs: 2000,
85
+ defaultInlineWaitMs: 20,
86
+ });
87
+ try {
88
+ const marker_path = path.join(fixture.root_path, "should-not-exist.txt");
89
+ const pending_result = startShellSession(state, fixture.context, {
90
+ cmd: `printf denied > ${JSON.stringify(marker_path)}`,
91
+ cwd: fixture.root_path,
92
+ shell: "/bin/sh",
93
+ login: false,
94
+ sandbox: "unrestricted",
95
+ reason: "测试拒绝 unrestricted sandbox 不会执行命令。",
96
+ ownerContextId: "session_test",
97
+ });
98
+
99
+ const approval = await wait_for_approval(state);
100
+ assert.equal(approval.toolName, "shell_start");
101
+ assert.equal(fixture.events[0]?.type, "tool-approval-request");
102
+ assert.equal(fixture.events[0]?.approvalId, approval.approvalId);
103
+
104
+ assert.equal(
105
+ await denyShellApproval(state, fixture.context, approval.approvalId),
106
+ true,
107
+ );
108
+ const result = await pending_result;
109
+
110
+ assert.equal(result.shell.sandboxMode, "unrestricted");
111
+ assert.equal(result.shell.approvalStatus, "denied");
112
+ assert.equal(result.shell.status, "failed");
113
+ assert.match(result.chunk.output, /User denied unrestricted sandbox execution/);
114
+ await assert.rejects(fs.stat(marker_path), /ENOENT/);
115
+ assert.equal(fixture.events.at(-1)?.type, "tool-approval-result");
116
+ assert.equal(fixture.events.at(-1)?.decision, "denied");
117
+ } finally {
118
+ await closeAllShellSessions(state, true);
119
+ await fs.rm(fixture.root_path, { recursive: true, force: true });
120
+ }
121
+ });
122
+
123
+ test("shell_exec unrestricted approved executes in unrestricted sandbox", async () => {
124
+ const fixture = await create_fixture();
125
+ const state = createShellPluginState({
126
+ defaultApprovalTimeoutMs: 2000,
127
+ defaultInlineWaitMs: 20,
128
+ defaultExecTimeoutMs: 2000,
129
+ });
130
+ try {
131
+ const pending_result = execShellCommand(state, fixture.context, {
132
+ cmd: "printf unrestricted-ok",
133
+ cwd: fixture.root_path,
134
+ shell: "/bin/sh",
135
+ login: false,
136
+ sandbox: "unrestricted",
137
+ reason: "测试批准后执行 unrestricted sandbox 命令。",
138
+ timeoutMs: 2000,
139
+ });
140
+
141
+ const approval = await wait_for_approval(state);
142
+ assert.equal(approval.toolName, "shell_exec");
143
+ assert.equal(
144
+ await approveShellApproval(state, fixture.context, approval.approvalId),
145
+ true,
146
+ );
147
+ const result = await pending_result;
148
+
149
+ assert.equal(result.shell.sandboxed, false);
150
+ assert.equal(result.shell.sandboxMode, "unrestricted");
151
+ assert.equal(result.shell.approvalStatus, "approved");
152
+ assert.equal(result.shell.exitCode, 0);
153
+ assert.equal(result.chunk.output, "unrestricted-ok");
154
+ } finally {
155
+ await closeAllShellSessions(state, true);
156
+ await fs.rm(fixture.root_path, { recursive: true, force: true });
157
+ }
158
+ });
159
+
160
+ test("shell_write unrestricted requires reason", async () => {
161
+ const fixture = await create_fixture();
162
+ const state = createShellPluginState({
163
+ defaultApprovalTimeoutMs: 2000,
164
+ defaultInlineWaitMs: 20,
165
+ });
166
+ try {
167
+ const pending_result = startShellSession(state, fixture.context, {
168
+ cmd: "cat",
169
+ cwd: fixture.root_path,
170
+ shell: "/bin/sh",
171
+ login: false,
172
+ sandbox: "unrestricted",
173
+ reason: "测试启动 unrestricted 交互进程。",
174
+ ownerContextId: "session_test",
175
+ inlineWaitMs: 20,
176
+ });
177
+ const start_approval = await wait_for_approval(state);
178
+ await approveShellApproval(state, fixture.context, start_approval.approvalId);
179
+ const started = await pending_result;
180
+
181
+ await assert.rejects(
182
+ writeShellSession(state, fixture.context, {
183
+ shellId: started.shell.shellId,
184
+ chars: "should-not-write\n",
185
+ }),
186
+ /requires a non-empty reason/,
187
+ );
188
+ } finally {
189
+ await closeAllShellSessions(state, true);
190
+ await fs.rm(fixture.root_path, { recursive: true, force: true });
191
+ }
192
+ });
193
+
194
+ test("shell_write unrestricted denied does not write stdin", async () => {
195
+ const fixture = await create_fixture();
196
+ const state = createShellPluginState({
197
+ defaultApprovalTimeoutMs: 2000,
198
+ defaultInlineWaitMs: 20,
199
+ });
200
+ try {
201
+ const pending_result = startShellSession(state, fixture.context, {
202
+ cmd: "cat",
203
+ cwd: fixture.root_path,
204
+ shell: "/bin/sh",
205
+ login: false,
206
+ sandbox: "unrestricted",
207
+ reason: "测试启动 unrestricted 交互进程。",
208
+ ownerContextId: "session_test",
209
+ inlineWaitMs: 20,
210
+ });
211
+ const start_approval = await wait_for_approval(state);
212
+ await approveShellApproval(state, fixture.context, start_approval.approvalId);
213
+ const started = await pending_result;
214
+
215
+ const pending_write = writeShellSession(state, fixture.context, {
216
+ shellId: started.shell.shellId,
217
+ chars: "denied-write\n",
218
+ reason: "测试拒绝 unrestricted shell_write 不会写入 stdin。",
219
+ });
220
+ const write_approval = await wait_for_approval(state);
221
+ assert.equal(write_approval.toolName, "shell_write");
222
+ assert.equal(write_approval.operation, "write");
223
+ assert.equal(write_approval.inputPreview, "denied-write\n");
224
+ assert.equal(write_approval.inputChars, "denied-write\n".length);
225
+ assert.equal(fixture.events.at(-1)?.type, "tool-approval-request");
226
+ assert.equal(fixture.events.at(-1)?.toolName, "shell_write");
227
+ assert.equal(fixture.events.at(-1)?.operation, "write");
228
+
229
+ await denyShellApproval(state, fixture.context, write_approval.approvalId);
230
+ const denied = await pending_write;
231
+ assert.equal(denied.shell.approvalStatus, "denied");
232
+ assert.match(denied.chunk.output, /User denied unrestricted sandbox execution/);
233
+
234
+ const read = await readShellSession(state, fixture.context, {
235
+ shellId: started.shell.shellId,
236
+ fromCursor: 0,
237
+ maxOutputTokens: 1000,
238
+ });
239
+ assert.equal(read.chunk.output, "");
240
+ } finally {
241
+ await closeAllShellSessions(state, true);
242
+ await fs.rm(fixture.root_path, { recursive: true, force: true });
243
+ }
244
+ });
245
+
246
+ test("shell_write unrestricted approved writes stdin", async () => {
247
+ const fixture = await create_fixture();
248
+ const state = createShellPluginState({
249
+ defaultApprovalTimeoutMs: 2000,
250
+ defaultInlineWaitMs: 20,
251
+ });
252
+ try {
253
+ const pending_result = startShellSession(state, fixture.context, {
254
+ cmd: "cat",
255
+ cwd: fixture.root_path,
256
+ shell: "/bin/sh",
257
+ login: false,
258
+ sandbox: "unrestricted",
259
+ reason: "测试启动 unrestricted 交互进程。",
260
+ ownerContextId: "session_test",
261
+ inlineWaitMs: 20,
262
+ });
263
+ const start_approval = await wait_for_approval(state);
264
+ await approveShellApproval(state, fixture.context, start_approval.approvalId);
265
+ const started = await pending_result;
266
+
267
+ const pending_write = writeShellSession(state, fixture.context, {
268
+ shellId: started.shell.shellId,
269
+ chars: "approved-write\n",
270
+ reason: "测试批准 unrestricted shell_write 后写入 stdin。",
271
+ });
272
+ const write_approval = await wait_for_approval(state);
273
+ assert.equal(write_approval.toolName, "shell_write");
274
+ await approveShellApproval(state, fixture.context, write_approval.approvalId);
275
+ const written = await pending_write;
276
+ assert.equal(written.shell.approvalStatus, "approved");
277
+
278
+ const started_at = Date.now();
279
+ let output = "";
280
+ while (Date.now() - started_at < 1000) {
281
+ const read = await readShellSession(state, fixture.context, {
282
+ shellId: started.shell.shellId,
283
+ fromCursor: 0,
284
+ maxOutputTokens: 1000,
285
+ });
286
+ output = read.chunk.output;
287
+ if (output.includes("approved-write")) break;
288
+ await new Promise((resolve) => setTimeout(resolve, 20));
289
+ }
290
+ assert.match(output, /approved-write/);
291
+ } finally {
292
+ await closeAllShellSessions(state, true);
293
+ await fs.rm(fixture.root_path, { recursive: true, force: true });
294
+ }
295
+ });
296
+
297
+ test("shell_write safe writes without approval", async () => {
298
+ const fixture = await create_fixture();
299
+ const state = createShellPluginState({
300
+ defaultApprovalTimeoutMs: 2000,
301
+ defaultInlineWaitMs: 20,
302
+ });
303
+ try {
304
+ const started = await startShellSession(state, fixture.context, {
305
+ cmd: "cat",
306
+ cwd: fixture.root_path,
307
+ shell: "/bin/sh",
308
+ login: false,
309
+ sandbox: "safe",
310
+ inlineWaitMs: 20,
311
+ });
312
+ await writeShellSession(state, fixture.context, {
313
+ shellId: started.shell.shellId,
314
+ chars: "safe-write\n",
315
+ });
316
+ assert.equal(state.approvals.size, 0);
317
+
318
+ const started_at = Date.now();
319
+ let output = "";
320
+ while (Date.now() - started_at < 1000) {
321
+ const read = await readShellSession(state, fixture.context, {
322
+ shellId: started.shell.shellId,
323
+ fromCursor: 0,
324
+ maxOutputTokens: 1000,
325
+ });
326
+ output = read.chunk.output;
327
+ if (output.includes("safe-write")) break;
328
+ await new Promise((resolve) => setTimeout(resolve, 20));
329
+ }
330
+ assert.match(output, /safe-write/);
331
+ } finally {
332
+ await closeAllShellSessions(state, true);
333
+ await fs.rm(fixture.root_path, { recursive: true, force: true });
334
+ }
335
+ });
@@ -29,8 +29,11 @@ import {
29
29
  closeAllShellSessions,
30
30
  closeShellSession,
31
31
  createShellPluginState,
32
+ approveShellApproval,
33
+ denyShellApproval,
32
34
  execShellCommand,
33
35
  getShellSessionStatus,
36
+ listShellApprovals,
34
37
  readShellSession,
35
38
  startShellSession,
36
39
  waitShellSession,
@@ -108,6 +111,42 @@ export class ShellPlugin extends BasePlugin {
108
111
  data: await this.close(params.context, params.payload as ShellCloseRequest),
109
112
  }),
110
113
  },
114
+ approvals: {
115
+ execute: async () => ({
116
+ success: true,
117
+ data: { approvals: listShellApprovals(this.state) },
118
+ }),
119
+ },
120
+ approve: {
121
+ execute: async (params) => {
122
+ const payload = params.payload as { approvalId?: unknown; approval_id?: unknown };
123
+ const approvalId = String(payload?.approvalId || payload?.approval_id || "").trim();
124
+ if (!approvalId) {
125
+ return { success: false, error: "approvalId is required" };
126
+ }
127
+ const ok = await approveShellApproval(this.state, params.context, approvalId);
128
+ return {
129
+ success: ok,
130
+ data: { approvalId, approved: ok },
131
+ ...(ok ? {} : { error: "approval request not found" }),
132
+ };
133
+ },
134
+ },
135
+ deny: {
136
+ execute: async (params) => {
137
+ const payload = params.payload as { approvalId?: unknown; approval_id?: unknown };
138
+ const approvalId = String(payload?.approvalId || payload?.approval_id || "").trim();
139
+ if (!approvalId) {
140
+ return { success: false, error: "approvalId is required" };
141
+ }
142
+ const ok = await denyShellApproval(this.state, params.context, approvalId);
143
+ return {
144
+ success: ok,
145
+ data: { approvalId, denied: ok },
146
+ ...(ok ? {} : { error: "approval request not found" }),
147
+ };
148
+ },
149
+ },
111
150
  };
112
151
 
113
152
  this.lifecycle = {
@@ -122,6 +161,7 @@ export class ShellPlugin extends BasePlugin {
122
161
  }
123
162
  }
124
163
  this.state.sessions.clear();
164
+ this.state.approvals.clear();
125
165
  this.state.context = null;
126
166
  },
127
167
  };
@@ -9,9 +9,74 @@
9
9
 
10
10
  import type { ChildProcessWithoutNullStreams } from "node:child_process";
11
11
  import type { AgentContext } from "@downcity/agent/internal/types/runtime/agent/AgentContext.js";
12
- import type { ShellSessionSnapshot } from "@downcity/agent/internal/executor/tools/shell/types/ShellPlugin.js";
12
+ import type {
13
+ ShellApprovalStatus,
14
+ ShellApprovalToolName,
15
+ ShellSessionSnapshot,
16
+ } from "@downcity/agent/internal/executor/tools/shell/types/ShellPlugin.js";
13
17
  import type { ResolvedShellPluginOptions } from "@/shell/types/ShellPluginOptions.js";
14
18
 
19
+ /**
20
+ * unrestricted sandbox 审批运行态。
21
+ */
22
+ export type ShellApprovalRuntimeState = {
23
+ /**
24
+ * 当前审批请求 ID。
25
+ */
26
+ approvalId: string;
27
+ /**
28
+ * 关联的 shell_id。
29
+ */
30
+ shellId: string;
31
+ /**
32
+ * 所属 session/聊天上下文。
33
+ */
34
+ ownerContextId?: string;
35
+ /**
36
+ * 关联工具名。
37
+ */
38
+ toolName: ShellApprovalToolName;
39
+ /**
40
+ * 申请执行的命令。
41
+ *
42
+ * 说明(中文)
43
+ * - `shell_write` 使用该字段保存 stdin 写入预览,保持审批队列结构统一。
44
+ */
45
+ cmd: string;
46
+ /**
47
+ * 审批动作类型。
48
+ */
49
+ operation: "exec" | "start" | "write";
50
+ /**
51
+ * stdin 写入内容预览;仅 `shell_write` 审批存在。
52
+ */
53
+ inputPreview?: string;
54
+ /**
55
+ * stdin 写入字符数;仅 `shell_write` 审批存在。
56
+ */
57
+ inputChars?: number;
58
+ /**
59
+ * 命令执行目录。
60
+ */
61
+ cwd: string;
62
+ /**
63
+ * 申请原因。
64
+ */
65
+ reason: string;
66
+ /**
67
+ * 当前审批创建时间。
68
+ */
69
+ createdAt: number;
70
+ /**
71
+ * 审批超时定时器。
72
+ */
73
+ timer: NodeJS.Timeout;
74
+ /**
75
+ * 兑现审批结果。
76
+ */
77
+ resolve: (status: ShellApprovalStatus) => void;
78
+ };
79
+
15
80
  /**
16
81
  * 单个 shell wait 调用挂起时注册的 waiter。
17
82
  */
@@ -88,6 +153,10 @@ export type ShellPluginState = {
88
153
  * 当前实例持有的全部 in-memory shell session。
89
154
  */
90
155
  sessions: Map<string, ShellSessionRuntimeState>;
156
+ /**
157
+ * 当前实例持有的全部 pending unrestricted sandbox 审批。
158
+ */
159
+ approvals: Map<string, ShellApprovalRuntimeState>;
91
160
  /**
92
161
  * 当前实例最近一次启动时绑定的 agent context。
93
162
  *