diff --git a/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.i18n.yaml index 7b6a74b7e8..a7eaaa2e3a 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md -2026-07-28-portable-execution-world-consumers.md: d68fc92308079aa68a8f4e1accadde0addb0e5aa -2026-07-28-portable-execution-world-consumers.zh.md: 5341cf1643cabb1043b977e18179dc857e37e121 +2026-07-28-portable-execution-world-consumers.md: 260f38981ea0cd034f506594057483c6ccfa9617 +2026-07-28-portable-execution-world-consumers.zh.md: 0bf7b4dd1a87378b76f6a71343a8cb78766b7b3a diff --git a/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md b/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md index d68fc92308..260f38981e 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md +++ b/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md @@ -22,7 +22,7 @@ Generic consumers use that execution world: - `dsh-bash-local` continues to map Bash semantics onto ordinary `ctx.subprocess.spawn()`. - `dsh-lsp-local` reads and contains source through `ctx.fs`, resolves and launches language servers through `ctx.subprocess`, and carries provider-owned file URIs through initialization and result rendering. One provider-lifetime signal aborts filesystem and protocol work during disposal, including workspace lookup before queue ownership; its JSON-RPC, pooling, synchronization, and normalization stay unchanged. -- `dsh-pty-local` maps persistent-shell semantics onto `ctx.subprocess.spawnTerminal()`. The local `node-pty` and process-inspection implementation moves into `dsh-subprocess-local`; another subprocess provider supplies the same primitive. Prompt and silence evidence collected during asynchronous pre-write inspection is discarded when the provider write begins. Cancellation retains the send reservation while an in-flight write settles and then signals the foreground group, so late bytes or the signal cannot target a successor; a rejected write sends no signal. The absolute deadline remains armed throughout cancellation. A signal failure becomes terminal transport failure. Completion of a stale inspection resumes polling for the current send. Close rejects new public signals and delegates complete-session quiescence to the handle's awaited termination operation. +- `dsh-pty-local` maps persistent-shell semantics onto `ctx.subprocess.spawnTerminal()`. The local `node-pty` and process-inspection implementation moves into `dsh-subprocess-local`; another subprocess provider supplies the same primitive. `danger-full-access` needs no `ctx.sandbox`; a confined mode requires a same-world sandbox provider and fails before spawn when none is mounted. Prompt and silence evidence collected during asynchronous pre-write inspection is discarded when the provider write begins. Cancellation retains the send reservation while an in-flight write settles and then signals the foreground group, so late bytes or the signal cannot target a successor; a rejected write sends no signal. The absolute deadline remains armed throughout cancellation. A signal failure becomes terminal transport failure. Completion of a stale inspection resumes polling for the current send. Close rejects new public signals and delegates complete-session quiescence to the handle's awaited termination operation. - `dsh-code-runtime-subprocess` materializes a dependency-free runner through `ctx.fs` and launches it through `ctx.subprocess`, preserving the Code Runtime binding and output contract across local or remote worlds. The fixed runner is adapter-owned infrastructure below `ctx.subprocess.runtimeRoot`, so its write carries an explicit `danger-full-access` policy instead of inheriting the model-facing filesystem mode. It shares host-side worker mechanics through the non-plugin `dsh-code-runtime-worker/runtime-host` subpath instead of copying them. Preparation carries one lifecycle signal through filesystem resolution, materialization, and executable lookup so disposal can abort a stalled provider. The heap-bounded worker rejects oversized binding frames before transfer, each outer hop enforces the same bound before forwarding, and raw subprocess pipes carry newline-delimited UTF-8 JSON without a redundant base64 representation. The launcher publishes an accepted terminal frame before reaping its controller so a descendant that inherits controller pipes cannot suppress completion; the host still awaits process-group quiescence. `dsh-code-runtime-worker` remains a separate implementation. It is the smaller in-process backend and works in single-file distributions that cannot assume an installed Node executable. Remote filesystem/process compositions select `dsh-code-runtime-subprocess`; they do not need a provider-specific Code Runtime package. diff --git a/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.zh.md b/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.zh.md index 5341cf1643..0bf7b4dd1a 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.zh.md @@ -22,7 +22,7 @@ Status: implemented - `dsh-bash-local` 继续把 Bash 语义映射到普通的 `ctx.subprocess.spawn()`。 - `dsh-lsp-local` 通过 `ctx.fs` 读取源文件并验证包含关系,通过 `ctx.subprocess` 解析和启动语言服务器,并让由提供方负责的文件 URI 贯穿初始化与结果渲染。一个提供方生命周期信号会在资源释放期间中止文件系统与协议操作,包括取得队列所有权之前的工作区查找;其 JSON-RPC、池化、同步和规范化保持不变。 -- `dsh-pty-local` 把持久 shell 语义映射到 `ctx.subprocess.spawnTerminal()`。本地 `node-pty` 与进程检查实现移入 `dsh-subprocess-local`;其他进程管理提供方则提供相同原语。提供方开始写入时,系统会丢弃异步写入前检查期间收集的提示符与静默证据。取消会在在途写入结算期间保留发送预留,随后向前台进程组发送信号,因此延迟字节和该信号都无法落到后续发送;写入被拒绝时不会发送信号。绝对截止时间会在整个取消期间保持启用。信号发送失败会成为终结性传输失败。陈旧检查完成后,会针对当前发送恢复轮询。关闭操作会拒绝新的公开信号,并把完整会话的完全停稳委托给句柄上须等待的终止操作。 +- `dsh-pty-local` 把持久 shell 语义映射到 `ctx.subprocess.spawnTerminal()`。本地 `node-pty` 与进程检查实现移入 `dsh-subprocess-local`;其他进程管理提供方则提供相同原语。`danger-full-access` 不需要 `ctx.sandbox`;受限模式要求同一执行世界中存在沙箱提供方,未挂载时会在 spawn 前失败。提供方开始写入时,系统会丢弃异步写入前检查期间收集的提示符与静默证据。取消会在在途写入结算期间保留发送预留,随后向前台进程组发送信号,因此延迟字节和该信号都无法落到后续发送;写入被拒绝时不会发送信号。绝对截止时间会在整个取消期间保持启用。信号发送失败会成为终结性传输失败。陈旧检查完成后,会针对当前发送恢复轮询。关闭操作会拒绝新的公开信号,并把完整会话的完全停稳委托给句柄上须等待的终止操作。 - `dsh-code-runtime-subprocess` 通过 `ctx.fs` 物化无依赖 runner,并通过 `ctx.subprocess` 启动它,从而在本地或远程执行世界中保留代码运行时的绑定与输出契约。固定 runner 是位于 `ctx.subprocess.runtimeRoot` 下的适配器自有基础设施,因此其写入携带显式 `danger-full-access` 策略,而不继承面向模型的文件系统模式。它通过非插件子路径 `dsh-code-runtime-worker/runtime-host` 共享宿主侧 worker 机制,而不是复制这些机制。准备阶段让同一个生命周期信号贯穿文件系统解析、物化和可执行文件查找,使资源释放能够中止停滞的提供方操作。受堆上限约束的 worker 会在传输前拒绝过大的绑定帧;每个外层转发环节都会在转发前执行相同的上限检查;原始子进程管道承载以换行符分隔的 UTF-8 JSON,无需冗余的 base64 表示;launcher 会在回收 controller 前发布已接纳的终态帧,使继承 controller 管道的后代进程无法阻止完成;宿主仍会等待进程组完全停稳。 `dsh-code-runtime-worker` 仍是独立实现。它是较小的进程内后端,可用于无法假定已安装 Node 可执行文件的单文件分发。远程文件系统/进程组合选择 `dsh-code-runtime-subprocess`;它们不需要提供方专用的代码运行时包。 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index 2694664183..72944d7433 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md -2026-07-16-persistent-pty-sessions.md: f94dd57622c555e195564cc2eee9b3228841d5cc -2026-07-16-persistent-pty-sessions.zh.md: 6228571a6496e9d4cb031a2df958b80dee102fd3 +2026-07-16-persistent-pty-sessions.md: 88296a9318b2c398fead382153bd2c652dbd9e68 +2026-07-16-persistent-pty-sessions.zh.md: 40f26c67fe046ad292f05919501600bd2e146c62 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index f94dd57622..88296a9318 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -41,7 +41,7 @@ Agent-scope disposal closes registrations first, then awaits quiescent teardown A registered `shell` backend constrains how a terminal starts; it does not constrain commands typed after startup. `dsh-pty-local` therefore applies two protections before spawning: - It supplies only terminal-specific environment overrides; the mounted subprocess provider applies the shared credential-shaped-name scrub before merging them. -- It requires `ctx.sandbox` and the shared `ctx.sandboxPolicy`. At spawn, the backend resolves the owner's effective session mode over the deployment default and wraps the shell argv once; that mode and workspace root remain the process boundary for the PTY lifetime. A write that would change the effective `sandbox/mode` is rejected before commit while the owner has any open PTY or unpublished spawn, with an instruction to wait for creation to settle and close those sessions first; same-effective-mode writes remain valid. The pending reservation spans backend setup through publication, so there is no race in which a wider terminal appears after a downgrade. `danger-full-access` is the existing explicit unconfined choice rather than a PTY-specific bypass. +- It requires the shared `ctx.sandboxPolicy`. At spawn, the backend resolves the owner's effective session mode over the deployment default; `danger-full-access` starts the shell directly, while confined modes require a same-world `ctx.sandbox` provider and wrap the shell argv once. That mode and workspace root remain the process boundary for the PTY lifetime. A write that would change the effective `sandbox/mode` is rejected before commit while the owner has any open PTY or unpublished spawn, with an instruction to wait for creation to settle and close those sessions first; same-effective-mode writes remain valid. The pending reservation spans backend setup through publication, so there is no race in which a wider terminal appears after a downgrade. `danger-full-access` is the existing explicit unconfined choice rather than a PTY-specific bypass. Sandboxing confines local process effects but does not make arbitrary shell input safe: network calls and other external side effects remain governed by deployment policy. Tool descriptions state that PTY sessions are less auditable than one-shot tools and should be used only when persistence or interactive stdin is necessary. diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index 6228571a64..40f26c67fe 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -41,7 +41,7 @@ agent scope dispose 时先关闭注册,再等待全部所属 PTY 静默退出 注册的 `shell` 后端只约束终端如何启动,不约束启动后输入的命令。因此 `dsh-pty-local` 在 spawn 前应用两层保护: - 它只提供终端专用的环境覆盖;挂载的子进程提供方先清除名称形似凭据的环境变量,再合并这些覆盖。 -- 它要求 `ctx.sandbox` 和共享的 `ctx.sandboxPolicy`。后端在 spawn 时,以部署默认值为底折叠 owner 的有效 session mode,并只包装一次 shell argv;该 mode 与 workspace root 在 PTY 的整个生命周期中充当进程边界。只要 owner 有任何已打开的 PTY 或尚未发布的 spawn,任何会改变生效 `sandbox/mode` 的写入都会在提交前被拒绝,并提示先等待创建操作结算,再关闭这些会话;不会改变生效模式的写入仍然有效。这项进行中的预留从后端 setup 持续到发布完成,因此不存在降级后又出现权限更宽的终端这一竞态。`danger-full-access` 是现有的显式无约束选择,不另设 PTY 私有 bypass。 +- 它要求共享的 `ctx.sandboxPolicy`。后端在 spawn 时,以部署默认值为底折叠 owner 的有效 session mode;`danger-full-access` 会直接启动 shell,受限模式则要求同一执行世界中存在 `ctx.sandbox` 提供方,并只包装一次 shell argv。该 mode 与 workspace root 在 PTY 的整个生命周期中充当进程边界。只要 owner 有任何已打开的 PTY 或尚未发布的 spawn,任何会改变生效 `sandbox/mode` 的写入都会在提交前被拒绝,并提示先等待创建操作结算,再关闭这些会话;不会改变生效模式的写入仍然有效。这项进行中的预留从后端 setup 持续到发布完成,因此不存在降级后又出现权限更宽的终端这一竞态。`danger-full-access` 是现有的显式无约束选择,不另设 PTY 私有 bypass。 沙箱限制本地进程副作用,但不会让任意 shell 输入自动安全:网络调用和其他外部副作用仍由部署策略治理。工具描述会说明 PTY 会话比一次性工具更难审计,只应在确实需要持久状态或交互式 stdin 时使用。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b70f468960..0514cdd969 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1072,7 +1072,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:69`](../packages/plan/plan-mode/s ## `@deepseek-ai/dsh-pty-local` -Requires: `pty` · `sandbox` · `sandboxPolicy` · `subprocess` +Requires: `pty` · `sandboxPolicy` · `subprocess` ```ts config-catalog /** Public plugin configuration. */ diff --git a/packages/code-runtime/code-runtime-worker/src/runtime-host.ts b/packages/code-runtime/code-runtime-worker/src/runtime-host.ts index 474a058e9f..d2bb1082b8 100644 --- a/packages/code-runtime/code-runtime-worker/src/runtime-host.ts +++ b/packages/code-runtime/code-runtime-worker/src/runtime-host.ts @@ -223,4 +223,5 @@ export class RuntimeOutputLedger { } export { decodeWorkerJson, encodeWorkerJson, snapshotCodeJsonValue } from './worker-json.ts' +export { jsonValueBytesUpTo } from './output-json.ts' export type { WorkerJsonWire } from './worker-json.ts' diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index 0e9468cba9..fc7e50787f 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -106,6 +106,16 @@ export const Config: z = z.object({ servers: z.dict(LspLocalServerConfig).required(), }) +/** Propagate teardown failures only after every sibling has settled. */ +function throwTeardownFailures(results: readonly PromiseSettledResult[], message: string): void { + const failures: unknown[] = [] + for (const result of results) { + if (result.status === 'rejected') failures.push(result.reason) + } + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, message) +} + /** * Register the configured stdio LSP providers. Resolves every executable at load (after credential * scrubbing) before publishing any provider; each process launches lazily on its first matching @@ -169,7 +179,8 @@ export async function apply(ctx: Context, config: Config): Promise { return async () => { // Remove every route before process teardown so no new query can enter a draining provider. for (const dispose of disposers.reverse()) dispose() - await Promise.all(providers.map(provider => provider.disposeAll())) + const results = await Promise.allSettled(providers.map(provider => provider.disposeAll())) + throwTeardownFailures(results, 'lsp-local provider teardown failed') } }, 'lsp-local.registerProviders') } @@ -346,12 +357,13 @@ class LocalLspProvider implements LspProvider { const draining = [...this.queues.values()] const resolving = [...this.workspaceLookups] this.instances.clear() - await Promise.all([ + const results = await Promise.allSettled([ ...live.map(instance => instance.dispose()), ...draining, ...resolving, ]) this.queues.clear() this.workspaceLookups.clear() + throwTeardownFailures(results, 'lsp-local instance teardown failed') } } diff --git a/packages/lsp/lsp-local/tests/lifecycle.spec.ts b/packages/lsp/lsp-local/tests/lifecycle.spec.ts index 63aea8742c..7a30232e22 100644 --- a/packages/lsp/lsp-local/tests/lifecycle.spec.ts +++ b/packages/lsp/lsp-local/tests/lifecycle.spec.ts @@ -350,6 +350,78 @@ describe('lsp-local end to end over a fake server', () => { expect(signal.aborted).toBe(true) }) + it('waits for every owned teardown before aggregating instance failures', async () => { + let provider: LspProvider | undefined + const ctx = await mount({ LSP_FAKE_DEF: 'null' }, {}, (registered) => { provider = registered }) + if (provider === undefined) throw new Error('expected lsp-local to register a provider') + const internals = provider as unknown as { + readonly instances: Map }> + readonly queues: Map> + readonly workspaceLookups: Set> + disposeAll(): Promise + } + const firstFailure = new Error('first instance cleanup failed') + const secondFailure = new Error('second instance cleanup failed') + const release = Promise.withResolvers() + internals.instances.set('first', { dispose: async () => { throw firstFailure } }) + internals.instances.set('second', { dispose: async () => { throw secondFailure } }) + internals.queues.set('pending', release.promise) + internals.workspaceLookups.add(Promise.resolve()) + + let settled = false + const disposing = internals.disposeAll().finally(() => { settled = true }) + await new Promise(resolve => setImmediate(resolve)) + expect(settled).toBe(false) + release.resolve(undefined) + await expect(disposing).rejects.toMatchObject({ + errors: [firstFailure, secondFailure], + message: 'lsp-local instance teardown failed', + }) + expect(internals.instances.size).toBe(0) + expect(internals.queues.size).toBe(0) + expect(internals.workspaceLookups.size).toBe(0) + await ctx.fiber.dispose() + }) + + it('waits for every provider before reporting plugin teardown failure', async () => { + const ctx = new Context() + const disposalErrors: unknown[] = [] + ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error + await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) + await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) + const providers: LspProvider[] = [] + const register = ctx.lsp.registerProvider.bind(ctx.lsp) + const registrationSpy = vi.spyOn(ctx.lsp, 'registerProvider').mockImplementation((provider) => { + providers.push(provider) + return register(provider) + }) + const fiber = await ctx.plugin(LspLocal, { + servers: { + first: fakeServer(), + second: fakeServer({}, { extensionToLanguage: { '.js': 'javascript' } }), + }, + }) + registrationSpy.mockRestore() + expect(providers).toHaveLength(2) + const failure = new Error('provider cleanup failed') + const release = Promise.withResolvers() + const first = providers[0] as LspProvider & { disposeAll(): Promise } + const second = providers[1] as LspProvider & { disposeAll(): Promise } + first.disposeAll = async () => { throw failure } + second.disposeAll = async () => { await release.promise } + + let disposed = false + const disposing = fiber.dispose().then(() => { disposed = true }) + await new Promise(resolve => setImmediate(resolve)) + expect(disposed).toBe(false) + expect(disposalErrors).toEqual([]) + release.resolve(undefined) + await disposing + expect(disposalErrors).toEqual([failure]) + await ctx.fiber.dispose() + }) + it('runs distinct workspaces in parallel instances', async () => { const ws2 = join(root, 'ws2') await mkdir(ws2) diff --git a/packages/pty/pty-local/README.i18n.yaml b/packages/pty/pty-local/README.i18n.yaml index c2a0f9b647..2689103443 100644 --- a/packages/pty/pty-local/README.i18n.yaml +++ b/packages/pty/pty-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/pty/pty-local/README.md -README.md: c4af7bc8293689d64c58eebab2606d4f9b52fd2f -README.zh.md: 21075ccbf53d173a54220b58b384f01cfe50ced1 +README.md: 92a3d7be68ada6f38ab3c4ca5bdd2622ebefc8ee +README.zh.md: 016043f7842df8bb963cdc5eea015666c9ce6941 diff --git a/packages/pty/pty-local/README.md b/packages/pty/pty-local/README.md index c4af7bc829..92a3d7be68 100644 --- a/packages/pty/pty-local/README.md +++ b/packages/pty/pty-local/README.md @@ -6,7 +6,7 @@ Persistent shell backend for `ctx.pty` over `ctx.subprocess.spawnTerminal`. It s ## Plugin (`pty-local`) -The plugin injects `pty`, `sandbox`, `sandboxPolicy`, and `subprocess`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The effective session mode is resolved at spawn. 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. +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. The effective session mode is resolved at spawn. 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; `PtyBackendCleanupError` 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. diff --git a/packages/pty/pty-local/README.zh.md b/packages/pty/pty-local/README.zh.md index 21075ccbf5..016043f784 100644 --- a/packages/pty/pty-local/README.zh.md +++ b/packages/pty/pty-local/README.zh.md @@ -2,15 +2,15 @@ [English](README.md) | 中文 -这是基于 `ctx.subprocess.spawnTerminal` 的 `ctx.pty` 持久 shell 后端。它在共享 `ctx.sandboxPolicy` 下启动交互式 shell,保留有界的逐行输出并检测就绪状态;进程管理提供方负责 PTY 分配、环境清理、前台进程组、信号发送和完整终端会话清理。因此,同一个 PTY 后端可以与本地或远程执行世界提供方组合。 +这是一个基于 `ctx.subprocess.spawnTerminal`、为 `ctx.pty` 提供的持久 shell 后端。它在共享 `ctx.sandboxPolicy` 下启动交互式 shell,保留有界的逐行输出并检测就绪状态;进程管理提供方则负责 PTY 分配、环境清理、前台进程组、信号发送和完整终端会话清理。因此,同一个 PTY 后端可以与本地或远程执行世界提供方组合。 ## 插件(`pty-local`) -该插件注入 `pty`、`sandbox`、`sandboxPolicy` 和 `subprocess`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 会直接启动 shell;受限模式则通过 `ctx.sandbox` 包装确切的 shell argv。系统在 spawn 时解析会话的实际模式。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建结算并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。 +该插件注入 `pty`、`sandboxPolicy` 和 `subprocess`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 无需沙箱提供方即可直接启动 shell;受限模式要求同一执行世界中存在 `ctx.sandbox`,并通过它包装确切的 shell argv,未挂载时会在 spawn 前失败。系统在 spawn 时解析会话的实际模式。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建完成并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。 -就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、提供方报告的前台 stdin 等待事实、静默回退和绝对超时。只有最近一个自有标记之后的可打印尾部与受控 `PS1` 完全相等时,系统才会把标记视为就绪;即使 OSC 标记和提示符被拆到多个数据回调中也是如此。因此,如果回显的输入或输出跟在先前提示符之后,该提示符无法使当前 send 完成。系统会在写入边界丢弃提供方写入前收集的提示符与静默证据,包括写入前的前台检查尚未完成时收集的证据。如果 bash 在终端提供方发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法持续压制推断空闲就绪,最多只能延续到绝对超时。未知的前台状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝;`PtyBackendCleanupError` 会单独保留清理失败。调用方信号会转发给终端分配和就绪初始化;句柄一经发布,便负责自身生命周期。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。格式错误的 UTF-8 终端输出使用替换字符;末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。 +就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、提供方报告的前台 stdin 等待事实、静默回退和绝对超时。只有最新自有标记之后的可打印尾部与受控 `PS1` 完全相等,标记才算就绪;即使 OSC 标记和提示符被拆到多个数据回调中也一样。因此,较早提示符之后的回显输入或输出无法使当前 send 完成。提供方写入前收集的提示符与静默证据,包括写入前前台检查仍在等待时收集的证据,都会在写入边界丢弃。如果 bash 在终端提供方发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法一直抑制推断空闲就绪直至绝对超时。未知的前台状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝;`PtyBackendCleanupError` 会单独保留清理失败。调用方的 signal 会转发给终端分配与就绪初始化;发布后,句柄负责其生命周期。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。格式错误的 UTF-8 终端输出使用替换字符;末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。 -取消发送会先把排队输入标记为已取消,再请求终端句柄向当前前台进程组发送真正的 `SIGINT`;如果异步的写入前检查随后才结算,也无法执行该输入。如果提供方写入已在途,信号发送会等待写入结算;写入被拒绝时不会发送信号。被取消的发送会保留其槽位,直至写入和前台信号发送都结算,因此后续发送既不会收到延迟字节,也不会成为该信号的目标。取消等待期间,绝对截止时间仍保持启用。信号发送失败属于终结性传输失败,并会使当前发送被拒绝。取消绝不会通过写入 `\x03` 模拟中断,因此原始模式程序仍可取消。关闭操作会拒绝新的公开信号、停止就绪轮询,并等待句柄执行由提供方负责的完整会话终止,之后才将当前发送以 `session_exit` 结算。 +取消发送时,系统会先把排队输入标记为已取消,再要求终端句柄向当前前台进程组发送真正的 `SIGINT`;异步写入前检查即使随后结算,也无法执行该输入。如果提供方写入已在途,信号发送会等待其结算;写入被拒绝时不会发送信号。已取消的 send 会保留其位置,直到写入与前台信号发送都结算,因此后继 send 不会收到延迟字节或该信号。取消等待期间,绝对 deadline 仍保持启用。信号发送失败是终端传输失败,会拒绝活跃 send。取消绝不会通过写入 `\x03` 模拟中断,因此,即使程序运行在 raw 模式下,也仍可取消。关闭操作会拒绝新的公开信号、停止就绪轮询,并等待由句柄提供方负责的完整会话终止,然后才把活跃 send 结算为 `session_exit`。 ## 模型体验 @@ -22,15 +22,15 @@ #### Token 影响 -消费方返回有界的后端输出前没有影响。此包不会把保留的 PTY scrollback 放入模型历史。 +消费方返回有界的后端输出前没有影响。此包(package)不会把保留的 PTY scrollback 放入模型历史。 #### KV Cache 影响 -不会直接失效;提示词、schema 与追加结果由消费方负责。 +不会直接使 KV Cache 失效;提示词、schema 与追加结果由消费方负责。 -## 已知限制与暂缓工作 +## 已知限制与暂缓事项 - 输出按行规范化;不支持全屏备用缓冲区交互。 -- 精确 stdin 等待检测取决于挂载的进程管理提供方;无法证明该事实的提供方使用提示符标记和静默/超时就绪机制。 -- 清理保证遵循 `SubprocessTerminalHandle`;提供方特有缺口属于该实现的契约,而非此 PTY 消费方。 -- 会话无法跨 harness 进程退出保留。 +- 精确 stdin 等待检测取决于已挂载的进程管理提供方;无法证明该状态的提供方使用提示符标记和静默/超时就绪机制。 +- 清理保证以 `SubprocessTerminalHandle` 的保证为准;提供方特定的缺口属于该实现的契约,而非这个 PTY 消费方。 +- harness 进程退出后,会话无法继续存在。 diff --git a/packages/pty/pty-local/src/index.ts b/packages/pty/pty-local/src/index.ts index a8d99cb243..1fa3357002 100644 --- a/packages/pty/pty-local/src/index.ts +++ b/packages/pty/pty-local/src/index.ts @@ -22,7 +22,7 @@ export type { Config as PtyLocalConfig } from './config.ts' /** Cordis plugin name. */ export const name = 'pty-local' /** Required services: PTY registry, shared confinement policy, and process substrate. */ -export const inject = ['pty', 'sandbox', 'sandboxPolicy', 'subprocess'] +export const inject = ['pty', 'sandboxPolicy', 'subprocess'] interface SandboxModeFenceState { pty: Context['pty'] @@ -72,7 +72,11 @@ function spawnArgv(ctx: Context, config: ResolvedConfig, spec: PtyBackendSpawnSp const argv = [config.shellPath, ...config.shellArgs] const mode: SandboxMode = effectiveSandboxMode(spec.owner.session.events) ?? ctx.sandboxPolicy.defaultMode if (mode === 'danger-full-access') return argv - return ctx.sandbox.confine(argv, { + const sandbox = ctx.get('sandbox') + if (sandbox === undefined) { + throw new Error(`pty-local: sandbox mode "${mode}" requires a ctx.sandbox provider in the execution world`) + } + return sandbox.confine(argv, { mode: mode, workspaceRoot: ctx.sandboxPolicy.workspaceRoot, }).argv diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index 48c8272106..9206dd89ef 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -120,7 +120,6 @@ describe('LocalPtyBackend startup rollback', () => { it('closes failed startup and aggregates cleanup failure', async () => { const ctx = new Context() - await ctx.plugin(EmptySandbox) await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' }) const spawnTerminal = async (): Promise => terminalHandle() @@ -186,6 +185,20 @@ describe('LocalPtyBackend startup rollback', () => { expect(initialized).toHaveBeenCalledWith(undefined) }) + 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 LocalPtyBackend( + 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) @@ -266,7 +279,7 @@ describe('pty-local plugin shape', () => { const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(ptyLocal) as Record expect(unwrapped.name).toBe('pty-local') - expect(unwrapped.inject).toEqual(['pty', 'sandbox', 'sandboxPolicy', 'subprocess']) + expect(unwrapped.inject).toEqual(['pty', 'sandboxPolicy', 'subprocess']) expect(unwrapped.Config).toBeDefined() }) @@ -274,7 +287,6 @@ describe('pty-local plugin shape', () => { const ctx = new Context() await ctx.plugin(AgentRegistry) await ctx.plugin(PtyService) - await ctx.plugin(EmptySandbox) await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' }) await ctx.plugin(StubSubprocessService) const fiber = await ctx.plugin(ptyLocal, config())