= {
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)