refactor: apply repository naming contract
Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
6
packages/terminal/terminal-bash/README.i18n.yaml
Normal file
6
packages/terminal/terminal-bash/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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 packages/terminal/terminal-bash/README.md
|
||||
README.md: 36e725fd4ce86be09755768a9e21ccb66d025251
|
||||
README.zh.md: 080f45eb03dfdeece91269a3253aeb3fb280864d
|
||||
36
packages/terminal/terminal-bash/README.md
Normal file
36
packages/terminal/terminal-bash/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# @deepseek-ai/dsh-terminal-bash
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Persistent shell backend for `ctx.terminals` over `ctx.subprocess.spawnTerminal`. It starts an interactive shell under the shared `ctx.sandboxPolicy`, retains bounded line-oriented output, and detects readiness while the subprocess provider owns PTY allocation, environment scrubbing, foreground process groups, signalling, and complete terminal-session cleanup. The same PTY backend therefore composes with local or remote execution-world providers.
|
||||
|
||||
## Plugin (`terminal-bash`)
|
||||
|
||||
The plugin injects `pty`, `sandboxPolicy`, and `subprocess`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly without requiring a sandbox provider; confined modes require a same-world `ctx.sandbox` and wrap the exact shell argv through it, failing before spawn when none is mounted. At spawn, one `ctx.sandboxPolicy.resolve({ session })` call supplies both the effective mode and the session workspace root; the same root is the default shell cwd when the caller omits one. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade.
|
||||
|
||||
Readiness combines a foreground-verified private bash prompt marker, provider-reported foreground stdin-wait facts, silence fallback, and absolute timeout. A marker is not ready until the printable tail after the latest owned marker exactly equals the controlled `PS1`, including when the OSC marker and prompt are split across data callbacks; echoed input or output following an earlier prompt therefore cannot settle the current send. Prompt and silence evidence collected before the provider write, including while pre-write foreground inspection is pending, is discarded at the write boundary. When bash prints the marker before the terminal provider publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unknown foreground state is never a positive exact-idle signal. A foreground group's stdin wait that existed before a send is likewise not post-write readiness: the same group must be observed outside that wait before a later wait can settle the send, while a changed foreground group is new evidence. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason; `TerminalBackendCleanupError` separately preserves a cleanup failure. The caller's signal is forwarded for terminal allocation and readiness initialization; after publication the handle owns its lifetime. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; malformed UTF-8 terminal output uses replacement characters, and a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
|
||||
|
||||
Send cancellation marks queued input as canceled before asking the terminal handle to signal the current foreground process group with a real `SIGINT`; if asynchronous pre-write inspection later settles, it cannot execute that input. If a provider write is already in flight, signalling waits for it to settle; a rejected write sends no signal. The canceled send retains its slot until the write and foreground signalling settle, so a successor cannot receive either late bytes or that signal. A provider write or signal that never settles therefore retains the slot indefinitely; closing the session (`terminal_close`) is the recovery. The absolute deadline remains armed while cancellation waits. A signal failure is a terminal transport failure and rejects the active send. Cancellation never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close rejects new public signals, stops readiness polling, and awaits the handle's provider-owned complete-session termination before settling the active send as `session_exit`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Current file policy and indirect consumer
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The policy owner contributes capability-neutral `sandbox:policy` context. Through `@deepseek-ai/dsh-tool-terminal` or another PTY consumer, the model may also receive bounded MOTD, send deltas, scrollback pages, readiness reasons, and cleanup errors.
|
||||
|
||||
#### Token effect
|
||||
|
||||
The current-policy clause is present while this backend is mounted. Retained PTY scrollback is not placed in model history until a consumer returns bounded output.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
A standing-policy change appends an owner-rendered superseding runtime-context snapshot after retained history; consumer results remain append-only.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- Line-oriented output is normalized; full-screen alternate-buffer interaction is unsupported.
|
||||
- Exact stdin-wait detection depends on the mounted subprocess provider; providers that cannot prove it use prompt-marker and silence/timeout readiness.
|
||||
- Cleanup guarantees are those of `SubprocessTerminalHandle`; provider-specific gaps belong to that implementation's contract rather than this PTY consumer.
|
||||
- Sessions do not survive harness process exit.
|
||||
36
packages/terminal/terminal-bash/README.zh.md
Normal file
36
packages/terminal/terminal-bash/README.zh.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# @deepseek-ai/dsh-terminal-bash
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
这是一个基于 `ctx.subprocess.spawnTerminal`、为 `ctx.terminals` 提供的持久 shell 后端。它在共享 `ctx.sandboxPolicy` 下启动交互式 shell,保留有界的逐行输出并检测就绪状态;进程管理提供方则负责 PTY 分配、环境清理、前台进程组、信号发送和完整终端会话清理。因此,同一个 PTY 后端可以与本地或远程执行世界提供方组合。
|
||||
|
||||
## 插件(`terminal-bash`)
|
||||
|
||||
该插件注入 `pty`、`sandboxPolicy` 和 `subprocess`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 无需沙箱提供方即可直接启动 shell;受限模式要求同一执行世界中存在 `ctx.sandbox`,并通过它包装确切的 shell argv,未挂载时会在 spawn 前失败。spawn 时,一次 `ctx.sandboxPolicy.resolve({ session })` 调用会同时给出实际模式与会话工作区根目录;调用方省略 cwd 时,同一根目录也是 shell 的默认 cwd。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建完成并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。
|
||||
|
||||
就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、提供方报告的前台 stdin 等待事实、静默回退和绝对超时。只有最新自有标记之后的可打印尾部与受控 `PS1` 完全相等,标记才算就绪;即使 OSC 标记和提示符被拆到多个数据回调中也一样。因此,较早提示符之后的回显输入或输出无法使当前 send 完成。提供方写入前收集的提示符与静默证据,包括写入前前台检查仍在等待时收集的证据,都会在写入边界丢弃。如果 bash 在终端提供方发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法一直抑制推断空闲就绪直至绝对超时。未知的前台状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝;`TerminalBackendCleanupError` 会单独保留清理失败。调用方的 signal 会转发给终端分配与就绪初始化;发布后,句柄负责其生命周期。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。格式错误的 UTF-8 终端输出使用替换字符;末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。
|
||||
|
||||
取消发送时,系统会先把排队输入标记为已取消,再要求终端句柄向当前前台进程组发送真正的 `SIGINT`;异步写入前检查即使随后结算,也无法执行该输入。如果提供方写入已在途,信号发送会等待其结算;写入被拒绝时不会发送信号。已取消的 send 会保留其位置,直到写入与前台信号发送都结算,因此后继 send 不会收到延迟字节或该信号。因此,永不结算的提供方写入或信号会无限期保留该位置;恢复手段是关闭会话(`terminal_close`)。取消等待期间,绝对 deadline 仍保持启用。信号发送失败是终端传输失败,会拒绝活跃 send。取消绝不会通过写入 `\x03` 模拟中断,因此,即使程序运行在 raw 模式下,也仍可取消。关闭操作会拒绝新的公开信号、停止就绪轮询,并等待由句柄提供方负责的完整会话终止,然后才把活跃 send 结算为 `session_exit`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 当前文件策略与间接消费方
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
策略归属方会贡献与具体能力无关的 `sandbox:policy` 上下文。模型通过 `@deepseek-ai/dsh-tool-terminal` 或其他 PTY 消费方还可能收到有界的 MOTD、发送增量、scrollback 页、就绪原因和清理错误。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
装载该后端期间,当前策略子句会一直存在。消费方返回有界输出前,保留的 PTY scrollback 不会进入模型历史。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
常驻策略发生变化时,会在保留的历史之后追加一份由归属方渲染、取代先前状态的运行时上下文快照;消费方结果保持仅追加。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- 输出按行规范化;不支持全屏备用缓冲区交互。
|
||||
- 精确 stdin 等待检测取决于已挂载的进程管理提供方;无法证明该状态的提供方使用提示符标记和静默/超时就绪机制。
|
||||
- 清理保证以 `SubprocessTerminalHandle` 的保证为准;提供方特定的缺口属于该实现的约定,而非这个 PTY 消费方。
|
||||
- harness 进程退出后,会话无法继续存在。
|
||||
58
packages/terminal/terminal-bash/package.json
Normal file
58
packages/terminal/terminal-bash/package.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-terminal-bash",
|
||||
"description": "Persistent shell PTY backend over the DeepSeek Harness subprocess terminal primitive",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/terminal/terminal-bash"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-terminal": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-terminal": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
81
packages/terminal/terminal-bash/src/config.ts
Normal file
81
packages/terminal/terminal-bash/src/config.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/** Validated configuration for the local PTY backend. */
|
||||
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
|
||||
/** Public plugin configuration. */
|
||||
export interface Config {
|
||||
/** Backend registry type (default: `shell`). */
|
||||
backendType?: string
|
||||
/** Interactive shell executable (default: `/bin/bash`). */
|
||||
shellPath?: string
|
||||
/** Shell arguments (default: `--noprofile --norc -i`). */
|
||||
shellArgs?: string[]
|
||||
/** Terminal rows. */
|
||||
rows?: number
|
||||
/** Terminal columns. */
|
||||
cols?: number
|
||||
/** Maximum retained logical lines. */
|
||||
scrollbackLines?: number
|
||||
/** Maximum retained UTF-8 bytes. */
|
||||
scrollbackMaxBytes?: number
|
||||
/** Maximum bytes returned by one read or settled viewport. */
|
||||
maxReadBytes?: number
|
||||
/** Readiness polling interval. */
|
||||
pollIntervalMs?: number
|
||||
/** Delay before Linux exact syscall probes. */
|
||||
exactProbeAfterMs?: number
|
||||
/** Silence duration that yields `inferred_idle`. */
|
||||
idleSilenceMs?: number
|
||||
/**
|
||||
* Extra wait beyond `idleSilenceMs`, once a prompt marker was seen, for the shell to
|
||||
* regain the foreground before `inferred_idle` settles; at least one `pollIntervalMs`.
|
||||
*/
|
||||
handoffGraceMs?: number
|
||||
/** Absolute send wait bound. */
|
||||
timeoutMs?: number
|
||||
/** Grace before teardown escalates to `SIGKILL`. */
|
||||
disposeGraceMs?: number
|
||||
}
|
||||
|
||||
/** Configuration after Schemastery defaults. */
|
||||
export type ResolvedConfig = Required<Config>
|
||||
|
||||
/** Schemastery config exposed by the plugin. */
|
||||
export const Config: z<Config> = z.object({
|
||||
backendType: z.string().default('shell'),
|
||||
shellPath: z.string().default('/bin/bash'),
|
||||
shellArgs: z.array(z.string()).default(['--noprofile', '--norc', '-i']),
|
||||
rows: z.number().default(40),
|
||||
cols: z.number().default(160),
|
||||
scrollbackLines: z.number().default(10_000),
|
||||
scrollbackMaxBytes: z.number().default(4 * 1024 * 1024),
|
||||
maxReadBytes: z.number().default(256 * 1024),
|
||||
pollIntervalMs: z.number().default(50),
|
||||
exactProbeAfterMs: z.number().default(150),
|
||||
idleSilenceMs: z.number().default(3_000),
|
||||
handoffGraceMs: z.number().default(500),
|
||||
timeoutMs: z.number().default(30_000),
|
||||
disposeGraceMs: z.number().default(3_000),
|
||||
})
|
||||
|
||||
/**
|
||||
* Assert every numeric config field is a positive safe integer and bounds compose.
|
||||
* @param config - Schemastery-resolved plugin configuration.
|
||||
* @returns Narrows the input to the fully resolved configuration.
|
||||
*/
|
||||
export function validateConfig(config: Config): asserts config is ResolvedConfig {
|
||||
const resolved = config as ResolvedConfig
|
||||
if (resolved.backendType.length === 0) throw new Error('terminal-bash: backendType must be non-empty')
|
||||
if (resolved.shellPath.length === 0) throw new Error('terminal-bash: shellPath must be non-empty')
|
||||
for (const [name, value] of Object.entries(resolved)) {
|
||||
if (typeof value === 'number' && (!Number.isSafeInteger(value) || value <= 0)) {
|
||||
throw new Error(`terminal-bash: ${name} must be a positive safe integer`)
|
||||
}
|
||||
}
|
||||
if (resolved.maxReadBytes > resolved.scrollbackMaxBytes) {
|
||||
throw new Error('terminal-bash: maxReadBytes must not exceed scrollbackMaxBytes')
|
||||
}
|
||||
if (resolved.handoffGraceMs < resolved.pollIntervalMs) {
|
||||
throw new Error('terminal-bash: handoffGraceMs must be at least pollIntervalMs so one readiness poll runs inside the grace window')
|
||||
}
|
||||
}
|
||||
153
packages/terminal/terminal-bash/src/index.ts
Normal file
153
packages/terminal/terminal-bash/src/index.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Persistent shell PTY backend over the subprocess terminal primitive, shared
|
||||
* sandbox policy, bounded output, and provider-owned session cleanup.
|
||||
* @module @deepseek-ai/dsh-terminal-bash
|
||||
*/
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { TerminalBackendCleanupError } from '@deepseek-ai/dsh-terminal'
|
||||
import type { TerminalBackend, TerminalBackendSpawnSpec } from '@deepseek-ai/dsh-terminal'
|
||||
import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { type Config, type ResolvedConfig, validateConfig } from './config.ts'
|
||||
import { LocalPtySession } from './session.ts'
|
||||
import { CONTROLLED_PROMPT } from './sanitize.ts'
|
||||
|
||||
export { Config } from './config.ts'
|
||||
export type { Config as TerminalLocalConfig } from './config.ts'
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'terminal-bash'
|
||||
/** Required services: PTY registry, shared confinement policy, and process substrate. */
|
||||
export const inject = ['terminals', 'sandboxPolicy', 'subprocess']
|
||||
|
||||
interface SandboxModeFenceState {
|
||||
pty: Context['terminals']
|
||||
sandboxPolicy: Context['sandboxPolicy']
|
||||
}
|
||||
|
||||
const sandboxModeFences = new WeakMap<Agent, SandboxModeFenceState>()
|
||||
|
||||
function ensureSandboxModeFence(ctx: Context, owner: Agent): void {
|
||||
const existing = sandboxModeFences.get(owner)
|
||||
if (existing !== undefined) {
|
||||
existing.pty = ctx.terminals
|
||||
existing.sandboxPolicy = ctx.sandboxPolicy
|
||||
return
|
||||
}
|
||||
const state: SandboxModeFenceState = { pty: ctx.terminals, sandboxPolicy: ctx.sandboxPolicy }
|
||||
sandboxModeFences.set(owner, state)
|
||||
owner.ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const [session, event] = args as [Session, SessionEvent]
|
||||
if (session !== owner.session || event.type !== 'sandbox/mode') return
|
||||
const currentMode = effectiveSandboxMode(session.events) ?? state.sandboxPolicy.defaultMode
|
||||
if (event.data.mode === currentMode || !state.pty.hasOwnerActivity(owner)) return
|
||||
throw new Error(
|
||||
`cannot change sandbox mode from "${currentMode}" to "${event.data.mode}" while persistent terminal sessions are open or being created; wait for creation to settle and close them first`,
|
||||
)
|
||||
}, { global: true })
|
||||
}
|
||||
|
||||
function childEnvironment(spec: TerminalBackendSpawnSpec): Record<string, string> {
|
||||
// The subprocess provider supplies its own scrubbed ambient base; these are
|
||||
// deliberate terminal-specific overrides layered after it.
|
||||
return {
|
||||
TERM: 'dumb',
|
||||
PAGER: 'cat',
|
||||
GIT_PAGER: 'cat',
|
||||
PS1: CONTROLLED_PROMPT,
|
||||
PROMPT_COMMAND: 'printf "\\033]133;D;%s\\007" "$?"',
|
||||
BASH_SILENCE_DEPRECATION_WARNING: '1',
|
||||
DSH_SHELL: '1',
|
||||
DSH_SESSION_ID: spec.owner.id,
|
||||
DSH_PTY_SESSION_ID: spec.sessionId,
|
||||
}
|
||||
}
|
||||
|
||||
function spawnArgv(ctx: Context, config: ResolvedConfig, policy: SandboxExecutionPolicy): string[] {
|
||||
const argv = [config.shellPath, ...config.shellArgs]
|
||||
if (policy.mode === 'danger-full-access') return argv
|
||||
const sandbox = ctx.get('sandbox')
|
||||
if (sandbox === undefined) {
|
||||
throw new Error(`terminal-bash: sandbox mode "${policy.mode}" requires a ctx.sandbox provider in the execution world`)
|
||||
}
|
||||
// Re-state the discriminant because object spread does not preserve its narrowed type.
|
||||
return sandbox.confine(argv, { ...policy, mode: policy.mode }).argv
|
||||
}
|
||||
|
||||
// TODO(pty-initialize-race-home): Fold this outer abort race into
|
||||
// LocalPtySession.initialize when the send-state consolidation lands; the
|
||||
// session already owns the send lifecycle the race protects.
|
||||
async function initializeSession(session: LocalPtySession, signal?: AbortSignal): Promise<void> {
|
||||
if (signal === undefined) {
|
||||
await session.initialize(signal)
|
||||
return
|
||||
}
|
||||
const aborted = Promise.withResolvers<never>()
|
||||
const onAbort = (): void => { aborted.reject(signal.reason) }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
try {
|
||||
signal.throwIfAborted()
|
||||
await Promise.race([session.initialize(signal), aborted.promise])
|
||||
} finally {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
}
|
||||
|
||||
/** Local shell backend registered under the configured type. */
|
||||
export class BashTerminalBackend implements TerminalBackend {
|
||||
readonly type: string
|
||||
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
private readonly config: ResolvedConfig,
|
||||
private readonly spawnTerminal: (
|
||||
spec: SubprocessTerminalSpawnSpec,
|
||||
) => Promise<SubprocessTerminalHandle> = spec => ctx.subprocess.spawnTerminal(spec),
|
||||
private readonly createSession: (
|
||||
terminal: SubprocessTerminalHandle,
|
||||
config: ResolvedConfig,
|
||||
) => LocalPtySession = (terminal, config) => new LocalPtySession(terminal, config),
|
||||
) {
|
||||
this.type = config.backendType
|
||||
}
|
||||
|
||||
async spawn(spec: TerminalBackendSpawnSpec): Promise<LocalPtySession> {
|
||||
spec.signal?.throwIfAborted()
|
||||
ensureSandboxModeFence(this.ctx, spec.owner)
|
||||
const policy = this.ctx.sandboxPolicy.resolve({ session: spec.owner.session })
|
||||
const argv = spawnArgv(this.ctx, this.config, policy)
|
||||
if (argv[0] === undefined) throw new Error('terminal-bash: sandbox returned empty argv')
|
||||
const terminal = await this.spawnTerminal({
|
||||
argv,
|
||||
cwd: spec.cwd ?? policy.workspaceRoot,
|
||||
env: childEnvironment(spec),
|
||||
rows: this.config.rows,
|
||||
cols: this.config.cols,
|
||||
graceMs: this.config.disposeGraceMs,
|
||||
signal: spec.signal,
|
||||
})
|
||||
const session = this.createSession(terminal, this.config)
|
||||
try {
|
||||
await initializeSession(session, spec.signal)
|
||||
return session
|
||||
} catch (error) {
|
||||
try {
|
||||
await session.close('PTY startup failed')
|
||||
} catch (closeError: unknown) {
|
||||
throw new TerminalBackendCleanupError(error, closeError)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the local PTY backend. */
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
validateConfig(config)
|
||||
ctx.terminals.registerBackend(new BashTerminalBackend(ctx, config))
|
||||
}
|
||||
30
packages/terminal/terminal-bash/src/invariant.ts
Normal file
30
packages/terminal/terminal-bash/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-terminal-bash`.
|
||||
* @module @deepseek-ai/dsh-terminal-bash/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-terminal-bash'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'terminal-bash-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: readiness, terminal buffers, and process-tree state are private per-session
|
||||
* implementation state, and the backend publishes no independent lifecycle stream or snapshot.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
188
packages/terminal/terminal-bash/src/sanitize.ts
Normal file
188
packages/terminal/terminal-bash/src/sanitize.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/** Streaming terminal-control sanitizer for the line-oriented first release. */
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
|
||||
/** OSC marker emitted by the controlled bash before each prompt. */
|
||||
export const PROMPT_MARKER_PREFIX = '133;D;'
|
||||
|
||||
/** Exact printable prompt emitted after the private marker. */
|
||||
export const CONTROLLED_PROMPT = 'dsh> '
|
||||
|
||||
/** One sanitized chunk plus whether it contained the owned prompt marker. */
|
||||
export interface SanitizedChunk {
|
||||
text: string
|
||||
prompt: boolean
|
||||
/** Printable text after the latest owned marker in this chunk. */
|
||||
promptTail?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove CSI/OSC/short escape sequences while preserving split-sequence carry.
|
||||
* Full terminal emulation is deliberately deferred; ordinary line output and
|
||||
* the private prompt marker are the supported contract.
|
||||
*/
|
||||
export class TerminalSanitizer {
|
||||
private pending = ''
|
||||
private discardMode: 'osc' | 'csi' | undefined
|
||||
private discardOscEscape = false
|
||||
private trailingCarriageReturn = false
|
||||
private trackingPromptTail = false
|
||||
|
||||
constructor(private readonly maxPendingBytes: number) {}
|
||||
|
||||
/**
|
||||
* Consume one decoded `node-pty` data chunk.
|
||||
* @param chunk - decoded terminal data.
|
||||
* @returns Printable text and whether the private prompt marker completed.
|
||||
*/
|
||||
push(chunk: string): SanitizedChunk {
|
||||
this.pending += this.discardPrefix(chunk)
|
||||
let text = ''
|
||||
let prompt = false
|
||||
let includePromptTail = this.trackingPromptTail
|
||||
let promptTail = ''
|
||||
let index = 0
|
||||
const appendText = (value: string): void => {
|
||||
text += value
|
||||
if (this.trackingPromptTail) promptTail += value
|
||||
}
|
||||
while (index < this.pending.length) {
|
||||
const escape = this.pending.indexOf('\x1b', index)
|
||||
if (escape < 0) {
|
||||
appendText(this.pending.slice(index))
|
||||
index = this.pending.length
|
||||
break
|
||||
}
|
||||
appendText(this.pending.slice(index, escape))
|
||||
if (escape + 1 >= this.pending.length) {
|
||||
index = escape
|
||||
break
|
||||
}
|
||||
const kind = this.pending[escape + 1]
|
||||
if (kind === ']') {
|
||||
const bel = this.pending.indexOf('\x07', escape + 2)
|
||||
const stringTerminator = this.pending.indexOf('\x1b\\', escape + 2)
|
||||
let end = -1
|
||||
if (bel >= 0 && stringTerminator >= 0) end = Math.min(bel + 1, stringTerminator + 2)
|
||||
else if (bel >= 0) end = bel + 1
|
||||
else if (stringTerminator >= 0) end = stringTerminator + 2
|
||||
if (end < 0) {
|
||||
index = escape
|
||||
break
|
||||
}
|
||||
const terminatorBytes = this.pending[end - 1] === '\x07' ? 1 : 2
|
||||
const content = this.pending.slice(escape + 2, end - terminatorBytes)
|
||||
if (content.startsWith(PROMPT_MARKER_PREFIX)) {
|
||||
prompt = true
|
||||
this.trackingPromptTail = true
|
||||
includePromptTail = true
|
||||
promptTail = ''
|
||||
}
|
||||
index = end
|
||||
continue
|
||||
}
|
||||
if (kind === '[') {
|
||||
let end = escape + 2
|
||||
while (end < this.pending.length) {
|
||||
const code = this.pending.charCodeAt(end)
|
||||
if (code >= 0x40 && code <= 0x7e) break
|
||||
end += 1
|
||||
}
|
||||
if (end >= this.pending.length) {
|
||||
index = escape
|
||||
break
|
||||
}
|
||||
index = end + 1
|
||||
continue
|
||||
}
|
||||
// Two-byte escape family (save/restore cursor and similar).
|
||||
index = escape + 2
|
||||
}
|
||||
this.pending = this.pending.slice(index)
|
||||
this.enforcePendingBound()
|
||||
return {
|
||||
text: this.normalizeText(text),
|
||||
prompt,
|
||||
...includePromptTail ? { promptTail } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush a trailing printable fragment when the PTY exits.
|
||||
* @returns Remaining printable text; incomplete escapes are discarded.
|
||||
*/
|
||||
flush(): string {
|
||||
const text = this.pending.startsWith('\x1b') ? '' : this.pending
|
||||
this.pending = ''
|
||||
this.discardMode = undefined
|
||||
this.discardOscEscape = false
|
||||
this.trackingPromptTail = false
|
||||
const normalized = this.normalizeText(text)
|
||||
if (!this.trailingCarriageReturn) return normalized
|
||||
this.trailingCarriageReturn = false
|
||||
return `${normalized}\n`
|
||||
}
|
||||
|
||||
private normalizeText(text: string): string {
|
||||
let complete = this.trailingCarriageReturn ? `\r${text}` : text
|
||||
this.trailingCarriageReturn = false
|
||||
if (complete.endsWith('\r')) {
|
||||
complete = complete.slice(0, -1)
|
||||
this.trailingCarriageReturn = true
|
||||
}
|
||||
return normalizeTerminalText(complete)
|
||||
}
|
||||
|
||||
private enforcePendingBound(): void {
|
||||
if (Buffer.byteLength(this.pending) <= this.maxPendingBytes) return
|
||||
this.discardMode = this.pending[1] === ']' ? 'osc' : 'csi'
|
||||
this.pending = ''
|
||||
}
|
||||
|
||||
private discardPrefix(chunk: string): string {
|
||||
if (this.discardMode === undefined) return chunk
|
||||
if (this.discardMode === 'csi') {
|
||||
for (let index = 0; index < chunk.length; index += 1) {
|
||||
const code = chunk.charCodeAt(index)
|
||||
if (code >= 0x40 && code <= 0x7e) {
|
||||
this.discardMode = undefined
|
||||
return chunk.slice(index + 1)
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
let index = 0
|
||||
if (this.discardOscEscape) {
|
||||
this.discardOscEscape = false
|
||||
if (chunk.startsWith('\\')) {
|
||||
this.discardMode = undefined
|
||||
return chunk.slice(1)
|
||||
}
|
||||
}
|
||||
while (index < chunk.length) {
|
||||
if (chunk[index] === '\x07') {
|
||||
this.discardMode = undefined
|
||||
return chunk.slice(index + 1)
|
||||
}
|
||||
if (chunk[index] === '\x1b') {
|
||||
if (chunk[index + 1] === '\\') {
|
||||
this.discardMode = undefined
|
||||
return chunk.slice(index + 2)
|
||||
}
|
||||
if (index + 1 === chunk.length) this.discardOscEscape = true
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize CRLF and standalone carriage returns for line-oriented rendering.
|
||||
* @param text - sanitized terminal text.
|
||||
* @returns Line-normalized text with BEL removed.
|
||||
*/
|
||||
export function normalizeTerminalText(text: string): string {
|
||||
return text.replaceAll('\r\n', '\n').replaceAll('\r', '\n').replaceAll('\x07', '')
|
||||
}
|
||||
565
packages/terminal/terminal-bash/src/session.ts
Normal file
565
packages/terminal/terminal-bash/src/session.ts
Normal file
@@ -0,0 +1,565 @@
|
||||
/** Persistent PTY session over the subprocess seam's terminal primitive. */
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
import type {
|
||||
SubprocessOutcome,
|
||||
SubprocessTerminalForeground,
|
||||
SubprocessTerminalHandle,
|
||||
} from '@deepseek-ai/dsh-subprocess'
|
||||
import { TerminalError } from '@deepseek-ai/dsh-terminal'
|
||||
import type {
|
||||
TerminalBackendSession,
|
||||
TerminalReadRequest,
|
||||
TerminalReadResult,
|
||||
TerminalSendOperation,
|
||||
TerminalSendRead,
|
||||
TerminalSendRequest,
|
||||
TerminalSendResult,
|
||||
TerminalSessionStatus,
|
||||
TerminalSignal,
|
||||
TerminalSignalResult,
|
||||
TerminalWaitReason,
|
||||
} from '@deepseek-ai/dsh-terminal'
|
||||
import type { ResolvedConfig } from './config.ts'
|
||||
import { CONTROLLED_PROMPT, TerminalSanitizer } from './sanitize.ts'
|
||||
|
||||
function utf8Tail(text: string, maxBytes: number): { text: string; truncated: boolean } {
|
||||
if (Buffer.byteLength(text) <= maxBytes) return { text, truncated: false }
|
||||
const chars = Array.from(text)
|
||||
let bytes = 0
|
||||
let start = chars.length
|
||||
while (start > 0) {
|
||||
const next = Buffer.byteLength(chars[start - 1] as string)
|
||||
if (bytes + next > maxBytes) break
|
||||
bytes += next
|
||||
start -= 1
|
||||
}
|
||||
return { text: chars.slice(start).join(''), truncated: true }
|
||||
}
|
||||
|
||||
class BoundedTextBuffer {
|
||||
private value = ''
|
||||
private dropped = false
|
||||
|
||||
constructor(
|
||||
private readonly maxBytes: number,
|
||||
private readonly maxLines?: number,
|
||||
) {}
|
||||
|
||||
append(text: string): void {
|
||||
if (text.length === 0) return
|
||||
this.value += text
|
||||
if (this.maxLines !== undefined) {
|
||||
const lines = this.value.split('\n')
|
||||
if (lines.length > this.maxLines) {
|
||||
this.value = lines.slice(lines.length - this.maxLines).join('\n')
|
||||
this.dropped = true
|
||||
}
|
||||
}
|
||||
const tail = utf8Tail(this.value, this.maxBytes)
|
||||
this.value = tail.text
|
||||
this.dropped ||= tail.truncated
|
||||
}
|
||||
|
||||
consume(): TerminalSendRead {
|
||||
const delta = this.value
|
||||
const truncated = this.dropped
|
||||
this.value = ''
|
||||
this.dropped = false
|
||||
return { delta, truncated }
|
||||
}
|
||||
|
||||
snapshot(): { text: string; truncated: boolean } {
|
||||
return { text: this.value, truncated: this.dropped }
|
||||
}
|
||||
}
|
||||
|
||||
class LocalSendOperation implements TerminalSendOperation {
|
||||
private readonly output: BoundedTextBuffer
|
||||
private readonly promise: PromiseWithResolvers<TerminalSendResult>
|
||||
private finished = false
|
||||
private cancellationRequested = false
|
||||
private initialForegroundLeftWait: boolean
|
||||
private initialForegroundPgid: number | undefined
|
||||
|
||||
constructor(
|
||||
maxBytes: number,
|
||||
readonly startedAt: number,
|
||||
private readonly onCancel: () => void,
|
||||
) {
|
||||
this.output = new BoundedTextBuffer(maxBytes)
|
||||
this.promise = Promise.withResolvers<TerminalSendResult>()
|
||||
this.initialForegroundLeftWait = true
|
||||
}
|
||||
|
||||
get done(): Promise<TerminalSendResult> {
|
||||
return this.promise.promise
|
||||
}
|
||||
|
||||
get settled(): boolean {
|
||||
return this.finished
|
||||
}
|
||||
|
||||
get cancelRequested(): boolean {
|
||||
return this.cancellationRequested
|
||||
}
|
||||
|
||||
append(text: string): void {
|
||||
if (!this.finished) this.output.append(text)
|
||||
}
|
||||
|
||||
settle(waitReason: TerminalWaitReason, sessionStatus: TerminalSessionStatus, inheritedTruncation: boolean): void {
|
||||
if (this.finished) return
|
||||
this.finished = true
|
||||
const read = this.output.snapshot()
|
||||
this.promise.resolve({
|
||||
viewport: read.text,
|
||||
waitReason,
|
||||
sessionStatus,
|
||||
truncated: read.truncated || inheritedTruncation,
|
||||
})
|
||||
}
|
||||
|
||||
fail(error: unknown): void {
|
||||
if (this.finished) return
|
||||
this.finished = true
|
||||
this.promise.reject(error)
|
||||
}
|
||||
|
||||
readOutput(): TerminalSendRead {
|
||||
return this.output.consume()
|
||||
}
|
||||
|
||||
setInitialForeground(foreground: SubprocessTerminalForeground | undefined): void {
|
||||
this.initialForegroundPgid = foreground?.processGroupId
|
||||
this.initialForegroundLeftWait = foreground?.inputWaiting !== true
|
||||
}
|
||||
|
||||
acceptsStdinWait(pgid: number, waiting: boolean): boolean {
|
||||
// The same group may still expose the wait that existed before terminal.write.
|
||||
// Observe every poll so a departure before the exact-settlement threshold
|
||||
// still makes a later return to that wait post-write evidence.
|
||||
if (pgid !== this.initialForegroundPgid) return waiting
|
||||
if (!waiting) this.initialForegroundLeftWait = true
|
||||
return waiting && this.initialForegroundLeftWait
|
||||
}
|
||||
|
||||
cancel(): boolean {
|
||||
if (this.finished) return false
|
||||
this.cancellationRequested = true
|
||||
this.onCancel()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/** Backend session wrapping one provider-owned terminal process. */
|
||||
export class LocalPtySession implements TerminalBackendSession {
|
||||
motd = ''
|
||||
readonly pid: number
|
||||
private readonly decoder = new TextDecoder()
|
||||
private readonly sanitizer: TerminalSanitizer
|
||||
private readonly scrollback: BoundedTextBuffer
|
||||
private readonly outputEnded = Promise.withResolvers<void>()
|
||||
private readonly completion: Promise<void>
|
||||
private statusValue: TerminalSessionStatus = { kind: 'running' }
|
||||
// TODO(pty-send-state-consolidation): Fold the per-send fields below
|
||||
// (active/activeTimer/activeDeadlineTimer/activeAbort/interrupting/
|
||||
// activeWrite/pollingReady/polling) into one send-lifecycle owner; the
|
||||
// cancellation/readiness interplay now has enough pinned tests to carry
|
||||
// that refactor safely.
|
||||
private active: LocalSendOperation | undefined
|
||||
private activeTimer: NodeJS.Timeout | undefined
|
||||
private activeDeadlineTimer: NodeJS.Timeout | undefined
|
||||
private activeAbort: (() => void) | undefined
|
||||
private interrupting: LocalSendOperation | undefined
|
||||
private activeWrite: Promise<boolean> | undefined
|
||||
private pollingReady: LocalSendOperation | undefined
|
||||
private polling = false
|
||||
private promptSeen = false
|
||||
private promptTextSeen = false
|
||||
private promptTail = ''
|
||||
private shellPgid: number | undefined
|
||||
private initializing = false
|
||||
private lastOutputAt = Date.now()
|
||||
private closing = false
|
||||
private closePromise: Promise<void> | undefined
|
||||
private transportFailure: Error | undefined
|
||||
|
||||
constructor(
|
||||
private readonly terminal: SubprocessTerminalHandle,
|
||||
private readonly config: ResolvedConfig,
|
||||
) {
|
||||
this.pid = terminal.pid
|
||||
this.sanitizer = new TerminalSanitizer(config.maxReadBytes)
|
||||
this.scrollback = new BoundedTextBuffer(config.scrollbackMaxBytes, config.scrollbackLines)
|
||||
terminal.output.on('data', this.onTerminalData)
|
||||
terminal.output.once('end', this.onTerminalEnd)
|
||||
terminal.output.once('error', this.onTerminalError)
|
||||
this.completion = terminal.done.then(
|
||||
outcome => this.onExit(outcome),
|
||||
(error: unknown) => { this.onTransportFailure(error) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture startup output through the same readiness contract as later sends.
|
||||
* @param signal - optional cancellation while the shell reaches its first prompt.
|
||||
* @returns Resolves after startup readiness; rejects on exit or readiness timeout.
|
||||
*/
|
||||
async initialize(signal?: AbortSignal): Promise<void> {
|
||||
this.initializing = true
|
||||
try {
|
||||
const operation = this.startSend({ text: '', submit: false, ...signal !== undefined ? { signal } : {} })
|
||||
const result = await operation.done
|
||||
if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup')
|
||||
if (result.waitReason === 'timeout') throw new Error('PTY shell did not reach readiness before startup timeout')
|
||||
this.motd = result.viewport
|
||||
} catch (error: unknown) {
|
||||
signal?.throwIfAborted()
|
||||
throw error
|
||||
} finally {
|
||||
this.initializing = false
|
||||
}
|
||||
}
|
||||
|
||||
startSend(request: TerminalSendRequest): TerminalSendOperation {
|
||||
if (this.closing) throw new Error('PTY session is closing')
|
||||
if (this.statusValue.kind === 'exited') throw new Error('PTY session has exited')
|
||||
if (this.active !== undefined) {
|
||||
const draining = this.activeWrite !== undefined
|
||||
? ' or draining provider write'
|
||||
: this.interrupting !== undefined
|
||||
? ' or draining foreground interrupt'
|
||||
: ''
|
||||
throw new TerminalError(`PTY session already has an active send${draining}`, 'SEND_ACTIVE')
|
||||
}
|
||||
if (request.signal?.aborted === true) throw new Error('PTY send aborted before write')
|
||||
|
||||
const operation = new LocalSendOperation(
|
||||
this.config.maxReadBytes,
|
||||
Date.now(),
|
||||
() => { this.interrupt(operation) },
|
||||
)
|
||||
this.active = operation
|
||||
this.resetReadinessEvidence()
|
||||
|
||||
if (request.signal !== undefined) {
|
||||
const onAbort = (): void => { operation.cancel() }
|
||||
request.signal.addEventListener('abort', onAbort, { once: true })
|
||||
this.activeAbort = () => request.signal?.removeEventListener('abort', onAbort)
|
||||
}
|
||||
this.activeDeadlineTimer = setTimeout(() => {
|
||||
if (this.active === operation) {
|
||||
this.settleActive('timeout', this.activeWrite !== undefined || this.interrupting === operation)
|
||||
}
|
||||
}, this.config.timeoutMs)
|
||||
void this.beginSend(operation, request)
|
||||
return operation
|
||||
}
|
||||
|
||||
private async beginSend(operation: LocalSendOperation, request: TerminalSendRequest): Promise<void> {
|
||||
let foreground: SubprocessTerminalForeground | undefined
|
||||
try {
|
||||
foreground = await this.terminal.inspectForeground()
|
||||
} catch (error: unknown) {
|
||||
// A pre-write inspection failure while cancellation owns the slot must not
|
||||
// release it: interruptOnce's in-flight foreground signal could land on a
|
||||
// successor's foreground group. The interrupt path's post-signal tail
|
||||
// resumes polling, whose guarded catch propagates a persistent failure.
|
||||
// A retained settled operation implies that same in-flight interrupt, so
|
||||
// this guard admits only an unsettled active send.
|
||||
if (this.active === operation && !this.closing && this.interrupting !== operation) {
|
||||
this.failActive(error)
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (this.active !== operation || this.closing || this.interrupting === operation) return
|
||||
operation.setInitialForeground(foreground)
|
||||
const input = `${request.text}${request.submit ? '\r' : ''}`
|
||||
if (input.length > 0 && !operation.cancelRequested) {
|
||||
this.resetReadinessEvidence()
|
||||
const write = this.terminal.write(input)
|
||||
this.activeWrite = write.then(() => true, () => false)
|
||||
try {
|
||||
await write
|
||||
} finally {
|
||||
this.activeWrite = undefined
|
||||
}
|
||||
}
|
||||
// Cancellation owns post-write signalling and reservation release.
|
||||
if (operation.cancelRequested) return
|
||||
if (this.active === operation && operation.settled) {
|
||||
this.clearActive()
|
||||
return
|
||||
}
|
||||
// Closing can race the awaited provider write even though static analysis sees only local assignments.
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition -- awaited provider writes can close the session.
|
||||
if (this.active === operation && !this.closing) {
|
||||
this.pollingReady = operation
|
||||
this.schedulePoll(operation)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (this.active === operation && !this.closing) {
|
||||
if (operation.settled) this.clearActive()
|
||||
else this.failActive(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resetReadinessEvidence(): void {
|
||||
this.lastOutputAt = Date.now()
|
||||
this.promptSeen = false
|
||||
this.promptTextSeen = false
|
||||
this.promptTail = ''
|
||||
}
|
||||
|
||||
read(request: TerminalReadRequest): TerminalReadResult {
|
||||
const snapshot = this.scrollback.snapshot()
|
||||
const lines = snapshot.text.split('\n')
|
||||
const totalLines = snapshot.text.length === 0 ? 0 : lines.length
|
||||
const offset = request.offset ?? 0
|
||||
const count = request.count ?? 500
|
||||
if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('PTY read offset must be a non-negative safe integer')
|
||||
if (!Number.isSafeInteger(count) || count <= 0) throw new Error('PTY read count must be a positive safe integer')
|
||||
if (offset >= totalLines) {
|
||||
return { text: '', totalLines, lineBegin: offset, lineEnd: offset, truncated: snapshot.truncated }
|
||||
}
|
||||
const end = totalLines - offset
|
||||
const start = Math.max(0, end - count)
|
||||
const requested = lines.slice(start, end).join('\n')
|
||||
const bounded = utf8Tail(requested, this.config.maxReadBytes)
|
||||
const returnedLines = bounded.text.length === 0 ? 0 : bounded.text.split('\n').length
|
||||
return {
|
||||
text: bounded.text,
|
||||
totalLines,
|
||||
lineBegin: offset,
|
||||
lineEnd: offset + returnedLines,
|
||||
truncated: snapshot.truncated || bounded.truncated,
|
||||
}
|
||||
}
|
||||
|
||||
async signal(signal: TerminalSignal): Promise<TerminalSignalResult> {
|
||||
if (this.closing) throw new Error('PTY session is closing')
|
||||
const targetPgid = await this.terminal.signalForeground(signal)
|
||||
return { delivered: true, targetPgid }
|
||||
}
|
||||
|
||||
status(): TerminalSessionStatus {
|
||||
return this.statusValue
|
||||
}
|
||||
|
||||
close(reason: string): Promise<void> {
|
||||
this.closing = true
|
||||
if (this.closePromise !== undefined) return this.closePromise
|
||||
const closing = this.closeOnce(reason).catch((error: unknown) => {
|
||||
this.closePromise = undefined
|
||||
this.failActive(error)
|
||||
throw error
|
||||
})
|
||||
this.closePromise = closing
|
||||
return closing
|
||||
}
|
||||
|
||||
private readonly onTerminalData = (chunk: Buffer | Uint8Array | string): void => {
|
||||
const bytes = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk
|
||||
this.onData(this.decoder.decode(bytes, { stream: true }))
|
||||
}
|
||||
|
||||
private readonly onTerminalEnd = (): void => {
|
||||
this.onData(this.decoder.decode())
|
||||
this.appendOutput(this.sanitizer.flush())
|
||||
this.outputEnded.resolve()
|
||||
}
|
||||
|
||||
private readonly onTerminalError = (error: Error): void => {
|
||||
this.onTransportFailure(error)
|
||||
this.outputEnded.resolve()
|
||||
}
|
||||
|
||||
private onData(data: string): void {
|
||||
const sanitized = this.sanitizer.push(data)
|
||||
this.appendOutput(sanitized.text)
|
||||
if (sanitized.prompt) {
|
||||
// TODO(pty-delayed-signal-prompt): With a reproducer, define a marker-generation boundary
|
||||
// before attributing a signal-delayed prompt to a later send.
|
||||
// Bash can print PROMPT_COMMAND before the kernel publishes its return
|
||||
// to the foreground process group. Retain the marker; polling below is
|
||||
// the authority that accepts it only after bash owns the foreground.
|
||||
this.promptSeen = true
|
||||
this.promptTail = ''
|
||||
this.lastOutputAt = Date.now()
|
||||
}
|
||||
if (this.promptSeen && sanitized.promptTail !== undefined) {
|
||||
const remaining = Math.max(0, CONTROLLED_PROMPT.length + 1 - this.promptTail.length)
|
||||
this.promptTail += sanitized.promptTail.slice(0, remaining)
|
||||
if (sanitized.promptTail.length > remaining) this.promptTail = `${CONTROLLED_PROMPT}\0`
|
||||
this.promptTextSeen = this.promptTail === CONTROLLED_PROMPT
|
||||
}
|
||||
}
|
||||
|
||||
private async onExit(outcome: SubprocessOutcome): Promise<void> {
|
||||
await this.outputEnded.promise
|
||||
if (this.transportFailure !== undefined) return
|
||||
this.statusValue = { kind: 'exited', exitCode: outcome.exitCode, signal: outcome.signal }
|
||||
this.settleActive('session_exit')
|
||||
}
|
||||
|
||||
private onTransportFailure(error: unknown): void {
|
||||
const failure = error instanceof Error ? error : new Error(String(error))
|
||||
this.transportFailure ??= failure
|
||||
this.statusValue = { kind: 'exited', exitCode: null, signal: null }
|
||||
this.failActive(failure)
|
||||
void this.terminal.terminate().catch(() => {})
|
||||
}
|
||||
|
||||
private appendOutput(text: string): void {
|
||||
if (text.length === 0) return
|
||||
this.lastOutputAt = Date.now()
|
||||
this.scrollback.append(text)
|
||||
this.active?.append(text)
|
||||
}
|
||||
|
||||
private schedulePoll(operation: LocalSendOperation, delayMs = this.config.pollIntervalMs): void {
|
||||
if (this.active !== operation || this.interrupting === operation || this.polling) return
|
||||
if (this.activeTimer !== undefined) clearTimeout(this.activeTimer)
|
||||
this.activeTimer = setTimeout(() => {
|
||||
this.activeTimer = undefined
|
||||
void this.pollReadiness(operation)
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
private async pollReadiness(operation: LocalSendOperation): Promise<void> {
|
||||
if (this.active !== operation || this.polling) return
|
||||
this.polling = true
|
||||
try {
|
||||
if (this.statusValue.kind === 'exited') {
|
||||
this.settleActive('session_exit')
|
||||
return
|
||||
}
|
||||
const foreground = await this.terminal.inspectForeground()
|
||||
if (this.active !== operation || this.closing || this.interrupting === operation) return
|
||||
const idleFor = Date.now() - this.lastOutputAt
|
||||
if (this.promptSeen && foreground !== undefined && this.shellPgid === undefined) {
|
||||
this.shellPgid = foreground.processGroupId
|
||||
}
|
||||
if (this.promptSeen && this.promptTextSeen && idleFor >= this.config.pollIntervalMs
|
||||
&& foreground?.processGroupId === this.shellPgid) {
|
||||
this.settleActive('stdin_read')
|
||||
return
|
||||
}
|
||||
const elapsed = Date.now() - operation.startedAt
|
||||
const startupHasOutput = !this.initializing || this.scrollback.snapshot().text.length > 0
|
||||
const acceptsStdinWait = startupHasOutput && foreground !== undefined
|
||||
&& operation.acceptsStdinWait(foreground.processGroupId, foreground.inputWaiting)
|
||||
if (elapsed >= this.config.exactProbeAfterMs && acceptsStdinWait) {
|
||||
this.settleActive('stdin_read')
|
||||
return
|
||||
}
|
||||
// A prompt candidate can race bash's foreground handoff, but an interactive
|
||||
// child also inherits PROMPT_COMMAND. Silence therefore remains the bound
|
||||
// on waiting for shell ownership instead of letting a child marker suppress
|
||||
// readiness until the absolute timeout.
|
||||
const handoffGrace = this.promptSeen ? this.config.handoffGraceMs : 0
|
||||
if (startupHasOutput && idleFor >= this.config.idleSilenceMs + handoffGrace) {
|
||||
this.settleActive('inferred_idle')
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (this.active === operation && !this.closing && this.interrupting !== operation) this.failActive(error)
|
||||
} finally {
|
||||
this.polling = false
|
||||
const active = this.active
|
||||
// Awaited provider inspection can clear or replace the active send despite static analysis.
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition -- awaited inspection can replace the active send.
|
||||
if (active !== undefined && this.pollingReady === active) this.schedulePoll(active)
|
||||
}
|
||||
}
|
||||
|
||||
private settleActive(waitReason: TerminalWaitReason, retainOwnership = false): void {
|
||||
const operation = this.active
|
||||
if (operation === undefined) return
|
||||
const scrollbackTruncated = this.scrollback.snapshot().truncated
|
||||
if (retainOwnership) {
|
||||
this.stopPolling()
|
||||
this.activeAbort?.()
|
||||
this.activeAbort = undefined
|
||||
} else {
|
||||
this.clearActive()
|
||||
}
|
||||
operation.settle(waitReason, this.statusValue, scrollbackTruncated)
|
||||
}
|
||||
|
||||
private stopPolling(): void {
|
||||
this.stopReadinessPolling()
|
||||
if (this.activeDeadlineTimer !== undefined) clearTimeout(this.activeDeadlineTimer)
|
||||
this.activeDeadlineTimer = undefined
|
||||
}
|
||||
|
||||
private stopReadinessPolling(): void {
|
||||
if (this.activeTimer !== undefined) clearTimeout(this.activeTimer)
|
||||
this.activeTimer = undefined
|
||||
this.pollingReady = undefined
|
||||
}
|
||||
|
||||
private clearActive(): void {
|
||||
const operation = this.active
|
||||
this.stopPolling()
|
||||
this.activeAbort?.()
|
||||
this.activeAbort = undefined
|
||||
if (this.interrupting === operation) this.interrupting = undefined
|
||||
this.pollingReady = undefined
|
||||
this.active = undefined
|
||||
}
|
||||
|
||||
private failActive(error: unknown): void {
|
||||
const operation = this.active
|
||||
if (operation === undefined) return
|
||||
this.clearActive()
|
||||
operation.fail(error)
|
||||
}
|
||||
|
||||
private interrupt(operation: LocalSendOperation): void {
|
||||
if (this.active !== operation) return
|
||||
this.interrupting = operation
|
||||
this.stopReadinessPolling()
|
||||
void this.interruptOnce(operation)
|
||||
}
|
||||
|
||||
private async interruptOnce(operation: LocalSendOperation): Promise<void> {
|
||||
try {
|
||||
const activeWrite = this.activeWrite
|
||||
if (activeWrite !== undefined && !await activeWrite) return
|
||||
await this.terminal.signalForeground('SIGINT')
|
||||
} catch (error: unknown) {
|
||||
if (this.active === operation && !this.closing) this.onTransportFailure(error)
|
||||
return
|
||||
} finally {
|
||||
if (this.interrupting === operation) this.interrupting = undefined
|
||||
}
|
||||
if (this.active === operation && operation.settled) {
|
||||
this.clearActive()
|
||||
} else if (this.active === operation && !this.closing) {
|
||||
this.pollingReady = operation
|
||||
this.schedulePoll(operation, 0)
|
||||
}
|
||||
}
|
||||
|
||||
private async closeOnce(reason: string): Promise<void> {
|
||||
// Stop readiness polling but retain the active operation: teardown settles
|
||||
// it as session_exit below, so an in-flight send is never mis-settled as
|
||||
// stdin_read/inferred_idle/timeout during the grace period.
|
||||
this.stopPolling()
|
||||
try {
|
||||
await this.terminal.terminate()
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`PTY cleanup failed (${reason})`, { cause: error })
|
||||
}
|
||||
// Quiescence is the active send's terminal outcome.
|
||||
this.settleActive('session_exit')
|
||||
await this.completion
|
||||
this.terminal.output.off('data', this.onTerminalData)
|
||||
this.terminal.output.off('end', this.onTerminalEnd)
|
||||
this.terminal.output.off('error', this.onTerminalError)
|
||||
if (this.transportFailure !== undefined) throw this.transportFailure
|
||||
}
|
||||
}
|
||||
32
packages/terminal/terminal-bash/tests/config.spec.ts
Normal file
32
packages/terminal/terminal-bash/tests/config.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Config } from '@deepseek-ai/dsh-terminal-bash/src/config.ts'
|
||||
import { validateConfig } from '@deepseek-ai/dsh-terminal-bash/src/config.ts'
|
||||
|
||||
function config(overrides: Partial<Config> = {}): Config {
|
||||
return {
|
||||
backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 40, cols: 160,
|
||||
scrollbackLines: 100, scrollbackMaxBytes: 1024, maxReadBytes: 512,
|
||||
pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 100, handoffGraceMs: 50, timeoutMs: 1000,
|
||||
disposeGraceMs: 100,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('terminal-bash config', () => {
|
||||
it('accepts resolved positive bounds', () => {
|
||||
expect(() => { validateConfig(config()) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects empty names, invalid numbers, and a read cap above retention', () => {
|
||||
expect(() => { validateConfig(config({ backendType: '' })) }).toThrow('backendType')
|
||||
expect(() => { validateConfig(config({ shellPath: '' })) }).toThrow('shellPath')
|
||||
expect(() => { validateConfig(config({ rows: 0 })) }).toThrow('rows')
|
||||
expect(() => { validateConfig(config({ rows: 1.5 })) }).toThrow('rows')
|
||||
expect(() => { validateConfig(config({ maxReadBytes: 2048 })) }).toThrow('must not exceed')
|
||||
})
|
||||
|
||||
it('rejects a handoff grace shorter than one readiness poll', () => {
|
||||
expect(() => { validateConfig(config({ handoffGraceMs: 9, pollIntervalMs: 10 })) }).toThrow('handoffGraceMs must be at least pollIntervalMs')
|
||||
expect(() => { validateConfig(config({ handoffGraceMs: 10, pollIntervalMs: 10 })) }).not.toThrow()
|
||||
})
|
||||
})
|
||||
462
packages/terminal/terminal-bash/tests/index.spec.ts
Normal file
462
packages/terminal/terminal-bash/tests/index.spec.ts
Normal file
@@ -0,0 +1,462 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { Inbox, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SandboxProvider from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import TerminalSessionService, { TerminalBackendCleanupError, TerminalSessionId } from '@deepseek-ai/dsh-terminal'
|
||||
import { BashTerminalBackend } from '@deepseek-ai/dsh-terminal-bash'
|
||||
import * as ptyLocal from '@deepseek-ai/dsh-terminal-bash'
|
||||
import type { ResolvedConfig } from '@deepseek-ai/dsh-terminal-bash/src/config.ts'
|
||||
import type { LocalPtySession } from '@deepseek-ai/dsh-terminal-bash/src/session.ts'
|
||||
import { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
|
||||
import type {
|
||||
SubprocessHandle,
|
||||
SubprocessSpawnSpec,
|
||||
SubprocessTerminalHandle,
|
||||
SubprocessTerminalSpawnSpec,
|
||||
} from '@deepseek-ai/dsh-subprocess'
|
||||
|
||||
class EmptySandbox extends SandboxProvider {
|
||||
confine(_argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv {
|
||||
return { argv: [], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
|
||||
}
|
||||
}
|
||||
|
||||
class RecordingSandbox extends SandboxProvider {
|
||||
calls: { argv: readonly string[]; policy: SandboxPolicy }[] = []
|
||||
|
||||
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
|
||||
this.calls.push({ argv, policy })
|
||||
return { argv: ['/sandbox', '--', ...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
|
||||
}
|
||||
}
|
||||
|
||||
function config(): ResolvedConfig {
|
||||
return {
|
||||
backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80,
|
||||
scrollbackLines: 10, scrollbackMaxBytes: 100, maxReadBytes: 50,
|
||||
pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, handoffGraceMs: 10, timeoutMs: 100,
|
||||
disposeGraceMs: 10,
|
||||
}
|
||||
}
|
||||
|
||||
function agent(ctx: Context, cwd?: string): Agent {
|
||||
const id = SessionId('agent')
|
||||
const session = Session.create(id, undefined, { version: 0, id, createdAt: 0, ...cwd === undefined ? {} : { cwd } })
|
||||
return {
|
||||
id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
|
||||
status: 'idle',
|
||||
ctx,
|
||||
send: () => {},
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
|
||||
runMaintenance: task => task(new AbortController().signal),
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
function terminalHandle(): SubprocessTerminalHandle {
|
||||
const output = new PassThrough()
|
||||
return {
|
||||
pid: 123,
|
||||
output,
|
||||
done: Promise.resolve({ exitCode: 0, signal: null }),
|
||||
write: async () => {},
|
||||
inspectForeground: async () => ({ processGroupId: 123, inputWaiting: true }),
|
||||
signalForeground: async () => 123,
|
||||
terminate: async () => { output.end() },
|
||||
}
|
||||
}
|
||||
|
||||
class StubSubprocessRuntime extends SubprocessRuntime {
|
||||
async resolveExecutable(command: string): Promise<string> { return command }
|
||||
spawn(_spec: SubprocessSpawnSpec): SubprocessHandle { throw new Error('unused') }
|
||||
async spawnTerminal(_spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> {
|
||||
return terminalHandle()
|
||||
}
|
||||
}
|
||||
|
||||
function spec(owner: Agent, signal?: AbortSignal) {
|
||||
return {
|
||||
sessionId: TerminalSessionId('pty-1'), owner, type: 'shell',
|
||||
...signal !== undefined ? { signal } : {},
|
||||
}
|
||||
}
|
||||
|
||||
function stubLocalSession(initialize: () => Promise<void> = () => Promise.resolve()): LocalPtySession {
|
||||
return {
|
||||
motd: '',
|
||||
initialize,
|
||||
startSend: () => { throw new Error('unused') },
|
||||
read: () => { throw new Error('unused') },
|
||||
signal: () => Promise.resolve({ delivered: true, targetPgid: 1 }),
|
||||
status: () => ({ kind: 'running' as const }),
|
||||
close: () => Promise.resolve(),
|
||||
} as unknown as LocalPtySession
|
||||
}
|
||||
|
||||
function registerStubLocalBackend(ctx: Context, createSession: () => LocalPtySession) {
|
||||
return ctx.inject(['terminals', 'sandbox', 'sandboxPolicy', 'subprocess'], (providerCtx) => {
|
||||
providerCtx.terminals.registerBackend(new BashTerminalBackend(
|
||||
providerCtx,
|
||||
{ ...config(), backendType: 'stub' },
|
||||
async () => terminalHandle(),
|
||||
createSession,
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
describe('BashTerminalBackend startup rollback', () => {
|
||||
it('rejects pre-aborted setup and empty sandbox argv', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(EmptySandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: '/tmp' })
|
||||
const backend = new BashTerminalBackend(ctx, config(), async () => terminalHandle())
|
||||
const controller = new AbortController()
|
||||
const abortReason = new Error('spawn aborted')
|
||||
controller.abort(abortReason)
|
||||
await expect(backend.spawn(spec(agent(ctx), controller.signal))).rejects.toBe(abortReason)
|
||||
await expect(backend.spawn(spec(agent(ctx)))).rejects.toThrow('empty argv')
|
||||
})
|
||||
|
||||
it('closes failed startup and aggregates cleanup failure', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
|
||||
const spawnTerminal = async (): Promise<SubprocessTerminalHandle> => terminalHandle()
|
||||
|
||||
const closed = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
|
||||
const failed = { initialize: () => Promise.reject(new Error('startup failed')), close: closed } as unknown as LocalPtySession
|
||||
const backend = new BashTerminalBackend(ctx, config(), spawnTerminal, () => failed)
|
||||
await expect(backend.spawn(spec(agent(ctx)))).rejects.toThrow('startup failed')
|
||||
expect(closed).toHaveBeenCalledWith('PTY startup failed')
|
||||
|
||||
const startupFailure = new Error('startup failed')
|
||||
const cleanupFailure = new Error('cleanup failed')
|
||||
const doublyFailed = {
|
||||
initialize: () => Promise.reject(startupFailure),
|
||||
close: () => Promise.reject(cleanupFailure),
|
||||
} as unknown as LocalPtySession
|
||||
const aggregate = new BashTerminalBackend(ctx, config(), spawnTerminal, () => doublyFailed)
|
||||
await expect(aggregate.spawn(spec(agent(ctx)))).rejects.toEqual(expect.objectContaining({
|
||||
name: 'TerminalBackendCleanupError',
|
||||
spawnError: startupFailure,
|
||||
cleanupError: cleanupFailure,
|
||||
} satisfies Partial<TerminalBackendCleanupError>))
|
||||
})
|
||||
|
||||
it('starts startup rollback when cancellation wins a stalled initialization', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
|
||||
const initialization = Promise.withResolvers<undefined>()
|
||||
const initializationStarted = Promise.withResolvers<undefined>()
|
||||
const close = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
|
||||
const session = {
|
||||
initialize: () => {
|
||||
initializationStarted.resolve(undefined)
|
||||
return initialization.promise
|
||||
},
|
||||
close,
|
||||
} as unknown as LocalPtySession
|
||||
const backend = new BashTerminalBackend(ctx, config(), async () => terminalHandle(), () => session)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancel stalled startup')
|
||||
|
||||
const spawning = backend.spawn(spec(agent(ctx), controller.signal))
|
||||
await initializationStarted.promise
|
||||
controller.abort(reason)
|
||||
|
||||
await expect(spawning).rejects.toBe(reason)
|
||||
expect(close).toHaveBeenCalledWith('PTY startup failed')
|
||||
initialization.resolve(undefined)
|
||||
})
|
||||
|
||||
it('wraps confined argv, scrubs the environment, and returns initialized sessions', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(RecordingSandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: '/workspace' })
|
||||
const terminal = terminalHandle()
|
||||
let spawned: SubprocessTerminalSpawnSpec | undefined
|
||||
const spawnTerminal = async (spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> => {
|
||||
spawned = spec
|
||||
return terminal
|
||||
}
|
||||
const initialized = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
|
||||
const session = { initialize: initialized } as unknown as LocalPtySession
|
||||
const backend = new BashTerminalBackend(
|
||||
ctx,
|
||||
{ ...config(), shellArgs: ['-i'] },
|
||||
spawnTerminal,
|
||||
() => session,
|
||||
)
|
||||
const previous = process.env.PTY_TEST_SECRET
|
||||
process.env.PTY_TEST_SECRET = 'must-not-leak'
|
||||
try {
|
||||
expect(await backend.spawn({ ...spec(agent(ctx)), cwd: '/work' })).toBe(session)
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.PTY_TEST_SECRET
|
||||
else process.env.PTY_TEST_SECRET = previous
|
||||
}
|
||||
|
||||
expect(spawned).toMatchObject({
|
||||
argv: ['/sandbox', '--', '/bin/bash', '-i'],
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/work',
|
||||
graceMs: 10,
|
||||
env: {
|
||||
TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat', PS1: 'dsh> ', BASH_SILENCE_DEPRECATION_WARNING: '1',
|
||||
DSH_SHELL: '1', DSH_SESSION_ID: 'agent', DSH_PTY_SESSION_ID: 'pty-1',
|
||||
},
|
||||
})
|
||||
expect(spawned?.env?.PTY_TEST_SECRET).toBeUndefined()
|
||||
expect(initialized).toHaveBeenCalledWith(undefined)
|
||||
expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{
|
||||
argv: ['/bin/bash', '-i'],
|
||||
policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: '/workspace' },
|
||||
}])
|
||||
})
|
||||
|
||||
it('resolves session mode and root together before wrapping the shell', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(RecordingSandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: '/deployment-fallback' })
|
||||
const terminal = terminalHandle()
|
||||
let spawned: SubprocessTerminalSpawnSpec | undefined
|
||||
const spawnTerminal = async (spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> => {
|
||||
spawned = spec
|
||||
return terminal
|
||||
}
|
||||
const initialized = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
|
||||
const session = { initialize: initialized } as unknown as LocalPtySession
|
||||
const backend = new BashTerminalBackend(
|
||||
ctx,
|
||||
{ ...config(), shellArgs: ['-i'] },
|
||||
spawnTerminal,
|
||||
() => session,
|
||||
)
|
||||
const owner = agent(ctx, '/session-workspace')
|
||||
setSandboxMode(owner.session, 'workspace-write')
|
||||
expect(await backend.spawn(spec(owner))).toBe(session)
|
||||
|
||||
expect(spawned).toMatchObject({
|
||||
argv: ['/sandbox', '--', '/bin/bash', '-i'],
|
||||
cwd: '/session-workspace',
|
||||
})
|
||||
expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{
|
||||
argv: ['/bin/bash', '-i'],
|
||||
policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: '/session-workspace' },
|
||||
}])
|
||||
})
|
||||
|
||||
it('rejects a confined spawn without a sandbox provider', async () => {
|
||||
const confinedCtx = new Context()
|
||||
await confinedCtx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: '/workspace' })
|
||||
const confined = new BashTerminalBackend(
|
||||
confinedCtx,
|
||||
config(),
|
||||
async () => { throw new Error('terminal spawn must not run') },
|
||||
() => stubLocalSession(),
|
||||
)
|
||||
await expect(confined.spawn(spec(agent(confinedCtx)))).rejects.toThrow(
|
||||
'sandbox mode "workspace-write" requires a ctx.sandbox provider in the execution world',
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards terminal allocation cancellation directly', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(EmptySandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
|
||||
|
||||
const publishedController = new AbortController()
|
||||
let publishedSignal: AbortSignal | undefined
|
||||
const published = new BashTerminalBackend(
|
||||
ctx,
|
||||
config(),
|
||||
async (spawnSpec) => {
|
||||
publishedSignal = spawnSpec.signal
|
||||
return terminalHandle()
|
||||
},
|
||||
() => stubLocalSession(),
|
||||
)
|
||||
await published.spawn(spec(agent(ctx), publishedController.signal))
|
||||
expect(publishedSignal).toBe(publishedController.signal)
|
||||
publishedController.abort(new Error('originating turn ended'))
|
||||
expect(publishedSignal?.aborted).toBe(true)
|
||||
|
||||
const pendingController = new AbortController()
|
||||
const seen = Promise.withResolvers<AbortSignal>()
|
||||
const pending = new BashTerminalBackend(
|
||||
ctx,
|
||||
config(),
|
||||
async spawnSpec => await new Promise<SubprocessTerminalHandle>((_resolve, reject) => {
|
||||
const setupSignal = spawnSpec.signal as AbortSignal
|
||||
seen.resolve(setupSignal)
|
||||
const onAbort = (): void => {
|
||||
reject(setupSignal.reason instanceof Error ? setupSignal.reason : new Error(String(setupSignal.reason)))
|
||||
}
|
||||
setupSignal.addEventListener('abort', onAbort, { once: true })
|
||||
}),
|
||||
() => stubLocalSession(),
|
||||
)
|
||||
const spawning = pending.spawn(spec(agent(ctx), pendingController.signal))
|
||||
const pendingSignal = await seen.promise
|
||||
const reason = new Error('cancel pending allocation')
|
||||
pendingController.abort(reason)
|
||||
await expect(spawning).rejects.toBe(reason)
|
||||
expect(pendingSignal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('composes the default local session around a spawned terminal', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(EmptySandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' })
|
||||
const output = new PassThrough()
|
||||
const outcome = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>()
|
||||
const terminal: SubprocessTerminalHandle = {
|
||||
pid: 123,
|
||||
output,
|
||||
done: outcome.promise,
|
||||
write: async () => {},
|
||||
inspectForeground: async () => ({ processGroupId: 123, inputWaiting: true }),
|
||||
signalForeground: async () => 123,
|
||||
async terminate() {
|
||||
output.end()
|
||||
outcome.resolve({ exitCode: null, signal: 'SIGTERM' })
|
||||
},
|
||||
}
|
||||
queueMicrotask(() => { output.write(Buffer.from('\x1b]133;D;0\x07dsh> ')) })
|
||||
const backend = new BashTerminalBackend(
|
||||
ctx,
|
||||
config(),
|
||||
async () => terminal,
|
||||
)
|
||||
const session = await backend.spawn(spec(agent(ctx)))
|
||||
expect(session.motd).toBe('dsh> ')
|
||||
await session.close('test complete')
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal-bash plugin shape', () => {
|
||||
it('keeps name, inject, and Config through Loader unwrapExports', () => {
|
||||
expect('default' in ptyLocal).toBe(false)
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(ptyLocal) as Record<string, unknown>
|
||||
expect(unwrapped.name).toBe('terminal-bash')
|
||||
expect(unwrapped.inject).toEqual(['terminals', 'sandboxPolicy', 'subprocess'])
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
})
|
||||
|
||||
it('validates config and registers the configured backend', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TerminalSessionService)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
|
||||
await ctx.plugin(StubSubprocessRuntime)
|
||||
const fiber = await ctx.plugin(ptyLocal, config())
|
||||
expect(ctx.terminals.listBackends()).toEqual(['shell'])
|
||||
await fiber.dispose()
|
||||
expect(ctx.terminals.listBackends()).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores unrelated session events and mode changes without a live owner', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TerminalSessionService)
|
||||
await ctx.plugin(EmptySandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
|
||||
await ctx.plugin(StubSubprocessRuntime)
|
||||
await ctx.plugin(ptyLocal, config())
|
||||
|
||||
const session = ctx.sessions.create(SessionId('unowned-mode'))
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1 })
|
||||
}).not.toThrow()
|
||||
expect(() => { setSandboxMode(session, 'read-only') }).not.toThrow()
|
||||
})
|
||||
|
||||
it('keeps the owner-lifetime sandbox fence after the local provider unloads', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TerminalSessionService)
|
||||
await ctx.plugin(RecordingSandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
|
||||
await ctx.plugin(StubSubprocessRuntime)
|
||||
|
||||
const session = ctx.sessions.create(SessionId('mode-owner'))
|
||||
const ownerFiber = await ctx.plugin(() => {})
|
||||
const owner: Agent = {
|
||||
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
|
||||
status: 'idle',
|
||||
ctx: ownerFiber.ctx,
|
||||
send: () => {},
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
|
||||
runMaintenance: task => task(new AbortController().signal),
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
ctx.agents.register(owner)
|
||||
const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession())
|
||||
const created = await ctx.terminals.spawn(owner, { type: 'stub' })
|
||||
|
||||
const unrelated = ctx.sessions.create(SessionId('unrelated-mode'))
|
||||
expect(() => { setSandboxMode(unrelated, 'read-only') }).not.toThrow()
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1 })
|
||||
}).not.toThrow()
|
||||
|
||||
expect(() => { setSandboxMode(session, 'danger-full-access') }).not.toThrow()
|
||||
await providerFiber.dispose()
|
||||
expect(ctx.terminals.listBackends()).toEqual([])
|
||||
expect(() => { setSandboxMode(session, 'read-only') }).toThrow(
|
||||
'cannot change sandbox mode from "danger-full-access" to "read-only" while persistent terminal sessions are open or being created; wait for creation to settle and close them first',
|
||||
)
|
||||
expect(session.events.filter(event => event.type === 'sandbox/mode')).toHaveLength(1)
|
||||
|
||||
const replacementFiber = await registerStubLocalBackend(ctx, () => stubLocalSession())
|
||||
const second = await ctx.terminals.spawn(owner, { type: 'stub' })
|
||||
await replacementFiber.dispose()
|
||||
expect(() => { setSandboxMode(session, 'read-only') }).toThrow('open or being created')
|
||||
|
||||
await ctx.terminals.kill(owner, created.sessionId)
|
||||
await ctx.terminals.kill(owner, second.sessionId)
|
||||
expect(() => { setSandboxMode(session, 'read-only') }).not.toThrow()
|
||||
expect(session.events.filter(event => event.type === 'sandbox/mode')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('also fences sandbox-mode changes across unpublished PTY creation', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TerminalSessionService)
|
||||
await ctx.plugin(RecordingSandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
|
||||
await ctx.plugin(StubSubprocessRuntime)
|
||||
|
||||
const session = ctx.sessions.create(SessionId('pending-mode-owner'))
|
||||
const ownerFiber = await ctx.plugin(() => {})
|
||||
const owner: Agent = {
|
||||
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
|
||||
status: 'idle',
|
||||
ctx: ownerFiber.ctx,
|
||||
send: () => {},
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
|
||||
runMaintenance: task => task(new AbortController().signal),
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
ctx.agents.register(owner)
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
await registerStubLocalBackend(ctx, () => stubLocalSession(() => gate.promise))
|
||||
const spawning = ctx.terminals.spawn(owner, { type: 'stub' })
|
||||
|
||||
expect(ctx.terminals.hasOwnerActivity(owner)).toBe(true)
|
||||
expect(() => { setSandboxMode(session, 'read-only') }).toThrow('open or being created')
|
||||
gate.resolve(undefined)
|
||||
const created = await spawning
|
||||
await ctx.terminals.kill(owner, created.sessionId)
|
||||
expect(ctx.terminals.hasOwnerActivity(owner)).toBe(false)
|
||||
})
|
||||
})
|
||||
249
packages/terminal/terminal-bash/tests/local.spec.ts
Normal file
249
packages/terminal/terminal-bash/tests/local.spec.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import TerminalSessionService from '@deepseek-ai/dsh-terminal'
|
||||
import type { TerminalSendOperation } from '@deepseek-ai/dsh-terminal'
|
||||
import SandboxProvider from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
|
||||
import * as ptyLocal from '@deepseek-ai/dsh-terminal-bash'
|
||||
|
||||
const roots: string[] = []
|
||||
const contexts: Context[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
class PassthroughSandbox extends SandboxProvider {
|
||||
calls: { argv: readonly string[]; policy: SandboxPolicy }[] = []
|
||||
|
||||
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
|
||||
this.calls.push({ argv, policy })
|
||||
return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
|
||||
}
|
||||
}
|
||||
|
||||
function stubAgent(ctx: Context, rawId: string): Agent {
|
||||
const id = SessionId(rawId)
|
||||
const scope = ctx.plugin(() => {})
|
||||
const session = Session.create(id)
|
||||
return {
|
||||
id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
|
||||
status: 'idle',
|
||||
ctx: scope.ctx,
|
||||
send: () => {},
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
|
||||
runMaintenance: task => task(new AbortController().signal),
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
async function harness(
|
||||
mode: 'danger-full-access' | 'workspace-write',
|
||||
timing: { idleSilenceMs?: number; handoffGraceMs?: number; timeoutMs?: number } = {},
|
||||
) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-pty-local-'))
|
||||
roots.push(root)
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TerminalSessionService)
|
||||
await ctx.plugin(PassthroughSandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: root })
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
const fiber = await ctx.plugin(ptyLocal, {
|
||||
pollIntervalMs: 10,
|
||||
exactProbeAfterMs: 20,
|
||||
idleSilenceMs: timing.idleSilenceMs ?? 250,
|
||||
handoffGraceMs: timing.handoffGraceMs ?? 250,
|
||||
timeoutMs: timing.timeoutMs ?? 2_000,
|
||||
disposeGraceMs: 500,
|
||||
scrollbackLines: 100,
|
||||
scrollbackMaxBytes: 32_768,
|
||||
maxReadBytes: 16_384,
|
||||
})
|
||||
const agent = stubAgent(ctx, `agent-${mode}`)
|
||||
ctx.agents.register(agent)
|
||||
return { ctx, root, agent, fiber, sandbox: ctx.sandbox as PassthroughSandbox }
|
||||
}
|
||||
|
||||
// TerminalSendOperation.append drops output once the operation settles, so this only
|
||||
// observes a marker the child prints while `operation` is still active. A caller
|
||||
// whose child is slow to print must raise the harness `timing` bounds too;
|
||||
// extending this deadline alone cannot recover output the operation never collected.
|
||||
async function waitForOutput(operation: TerminalSendOperation, expected: string, timeoutMs = 2_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let output = ''
|
||||
while (!output.includes(expected) && Date.now() < deadline) {
|
||||
output += operation.readOutput().delta
|
||||
if (!output.includes(expected)) await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
expect(output).toContain(expected)
|
||||
}
|
||||
|
||||
// A send the test interrupts settles when bash returns to its prompt, so the
|
||||
// kernel may publish the foreground handoff on either side of the silence
|
||||
// bound. `handoffGraceMs` widens the window that wins the exact attribution but
|
||||
// cannot remove the race on a loaded host, so these settles assert that the
|
||||
// session became usable again, not which readiness tier observed it.
|
||||
function expectReadyForNextSend(waitReason: string): void {
|
||||
expect(['stdin_read', 'inferred_idle']).toContain(waitReason)
|
||||
}
|
||||
|
||||
function processIsRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
} catch (_missingProcess) {
|
||||
return false
|
||||
}
|
||||
if (process.platform !== 'linux') return true
|
||||
try {
|
||||
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
|
||||
const state = stat.slice(stat.lastIndexOf(')') + 2).split(/\s+/, 1)[0]
|
||||
return !/^[ZXx]$/.test(state ?? '')
|
||||
} catch (_unreadableProcEntry) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
describe('terminal-bash real shell', () => {
|
||||
it('persists cwd and environment across sends, scrubs secrets, and closes', async () => {
|
||||
const previous = process.env.DSH_TEST_SECRET
|
||||
process.env.DSH_TEST_SECRET = 'must-not-leak'
|
||||
try {
|
||||
const { ctx, root, agent } = await harness('danger-full-access')
|
||||
const created = await ctx.terminals.spawn(agent, { type: 'shell', name: 'main', cwd: root })
|
||||
expect(created.motd).toContain('dsh> ')
|
||||
|
||||
const first = ctx.terminals.startSend(agent, created.sessionId, { text: 'export KEEP=ok; cd /', submit: true })
|
||||
expect((await first.done).waitReason).toBe('stdin_read')
|
||||
const second = ctx.terminals.startSend(agent, created.sessionId, { text: 'printf "cwd=%s keep=%s secret=%s\\n" "$PWD" "$KEEP" "${DSH_TEST_SECRET-unset}"', submit: true })
|
||||
expect((await second.done).viewport).toContain('cwd=/ keep=ok secret=unset')
|
||||
|
||||
expect(ctx.terminals.read(agent, created.sessionId, { offset: 0, count: 20 }).text).toContain('cwd=/ keep=ok secret=unset')
|
||||
expect(await ctx.terminals.kill(agent, created.sessionId)).toBe(true)
|
||||
expect(ctx.terminals.list(agent)).toEqual([])
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.DSH_TEST_SECRET
|
||||
else process.env.DSH_TEST_SECRET = previous
|
||||
}
|
||||
}, 10_000)
|
||||
|
||||
it('wraps the exact shell argv under confined policy and unregisters on reload', async () => {
|
||||
const { ctx, root, agent, fiber, sandbox } = await harness('workspace-write')
|
||||
const created = await ctx.terminals.spawn(agent, { type: 'shell' })
|
||||
expect(sandbox.calls).toEqual([{
|
||||
argv: ['/bin/bash', '--noprofile', '--norc', '-i'],
|
||||
policy: { mode: 'workspace-write', workspaceRoot: realpathSync.native(root), sessionId: 'agent-workspace-write' },
|
||||
}])
|
||||
await fiber.dispose()
|
||||
expect(ctx.terminals.listBackends()).toEqual([])
|
||||
expect(ctx.terminals.list(agent)).toHaveLength(1)
|
||||
await ctx.terminals.kill(agent, created.sessionId)
|
||||
}, 10_000)
|
||||
|
||||
it('signals a foreground command and kills a TERM-ignoring background descendant', async () => {
|
||||
const { ctx, agent } = await harness('danger-full-access')
|
||||
const created = await ctx.terminals.spawn(agent, { type: 'shell' })
|
||||
|
||||
const foreground = ctx.terminals.startSend(agent, created.sessionId, { text: 'sleep 60', submit: true })
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect((await ctx.terminals.signal(agent, created.sessionId, 'SIGINT')).delivered).toBe(true)
|
||||
expectReadyForNextSend((await foreground.done).waitReason)
|
||||
|
||||
const background = ctx.terminals.startSend(agent, created.sessionId, {
|
||||
text: 'sh -c \'trap "" TERM; sleep 60\' & echo CHILD=$!',
|
||||
submit: true,
|
||||
})
|
||||
const output = (await background.done).viewport
|
||||
const child = /CHILD=(\d+)/.exec(output)?.[1]
|
||||
expect(child).toBeDefined()
|
||||
const pid = Number(child)
|
||||
expect(() => process.kill(pid, 0)).not.toThrow()
|
||||
await ctx.terminals.kill(agent, created.sessionId)
|
||||
expect(() => process.kill(pid, 0)).toThrow()
|
||||
}, 10_000)
|
||||
|
||||
it('quiesces a disowned same-session descendant after the shell exits naturally', async () => {
|
||||
const { ctx, root, agent } = await harness('danger-full-access')
|
||||
const created = await ctx.terminals.spawn(agent, { type: 'shell' })
|
||||
const pidFile = join(root, 'disowned.pid')
|
||||
let pid: number | undefined
|
||||
try {
|
||||
const background = ctx.terminals.startSend(agent, created.sessionId, {
|
||||
text: `sh -c 'trap "" TERM; printf "%s" "$$" > "$1"; sleep 60' dsh "${pidFile}" & disown`,
|
||||
submit: true,
|
||||
})
|
||||
await background.done
|
||||
const pidDeadline = Date.now() + 2_000
|
||||
let childPid = 0
|
||||
while (childPid === 0 && Date.now() < pidDeadline) {
|
||||
if (existsSync(pidFile)) childPid = Number(readFileSync(pidFile, 'utf8'))
|
||||
if (childPid > 0) break
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
expect(existsSync(pidFile), ctx.terminals.read(agent, created.sessionId, { offset: 0, count: 100 }).text).toBe(true)
|
||||
expect(childPid).toBeGreaterThan(0)
|
||||
pid = childPid
|
||||
expect(() => process.kill(childPid, 0)).not.toThrow()
|
||||
await ctx.terminals.startSend(agent, created.sessionId, { text: 'exit', submit: true }).done
|
||||
const deadline = Date.now() + 2_000
|
||||
while (ctx.terminals.list(agent)[0]?.status.kind !== 'exited' && Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
expect(ctx.terminals.list(agent)[0]?.status.kind).toBe('exited')
|
||||
await ctx.terminals.kill(agent, created.sessionId)
|
||||
expect(processIsRunning(childPid)).toBe(false)
|
||||
} finally {
|
||||
if (pid !== undefined) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL')
|
||||
} catch (_alreadyReaped) {
|
||||
// Product cleanup is the expected path; this only contains a failed regression.
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 10_000)
|
||||
|
||||
it('cancels a slow-starting raw-mode foreground process with a real SIGINT', async () => {
|
||||
const { ctx, agent } = await harness('danger-full-access', {
|
||||
idleSilenceMs: 10_000,
|
||||
timeoutMs: 15_000,
|
||||
})
|
||||
const created = await ctx.terminals.spawn(agent, { type: 'shell' })
|
||||
const controller = new AbortController()
|
||||
const ready = 'RAW_READY'
|
||||
// Delay readiness beyond the shared harness's short send bound so this
|
||||
// process test owns enough slack for loaded macOS startup and shell echo.
|
||||
// The interactive shell echoes the command, so only child output may contain the readiness marker.
|
||||
const command = 'python3 -c \'import signal,sys,termios,time; signal.signal(signal.SIGINT, lambda *_: (print("SIGINT_SEEN", flush=True), sys.exit(0))); attrs=termios.tcgetattr(0); attrs[3] &= ~termios.ISIG; termios.tcsetattr(0, termios.TCSANOW, attrs); time.sleep(2.1); print("RAW_" + "READY", flush=True); time.sleep(60)\''
|
||||
expect(command).not.toContain(ready)
|
||||
const foreground = ctx.terminals.startSend(agent, created.sessionId, {
|
||||
text: command,
|
||||
submit: true,
|
||||
signal: controller.signal,
|
||||
})
|
||||
await waitForOutput(foreground, ready, 15_000)
|
||||
controller.abort()
|
||||
const result = await foreground.done
|
||||
expectReadyForNextSend(result.waitReason)
|
||||
const afterReady = 'AFTER_SIGINT'
|
||||
const afterCommand = 'printf "AFTER_%s\\n" SIGINT'
|
||||
expect(afterCommand).not.toContain(afterReady)
|
||||
const after = ctx.terminals.startSend(agent, created.sessionId, {
|
||||
text: afterCommand,
|
||||
submit: true,
|
||||
})
|
||||
await waitForOutput(after, afterReady, 15_000)
|
||||
expectReadyForNextSend((await after.done).waitReason)
|
||||
await ctx.terminals.kill(agent, created.sessionId)
|
||||
}, 35_000)
|
||||
})
|
||||
76
packages/terminal/terminal-bash/tests/sanitize.spec.ts
Normal file
76
packages/terminal/terminal-bash/tests/sanitize.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeTerminalText, TerminalSanitizer } from '@deepseek-ai/dsh-terminal-bash/src/sanitize.ts'
|
||||
|
||||
describe('TerminalSanitizer', () => {
|
||||
it('removes split CSI and owned OSC prompt markers', () => {
|
||||
const sanitizer = new TerminalSanitizer(64)
|
||||
expect(sanitizer.push('red\x1b[3')).toEqual({ text: 'red', prompt: false })
|
||||
expect(sanitizer.push('1m text\x1b[0m\r\n')).toEqual({ text: ' text\n', prompt: false })
|
||||
expect(sanitizer.push('\x1b]133;')).toEqual({ text: '', prompt: false })
|
||||
expect(sanitizer.push('D;0\x07dsh> ')).toEqual({ text: 'dsh> ', prompt: true, promptTail: 'dsh> ' })
|
||||
})
|
||||
|
||||
it('drops unrelated OSC, short escapes, BEL, and incomplete trailing escape', () => {
|
||||
const sanitizer = new TerminalSanitizer(64)
|
||||
expect(sanitizer.push('a\x1b]0;title\x1b\\b\x1b7c\x07')).toEqual({ text: 'abc', prompt: false })
|
||||
expect(sanitizer.push('tail\x1b')).toEqual({ text: 'tail', prompt: false })
|
||||
expect(sanitizer.flush()).toBe('')
|
||||
expect(sanitizer.flush()).toBe('')
|
||||
expect(sanitizer.push('\x1b]0;one\x07middle\x1b\\')).toEqual({ text: 'middle', prompt: false })
|
||||
expect(sanitizer.push('\x1b]0;one\x1b\\middle\x07')).toEqual({ text: 'middle', prompt: false })
|
||||
expect(sanitizer.push('\x1b]0;title\x1b\\')).toEqual({ text: '', prompt: false })
|
||||
})
|
||||
|
||||
it('normalizes CRLF and standalone carriage returns', () => {
|
||||
expect(normalizeTerminalText('a\r\nb\rc\x07')).toBe('a\nb\nc')
|
||||
})
|
||||
|
||||
it('carries a trailing carriage return across data chunks and flushes standalone CR', () => {
|
||||
const sanitizer = new TerminalSanitizer(64)
|
||||
expect(sanitizer.push('a\r')).toEqual({ text: 'a', prompt: false })
|
||||
expect(sanitizer.push('\nb')).toEqual({ text: '\nb', prompt: false })
|
||||
expect(sanitizer.push('\r')).toEqual({ text: '', prompt: false })
|
||||
expect(sanitizer.flush()).toBe('\n')
|
||||
})
|
||||
|
||||
it('reports printable prompt text that follows a marker in a later chunk', () => {
|
||||
const sanitizer = new TerminalSanitizer(64)
|
||||
expect(sanitizer.push('\x1b]133;D;0\x07')).toEqual({ text: '', prompt: true, promptTail: '' })
|
||||
expect(sanitizer.push('dsh> ')).toEqual({ text: 'dsh> ', prompt: false, promptTail: 'dsh> ' })
|
||||
})
|
||||
|
||||
it('bounds and discards unterminated control sequences through their terminators', () => {
|
||||
const oscBel = new TerminalSanitizer(8)
|
||||
expect(oscBel.push(`\x1b]0;${'x'.repeat(16)}`)).toEqual({ text: '', prompt: false })
|
||||
expect(oscBel.push('more\x07tail')).toEqual({ text: 'tail', prompt: false })
|
||||
|
||||
const oscSt = new TerminalSanitizer(8)
|
||||
oscSt.push(`\x1b]0;${'x'.repeat(16)}`)
|
||||
expect(oscSt.push('more\x1b')).toEqual({ text: '', prompt: false })
|
||||
expect(oscSt.push('\\tail')).toEqual({ text: 'tail', prompt: false })
|
||||
|
||||
const oscDirectSt = new TerminalSanitizer(8)
|
||||
oscDirectSt.push(`\x1b]0;${'x'.repeat(16)}`)
|
||||
expect(oscDirectSt.push('more\x1b\\tail')).toEqual({ text: 'tail', prompt: false })
|
||||
|
||||
const oscFalseSt = new TerminalSanitizer(8)
|
||||
oscFalseSt.push(`\x1b]0;${'x'.repeat(16)}`)
|
||||
oscFalseSt.push('\x1b')
|
||||
expect(oscFalseSt.push('more')).toEqual({ text: '', prompt: false })
|
||||
expect(oscFalseSt.push('\x07tail')).toEqual({ text: 'tail', prompt: false })
|
||||
|
||||
const oscNonTerminatingEscape = new TerminalSanitizer(8)
|
||||
oscNonTerminatingEscape.push(`\x1b]0;${'x'.repeat(16)}`)
|
||||
expect(oscNonTerminatingEscape.push('more\x1bxmore\x07tail')).toEqual({ text: 'tail', prompt: false })
|
||||
|
||||
const csi = new TerminalSanitizer(8)
|
||||
expect(csi.push(`\x1b[${'1'.repeat(16)}`)).toEqual({ text: '', prompt: false })
|
||||
expect(csi.push('123')).toEqual({ text: '', prompt: false })
|
||||
expect(csi.push('mtext')).toEqual({ text: 'text', prompt: false })
|
||||
|
||||
const flushed = new TerminalSanitizer(8)
|
||||
flushed.push(`\x1b]0;${'x'.repeat(16)}`)
|
||||
expect(flushed.flush()).toBe('')
|
||||
expect(flushed.push('text')).toEqual({ text: 'text', prompt: false })
|
||||
})
|
||||
})
|
||||
1295
packages/terminal/terminal-bash/tests/session.spec.ts
Normal file
1295
packages/terminal/terminal-bash/tests/session.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
42
packages/terminal/terminal-bash/tsconfig.json
Normal file
42
packages/terminal/terminal-bash/tsconfig.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../terminal"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../subprocess/subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user