fix(web): start sidebar terminals in the session directory

This commit is contained in:
Tianyi Cui committed 2026-10-06 01:27:55 +08:00
1 parent 6f23c5dfd1
commit b446e2e290
18 files changed
+168 -38

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-13-session-working-directory.md
2026-09-13-session-working-directory.md: af04b67931f99516e54677b5c9a7e8eaf652139f
2026-09-13-session-working-directory.zh.md: a948fb02d8cf143891935dc69b6c487d8e6eb3dc
2026-09-13-session-working-directory.md: d14f9c7f96c3a36c97c5fc3ef1e8383bce6876f9
2026-09-13-session-working-directory.zh.md: 8a4e7448d5fe509c2e610e17973b99ec982b760d
@@ -20,6 +20,8 @@ Subagents capture the parent's current directory or an explicit override and com
Live Session skill catalogs and file completion validate through the same directory owner. These reads can commit recovery and queue its notice before discovery, keeping discovered files and subsequent operations tied to the committed Session directory. Cold skill catalogs read the recorded projection without activating an Agent.
Web sidebar terminal environment lookup reads the effective directory without filesystem validation or recovery, so a missing directory does not prevent reconnecting to a retained terminal. New terminal creation validates and recovers through the directory owner before spawning; the process keeps that directory independently of later Session changes.
File links and command directory labels use result metadata captured when each operation runs, so historical rows remain correct after later directory changes.
The [prompt-variables note](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) retains strict interpolation, route-variable ownership, and tool-guidance ownership. The [environment-suffix note](../bug-fix/2026-09-06-environment-prompt-suffix.md) retains ordered prefix/suffix placement for deployment, Harness source, and Web guidance. This decision owns directory state and placement in user context.
@@ -20,6 +20,8 @@ Status: implemented
活跃 Session 的技能目录与文件补全通过同一个目录 owner 验证目录。这些读取可在发现操作之前提交恢复并排入通知,使发现的文件与后续操作关联到已提交的 Session 目录。冷态技能目录读取已记录的投影,不激活 Agent。
Web 侧栏终端的环境查询读取有效目录,不验证文件系统或执行恢复,因此目录缺失不会阻止重新连接保留的终端。新终端在创建进程前通过目录 owner 验证和恢复目录;后续 Session 目录变更不会改变该进程的目录。
文件链接与命令目录标签使用各次操作执行时记录的结果元数据,因此后续目录变更不会使历史记录指向错误的位置。
[提示词变量记录](2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md)保留严格插值、路由变量归属与工具指导归属。[环境后缀记录](../bug-fix/2026-09-06-environment-prompt-suffix.zh.md)保留部署、Harness 源码与 Web 指导的有序前缀/后缀位置。本决策拥有目录状态及其用户上下文位置。
+39
View File
@@ -1,5 +1,6 @@
/** Shipped sidebar terminal over the real Loader, Remote mux, Chromium and local PTY. */
import { mkdir } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium, type Browser, type Page } from 'playwright'
import { afterEach, beforeEach, describe, expect, it, onTestFailed, vi } from 'vitest'
@@ -246,6 +247,44 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
expect(tripwire.pageErrors).toEqual([])
})
it('starts in the current directory while earlier terminals and sandbox roots stay independent', async () => {
onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-directory'))
const agent = scaffold.ctx.agents.list()[0]!
const directories = agent.ctx.get('workingDirectory')
if (directories === undefined) throw new Error('Session working-directory provider is missing')
const original = directories.get(agent.session)
const child = join(original, 'terminal-child')
await mkdir(child)
const sandboxPolicy = agent.ctx.get('sandboxPolicy')
if (sandboxPolicy === undefined) throw new Error('Session sandbox policy provider is missing')
const policy = sandboxPolicy.resolve({ session: agent.session })
const sandbox = agent.ctx.get('sandbox')
if (sandbox === undefined) throw new Error('Session sandbox provider is missing')
const confine = vi.spyOn(sandbox, 'confine')
const selected = await directories.set(agent, child)
await openTerminal(page)
const firstProcess = processIdentity(0)
const screen = page.locator('.xterm-rows:visible')
const checkDirectory = async (marker: string, directory: string): Promise<void> => {
const literal = `'${directory.replaceAll("'", "'\\''")}'`
await command(page, `[ "$PWD" = ${literal} ]; printf '${marker}:%s\\n' "$?"`)
await expect.poll(async () => await screen.innerText()).toContain(`${marker}:0`)
}
await checkDirectory('FIRST_DIRECTORY_STATUS', selected)
const returned = await directories.set(agent, original)
await checkDirectory('RETAINED_DIRECTORY_STATUS', selected)
await openTerminal(page)
await checkDirectory('SECOND_DIRECTORY_STATUS', returned)
expect(scaffold.ctx.terminalController.list(agent.id).map(terminal => terminal.cwd)).toEqual([selected, returned])
expect(alive(firstProcess)).toBe(true)
expect(sandboxPolicy.resolve({ session: agent.session })).toEqual(policy)
expect(confine.mock.calls.length).toBeGreaterThanOrEqual(2)
expect(confine.mock.calls.every(call => call[1].mode === policy.mode && call[1].workspaceRoot === policy.workspaceRoot)).toBe(true)
expect(tripwire.pageErrors).toEqual([])
})
it('completes commands, preserves the process through collapse and reload, resizes, and kills on tab close', async () => {
onTestFailed(() => saveFailureShot(page, 'sidebar-terminal'))
await openTerminal(page)
+2 -2
View File
@@ -232,10 +232,10 @@ Typed Remote control of transient Session-owned terminal processes.
```ts cordis-catalog
/**
* Read the Session working directory and terminal limits without resolving a shell.
* Read the Session's current directory and terminal limits without filesystem validation or shell lookup.
* @param agent - Session owner supplied by the Gateway.
* @param signal - request cancellation.
* @returns the Session workspace directory and terminal limits.
* @returns the logged current directory and terminal limits; retained terminals keep their own process directories.
*/
@Remote environment(agent: Agent, signal: AbortSignal): TerminalEnvironment
+2 -2
View File
@@ -232,10 +232,10 @@ Typed Remote control of transient Session-owned terminal processes.
```ts cordis-catalog
/**
* Read the Session working directory and terminal limits without resolving a shell.
* Read the Session's current directory and terminal limits without filesystem validation or shell lookup.
* @param agent - Session owner supplied by the Gateway.
* @param signal - request cancellation.
* @returns the Session workspace directory and terminal limits.
* @returns the logged current directory and terminal limits; retained terminals keep their own process directories.
*/
@Remote environment(agent: Agent, signal: AbortSignal): TerminalEnvironment
+5 -5
View File
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
Open the execution environment's default shell in a Session workspace from the Web sidebar. Reconnect to existing processes and close their complete provider-owned process ranges. Terminal output stays outside the Agent transcript. Keeping a terminal open retains its process and a bounded screen buffer.
Open the execution environment's default shell in the Session's current working directory from the Web sidebar. Reconnect to existing processes and close their complete provider-owned process ranges. Terminal output stays outside the Agent transcript. Keeping a terminal open retains its process and a bounded screen buffer.
## Table of Contents
@@ -25,9 +25,9 @@ Open the execution environment's default shell in a Session workspace from the W
<a id="use-this-package"></a>
## Use this package
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.
The Web bundle mounts this package with the subprocess provider, working-directory service, sandbox policy and Typert Gateway. `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 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.
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 reads the current directory and limits without filesystem validation or shell resolution, so unavailable directories or default shells do not prevent reattaching to an existing process. Automatic POSIX profiles start interactively, and PowerShell uses `-NoLogo`, so completion and startup configuration remain shell-owned. New terminals validate the current directory through the [working-directory service](../../session/working-directory/README.md) before spawning, including its recovery behavior. Existing terminals keep their process directories. 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 |
|---|---|---|
@@ -76,11 +76,11 @@ Closing saves an unfinished cleanup request before releasing the tab, then await
<a id="model-experience"></a>
## Model Experience
None, as this package handles user terminal interaction without adding model input.
Indirectly, through [working-directory recovery](../../session/working-directory/README.md): creating a terminal may update directory user context, while terminal output stays outside model history.
#### KV Cache effect
None; terminal output travels only between the browser and Host.
Directory recovery appends user context without replacing the system-prompt prefix; terminal output stays outside model requests.
## Known Limitations and Deferred Work
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
从 Web 侧栏在会话工作区打开执行环境的默认 shell。重新连接已有进程,并关闭 provider 管理的完整进程范围。终端输出不进入 Agent 对话记录。保留终端会占用进程和有界屏幕缓存。
从 Web 侧栏在 Session 当前工作目录打开执行环境的默认 shell。重新连接已有进程,并关闭 provider 管理的完整进程范围。终端输出不进入 Agent 对话记录。保留终端会占用进程和有界屏幕缓存。
## 目录
@@ -25,9 +25,9 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用此包
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,也不会产生恢复错误。
Web bundle 将此包与 subprocess provider、working-directory 服务、sandbox policy 和 Typert Gateway 一起挂载。`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`,并省略确定未找到的候选。同一可执行文件名(不区分大小写,忽略 `.exe`)只列出最早的一项,因为 PATH 查找可能经由其他目录找到默认 shell,例如 merged-`/usr` 系统上 `/bin/bash` 对应的 `/usr/bin/bash`。创建请求接受探测返回的 `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/working-directory/README.zh.md)校验当前目录,并采用该服务的恢复行为。已有终端保留其进程目录。用户终端使用执行环境中系统用户的权限,独立于 Agent 的沙箱模式和审批策略。操作系统和容器的限制仍然生效;DSH 不提升用户权限。Subprocess provider 继续清除环境中的凭据变量。
| 配置 | 默认值 | 含义 |
|---|---|---|
@@ -76,11 +76,11 @@ Client 在分配前将每条 Session/内容与终端身份的关联保存到独
<a id="model-experience"></a>
## 模型体验
无;此包只处理用户交互式终端,不向模型请求添加内容。
间接影响由[工作目录恢复](../../session/working-directory/README.zh.md)拥有:创建终端可能更新目录用户上下文,终端输出仍不进入模型历史。
#### KV 缓存影响
无;终端输出只在浏览器与 Host 之间传输。
目录恢复追加用户上下文,不替换 system-prompt 前缀;终端输出仍不进入模型请求。
## 已知限制与延期工作
@@ -80,7 +80,8 @@
"@deepseek-ai/dsh-session-projection": "workspace:*",
"@deepseek-ai/dsh-subprocess": "workspace:*",
"@deepseek-ai/dsh-subprocess-local": "workspace:*",
"@deepseek-ai/dsh-util-crypto": "workspace:*"
"@deepseek-ai/dsh-util-crypto": "workspace:*",
"@deepseek-ai/dsh-working-directory": "workspace:*"
},
"files": [
"lib/index.js",
+14 -11
View File
@@ -4,6 +4,7 @@ import z from '@deepseek-ai/schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import type {} from '@deepseek-ai/dsh-working-directory'
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import { discoverShells, resolveShell } from './shells.ts'
import { BrowserTerminal } from './terminal.ts'
@@ -73,7 +74,7 @@ interface OwnedSession {
/** Typed Remote control of transient Session-owned terminal processes. */
export class TerminalController extends TypertRemoteService {
static inject = ['subprocess', 'sandboxPolicy', 'typert']
static inject = ['subprocess', 'sandboxPolicy', 'workingDirectory', 'typert']
static Config: z<Config> = z.object({
shell: z.union([z.object({
path: z.string().required(), name: z.string().required(), args: z.array(z.string()).default([]),
@@ -109,16 +110,16 @@ export class TerminalController extends TypertRemoteService {
}
/**
* Read the Session working directory and terminal limits without resolving a shell.
* Read the Session's current directory and terminal limits without filesystem validation or shell lookup.
* @param agent - Session owner supplied by the Gateway.
* @param signal - request cancellation.
* @returns the Session workspace directory and terminal limits.
* @returns the logged current directory and terminal limits; retained terminals keep their own process directories.
*/
@Remote
environment(agent: Agent, signal: AbortSignal): TerminalEnvironment {
signal.throwIfAborted()
const { sandboxPolicy } = this.execution(agent)
return { cwd: agent.session.header.cwd ?? sandboxPolicy.workspaceRoot,
const { workingDirectory } = this.execution(agent)
return { cwd: workingDirectory.get(agent.session),
maxInputBytes: this.config.maxInputBytes, maxCols: this.config.maxCols,
maxRows: this.config.maxRows, scrollback: this.config.scrollback }
}
@@ -328,29 +329,31 @@ export class TerminalController extends TypertRemoteService {
|| !Number.isSafeInteger(rows) || rows < 1 || rows > this.config.maxRows) throw new Error('Terminal dimensions exceed the configured limits')
}
private execution(agent: Agent): { subprocess: Context['subprocess']; sandboxPolicy: Context['sandboxPolicy'] } {
private execution(agent: Agent): { subprocess: Context['subprocess']; sandboxPolicy: Context['sandboxPolicy']; workingDirectory: Context['workingDirectory'] } {
// The Agent context selects execution providers but does not inject consumer services.
const subprocess = agent.ctx.get('subprocess')
const sandboxPolicy = agent.ctx.get('sandboxPolicy')
if (subprocess === undefined || sandboxPolicy === undefined) throw new Error('The Session execution environment requires subprocess and sandbox policy providers')
return { subprocess, sandboxPolicy }
const workingDirectory = agent.ctx.get('workingDirectory')
if (workingDirectory === undefined) throw new Error('The Session execution environment requires a working-directory provider')
return { subprocess, sandboxPolicy, workingDirectory }
}
private async spawn(agent: Agent, owner: OwnedSession, request: TerminalCreateRequest, signal: AbortSignal): Promise<BrowserTerminal> {
const environment = this.environment(agent, signal)
const { subprocess } = this.execution(agent)
const { subprocess, workingDirectory } = this.execution(agent)
const cwd = await workingDirectory.ensure(agent, signal)
const shell = request.shellPath === undefined
? await resolveShell(subprocess, this.config.shell, signal)
: (await this.shells(agent, signal)).find(candidate => candidate.path === request.shellPath)
if (shell === undefined) throw new Error('Selected shell is not available in this execution environment')
const handle = await subprocess.spawnTerminal({
argv: [shell.path, ...shell.args], cwd: environment.cwd, cols: request.cols, rows: request.rows,
argv: [shell.path, ...shell.args], cwd, cols: request.cols, rows: request.rows,
terminalType: 'xterm-256color', env: { DSH_SESSION_ID: agent.id },
shellActivity: true,
graceMs: this.config.disposeGraceMs, signal,
})
const info: WebTerminalInfo = {
id: request.id, shell, title: shell.name, cwd: environment.cwd,
id: request.id, shell, title: shell.name, cwd,
cols: request.cols, rows: request.rows, state: 'running', exitCode: null,
}
try {
@@ -30,8 +30,9 @@ export interface TerminalShell {
readonly name: string
}
/** Working directory and limits shared by new and restored terminals. */
/** Current Session directory and terminal limits; retained terminals keep their own process directories. */
export interface TerminalEnvironment {
/** Logged current directory, read without filesystem validation. */
readonly cwd: string
readonly maxInputBytes: number
readonly maxCols: number
@@ -30,7 +30,12 @@ function fixture(overrides: Partial<Config> = {}) {
roots.push(ctx)
const effects = vi.spyOn(ctx.fiber, 'effect')
const sandboxPolicy = { defaultMode: 'danger-full-access', workspaceRoot: '/workspace', resolve: vi.fn((): SandboxExecutionPolicy => ({ mode: 'danger-full-access', workspaceRoot: '/workspace' })) }
const workingDirectory = {
get: vi.fn<(session: Agent['session']) => string>(session => session.header.cwd ?? '/workspace'),
ensure: vi.fn<(agent: Agent, signal?: AbortSignal) => Promise<string>>(async agent => agent.session.header.cwd ?? '/workspace'),
}
ctx.provide('sandboxPolicy', sandboxPolicy as never)
ctx.provide('workingDirectory', workingDirectory as never)
const output = new PassThrough()
const done = Promise.withResolvers<{ exitCode: number; signal: null }>()
const handle = {
@@ -48,7 +53,7 @@ function fixture(overrides: Partial<Config> = {}) {
if (result?.type !== 'return' || typeof result.value !== 'function') throw new Error(`Missing effect: ${label}`)
return result.value()
}
return { ctx, agent: owner(ctx), controller, subprocess, handle, sandboxPolicy, disposeEffect }
return { ctx, agent: owner(ctx), controller, subprocess, handle, sandboxPolicy, workingDirectory, disposeEffect }
}
describe('TerminalController', () => {
@@ -100,6 +105,75 @@ describe('TerminalController', () => {
expect(sandboxPolicy.resolve).not.toHaveBeenCalled()
})
it('uses the current directory for new user terminals while retaining earlier directories and permissions', async () => {
const { ctx, controller, agent, subprocess, sandboxPolicy, workingDirectory } = fixture()
const policy: SandboxExecutionPolicy = { mode: 'workspace-write', workspaceRoot: '/workspace', sessionId: agent.id }
sandboxPolicy.resolve.mockReturnValue(policy)
const confine = vi.fn(async (argv: readonly string[], _policy: SandboxExecutionPolicy) => ({ argv: [...argv] }))
ctx.provide('sandbox', { confine } as never)
const first = await controller.create(agent, request, signal())
workingDirectory.get.mockReturnValue('/workspace/selected')
workingDirectory.ensure.mockResolvedValue('/workspace/selected')
expect(controller.environment(agent, signal()).cwd).toBe('/workspace/selected')
expect(workingDirectory.ensure).toHaveBeenCalledOnce()
expect(await controller.create(agent, request, signal())).toBe(first)
expect(first.cwd).toBe('/workspace')
const second = await controller.create(agent, { ...request, id: 'second' as WebTerminalId }, signal())
expect(second.cwd).toBe('/workspace/selected')
expect(subprocess.spawnTerminal).toHaveBeenNthCalledWith(1, expect.objectContaining({ cwd: '/workspace' }))
expect(subprocess.spawnTerminal).toHaveBeenNthCalledWith(2, expect.objectContaining({ cwd: '/workspace/selected' }))
expect(confine).not.toHaveBeenCalled()
expect(sandboxPolicy.resolve).not.toHaveBeenCalled()
expect(controller.list(agent.id).map(terminal => terminal.cwd)).toEqual(['/workspace', '/workspace/selected'])
})
it('captures the validated directory before a delayed shell lookup', async () => {
const { controller, agent, subprocess, workingDirectory } = fixture()
workingDirectory.ensure.mockResolvedValue('/workspace/starting')
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
subprocess.resolveExecutable.mockImplementationOnce(async (path) => {
entered.resolve(undefined)
await release.promise
return path
})
const creating = controller.create(agent, request, signal())
try {
await entered.promise
workingDirectory.get.mockReturnValue('/workspace/later')
workingDirectory.ensure.mockResolvedValue('/workspace/later')
release.resolve(undefined)
expect((await creating).cwd).toBe('/workspace/starting')
expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ cwd: '/workspace/starting' }))
expect(workingDirectory.ensure).toHaveBeenCalledOnce()
} finally {
release.resolve(undefined)
await creating
}
})
it('uses recovered directory values for new creation and leaves environment reads unvalidated', async () => {
const { controller, agent, subprocess, workingDirectory } = fixture()
workingDirectory.get.mockReturnValue('/missing')
workingDirectory.ensure.mockResolvedValue('/workspace/recovered')
expect(controller.environment(agent, signal()).cwd).toBe('/missing')
expect(workingDirectory.ensure).not.toHaveBeenCalled()
const created = await controller.create(agent, request, signal())
expect(created.cwd).toBe('/workspace/recovered')
expect(workingDirectory.ensure).toHaveBeenCalledWith(agent, expect.any(AbortSignal))
expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ cwd: '/workspace/recovered' }))
workingDirectory.ensure.mockRejectedValue(new Error('original directory is unavailable'))
expect(controller.environment(agent, signal()).cwd).toBe('/missing')
expect(await controller.create(agent, request, signal())).toBe(created)
await expect(controller.create(agent, { ...request, id: 'unavailable' as WebTerminalId }, signal()))
.rejects.toThrow('original directory is unavailable')
expect(subprocess.spawnTerminal).toHaveBeenCalledOnce()
})
it('deduplicates concurrent creates, scopes access by Session, and preserves terminals when a stream aborts', async () => {
const { controller, agent, ctx, subprocess, handle } = fixture()
const [first, second] = await Promise.all([controller.create(agent, request, signal()), controller.create(agent, request, signal())])
@@ -299,13 +373,15 @@ describe('TerminalController', () => {
expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ cwd: '/another-workspace' }))
})
it.each(['subprocess', 'sandboxPolicy'] as const)('fails clearly when the Session lacks %s', (missing) => {
const { controller, subprocess, sandboxPolicy } = fixture()
it.each(['subprocess', 'sandboxPolicy', 'workingDirectory'] as const)('fails clearly when the Session lacks %s', (missing) => {
const { controller, subprocess, sandboxPolicy, workingDirectory } = fixture()
const isolated = new Context()
roots.push(isolated)
if (missing !== 'subprocess') isolated.provide('subprocess', subprocess as never)
if (missing !== 'sandboxPolicy') isolated.provide('sandboxPolicy', sandboxPolicy as never)
expect(() => controller.environment(owner(isolated), signal())).toThrow('requires subprocess and sandbox policy providers')
if (missing !== 'workingDirectory') isolated.provide('workingDirectory', workingDirectory as never)
expect(() => controller.environment(owner(isolated), signal())).toThrow(missing === 'workingDirectory'
? 'requires a working-directory provider' : 'requires subprocess and sandbox policy providers')
})
it('allows Agent sandbox-mode changes while retaining the same user terminal', async () => {
@@ -38,6 +38,9 @@
{
"path": "../../session/session-projection"
},
{
"path": "../../session/working-directory"
},
{
"path": "../../subprocess/subprocess"
},
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
Choose an installed shell from the right sidebar's Start page to run commands in the Session workspace. Rename terminals in their tabs and recover retained processes after reloading the page. Collapse the sidebar to keep commands running; close a terminal tab to request process termination. Tab completion follows the shell configuration. Commands use the execution environment’s system-user permissions independently of Agent permissions; see [user-terminal execution](../../api/terminal-controller/README.md#use-this-package).
Choose an installed shell from the right sidebar's Start page to run commands in the Session's current working directory. Rename terminals in their tabs and recover retained processes after reloading the page. Collapse the sidebar to keep commands running; close a terminal tab to request process termination. Tab completion follows the shell configuration. Commands use the execution environment’s system-user permissions independently of Agent permissions; see [user-terminal execution](../../api/terminal-controller/README.md#use-this-package).
## Table of Contents
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
从右侧栏开始页选择已安装的 shell,在会话工作区运行命令。在标签页上重命名终端,并在刷新页面后恢复保留的进程。折叠侧栏让命令继续运行,关闭终端标签页则请求结束进程。Tab 补全使用 shell 的配置。命令使用执行环境中系统用户的权限,独立于 Agent 权限;详见[用户终端执行](../../api/terminal-controller/README.zh.md#use-this-package)。
从右侧栏开始页选择已安装的 shell,在 Session 当前工作目录运行命令。在标签页上重命名终端,并在刷新页面后恢复保留的进程。折叠侧栏让命令继续运行,关闭终端标签页则请求结束进程。Tab 补全使用 shell 的配置。命令使用执行环境中系统用户的权限,独立于 Agent 权限;详见[用户终端执行](../../api/terminal-controller/README.zh.md#use-this-package)。
## 目录
@@ -3073,9 +3073,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
methods: [
{
signature: '@Remote environment(agent: Agent, signal: AbortSignal): TerminalEnvironment',
description: 'Read the Session working directory and terminal limits without resolving a shell.',
description: 'Read the Session\'s current directory and terminal limits without filesystem validation or shell lookup.',
parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'signal', description: 'request cancellation.' }],
returns: 'the Session workspace directory and terminal limits.',
returns: 'the logged current directory and terminal limits; retained terminals keep their own process directories.',
},
{
signature: '@Remote shells(agent: Agent, signal: AbortSignal): Promise<TerminalShell[]>',
+3
View File
@@ -1574,6 +1574,9 @@ importers:
'@deepseek-ai/dsh-util-crypto':
specifier: workspace:*
version: link:../../util/crypto
'@deepseek-ai/dsh-working-directory':
specifier: workspace:^
version: link:../../session/working-directory
packages/api/workspace-controller:
dependencies:
@@ -54,7 +54,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/experimental/voice-input-bundle': { kind: 'none', reason: 'Composes dictation and preparation plugins without adding any model-facing contribution.' },
'packages/telemetry/otel': { kind: 'none', reason: 'The service delivers caller-selected records without changing model context.' },
'packages/host/product-telemetry-otel': { kind: 'none', reason: 'The plugin exports explicit analytics records without contributing model context.' },
'packages/api/terminal-controller': { kind: 'none', reason: 'User-owned terminal processes and screen streams never enter model requests or Session events.' },
'packages/api/terminal-controller': { kind: 'indirect', reason: 'Working-directory recovery owns directory context; user terminal screens never enter model requests.' },
'packages/client/ui-sidebar-terminal': { kind: 'none', reason: 'The browser renders user terminal screens without exposing them to the model.' },
'packages/ssh/ssh': { kind: 'none', reason: 'The connection owner transports private provider operations; consumers own all model-facing content.' },
'packages/ssh/fs-ssh': { kind: 'indirect', reason: 'The remote filesystem delegates model rendering to the existing filesystem consumers.' },