Files
DeepSeek-Reasonix/internal/tool/shell_execution.go
T
mchenziyiandSivanCola 7ab1278266 feat(desktop): add safe cross-platform shell support / 增加安全的跨平台 Shell 支持 (#9519)
* feat(sandbox): 支持 Git-Bash 自主选择与跨平台一键自动安装

背景:
大语言模型生成的命令绝大多数为 POSIX 语法(如 grep、sed、管道、重定向等),
但 Windows 默认使用 PowerShell,语法不兼容导致命令执行频繁失败。
macOS/Linux 虽然自带 bash,但部分精简容器或特殊环境也可能缺失。

本次变更:
1. 深度自动探测 Git-Bash 安装路径
   - Windows:扫描 ProgramFiles、Scoop、Chocolatey、注册表(HKLM/HKCU)
   - 自动识别并纠正 git-bash.exe → bin/bash.exe(headless 可执行文件)
   - 注入 MSYS_NO_PATHCONV=1 防止 MSYS2 路径篡改

2. 跨平台一键自动安装后端
   - Windows:通过 winget install --id Git.Git 静默安装
   - macOS:通过 brew install bash git 安装
   - Linux:自动适配 apt-get/dnf/pacman/zypper/apk,非 root 自动 sudo

3. 桌面端设置面板集成
   - 当用户选择 bash/auto 但系统未检测到时,展示安装提示卡片
   - 一键安装按钮,带 Loading/成功/失败 三态反馈
   - 安装成功后自动写入配置并刷新生效状态

4. 多语言支持
   - 中文简体、英文、中文繁体三语本地化

* chore: format go code and update repolint baseline budget

* fix(desktop): harden Windows shell support

Problem:
The Git for Windows helper could rebuild the active controller after install, miss an explicitly configured portable Git Bash, and cancel only the direct winget process.

Root cause:
The frontend reused the session reload callback for detection refresh, capability probing ignored tools.shell.path, and the installer called exec.Cmd.Run without repository process-tree tracking.

Fix:
Separate pure settings refresh from explicit session reload, pass the configured path through shell inventory and install preflight, track the winget process tree, and add deterministic backend/frontend race coverage.

Verification:
go test ./... (desktop module)
go test -race ./internal/sandbox
go test -race focused desktop shell support
frontend shell support test, typecheck, hooks lint, and production build
Windows amd64 and arm64 desktop test-binary cross-compilation

* feat(desktop): add cross-platform shell repair guidance

Problem: shell support offered a safe Windows path but macOS and Linux only exposed generic manual guidance.

Root cause: the inventory reported only Bash on Unix and the Settings contract had no platform-specific, copy-only remediation.

Fix: report Bash, zsh, and sh capabilities; expose allowlisted Homebrew and distro commands without executing them or prescribing sudo; and invalidate discovery only on explicit session reload.

Verification: go test ./...; cd desktop && go test ./...; frontend full tests, performance, production build, bundle budgets, repolint, and Windows cross-compilation.

* feat(desktop): separate native shell fallback from Git support

Problem:
macOS repair guidance treated Bash as mandatory and coupled shell availability with Git installation.

Root cause:
the resolver only modeled Bash and PowerShell as executable shells, while Settings had no independent Git capability.

Fix:
fall back from Bash to native zsh and sh on macOS, discover Git independently, and expose platform-safe copy-only Git repair guidance without changing persisted shell preferences.

Verification:
go test ./...
cd desktop && go test ./...
frontend 230-suite test run, typecheck, performance, production build
Windows amd64/arm64 sandbox and desktop cross-compiles
repolint and diff checks

* fix(desktop): make Git Bash repair manual-only

Problem:
The Git.Git winget package may elevate even when invoked with --scope user, so the settings helper could unexpectedly trigger UAC or install machine-wide.

Root cause:
WinGet scope selection does not force the Git for Windows installer scope; the current package uses an elevating installer for both user and machine entries.

Fix:
Remove winget execution and cancellation state, return manual_required for Windows, and expose only the official download plus explicit re-detect flow. Keep the Wails methods as safe compatibility results/no-ops.

Verification:
- cd desktop && go test ./...
- focused shell-support frontend test (21/21)
- frontend typecheck and production build/bundle budget
- repolint in a clean current-patch worktree
- Windows amd64/arm64 test-binary compilation
- native Windows 11 ARM64 policy test

* chore(desktop): reconcile shell support with main-v2

Problem:
- The rebased shell-support branch retained stale automatic-installer comments and bundle ratchets from its older base.

Root cause:
- Main-v2 added notification-volume UI after this branch forked, while the shell repair policy changed to manual-only.

Fix:
- Measure the combined latest-main bundle and apply narrow ratchets.
- Align shell inventory comments with manual re-detection and the macOS zsh/sh fallback.

Verification:
- pnpm build
- pnpm test:all (baseline remote source-contract failure documented separately)
- go test -race ./internal/sandbox ./internal/control ./internal/shellrun ./internal/tool/builtin
- cd desktop && go test ./...
- Windows amd64/arm64 cross-compilation

* fix(sandbox): harden shell capability discovery

Problem:
- Passive Git detection could execute Apple's inactive /usr/bin/git shim and open the Command Line Tools installer.
- A retained PowerShell path could be admitted into the Windows Bash inventory after the shell preference changed.

Root cause:
- Git probing had no platform preflight for the Apple shim.
- The shell inventory cache and configured-path filtering ignored the selected shell preference.

Fix:
- Gate the Apple shim with a side-effect-free xcode-select preflight before running git --version.
- Key discovery by preference and path, and only admit configured Windows Bash paths compatible with the preference.
- Preserve the path-only compatibility APIs and add regression coverage for both cases.

Verification:
- go test ./...
- cd desktop && go test ./...
- go test -race ./internal/sandbox
- go vet ./... (root and desktop modules)
- go run ./tools/repolint
- Windows amd64/arm64 sandbox and desktop test-binary compilation

Co-authored-by: SivanCola <[email protected]>

* fix(sandbox): validate configured shell paths

Problem:
- Switching shell preferences preserved an explicit path that could belong to another interpreter, crossing Bash and PowerShell argv contracts.
- Explicit WSL and unresolved git-bash launchers could bypass inventory safeguards, and the checked-in repolint baseline did not reflect the branch's actual findings.

Root cause:
- ResolveShell and the integrated terminal trusted path existence independently of the selected shell kind, while the environment label reused raw config state.
- The baseline was regenerated from a stale working state.

Fix:
- Centralize configured-path compatibility, preserve unknown wrappers, reject known cross-kind, WSL, and MinTTY paths, align terminal and provider shell labels with resolved execution, and refresh the exact repolint snapshot.
- Add deterministic cross-kind, WSL, wrapper, argv, terminal, and shell-label regressions.

Verification:
- go test ./...
- go vet ./...
- cd desktop && go test ./...
- cd desktop && go vet ./...
- go test -race ./internal/sandbox
- Windows amd64/arm64 test compilation and Windows 11 ARM64 focused native tests
- pnpm build
- go run ./tools/repolint

Co-authored-by: SivanCola <[email protected]>

* fix(boot): preserve stable shell labels

Problem:
- The configured-shell correction made automatic shell resolution publish an absolute executable path in the provider-visible environment section.
- That widened cache keys for users without an explicit shell path and contradicted the low cache-impact contract.

Root cause:
- The label helper treated every resolved executable path as user-configured state.

Fix:
- Keep the stable interpreter kind for automatic resolution and emit a resolved executable path only when the user configured an explicit path.
- Cover both the cache-stable default and stale-path correction cases.

Verification:
- go test ./internal/boot ./internal/tool/builtin ./internal/sandbox
- go vet ./internal/boot ./internal/tool/builtin ./internal/sandbox
- go test -race ./internal/boot ./internal/sandbox
- go run ./tools/repolint
- git diff --check

Co-authored-by: SivanCola <[email protected]>

* fix(sandbox): honor configured Bash priority

Problem:
- Windows auto resolution and the Settings capability view selected a PATH Bash before an explicitly configured compatible Bash path.
- This contradicted the documented discovery order and made portable or pinned Git Bash configurations ineffective.

Root cause:
- The configured path lived in the candidate list, but both resolution and capability projection checked PATH before iterating those candidates.
- Capability probes also lacked an injected test seam, so a synthetic-path test was not portable to native Windows.

Fix:
- Resolve a compatible configured Windows Bash before PATH in auto mode and mirror the same order in capability projection.
- Keep arbitrary wrappers opt-in to prefer=bash, add a cached probe seam, and cover resolver plus Settings source priority.

Verification:
- go test ./... && go vet ./...
- go test -race ./internal/sandbox
- focused priority and shell regressions with -count=20
- desktop focused shell/settings contract tests
- Windows amd64/arm64 test compilation
- Windows 11 ARM64 native priority tests
- go run ./tools/repolint
- git diff --check

Co-authored-by: SivanCola <[email protected]>

* test(sandbox): use WaitGroup.Go in inventory races

Problem:
- The current root golangci-lint job rejected two manual Add/go/defer Done patterns in the shell inventory cache test.

Root cause:
- Go 1.26 and golangci-lint v2.12.2 now prefer sync.WaitGroup.Go for directly owned test goroutines.

Fix:
- Launch both cache-test goroutines through WaitGroup.Go without changing synchronization or assertions.

Verification:
- golangci-lint v2.12.2 run --timeout=5m ./...
- go test ./internal/sandbox
- go run ./tools/repolint
- git diff --check

Co-authored-by: SivanCola <[email protected]>

---------

Co-authored-by: SivanCola <[email protected]>
2026-08-28 18:55:26 +00:00

133 lines
5.1 KiB
Go

package tool
import (
"context"
"encoding/json"
)
// ShellExecution is local host metadata for one shell invocation. It is never
// part of the provider-visible tool schema or request bytes; ModelMessages and
// provider serializers must strip it before a model request leaves the host.
//
// Kind is always "shell" for shell invocations so UIs can distinguish this
// optional payload from other future execution kinds without guessing.
type ShellExecution struct {
Kind string `json:"kind"`
Shell string `json:"shell,omitempty"` // bash | zsh | sh | git-bash | powershell | pwsh
ShellVersion string `json:"shellVersion,omitempty"` // 5.1 | 7+ (PowerShell only)
Platform string `json:"platform,omitempty"` // windows | darwin | linux
// SupportsAndAnd is explicit even when false so UIs can show PowerShell 5.1
// chaining limits without treating omission as "unknown".
SupportsAndAnd bool `json:"supportsAndAnd"`
State string `json:"state,omitempty"` // running | completed | failed | timed_out | cancelled | background_started | not_run
FailurePhase string `json:"failurePhase,omitempty"` // preflight | authorization | dependency | launch | execution | timeout | cancellation
// ExitCode is set only when a child process started and produced an exit
// status. Zero is a valid successful code (*int keeps 0 distinct from unset).
ExitCode *int `json:"exitCode,omitempty"`
// OutputTail is the bounded tail of combined stdout+stderr, set only for a
// run that did not succeed. Both streams share one pipe so model-visible
// interleaving stays in child-write order, which rules out a stderr-only
// tail. At most 16 KiB; never a shell executable absolute path.
OutputTail string `json:"outputTail,omitempty"`
MutationRisk string `json:"mutationRisk,omitempty"` // none | not_started | may_have_completed | may_be_partial | unknown
Verification string `json:"verification,omitempty"` // not_verification | not_run | passed | failed
DurationMs int64 `json:"durationMs,omitempty"`
}
// Shell execution state values.
const (
ShellStateRunning = "running"
ShellStateCompleted = "completed"
ShellStateFailed = "failed"
ShellStateTimedOut = "timed_out"
ShellStateCancelled = "cancelled"
ShellStateBackgroundStarted = "background_started"
ShellStateNotRun = "not_run"
)
// Shell failure phase values.
const (
ShellPhasePreflight = "preflight"
ShellPhaseAuthorization = "authorization"
ShellPhaseDependency = "dependency"
ShellPhaseLaunch = "launch"
ShellPhaseExecution = "execution"
ShellPhaseTimeout = "timeout"
ShellPhaseCancellation = "cancellation"
)
// Shell mutation risk values.
const (
ShellMutationNone = "none"
ShellMutationNotStarted = "not_started"
ShellMutationMayHaveCompleted = "may_have_completed"
ShellMutationMayBePartial = "may_be_partial"
ShellMutationUnknown = "unknown"
)
// Shell verification values.
const (
ShellVerificationNotVerification = "not_verification"
ShellVerificationNotRun = "not_run"
ShellVerificationPassed = "passed"
ShellVerificationFailed = "failed"
)
// Shell name values for ShellExecution.Shell.
const (
ShellNameBash = "bash"
ShellNameZsh = "zsh"
ShellNameSh = "sh"
ShellNameGitBash = "git-bash"
ShellNamePowerShell = "powershell"
ShellNamePwsh = "pwsh"
)
// PowerShell version labels.
const (
ShellVersionPS51 = "5.1"
ShellVersionPS7 = "7+"
)
// OutputTailMaxBytes bounds the output tail retained on ShellExecution.
const OutputTailMaxBytes = 16 << 10
// DetailedResult is the structured outcome of a DetailedExecutor call.
// Output remains the model-visible text; Execution is host/UI metadata only.
type DetailedResult struct {
Output string
Images []string
Execution *ShellExecution
}
// DetailedExecutor is an optional Tool capability that returns structured
// execution metadata alongside the model-visible result text. Tools that do
// not implement it continue to use ImageTool/Tool.Execute.
type DetailedExecutor interface {
// ExecutionDescriptor returns a descriptor for the would-be execution
// before the process starts (shell identity, platform, chaining support).
// It must not launch a process. Args may be empty or invalid — return a
// best-effort descriptor from the bound shell configuration.
ExecutionDescriptor(args json.RawMessage) *ShellExecution
// ExecuteDetailed runs the tool and returns structured metadata. On
// policy/preflight blocks, Execution must still be populated (state=not_run).
ExecuteDetailed(ctx context.Context, args json.RawMessage) (DetailedResult, error)
}
// CloneShellExecution returns a deep copy suitable for attaching to events or
// session messages without sharing mutable pointers (e.g. ExitCode).
func CloneShellExecution(in *ShellExecution) *ShellExecution {
if in == nil {
return nil
}
out := *in
if in.ExitCode != nil {
code := *in.ExitCode
out.ExitCode = &code
}
return &out
}
// IntPtr returns a pointer to v for ShellExecution.ExitCode.
func IntPtr(v int) *int { return &v }