Merge pull request #5426 from deepseek-harness/worktree/list-terminal-duplicate-shells-e8dc50

fix(web): list each shell once in the new-terminal menu
This commit is contained in:
Yichen Jiang authored and GitHub committed 2026-09-29 10:58:42 +08:00
commit c02a23c1f8
5 files changed
+29 -11

No files matched your search

@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 1fa9a72d1ee045cc
/deepseek-ai-dsh-api-terminal-controller/use-this-package:
en: 234403af4f95ea67
zh: d711c2e62d7d37f8
en: 0148cabb3a856226
zh: 059a60413474a173
/deepseek-ai-dsh-api-terminal-controller/understand-the-implementation:
en: 94dcd1835c55acc2
zh: cbfeb2da8cb60395
+1 -1
View File
@@ -27,7 +27,7 @@ Open the execution environment's default shell in a Session workspace from the W
The Web bundle mounts this package with the subprocess provider, sandbox policy and Typert Gateway. Sandbox policy supplies only the fallback working directory for Sessions without a cwd. `remote.terminal` exposes `environment`, `shells`, `list`, `create`, `retain`, `follow`, `write`, `resize`, `rename` and `close`; each operation is scoped by Session identity. Listing reads retained Host terminals directly, so viewing an offline Session neither activates an Agent nor produces a recovery error.
Shell discovery lists the execution environment's declared default shell first. Only when the provider omits that default does resolution use `/bin/sh` on POSIX or `cmd.exe` on Windows. An optional `shell` profile overrides that choice with executable `path`, display `name` and `args` (default `[]`). The selector also probes `shellCandidates` through the execution provider and omits only confirmed lookup misses. Creation accepts a discovered `shellPath` and verifies it again; resolution or transport failure is reported without launching a different shell. Environment lookup returns the working directory and limits without resolving a shell, so an unavailable default does not prevent reattaching to an existing process. Automatic POSIX profiles start interactively, and PowerShell uses `-NoLogo`, so completion and startup configuration remain shell-owned. The Session workspace supplies the initial directory. User terminals run with the execution environment’s system-user permissions, independently of the Agent’s sandbox mode and approval policy. Operating-system and container restrictions still apply; DSH does not elevate the user. The subprocess provider retains its credential-environment scrubbing.
Shell discovery lists the execution environment's declared default shell first. Only when the provider omits that default does resolution use `/bin/sh` on POSIX or `cmd.exe` on Windows. An optional `shell` profile overrides that choice with executable `path`, display `name` and `args` (default `[]`). The selector also probes `shellCandidates` through the execution provider and omits confirmed lookup misses. It lists each executable name once, ignoring letter case and `.exe`, and keeps the earliest entry, because PATH lookup can reach the default through another directory, such as `/usr/bin/bash` for `/bin/bash` on merged-`/usr` systems. Creation accepts a discovered `shellPath` and verifies it again; resolution or transport failure is reported without launching a different shell. Environment lookup returns the working directory and limits without resolving a shell, so an unavailable default does not prevent reattaching to an existing process. Automatic POSIX profiles start interactively, and PowerShell uses `-NoLogo`, so completion and startup configuration remain shell-owned. The Session workspace supplies the initial directory. User terminals run with the execution environment’s system-user permissions, independently of the Agent’s sandbox mode and approval policy. Operating-system and container restrictions still apply; DSH does not elevate the user. The subprocess provider retains its credential-environment scrubbing.
| Configuration | Default | Meaning |
|---|---|---|
@@ -27,7 +27,7 @@ kind: "package-reference"
Web bundle 将此包与 subprocess provider、sandbox policy 和 Typert Gateway 一起挂载。Sandbox policy 仅为没有 cwd 的 Session 提供默认工作目录。`remote.terminal` 提供 `environment`、`shells`、`list`、`create`、`retain`、`follow`、`write`、`resize`、`rename` 和 `close`;每个操作均按 Session 标识限定范围。列表直接读取 Host 保留的终端,因此查看离线 Session 不会激活 Agent,也不会产生恢复错误。
Shell 探测结果首先列出执行环境声明的默认 shell。仅当 provider 未声明默认值时,才在 POSIX 使用 `/bin/sh`,在 Windows 使用 `cmd.exe`。可选的 `shell` profile 通过可执行路径 `path`、显示名称 `name` 和参数 `args`(默认 `[]`)覆盖这一选择。选择器还会通过执行 provider 探测 `shellCandidates`,仅省略确定未找到的候选。创建请求接受探测返回的 `shellPath` 并再次验证;解析或传输失败会直接报告,不启动其他 shell。环境查询只返回工作目录和限制,不解析 shell,因此默认 shell 不可用时仍可重新连接已有进程。POSIX 自动 profile 以交互模式启动,PowerShell 使用 `-NoLogo`,补全和启动配置仍由 shell 提供。初始目录来自 Session 工作区。用户终端使用执行环境中系统用户的权限,独立于 Agent 的沙箱模式和审批策略。操作系统和容器的限制仍然生效;DSH 不提升用户权限。Subprocess provider 继续清除环境中的凭据变量。
Shell 探测结果首先列出执行环境声明的默认 shell。仅当 provider 未声明默认值时,才在 POSIX 使用 `/bin/sh`,在 Windows 使用 `cmd.exe`。可选的 `shell` profile 通过可执行路径 `path`、显示名称 `name` 和参数 `args`(默认 `[]`)覆盖这一选择。选择器还会通过执行 provider 探测 `shellCandidates`,并省略确定未找到的候选。同一可执行文件名(不区分大小写,忽略 `.exe`)只列出最早的一项,因为 PATH 查找可能经由其他目录找到默认 shell,例如 merged-`/usr` 系统上 `/bin/bash` 对应的 `/usr/bin/bash`。创建请求接受探测返回的 `shellPath` 并再次验证;解析或传输失败会直接报告,不启动其他 shell。环境查询只返回工作目录和限制,不解析 shell,因此默认 shell 不可用时仍可重新连接已有进程。POSIX 自动 profile 以交互模式启动,PowerShell 使用 `-NoLogo`,补全和启动配置仍由 shell 提供。初始目录来自 Session 工作区。用户终端使用执行环境中系统用户的权限,独立于 Agent 的沙箱模式和审批策略。操作系统和容器的限制仍然生效;DSH 不提升用户权限。Subprocess provider 继续清除环境中的凭据变量。
| 配置 | 默认值 | 含义 |
|---|---|---|
+15 -7
View File
@@ -22,18 +22,26 @@ export async function resolveShell(
}
function profile(path: string): TerminalShell {
const name = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
const kind = name.toLowerCase().replace(/\.exe$/u, '')
return { path, name, args: kind === 'cmd' ? [] : kind === 'pwsh' || kind === 'powershell' ? ['-NoLogo'] : ['-i'] }
const kind = shellKind(path)
return { path, name: executableName(path), args: kind === 'cmd' ? [] : kind === 'pwsh' || kind === 'powershell' ? ['-NoLogo'] : ['-i'] }
}
function executableName(path: string): string {
return path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
}
function shellKind(path: string): string {
return executableName(path).toLowerCase().replace(/\.exe$/u, '')
}
/**
* List verified candidates after the configured or environment-default shell.
* List verified candidates after the configured or environment-default shell, one per executable name ignoring case and `.exe`.
* PATH lookup can reach the default through another directory, such as `/usr/bin/bash` for `/bin/bash` on merged-`/usr` systems.
* @param subprocess - target execution provider.
* @param configured - optional default profile.
* @param candidates - executable names or paths permitted for shell selection.
* @param signal - discovery cancellation.
* @returns unique installed shells, with the default first; transport failures reject.
* @returns installed shells with the default first, keeping the earliest entry per name; transport failures reject.
*/
export async function discoverShells(
subprocess: SubprocessRuntime, configured: TerminalShell | undefined,
@@ -50,8 +58,8 @@ export async function discoverShells(
const shells = new Map<string, TerminalShell>()
for (const shell of [preferred, ...found]) {
if (shell === undefined) continue
const key = shell.path.includes('\\') ? shell.path.toLowerCase() : shell.path
if (!shells.has(key)) shells.set(key, shell)
const kind = shellKind(shell.path)
if (!shells.has(kind)) shells.set(kind, shell)
}
return [...shells.values()]
}
@@ -546,6 +546,16 @@ it('deduplicates Windows executable paths regardless of letter case', async () =
expect(await h.controller.shells(h.agent, signal())).toEqual([{ path: 'C:\\Windows\\cmd.exe', name: 'Command Prompt', args: [] }])
})
it('lists each executable name once when PATH reaches the default shell through another directory', async () => {
const h = fixture({ shell: undefined, shellCandidates: ['zsh', 'bash'] })
// Merged /usr: the login shell is /bin/bash, while PATH lists /usr/bin before the /bin link.
h.subprocess.resolveExecutable.mockImplementation(async path => path.startsWith('/') ? path : `/usr/bin/${path}`)
expect(await h.controller.shells(h.agent, signal())).toEqual([
{ path: '/bin/bash', name: 'bash', args: ['-i'] },
{ path: '/usr/bin/zsh', name: 'zsh', args: ['-i'] },
])
})
it('retains a known terminal without Agent resolution and fences stream admission after explicit close', async () => {
const h = fixture()
await h.controller.create(h.agent, request, signal())