From da8dc7b2f23c0a9f51dd548a0d9c30bd8e7d9ffa Mon Sep 17 00:00:00 2001 From: zcai7675-bot <233973883+.zcai7675-bot@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:06:37 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(ui):=20full=20access=20mode=20?= =?UTF-8?q?=E2=80=94=20opt-in=20auto-run=20without=20destructive-command?= =?UTF-8?q?=20pause=20(PR=200004)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an opt-in 'Full access mode' for users who run the agent unattended on their own machine. When enabled together with auto-run tools, auto-run no longer pauses on bash commands flagged destructive (isDangerousBashCommand): every tool call executes automatically. Safety posture: - Off by default; only affects local auto-run behavior (no runtime layer). - First-time enable requires an explicit, unskippable risk acknowledgement dialog (destructive confirm). Reinstall/cleared storage re-asks. - A persistent amber 'Full access' badge stays visible in the toolbar while the mode is on; each run shows a dismissible warning toast. - Scope is limited to the destructive-bash pause: control-flow tools such as 'terminate' remain never-auto-executed. Without auto-run enabled the mode has no effect. - run-mode.ts: get/setFullAccessMode + get/setFullAccessAcknowledged (localStorage) + useSyncExternalStore subscription; useRunMode exposes fullAccessMode. - thread-store.ts: getFullAccessMode option read fresh at run time; bash interception skips the pause when enabled; per-run warning toast. - thread-playground.tsx: wires the option through and renders the badge. - Experimental settings: toggle + confirmation dialog (en/zh copy). Tests: thread-store-full-access.test.ts — dangerous bash auto-runs when mode is on, pauses when off, acknowledged flow, badge wiring. --- .../components/settings/experimental-page.tsx | 55 +++++++ apps/desktop/src/i18n/messages.ts | 16 ++ apps/desktop/src/i18n/playground-labels.ts | 2 + packages/ui/package.json | 1 + .../thread-playground/playground-labels.tsx | 3 + .../thread-playground/stores/run-mode.ts | 40 +++++ .../thread-playground/stores/thread-store.ts | 24 ++- .../thread-playground/thread-playground.tsx | 23 ++- packages/ui/src/lib/local-storage.ts | 2 + .../stores/thread-store-full-access.test.ts | 147 ++++++++++++++++++ specs/pr-plans/0004-full-access-mode.md | 99 ++++++++++++ 11 files changed, 408 insertions(+), 4 deletions(-) create mode 100644 packages/ui/tests/components/thread-playground/stores/thread-store-full-access.test.ts create mode 100644 specs/pr-plans/0004-full-access-mode.md diff --git a/apps/desktop/src/components/settings/experimental-page.tsx b/apps/desktop/src/components/settings/experimental-page.tsx index 670f7faf..2af8997b 100644 --- a/apps/desktop/src/components/settings/experimental-page.tsx +++ b/apps/desktop/src/components/settings/experimental-page.tsx @@ -1,6 +1,12 @@ "use client"; import { ConfirmDialog } from "@llm-space/ui/components/confirm-dialog"; +import { + getFullAccessAcknowledged, + getFullAccessMode, + setFullAccessAcknowledged, + setFullAccessMode, +} from "@llm-space/ui/components/thread-playground/stores/run-mode"; import { Separator } from "@llm-space/ui/ui/separator"; import { useState } from "react"; @@ -18,6 +24,33 @@ export function ExperimentalPage() { useExperimental(); const { executeCommand } = useCommands(); const [reloadPromptOpen, setReloadPromptOpen] = useState(false); + const [fullAccessMode, setFullAccessModeState] = useState(() => + getFullAccessMode() + ); + const [fullAccessDialogOpen, setFullAccessDialogOpen] = useState(false); + + const handleFullAccessChange = (next: boolean) => { + if (!next) { + setFullAccessMode(false); + setFullAccessModeState(false); + return; + } + // First-ever enable asks for the risk acknowledgement; afterwards the + // switch flips directly (and reinstalling/clearing storage re-asks). + if (getFullAccessAcknowledged()) { + setFullAccessMode(true); + setFullAccessModeState(true); + return; + } + setFullAccessDialogOpen(true); + }; + + const confirmFullAccess = () => { + setFullAccessAcknowledged(true); + setFullAccessMode(true); + setFullAccessModeState(true); + setFullAccessDialogOpen(false); + }; const handleReactScanChange = (next: boolean) => { setReactScanEnabled(next); @@ -33,6 +66,13 @@ export function ExperimentalPage() { className="overflow-y-auto" >
+ + ) : null}
+ { + setFullAccessDialogOpen(open); + // Dismissing without confirming leaves the switch off. + if (!open) setFullAccessModeState(false); + }} + dimBackground={false} + title={t.experimental.fullAccessDialogTitle} + description={t.experimental.fullAccessDialogDescription} + cancelLabel={t.experimental.fullAccessCancel} + confirmLabel={t.experimental.fullAccessConfirm} + confirmVariant="destructive" + onConfirm={confirmFullAccess} + /> = { runSettings: "Run settings", enableReActLoop: "Enable ReAct loop", autoRunTools: "Auto run tools", + fullAccessBadge: "Full access", dialogs: { add: "Add", remove: "Remove", @@ -453,6 +454,7 @@ export const PLAYGROUND_LABELS: Record = { runSettings: "运行设置", enableReActLoop: "启用 ReAct 循环", autoRunTools: "自动运行工具", + fullAccessBadge: "完全访问", dialogs: { add: "添加", remove: "移除", diff --git a/packages/ui/package.json b/packages/ui/package.json index ca614545..f3d43921 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -11,6 +11,7 @@ "./components/thread-playground": "./src/components/thread-playground/index.tsx", "./components/thread-playground/playground-labels": "./src/components/thread-playground/playground-labels.tsx", "./components/thread-playground/examples/prompts": "./src/components/thread-playground/examples/prompts.ts", + "./components/thread-playground/stores/run-mode": "./src/components/thread-playground/stores/run-mode.ts", "./components/code-editor": "./src/components/code-editor/index.tsx", "./components/*": "./src/components/*.tsx", "./styles/globals.css": "./src/styles/globals.css", diff --git a/packages/ui/src/components/thread-playground/playground-labels.tsx b/packages/ui/src/components/thread-playground/playground-labels.tsx index 65977d2b..f835872a 100644 --- a/packages/ui/src/components/thread-playground/playground-labels.tsx +++ b/packages/ui/src/components/thread-playground/playground-labels.tsx @@ -43,6 +43,8 @@ export interface PlaygroundLabels { runSettings: string; enableReActLoop: string; autoRunTools: string; + /** The persistent warning badge shown while full access mode is enabled. */ + fullAccessBadge: string; dialogs: { add: string; remove: string; @@ -418,6 +420,7 @@ export const DEFAULT_PLAYGROUND_LABELS: PlaygroundLabels = { runSettings: "Run settings", enableReActLoop: "Enable ReAct loop", autoRunTools: "Auto run tools", + fullAccessBadge: "Full access", dialogs: { add: "Add", remove: "Remove", diff --git a/packages/ui/src/components/thread-playground/stores/run-mode.ts b/packages/ui/src/components/thread-playground/stores/run-mode.ts index 70c851e1..1ca9281b 100644 --- a/packages/ui/src/components/thread-playground/stores/run-mode.ts +++ b/packages/ui/src/components/thread-playground/stores/run-mode.ts @@ -61,6 +61,33 @@ export function getEffectiveAutoRunTools(): boolean { return getReactLoop() || getAutoRunTools(); } +/** + * Whether full access mode is enabled. Opt-in and off by default: when on, + * tools auto-run without pausing for commands flagged as destructive (see + * `isDangerousBashCommand`). Requires a one-time risk acknowledgement before + * it can be switched on (see `getFullAccessAcknowledged`). + */ +export function getFullAccessMode(): boolean { + return _read(LOCAL_STORAGE_KEYS.fullAccessMode); +} + +export function setFullAccessMode(value: boolean): void { + _write(LOCAL_STORAGE_KEYS.fullAccessMode, value); +} + +/** + * Whether the user has confirmed the full-access-mode risk disclaimer. The + * switch only turns the mode on after this is set, so a reinstall or cleared + * storage asks for acknowledgement again. + */ +export function getFullAccessAcknowledged(): boolean { + return _read(LOCAL_STORAGE_KEYS.fullAccessAcknowledged); +} + +export function setFullAccessAcknowledged(value: boolean): void { + _write(LOCAL_STORAGE_KEYS.fullAccessAcknowledged, value); +} + function _subscribe(listener: () => void): () => void { listeners.add(listener); return () => { @@ -74,8 +101,14 @@ export interface RunMode { /** The effective flag: `true` whenever the ReAct loop is on. */ effectiveAutoRunTools: boolean; reactLoop: boolean; + /** + * Full access mode: auto-run never pauses for commands flagged destructive. + * Off by default; enabling it in settings requires a one-time acknowledgement. + */ + fullAccessMode: boolean; setAutoRunTools: (value: boolean) => void; setReactLoop: (value: boolean) => void; + setFullAccessMode: (value: boolean) => void; } /** @@ -89,11 +122,18 @@ export function useRunMode(): RunMode { () => false ); const reactLoop = useSyncExternalStore(_subscribe, getReactLoop, () => false); + const fullAccessMode = useSyncExternalStore( + _subscribe, + getFullAccessMode, + () => false + ); return { autoRunTools, effectiveAutoRunTools: reactLoop || autoRunTools, reactLoop, + fullAccessMode, setAutoRunTools, setReactLoop, + setFullAccessMode, }; } diff --git a/packages/ui/src/components/thread-playground/stores/thread-store.ts b/packages/ui/src/components/thread-playground/stores/thread-store.ts index e60efac4..5ac5cbeb 100644 --- a/packages/ui/src/components/thread-playground/stores/thread-store.ts +++ b/packages/ui/src/components/thread-playground/stores/thread-store.ts @@ -231,6 +231,13 @@ export function createThreadStore( * Read fresh at run time. Defaults to `false`. */ getReactLoop?: () => boolean; + /** + * Whether full access mode is enabled: auto-run executes every tool call, + * including bash commands flagged destructive, without pausing. Opt-in via + * Experimental settings (with a one-time risk acknowledgement); off by + * default. Read fresh at run time. Defaults to `false`. + */ + getFullAccessMode?: () => boolean; /** * Execute an MCP or built-in tool call, returning structured model-facing * content. Only used by the auto-run-tools path; manual tool runs go through @@ -568,6 +575,7 @@ export function createThreadStore( toolCall: ToolCall; tool: McpTool | BuiltinTool | PluginTool; }[] = []; + const fullAccessMode = options.getFullAccessMode?.() ?? false; for (const toolCall of toolCalls) { const tool = toolsByName.get(toolCall.input.name); if (!tool || !isExecutableTool(tool)) { @@ -576,8 +584,13 @@ export function createThreadStore( // A destructive `bash` command must never be auto-executed, even under // "auto run tools" or the ReAct loop — treat it like a `terminate` // tool: stop the loop and leave it pending for the user to review and - // run by hand. - if (tool.type === "builtin" && tool.name === "bash") { + // run by hand. Full access mode is the deliberate opt-out: the user + // acknowledged the risk and accepted that every tool runs unattended. + if ( + tool.type === "builtin" && + tool.name === "bash" && + !fullAccessMode + ) { const command = (toolCall.input.arguments as { command?: unknown }) ?.command; if ( @@ -1172,6 +1185,13 @@ export function createThreadStore( streamingMessage: null, executingToolCallIds: [], }); + // Persistent reminder (dismissible) that this run may execute + // anything without pausing — see the full access mode acknowledgement. + if (options.getFullAccessMode?.()) { + toast.warning( + "Full access mode is on — tools, including commands flagged destructive, run without confirmation." + ); + } // Commit the truncation while running so it folds into the run's // single undo step instead of becoming its own snapshot. diff --git a/packages/ui/src/components/thread-playground/thread-playground.tsx b/packages/ui/src/components/thread-playground/thread-playground.tsx index 32975ec6..6660452e 100644 --- a/packages/ui/src/components/thread-playground/thread-playground.tsx +++ b/packages/ui/src/components/thread-playground/thread-playground.tsx @@ -78,6 +78,7 @@ import { canUndo, createThreadStore, getAutoRunTools, + getFullAccessMode, getReactLoop, ThreadStoreContext, useRunMode, @@ -214,6 +215,7 @@ function _ThreadPlaygroundStore({ ), getAutoRunTools, getReactLoop, + getFullAccessMode, getProfileId, runtimeId: ownerRuntimeId, executeTool: toolExecutor ?? undefined, @@ -284,8 +286,13 @@ function ThreadPlaygroundContent({ () => planCompaction(messages, 0, { hasMetaUserPrompt }).turnCount >= 2, [hasMetaUserPrompt, messages] ); - const { effectiveAutoRunTools, reactLoop, setAutoRunTools, setReactLoop } = - useRunMode(); + const { + effectiveAutoRunTools, + reactLoop, + fullAccessMode, + setAutoRunTools, + setReactLoop, + } = useRunMode(); const { run, abort, undo, redo, syncTitle } = useThreadStoreActions(); const [systemPromptStreaming, setSystemPromptStreaming] = useState(false); const title = useMemo( @@ -464,6 +471,18 @@ function ThreadPlaygroundContent({ />
+ {/* Persistent warning badge while full access mode is on, so the + user never forgets tools run without confirmation. */} + {fullAccessMode && !readonlyFromProps ? ( + + + {labels.fullAccessBadge} + + + ) : null} { + globalThis.requestAnimationFrame = (callback) => + Number(setTimeout(() => callback(performance.now()), 0)); + globalThis.cancelAnimationFrame = (handle) => clearTimeout(handle); +}); + +afterAll(() => { + globalThis.requestAnimationFrame = originalRequestAnimationFrame; + globalThis.cancelAnimationFrame = originalCancelAnimationFrame; +}); + +function _event(value: unknown): AgentEvent { + return value as AgentEvent; +} + +/** A turn that calls the built-in bash tool with a destructive command. */ +function destructiveBashTransport(): AgentTransport { + return async function* () { + yield _event({ + type: "message_start", + message: { role: "assistant" }, + }); + yield _event({ + type: "message_update", + assistantMessageEvent: { + type: "toolcall_start", + contentIndex: 0, + partial: { + content: [ + { + type: "toolCall", + id: "tool-bash", + name: "bash", + arguments: {}, + }, + ], + }, + }, + }); + yield _event({ + type: "message_update", + assistantMessageEvent: { + type: "toolcall_delta", + contentIndex: 0, + delta: JSON.stringify({ command: "rm -rf /tmp/some-dir" }), + }, + }); + yield _event({ + type: "message_update", + assistantMessageEvent: { + type: "toolcall_end", + contentIndex: 0, + toolCall: { + type: "toolCall", + id: "tool-bash", + name: "bash", + arguments: { command: "rm -rf /tmp/some-dir" }, + }, + }, + }); + yield _event({ type: "message_end", message: { role: "assistant" } }); + }; +} + +const bashToolThread = { + context: { + messages: [ + { + id: "user-1", + role: "user" as const, + content: [{ type: "text" as const, text: "Clean that up" }], + }, + ], + tools: [ + { + type: "builtin" as const, + name: "bash", + description: "Run a shell command.", + parameters: { type: "object" as const, properties: {} }, + }, + ], + }, +}; + +describe("full access mode", () => { + test("pauses on a destructive bash command when disabled (default)", async () => { + let executed = 0; + const store = createThreadStore(bashToolThread, { + transport: destructiveBashTransport(), + resolveModel: () => ({ provider: "test", id: "test" }), + getAutoRunTools: () => true, + getFullAccessMode: () => false, + executeTool: async () => { + executed += 1; + return { content: [{ type: "text", text: "ran" }], isError: false }; + }, + }); + + await store.getState().run(); + + expect(executed).toBe(0); + expect(store.getState().status).toBe("idle"); + const last = store.getState().thread.context?.messages?.at(-1); + expect(last?.role).toBe("assistant"); + if (last?.role === "assistant") { + // toolcall_end leaves an empty placeholder; no executed result text. + expect(last.toolCalls?.[0]?.output?.content).toEqual([ + { type: "text", text: "" }, + ]); + } + }); + + test("auto-executes a destructive bash command when enabled", async () => { + let executed = 0; + const store = createThreadStore(bashToolThread, { + transport: destructiveBashTransport(), + resolveModel: () => ({ provider: "test", id: "test" }), + getAutoRunTools: () => true, + getFullAccessMode: () => true, + executeTool: async () => { + executed += 1; + return { content: [{ type: "text", text: "ran" }], isError: false }; + }, + }); + + await store.getState().run(); + + expect(executed).toBe(1); + expect(store.getState().status).toBe("idle"); + const last = store.getState().thread.context?.messages?.at(-1); + expect(last?.role).toBe("assistant"); + if (last?.role === "assistant") { + expect(last.toolCalls?.[0]?.output?.content).toEqual([ + { type: "text", text: "ran" }, + ]); + } + }); +}); diff --git a/specs/pr-plans/0004-full-access-mode.md b/specs/pr-plans/0004-full-access-mode.md new file mode 100644 index 00000000..648e0dd7 --- /dev/null +++ b/specs/pr-plans/0004-full-access-mode.md @@ -0,0 +1,99 @@ +# PR 计划 0004:完全访问模式(Full access) +> 状态:**已实现(2026-09-08 核对)**:`stores/run-mode.ts` + experimental 页开关 + 显式确认对话框;测试 `thread-store-full-access.test.ts` 通过。改动在工作树,未提交。 + +> 状态:计划(未写代码) +> 性质:安全相关改动 —— 默认关闭、显式确认、只影响本机。PR 描述需特别谨慎。 + +## 1. 背景与目标 + +现状:开启 ReAct loop / Auto-run tools 后,仍然有一批命令被判定为"危险"而**暂停自动执行**,需要用户手动点。对把 agent 当日常工具、明确知道自己在本机跑什么的用户来说,这层拦截在反复打断流程。 + +**目标**:提供 opt-in 的「完全访问模式」——开启后所有工具(含被判定为危险的命令)都自动执行;首次开启必须弹窗明确告知风险与免责。 + +## 2. 现状(代码事实) + +| 关注点 | 位置 | 现状 | +| --- | --- | --- | +| 自动运行开关 | `packages/ui/src/components/thread-playground/stores/run-mode.ts` | `LOCAL_STORAGE_KEYS.autoRunTools`(`local-storage.ts:13`);`getEffectiveAutoRunTools() = getReactLoop() \|\| getAutoRunTools()`(60-62) | +| 危险命令判定 | `packages/core/src/types/tools/index.ts` | `isDangerousBashCommand()`(311),基于 `DANGEROUS_BASH_PATTERNS`(292-303) | +| 判定规则全集 | 同上 | `rm `、`mkfs`、`dd ... of=`、fork bomb、`shutdown\|reboot\|halt\|poweroff`、`chmod -R`、`chown -R`、`> /dev/(sd\|nvme\|disk)`、`sudo`、`curl\|wget ... \| sh\|bash\|zsh` | +| **硬拦截点** | `stores/thread-store.ts` 576-593 | bash + `isDangerousBashCommand(command)` → `toast.warning("Auto-run paused for a risky command")` + `return null` | +| 其他受限工具 | `misc.ts` 189 | `terminate` 等通过工具自带的 "Never auto-executed" 属性控制 | +| 运行时层 | `packages/runtime`、`core/generator` | 未发现任何工具批准/危险门控 → **拦截只在 UI 层** | +| 确认弹窗 | `packages/ui/src/components/confirm-dialog.tsx` 17 | 现成 `ConfirmDialog`(title/description/confirmLabel/onConfirm) | +| 开关 + 确认范例 | `apps/desktop/src/components/settings/experimental-page.tsx` | `SettingsToggleRow`(36-41)+ 首次开启弹 `ConfirmDialog`(54-71) | + +**关键结论**:拦截是 UI 层单点(`thread-store.ts` 576-593),运行时层无二次拦截 → 改动面很小,不需要碰 agent 运行时。 + +## 3. 设计方案 + +### 3.1 开关 + +- 新增 localStorage 键(建议 `llm-space-full-access`),仿 `run-mode.ts` 写 get/set(含 `useSyncExternalStore` 订阅) +- 入口:Experimental 设置页 `SettingsToggleRow`,位于 auto-run 相关开关附近 +- **默认关闭** + +### 3.2 首次开启的风险确认 + +复用 `ConfirmDialog`,首次开启时弹出,文案要点: + +- 该模式下 agent 将**不经确认执行任何工具**,包括删除文件、格式化磁盘、sudo、执行远程脚本等不可逆操作 +- 这些操作由模型决定,可能出错,**造成的任何数据丢失或系统损坏由用户自行承担** +- 建议仅在可支配的环境(本机开发机 / 虚拟机 / 容器)中开启 +- 可随时在设置中关闭 + +确认后写入 `fullAccessAcknowledged`(localStorage),避免重复弹窗;**每次重新安装/清数据后重新确认**。 + +### 3.3 拦截逻辑改动 + +```ts +// thread-store.ts 576 附近 +if ( + !getFullAccessMode() && // ← 新增 + tool.type === "builtin" && + tool.name === "bash" && + isDangerousBashCommand(command) +) { + toast.warning(...); + return null; +} +``` + +即:完全访问模式下跳过危险命令暂停,其余逻辑不变。 + +### 3.4 视觉提示(重要) + +开启时必须有持久的醒目提示,避免用户忘记自己开过: + +- 工具栏/输入框附近显示一个「完全访问」徽章(警示色) +- 建议:每次运行开始时若该模式开启,toast 提示一次(可关) + +### 3.5 文案与 i18n + +`apps/desktop/src/i18n/messages.ts` en/zh 双树新增:开关标题、说明、确认弹窗正文、徽章文案。 + +## 4. 涉及文件 + +- `packages/ui/src/lib/local-storage.ts`(新键) +- `packages/ui/src/components/thread-playground/stores/run-mode.ts`(get/set 与订阅) +- `packages/ui/src/components/thread-playground/stores/thread-store.ts`(拦截处加判断,约 3 行) +- `packages/ui/src/components/thread-playground/thread-playground.tsx`(徽章) +- `apps/desktop/src/components/settings/experimental-page.tsx`(开关 + 确认弹窗) +- `apps/desktop/src/i18n/messages.ts` +- 新增/更新测试:`run-mode` 相关测试;thread-store 在两种模式下对危险命令的行为 + +## 5. 风险与待定 + +1. **maintainer 可能抵触**:这是主动降低安全护栏的改动。降低阻力的要点——默认关闭、只影响本机、显式免责确认、持久徽章、不触碰运行时层、可随时关闭。若 maintainer 认为不可接受,可退一步做成「危险命令二次确认的开关」而非「完全跳过」。 +2. **只覆盖 bash 危险命令拦截**:`terminate` 这类靠工具属性("Never auto-executed")限制的,**本 PR 是否也放开?** 建议**不放开**——它们是运行控制类工具,放开没有收益。需在 PR 描述中说明边界。 +3. **范围界定**:只跳过"危险暂停",不改变 auto-run 本身的语义;未开启 auto-run 时完全访问模式不产生额外效果(需明确,避免误解)。 +4. **免责声明措辞**:涉及责任声明的文案建议请 maintainer 定稿,不要自己拍板法律措辞。 + +## 6. 验证 + +- 单元测试:`getFullAccessMode` 默认 false;危险命令在关闭时暂停、开启时执行。 +- 手动:开启弹窗(只弹一次)、徽章显示、执行一条 `rm -rf /tmp/xxx` 类命令验证自动执行、关闭后恢复拦截。 + +## 7. 工作量 + +小-中(1-2 天)。代码改动量很小(核心 3 行 + 开关 + 弹窗),主要成本在文案、讨论与 maintainer 沟通。 From 34affdbd0c4f88b9fc8618f71c779c670c7d0f91 Mon Sep 17 00:00:00 2001 From: zcai7675-bot <233973883+.zcai7675-bot@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:37:49 +0800 Subject: [PATCH 2/3] fix(ui): re-check full access mode after a batch is prepared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The destructive-bash gate read full access mode once, before a batch that contains a Plugin Tool resolved its prompt variables. That resolution is a real await, so switching the mode off while the batch was being prepared still ran the batch — including a bash command flagged destructive. - thread-store.ts: gate through `isRiskPaused`, which reads the mode on every check, and run the gate again after the asynchronous preparation, immediately before anything executes. - thread-store-full-access.test.ts: mid-batch regression test (it executes both tools before this change and nothing after) plus a control that a Plugin Tool batch still auto-runs while the mode stays on. - run-mode.test.ts: cover the stored full-access state the settings acknowledgement flow depends on (off and unacknowledged by default, the acknowledgement and the mode on separate keys, cleared storage asks again). --- .../thread-playground/stores/thread-store.ts | 66 ++++--- .../thread-playground/stores/run-mode.test.ts | 76 ++++++++ .../stores/thread-store-full-access.test.ts | 176 ++++++++++++++++++ 3 files changed, 295 insertions(+), 23 deletions(-) create mode 100644 packages/ui/tests/components/thread-playground/stores/run-mode.test.ts diff --git a/packages/ui/src/components/thread-playground/stores/thread-store.ts b/packages/ui/src/components/thread-playground/stores/thread-store.ts index 5ac5cbeb..616080a7 100644 --- a/packages/ui/src/components/thread-playground/stores/thread-store.ts +++ b/packages/ui/src/components/thread-playground/stores/thread-store.ts @@ -575,34 +575,44 @@ export function createThreadStore( toolCall: ToolCall; tool: McpTool | BuiltinTool | PluginTool; }[] = []; - const fullAccessMode = options.getFullAccessMode?.() ?? false; + // A destructive `bash` command must never be auto-executed, even under + // "auto run tools" or the ReAct loop — treat it like a `terminate` + // tool: stop the loop and leave it pending for the user to review and + // run by hand. Full access mode is the deliberate opt-out: the user + // acknowledged the risk and accepted that every tool runs unattended. + // + // The mode is read on every check instead of captured once, because + // resolving plugin variables below yields to the event loop: a user who + // switches the mode off while a batch is being prepared must still stop + // the destructive command (see the re-check after the `await`). + const isRiskPaused = ( + toolCall: ToolCall, + tool: McpTool | BuiltinTool | PluginTool + ): boolean => { + if (options.getFullAccessMode?.() ?? false) { + return false; + } + if (tool.type !== "builtin" || tool.name !== "bash") { + return false; + } + const command = (toolCall.input.arguments as { command?: unknown }) + ?.command; + return typeof command === "string" && isDangerousBashCommand(command); + }; + const pauseForRiskyCommand = () => { + toast.warning("Auto-run paused for a risky command", { + description: + "A bash command looked destructive, so it wasn't run automatically. Review it and run it by hand if it's safe.", + }); + return null; + }; for (const toolCall of toolCalls) { const tool = toolsByName.get(toolCall.input.name); if (!tool || !isExecutableTool(tool)) { return null; } - // A destructive `bash` command must never be auto-executed, even under - // "auto run tools" or the ReAct loop — treat it like a `terminate` - // tool: stop the loop and leave it pending for the user to review and - // run by hand. Full access mode is the deliberate opt-out: the user - // acknowledged the risk and accepted that every tool runs unattended. - if ( - tool.type === "builtin" && - tool.name === "bash" && - !fullAccessMode - ) { - const command = (toolCall.input.arguments as { command?: unknown }) - ?.command; - if ( - typeof command === "string" && - isDangerousBashCommand(command) - ) { - toast.warning("Auto-run paused for a risky command", { - description: - "A bash command looked destructive, so it wasn't run automatically. Review it and run it by hand if it's safe.", - }); - return null; - } + if (isRiskPaused(toolCall, tool)) { + return pauseForRiskyCommand(); } executable.push({ toolCall, tool }); } @@ -623,6 +633,16 @@ export function createThreadStore( if (signal.aborted || get().activeRunId !== runId) { return null; } + // Re-check after the asynchronous preparation: that `await` is a real + // yield to the event loop, so full access mode may have been switched + // off while the batch was being prepared. This is the last point before + // anything runs, so gate again here rather than trusting the reading + // taken while the batch was collected. + if ( + executable.some(({ toolCall, tool }) => isRiskPaused(toolCall, tool)) + ) { + return pauseForRiskyCommand(); + } set({ executingToolCallIds: executable.map(({ toolCall }) => toolCall.id), }); diff --git a/packages/ui/tests/components/thread-playground/stores/run-mode.test.ts b/packages/ui/tests/components/thread-playground/stores/run-mode.test.ts new file mode 100644 index 00000000..1430f32d --- /dev/null +++ b/packages/ui/tests/components/thread-playground/stores/run-mode.test.ts @@ -0,0 +1,76 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; + +import { + getFullAccessAcknowledged, + getFullAccessMode, + setFullAccessAcknowledged, + setFullAccessMode, +} from "../../../../src/components/thread-playground/stores/run-mode"; + +const originalWindow = Reflect.get(globalThis, "window"); +const storageEntries = new Map(); + +/** + * The UI's storage helper reads `window.localStorage`, which the Bun test + * runtime does not provide, so install the smallest in-memory stand-in. + */ +function _installWindowStorage(): void { + Reflect.set(globalThis, "window", { + localStorage: { + getItem: (key: string) => storageEntries.get(key) ?? null, + setItem: (key: string, value: string) => { + storageEntries.set(key, value); + }, + removeItem: (key: string) => { + storageEntries.delete(key); + }, + clear: () => { + storageEntries.clear(); + }, + key: (index: number) => [...storageEntries.keys()][index] ?? null, + get length() { + return storageEntries.size; + }, + }, + }); +} + +beforeAll(() => { + _installWindowStorage(); +}); + +afterAll(() => { + Reflect.set(globalThis, "window", originalWindow); +}); + +describe("full access mode storage", () => { + test("is off and unacknowledged before anything is stored", () => { + // A reinstall or cleared storage must ask for the risk acknowledgement + // again, so both flags have to default to false. + expect(getFullAccessMode()).toBe(false); + expect(getFullAccessAcknowledged()).toBe(false); + }); + + test("keeps the acknowledgement and the enabled mode on separate keys", () => { + setFullAccessAcknowledged(true); + expect(getFullAccessAcknowledged()).toBe(true); + // Acknowledging the risk never turns the mode on by itself. + expect(getFullAccessMode()).toBe(false); + + setFullAccessMode(true); + expect(getFullAccessMode()).toBe(true); + setFullAccessMode(false); + expect(getFullAccessMode()).toBe(false); + expect(getFullAccessAcknowledged()).toBe(true); + }); + + test("clearing storage drops the acknowledgement and the mode together", () => { + setFullAccessAcknowledged(true); + setFullAccessMode(true); + + storageEntries.clear(); + + expect(getFullAccessAcknowledged()).toBe(false); + expect(getFullAccessMode()).toBe(false); + }); +}); diff --git a/packages/ui/tests/components/thread-playground/stores/thread-store-full-access.test.ts b/packages/ui/tests/components/thread-playground/stores/thread-store-full-access.test.ts index 57a8afde..b959fa63 100644 --- a/packages/ui/tests/components/thread-playground/stores/thread-store-full-access.test.ts +++ b/packages/ui/tests/components/thread-playground/stores/thread-store-full-access.test.ts @@ -91,6 +91,95 @@ const bashToolThread = { }, }; +/** A turn that calls a Plugin Tool and a destructive bash command together. */ +function riskyBatchTransport(): AgentTransport { + const toolCalls = [ + { + type: "toolCall" as const, + id: "tool-plugin", + name: "lookup", + arguments: { query: "anything" }, + }, + { + type: "toolCall" as const, + id: "tool-bash", + name: "bash", + arguments: { command: "rm -rf /tmp/some-dir" }, + }, + ]; + const events = [ + _event({ type: "message_start", message: { role: "assistant" } }), + ...toolCalls.flatMap((toolCall, index) => [ + _event({ + type: "message_update", + assistantMessageEvent: { + type: "toolcall_start", + contentIndex: index, + partial: { content: toolCalls }, + }, + }), + _event({ + type: "message_update", + assistantMessageEvent: { + type: "toolcall_delta", + contentIndex: index, + delta: JSON.stringify(toolCall.arguments), + }, + }), + _event({ + type: "message_update", + assistantMessageEvent: { + type: "toolcall_end", + contentIndex: index, + toolCall, + }, + }), + ]), + _event({ type: "message_end", message: { role: "assistant" } }), + ]; + return async function* () { + yield* events; + }; +} + +const pluginAndBashToolThread = { + context: { + // A skills variable makes the Plugin Tool batch resolve prompt variables + // through the injected loaders, i.e. across a real `await`. + variables: { + skills: { + type: "skills" as const, + skillNames: [], + format: "xml" as const, + indent: 0, + }, + }, + messages: [ + { + id: "user-1", + role: "user" as const, + content: [{ type: "text" as const, text: "Clean that up" }], + }, + ], + tools: [ + { + type: "plugin" as const, + pluginId: "fixture", + toolId: "plugin:fixture:tool:lookup", + name: "lookup", + description: "Look something up.", + parameters: { type: "object" as const, properties: {} }, + }, + { + type: "builtin" as const, + name: "bash", + description: "Run a shell command.", + parameters: { type: "object" as const, properties: {} }, + }, + ], + }, +}; + describe("full access mode", () => { test("pauses on a destructive bash command when disabled (default)", async () => { let executed = 0; @@ -144,4 +233,91 @@ describe("full access mode", () => { ]); } }); + + test("pauses a destructive bash command when the mode is switched off mid-batch", async () => { + let fullAccessEnabled = true; + let executed = 0; + let notifyBatchPreparation: (() => void) | undefined; + const batchPreparationStarted = new Promise((resolve) => { + notifyBatchPreparation = resolve; + }); + let releaseBatchPreparation: (() => void) | undefined; + const batchPreparationGate = new Promise((resolve) => { + releaseBatchPreparation = resolve; + }); + const store = createThreadStore(pluginAndBashToolThread, { + transport: riskyBatchTransport(), + resolveModel: () => ({ provider: "test", id: "test" }), + getAutoRunTools: () => true, + getFullAccessMode: () => fullAccessEnabled, + loadSkills: async () => { + // Only the pending-tool-call batch prepares while a trailing assistant + // message still has unexecuted tool calls; the run's own prompt + // rendering happens before any assistant message exists. Gating here + // therefore suspends the batch exactly where a user could flip the + // switch, instead of short-circuiting an earlier preparation step. + const last = store.getState().thread.context?.messages?.at(-1); + if (last?.role !== "assistant" || !(last.toolCalls?.length ?? 0)) { + return []; + } + notifyBatchPreparation?.(); + await batchPreparationGate; + return []; + }, + executeTool: async () => { + executed += 1; + return { content: [{ type: "text", text: "ran" }], isError: false }; + }, + }); + + const run = store.getState().run(); + await batchPreparationStarted; + // The user switches the mode off while the batch is still preparing. + fullAccessEnabled = false; + releaseBatchPreparation?.(); + await run; + + // Nothing in the batch may run: the risky command must not be executed on + // the full-access reading taken before the asynchronous preparation. + expect(executed).toBe(0); + expect(store.getState().status).toBe("idle"); + const last = store.getState().thread.context?.messages?.at(-1); + expect(last?.role).toBe("assistant"); + if (last?.role === "assistant") { + expect( + last.toolCalls?.map((toolCall) => toolCall.output?.content) + ).toEqual([[{ type: "text", text: "" }], [{ type: "text", text: "" }]]); + } + }); + + test("still auto-executes the batch when the mode stays on across the preparation", async () => { + let executed = 0; + const store = createThreadStore(pluginAndBashToolThread, { + transport: riskyBatchTransport(), + resolveModel: () => ({ provider: "test", id: "test" }), + getAutoRunTools: () => true, + getFullAccessMode: () => true, + loadSkills: async () => [], + executeTool: async () => { + executed += 1; + return { content: [{ type: "text", text: "ran" }], isError: false }; + }, + }); + + await store.getState().run(); + + // The re-check after the preparation must not turn the opt-out into a + // blanket refusal of every Plugin Tool batch. + expect(executed).toBe(2); + const last = store.getState().thread.context?.messages?.at(-1); + expect(last?.role).toBe("assistant"); + if (last?.role === "assistant") { + expect( + last.toolCalls?.map((toolCall) => toolCall.output?.content) + ).toEqual([ + [{ type: "text", text: "ran" }], + [{ type: "text", text: "ran" }], + ]); + } + }); }); From b2021cf4ac74d176f8e730108de5ba606a0cf1a3 Mon Sep 17 00:00:00 2001 From: zcai7675-bot <233973883+.zcai7675-bot@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:49:46 +0800 Subject: [PATCH 3/3] chore(ci): re-trigger the check run (previous run hit a flaky plugin-installer test)