From 967ba4c4b80bfe47502ec929a7ee88139e783168 Mon Sep 17 00:00:00 2001 From: gengruilin Date: Wed, 21 Oct 2026 11:00:34 +0800 Subject: [PATCH] fix(tools): disable history expansion in the persistent bash shell The persistent bash tool types each wrapped command into an interactive bash, which applies history expansion before parsing. A `!` after a single quote made bash reject the whole line with `event not found`, so neither marker printed and the call waited for the full timeout; `!!` silently substituted the previous history entry. Disable history expansion when the shell is initialized, as OpenHands does for its interactive terminal. Raw control characters other than newline and carriage return still reach readline as typed keys; the README records that limitation. --- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- packages/shell/tool-bash-persistent/README.i18n.yaml | 8 ++++---- packages/shell/tool-bash-persistent/README.md | 3 ++- packages/shell/tool-bash-persistent/README.zh.md | 3 ++- packages/shell/tool-bash-persistent/src/index.ts | 8 +++++--- .../tool-bash-persistent/tests/loader-composition.spec.ts | 8 ++++++++ 7 files changed, 23 insertions(+), 11 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 873b17f38b..5a2b0cdaf5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3832,7 +3832,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-bash-persistent` - `inject`: `tools` · `terminals` · `workingDirectory` -- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:445`](../packages/shell/tool-bash-persistent/src/index.ts) +- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:447`](../packages/shell/tool-bash-persistent/src/index.ts) ```ts config-catalog /** Configuration for the persistent Bash tool. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index b820344e40..a95c58cbf7 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3834,7 +3834,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-bash-persistent` - `inject`: `tools` · `terminals` · `workingDirectory` -- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:445`](../packages/shell/tool-bash-persistent/src/index.ts) +- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:447`](../packages/shell/tool-bash-persistent/src/index.ts) ```ts config-catalog /** Configuration for the persistent Bash tool. */ diff --git a/packages/shell/tool-bash-persistent/README.i18n.yaml b/packages/shell/tool-bash-persistent/README.i18n.yaml index ab0611f17b..cc6225da8c 100644 --- a/packages/shell/tool-bash-persistent/README.i18n.yaml +++ b/packages/shell/tool-bash-persistent/README.i18n.yaml @@ -39,8 +39,8 @@ en: 857e036f41c913ac zh: e9f383f7ee384c59 /deepseek-ai-dsh-tool-bash-persistent/understand-the-implementation/command-flow: - en: 3b7bf7c20b266c31 - zh: 85a325ef65fa7ac7 + en: c511f75009ba3743 + zh: b3b722e00b2910b1 /deepseek-ai-dsh-tool-bash-persistent/further-exploration: en: 42c387ccaab7e64c zh: e58ec4ea60cf7711 @@ -72,8 +72,8 @@ en: ba6a48888c3361e5 zh: 3a8f648053555dc9 /deepseek-ai-dsh-tool-bash-persistent/known-limitations-and-deferred-work: - en: dc5a908f2b2da040 - zh: 3afceaabcf324771 + en: b12111850d752f0b + zh: 92b855387ec187ff /deepseek-ai-dsh-tool-bash-persistent/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/shell/tool-bash-persistent/README.md b/packages/shell/tool-bash-persistent/README.md index 22ea00bed1..4bbea6bf1a 100644 --- a/packages/shell/tool-bash-persistent/README.md +++ b/packages/shell/tool-bash-persistent/README.md @@ -85,7 +85,7 @@ This section explains the design decisions behind the tool and points at the cod ### Command flow -A first command spawns the shell through `ctx.terminals.spawn`, disables input echo (`stty -echo`), and waits for readiness. Each command is then wrapped into one physical line — a printf of the start marker, the command body escaped with `$'…'`, and a printf of the end marker plus `$?` — so embedded newlines cannot leak terminal prompts into the result. The tool polls the scrollback in 1,000-line pages until the end marker appears, extracts the span, and renders it with any status marker. A timeout aborts the deadline, captures the partial output, and resets the shell. +A first command spawns the shell through `ctx.terminals.spawn`, disables input echo and history expansion (`stty -echo; set +H`), and waits for readiness. Each command is then wrapped into one physical line — a printf of the start marker, the command body escaped with `$'…'`, and a printf of the end marker plus `$?` — so embedded newlines cannot leak terminal prompts into the result. With history expansion off, `!` sequences reach bash unchanged instead of being rewritten or rejecting the whole line. The tool polls the scrollback in 1,000-line pages until the end marker appears, extracts the span, and renders it with any status marker. A timeout aborts the deadline, captures the partial output, and resets the shell. @@ -148,6 +148,7 @@ These limits define when the tool is a poor fit or needs special care. They are - **An interactive foreground child returns early with partial output only where the subprocess provider proves its stdin wait** — elsewhere the call runs to `timeoutMs`. - **Explicit `exit` and timeout discard shell state** — cancellation also resets and discards the result, even when a complete status marker is already observable; the next call starts a fresh shell. - **Environment facts such as network access and package mirrors belong in the configured `description`** — not this package's default. +- **Raw control characters other than newline and carriage return reach readline as typed keys** — a literal TAB, ESC, or DEL in the command is consumed as an editing key, so content that needs them, such as a Makefile heredoc, should be written with a file tool or produced by `printf` escapes. ### Dev Note diff --git a/packages/shell/tool-bash-persistent/README.zh.md b/packages/shell/tool-bash-persistent/README.zh.md index c51bb4a00f..6b4877e177 100644 --- a/packages/shell/tool-bash-persistent/README.zh.md +++ b/packages/shell/tool-bash-persistent/README.zh.md @@ -85,7 +85,7 @@ kind: "package-reference" ### 命令流程 -首条命令通过 `ctx.terminals.spawn` 生成 shell,禁用输入回显(`stty -echo`),并等待就绪。随后每条命令都包装成一行物理文本——printf 起始标记、用 `$'…'` 转义的命令体、printf 结束标记加 `$?`——因此内嵌换行无法把终端提示符泄漏进结果。工具以 1,000 行一页轮询 scrollback,直到出现结束标记,提取区间并连同任何状态标记一起渲染。超时会中止截止时间、捕获部分输出并重置 shell。 +首条命令通过 `ctx.terminals.spawn` 生成 shell,禁用输入回显和历史展开(`stty -echo; set +H`),并等待就绪。随后每条命令都包装成一行物理文本——printf 起始标记、用 `$'…'` 转义的命令体、printf 结束标记加 `$?`——因此内嵌换行无法把终端提示符泄漏进结果。关闭历史展开后,`!` 序列原样到达 bash,不会被改写,也不会使整行被拒绝。工具以 1,000 行一页轮询 scrollback,直到出现结束标记,提取区间并连同任何状态标记一起渲染。超时会中止截止时间、捕获部分输出并重置 shell。 @@ -148,6 +148,7 @@ kind: "package-reference" - **交互式前台子进程只在子进程提供方证明其 stdin 等待时才提前返回部分输出**——否则调用一直运行到 `timeoutMs`。 - **显式 `exit` 与超时会丢弃 shell 状态**——取消同样重置并丢弃结果,即使完整状态标记已经可观察;下一次调用启动全新 shell。 - **网络访问与包镜像等环境事实属于配置的 `description`**——而不是本包的默认描述。 +- **除换行与回车外,原始控制字符会作为按键到达 readline**——命令中的字面 TAB、ESC 或 DEL 会被当作编辑按键消耗,因此需要这些字符的内容(例如 Makefile heredoc)应通过文件工具写入,或用 `printf` 转义生成。 ### 开发备注 diff --git a/packages/shell/tool-bash-persistent/src/index.ts b/packages/shell/tool-bash-persistent/src/index.ts index 4f5d65736e..94de7ccf09 100644 --- a/packages/shell/tool-bash-persistent/src/index.ts +++ b/packages/shell/tool-bash-persistent/src/index.ts @@ -267,10 +267,12 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell live.delete(owner) }, 'tool-bash-persistent owner cache cleanup') } - // Echo suppression only: the prompt stays the backend's own, so the - // backend's prompt-based readiness detection keeps working. + // Suppress input echo and history expansion, which would otherwise + // rewrite `!` sequences or discard the whole wrapper line before bash + // parses it. The prompt stays the backend's own, so the backend's + // prompt-based readiness detection keeps working. const setup = ctx.terminals.startSend(owner, spawned.sessionId, { - text: 'stty -echo', + text: 'stty -echo; set +H', submit: true, signal: combinedSignal, }) diff --git a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts index f30e87b978..cbcb431f03 100644 --- a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts @@ -160,6 +160,14 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => { )) expect(heredoc).toBe('alpha\nbeta\n[Command finished with exit code 0]') + // Interactive bash applies history expansion to typed input before parsing; + // it must neither rewrite `!` sequences nor drop the whole wrapper line. + const history = text(await execute( + 'history-expansion', + "echo '' > page.html; cat page.html; echo 'it'; echo hi!!", + )) + expect(history).toBe('\nit\nhi!!\n[Command finished with exit code 0]') + const pipeline = text(await execute( 'pipeline', '{ sleep 0.1; printf "delayed\\n"; } | cat',