From 9a12505f86c8272ceabc7ea173d5535f0f298b6b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 25 Aug 2026 12:53:48 +0800 Subject: [PATCH 01/12] fix(pty): distinguish pipeline reads from terminal input --- ...26-07-16-persistent-pty-sessions.i18n.yaml | 4 +- .../2026-07-16-persistent-pty-sessions.md | 6 +- .../2026-07-16-persistent-pty-sessions.zh.md | 6 +- .../tests/loader-composition.spec.ts | 6 ++ .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 2 +- .../subprocess/subprocess-local/README.zh.md | 2 +- .../subprocess-local/src/process-inspector.ts | 33 +++++++-- .../subprocess-local/src/terminal.ts | 2 +- .../subprocess-local/src/windows-inspector.ts | 2 +- .../tests/process-inspector.spec.ts | 70 +++++++++++++------ .../subprocess-local/tests/terminal.spec.ts | 7 +- .../tests/windows-inspector.spec.ts | 2 +- 13 files changed, 103 insertions(+), 43 deletions(-) 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 1bfc08fcc6..3633aa06be 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: 3ca35cdd59f38570be478fddf4213335665556a5 -2026-07-16-persistent-pty-sessions.zh.md: a277a97fc73b8e8a901b6524918cdf4f9997461d +2026-07-16-persistent-pty-sessions.md: 44e87390c9a6e4dc97e9466d3113fdd07cd3c500 +2026-07-16-persistent-pty-sessions.zh.md: 0aad7cc409f951c1d36cf543415c6ac1faf143ba 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 3ca35cdd59..44e87390c9 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 @@ -74,7 +74,7 @@ With `run_in_background: true`, `dsh-tool-terminal` registers the in-flight send The local backend first recognizes a private OSC prompt marker emitted by its controlled bash startup, then requires the printable tail after the latest marker to exactly equal the controlled `PS1` before declaring prompt readiness and runs three bounded fallback tiers. Carrying that tail across data callbacks covers delivery where the marker and prompt arrive separately; requiring the exact tail rejects a delayed earlier prompt once echoed input or output follows it, so it cannot settle the current send. The marker is removed before output reaches the model and avoids a fixed silence delay for ordinary shell commands on both platforms. Unpublished startup does not accept zero-output silence as readiness; timeout rejects the spawn. If caller cancellation wins during startup, the backend closes the private session and propagates the exact `AbortSignal.reason`; a foreground PGID that is not observable yet cannot replace cancellation with a lookup error. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, `handoffGraceMs`, and `timeoutMs`. -On Linux, the inspector reads the shell's terminal foreground PGID from `/proc//stat`, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct `read(0)`, a permitted read of a `select`/`pselect6` or `poll`/`ppoll` argument containing fd 0, or an epoll interest list containing fd 0. A wait already present before terminal input is not post-write readiness: the same PGID must be observed outside that wait before re-entering it, while a changed foreground PGID is new evidence. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; unsupported architectures skip Tier 1. +On Linux, the inspector reads the shell's terminal foreground PGID from `/proc//stat`, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct `read(0)`, a permitted read of a `select`/`pselect6` or `poll`/`ppoll` argument containing fd 0, or an epoll interest list containing fd 0. The waiting process's `/proc//fd/0` must also resolve to the terminal shell's fd 0 target, so a pipeline reader blocked on its pipe remains a running command rather than terminal-input readiness. A wait already present before terminal input is not post-write readiness: the same PGID must be observed outside that wait before re-entering it, while a changed foreground PGID is new evidence. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; unsupported architectures skip Tier 1. On macOS there is no exact syscall tier. Output silence returns `inferred_idle` for any foreground process group, including Python and `gdb`; `ps`-derived terminal PGID is used for signaling, not as proof that only the shell can be idle. Pure process-inspector logic is injectable and unit-tested on Linux, while a macOS CI job exercises the real PTY and process-table path. @@ -157,9 +157,9 @@ The package ships concise tool guidance explaining persistent state, owner isola ## Verification - Per-file coverage pins owner fencing, concurrent reservations, cancellation during pre-write inspection, unpublished-spawn cancellation and awaited teardown, sandbox-mode change rejection, retriable lifecycle cleanup, readiness tiers, rejection of pre-write stdin waits and delayed earlier prompts, the configured handoff grace holding the idle fallback past one poll and its rejection below `pollIntervalMs`, sanitizer carry state, complete UTF-8 bounds, task integration, schemas, and exact render intents. -- Subprocess process fixtures cover non-leader and non-main-thread stdin waits, zombie quiescence, unreadable process state, supported syscall tables, unsupported architectures, and false-positive rejection; macOS inspector logic is injected into the same unit suite. +- Subprocess process fixtures cover non-leader and non-main-thread stdin waits, rejection of fd 0 backed by a pipe, zombie quiescence, unreadable process state, supported syscall tables, unsupported architectures, and other false-positive rejection; macOS inspector logic is injected into the same unit suite. - Real `node-pty` and PTY-consumer tests jointly exercise shell state, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT`, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts. -- A Loader-driven `cordis.yml` test mounts the real three-package composition. ACP and headless snapshots pin the six schemas, bounded results, and errors through opt-in overlays; TUI snapshots pin terminal and generic card presentation. +- A Loader-driven `cordis.yml` test mounts the real three-package composition and verifies that delayed pipeline output returns with the completed command instead of being classified as terminal-input readiness. ACP and headless snapshots pin the six schemas, bounded results, and errors through opt-in overlays; TUI snapshots pin terminal and generic card presentation. - Package contracts, the architecture map, subsystem pages, generated catalogs, and the website API describe the same shipped surface. ## Consequences 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 a277a97fc7..0aad7cc409 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 @@ -74,7 +74,7 @@ UI 渲染约定精确且不携带位置信息。`terminal_send` 只为前台发 本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker,并且只有在最近一个 marker 后的可打印尾部与受控 `PS1` 完全相等时才声明 prompt 就绪;除此之外,它还运行 3 个有界 fallback 层级。在 data callback 之间保留该尾部,可以适配 marker 与 prompt 被分开交付的情况;如果回显的输入或输出跟在延迟到达的先前 prompt 之后,要求尾部完全相等会拒绝该 prompt,使其无法完成当前 send。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。尚未发布的 startup 不会把零输出静默视为就绪;timeout 会拒绝 spawn。若调用方取消在 startup 期间胜出,后端会关闭私有会话并原样抛出 `AbortSignal.reason`;尚不可观察的前台 PGID 不会再用查找错误覆盖取消原因。所有时间参数都是经校验的配置字段:`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs`、`handoffGraceMs` 和 `timeoutMs`。 -在 Linux 上,检查器从 `/proc//stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6` 或 `poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。终端输入前就已存在的等待并不代表写入后就绪:必须先观察到同一 PGID 脱离该等待,之后再次进入等待才能使该次 send 完成;前台 PGID 发生变化则构成新的证据。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。架构表只包含对应 Linux UAPI 定义的 syscall number;不支持的架构跳过 Tier 1。 +在 Linux 上,检查器从 `/proc//stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6` 或 `poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。等待进程的 `/proc//fd/0` 还必须与终端 shell 的 fd 0 解析到同一目标,因此阻塞于管道的流水线读取端仍属于正在运行的命令,不构成终端输入就绪。终端输入前就已存在的等待并不代表写入后就绪:必须先观察到同一 PGID 脱离该等待,之后再次进入等待才能使该次 send 完成;前台 PGID 发生变化则构成新的证据。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。架构表只包含对应 Linux UAPI 定义的 syscall number;不支持的架构跳过 Tier 1。 macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 `inferred_idle`,包括 Python 和 `gdb`;从 `ps` 推导的终端 PGID 只用于发送信号,不作为「只有 shell 才能 idle」的证明。纯进程检查逻辑可注入,并在 Linux 上经过单元测试,同时由 macOS CI job 驱动真实 PTY 和进程表路径。 @@ -157,9 +157,9 @@ plugins: ## 验证 - 逐文件覆盖测试锁定了 owner 隔离、并发预留、写入前检查期间的取消、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、对写入前 stdin 等待与延迟到达的先前 prompt 的拒绝、配置化交接宽限把 idle fallback 顶过一次轮询以及低于 `pollIntervalMs` 时的拒绝、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。 -- 子进程 fixture(测试前置数据)覆盖非 leader 与非主线程的 stdin 等待、僵尸进程完全停稳、不可读进程状态、受支持的 syscall 表、不支持的架构和误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。 +- 子进程 fixture(测试前置数据)覆盖非 leader 与非主线程的 stdin 等待、拒绝把指向管道的 fd 0 当作终端输入、僵尸进程完全停稳、不可读进程状态、受支持的 syscall 表、不支持的架构和其他误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。 - 真实 `node-pty` 与 PTY 消费方测试共同在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、raw mode 前台 `SIGINT`、忽略 `SIGTERM` 的后代进程,以及 dispose 返回后立即完全停稳。 -- Loader 驱动的 `cordis.yml` 测试挂载真实三包组合。ACP 与 headless 快照通过 opt-in overlay 固定 6 个 schema、有界结果和错误;TUI 快照固定 terminal 与 generic 卡片展示。 +- Loader 驱动的 `cordis.yml` 测试挂载真实三包组合,并验证延迟到达的流水线输出随已完成命令返回,而不会被归类为终端输入就绪。ACP 与 headless 快照通过 opt-in overlay 固定 6 个 schema、有界结果和错误;TUI 快照固定 terminal 与 generic 卡片展示。 - 包约定、架构图、子系统页面、生成目录和 website API 描述同一个已发布接口。 ## 后果 diff --git a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts index 6d6affdb44..6909d9eea8 100644 --- a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts @@ -152,6 +152,12 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => { )) expect(heredoc).toBe('alpha\nbeta') + const pipeline = text(await execute( + 'pipeline', + '{ sleep 0.1; printf "delayed\\n"; } | cat', + )) + expect(pipeline).toBe('delayed') + const large = text(await execute('large-output', 'seq 1 12050')) expect(large.startsWith('1\n2\n3\n')).toBe(true) expect(large).toContain('') diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 2a77e8192c..229008e146 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-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/subprocess/subprocess-local/README.md -README.md: e77aa36d8e6dc4ac999261f3a7d0c21a81ed08ff -README.zh.md: 25f7fed19898c81ececb5308b67e8d8a1140e3af +README.md: 26b23f1e0b9a7efee9a2f20f259c69a832e64d53 +README.zh.md: 797eda99fc131eff93c2eaaf87603588a01b3067 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index e77aa36d8e..26b23f1e0b 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -11,7 +11,7 @@ Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA - **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). - **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. - **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative paths containing separators are rejected at the seam, and relative PATH entries resolve from the host process cwd. -- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes start identities with zero-time process-handle waits for liveness, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's termination because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. +- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Linux reports an exact input wait only when the waiting process's fd 0 resolves to the terminal shell's fd 0 target, so a pipeline reader blocked on `pipe:[…]` cannot publish terminal readiness. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes start identities with zero-time process-handle waits for liveness, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's termination because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. - **Terminate-and-join disposal** — the service retains live handles so its own disposal can escalate every running tree and await its exit; quiescent and spawn-failed handles leave the live set after whole-tree or terminal-session cleanup finishes. - **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener force-terminates every ordinary tree and observable terminal session still in the same live sets. The local-only operations send POSIX SIGKILL to the managed group, run Windows `taskkill /T /F`, and synchronously signal captured/current terminal identities around the PTY root kill; they create no promise or timer, preserve the host's exit code and diagnostic, contain each target's failure, and do not claim quiescence. Normal disposal keeps the awaited graceful path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md). diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index 25f7fed198..797eda99fc 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -11,7 +11,7 @@ - **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md)。 - **基于偏移量的读取**:收集模式的读取器按完整流的字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 - **可执行文件查找**:`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该 seam 处被拒绝,相对 PATH 条目从宿主进程 cwd 解析。 -- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 启动身份与进程句柄零时等待结合起来判断存活状态,并把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组)。拆卸会验证 shell 已终止,因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 +- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。Linux 只有在等待进程的 fd 0 与终端 shell 的 fd 0 解析到同一目标时才报告精确输入等待,因此阻塞于 `pipe:[…]` 的流水线读取端无法发布终端就绪状态。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 启动身份与进程句柄零时等待结合起来判断存活状态,并把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组)。拆卸会验证 shell 已终止,因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 - **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,使自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;完全停稳与 spawn 失败的句柄会在整棵进程树或 terminal session 清理完成后离开存活集合。 - **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会强制终止同一组存活集合中仍存在的每棵普通进程树和可观察 terminal session。这些仅供本地实现使用的操作会向受管 POSIX 进程组发送 SIGKILL、在 Windows 运行 `taskkill /T /F`,并在终止 PTY root 前后同步向已捕获及当前可观察的 terminal 身份发送信号;它们不会创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待温和路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md)。 diff --git a/packages/subprocess/subprocess-local/src/process-inspector.ts b/packages/subprocess/subprocess-local/src/process-inspector.ts index 89effc0082..05b775e5ef 100644 --- a/packages/subprocess/subprocess-local/src/process-inspector.ts +++ b/packages/subprocess/subprocess-local/src/process-inspector.ts @@ -1,6 +1,6 @@ /** Platform process-table inspection for terminal readiness, signals, and teardown. */ -import { closeSync, openSync, readFileSync, readdirSync, readSync } from 'node:fs' +import { closeSync, openSync, readFileSync, readdirSync, readlinkSync, readSync } from 'node:fs' import { execFileSync } from 'node:child_process' import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess' import { createWindowsProcessInspector } from './windows-inspector.ts' @@ -14,7 +14,14 @@ export interface ProcessIdentity { /** Injectable OS process operations used by one local PTY session. */ export interface ProcessInspector { foregroundPgid(shellPid: number): number | undefined - isStdinWaiting(pgid: number): boolean + /** + * Report whether the foreground group waits on the terminal shell's stdin. + * + * @param pgid Foreground process-group identifier. + * @param shellPid Persistent terminal shell process identifier. + * @returns Whether a group member is blocked reading the shell's terminal input. + */ + isStdinWaiting(pgid: number, shellPid: number): boolean /** Return the root and its current transitive descendants, children first. */ processTree(rootPid: number): ProcessIdentity[] /** Return current members of one POSIX process session when the platform exposes them. */ @@ -29,6 +36,7 @@ export interface ProcessInspector { export interface ProcessInspectorInternals { readFile(path: string): string readDir(path: string): string[] + readLink(path: string): string open(path: string): number read(fd: number, buffer: Buffer, length: number, position: number): number close(fd: number): void @@ -40,6 +48,7 @@ export interface ProcessInspectorInternals { const DEFAULT_INTERNALS: ProcessInspectorInternals = { readFile: path => readFileSync(path, 'utf8'), readDir: path => readdirSync(path), + readLink: path => readlinkSync(path, 'utf8'), open: path => openSync(path, 'r'), read: (fd, buffer, length, position) => readSync(fd, buffer, 0, length, position), close: closeSync, @@ -88,6 +97,14 @@ function readLinuxStat(internals: ProcessInspectorInternals, pid: number): ProcS } } +function readLinuxStdinTarget(internals: ProcessInspectorInternals, pid: number): string | undefined { + try { + return internals.readLink(`/proc/${pid}/fd/0`) + } catch (_unreadableStdinTarget) { + return undefined + } +} + /** * Report whether a Linux process group has an executing member. `false` * means the group contains only zombie/dead entries; `undefined` means the @@ -231,7 +248,7 @@ abstract class PosixProcessInspector implements ProcessInspector { constructor(protected readonly internals: ProcessInspectorInternals) {} abstract foregroundPgid(shellPid: number): number | undefined - abstract isStdinWaiting(pgid: number): boolean + abstract isStdinWaiting(pgid: number, shellPid: number): boolean abstract processTree(rootPid: number): ProcessIdentity[] abstract processSession(sessionId: number): ProcessIdentity[] abstract isAlive(identity: ProcessIdentity): boolean @@ -284,14 +301,18 @@ class LinuxProcessInspector extends PosixProcessInspector { return tpgid !== undefined && tpgid > 0 ? tpgid : undefined } - isStdinWaiting(pgid: number): boolean { + isStdinWaiting(pgid: number, shellPid: number): boolean { const table = SYSCALLS[this.arch] if (table === undefined) return false + const terminalStdinTarget = readLinuxStdinTarget(this.internals, shellPid) + if (terminalStdinTarget === undefined) return false for (const pid of numericEntries(this.internals, '/proc')) { if (readLinuxStat(this.internals, pid)?.pgrp !== pgid) continue for (const tid of numericEntries(this.internals, `/proc/${pid}/task`)) { const syscall = readSyscall(this.internals, pid, tid) - if (syscall !== undefined && syscallWaitsOnStdin(this.internals, pid, syscall, table)) return true + if (syscall !== undefined + && syscallWaitsOnStdin(this.internals, pid, syscall, table) + && readLinuxStdinTarget(this.internals, pid) === terminalStdinTarget) return true } } return false @@ -339,7 +360,7 @@ class MacProcessInspector extends PosixProcessInspector { } } - isStdinWaiting(_pgid: number): boolean { + isStdinWaiting(_pgid: number, _shellPid: number): boolean { return false } diff --git a/packages/subprocess/subprocess-local/src/terminal.ts b/packages/subprocess/subprocess-local/src/terminal.ts index 0a51287d24..80782e24e7 100644 --- a/packages/subprocess/subprocess-local/src/terminal.ts +++ b/packages/subprocess/subprocess-local/src/terminal.ts @@ -88,7 +88,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { if (processGroupId === undefined) return undefined return { processGroupId, - inputWaiting: this.inspector.isStdinWaiting(processGroupId), + inputWaiting: this.inspector.isStdinWaiting(processGroupId, this.pid), } } diff --git a/packages/subprocess/subprocess-local/src/windows-inspector.ts b/packages/subprocess/subprocess-local/src/windows-inspector.ts index 7280cbb9ee..6889a3e65d 100644 --- a/packages/subprocess/subprocess-local/src/windows-inspector.ts +++ b/packages/subprocess/subprocess-local/src/windows-inspector.ts @@ -93,7 +93,7 @@ export class WindowsProcessInspector implements ProcessInspector { return shellPid } - isStdinWaiting(_pgid: number): boolean { + isStdinWaiting(_pgid: number, _shellPid: number): boolean { return false } diff --git a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts index aadf2e1388..5115db1233 100644 --- a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts @@ -23,6 +23,7 @@ function syscall(number: number, ...args: number[]): string { function fakeInternals() { const files = new Map() const dirs = new Map() + const links = new Map() const memories = new Map() const fds = new Map() const kills: Array<[number, NodeJS.Signals]> = [] @@ -40,6 +41,11 @@ function fakeInternals() { if (value === undefined) throw new Error(`missing ${path}`) return value }, + readLink(path) { + const value = links.get(path) + if (value === undefined) throw new Error(`missing ${path}`) + return value + }, open(path) { if (!memories.has(path)) throw new Error(`missing ${path}`) const fd = nextFd++ @@ -61,7 +67,7 @@ function fakeInternals() { kill(pid, signal) { kills.push([pid, signal]) }, } return { - internals, files, dirs, memories, kills, + internals, files, dirs, links, memories, kills, setPs(value: string) { ps = value }, setTpgid(value: string) { tpgid = value }, } @@ -132,29 +138,48 @@ describe('Linux process inspector', () => { fake.files.set('/proc/101/stat', stat(101, 77, 100, 77, '2')) fake.dirs.set('/proc/100/task', ['100']) fake.dirs.set('/proc/101/task', ['101', '102']) + fake.links.set('/proc/100/fd/0', '/dev/pts/1') + fake.links.set('/proc/101/fd/0', '/dev/pts/1') const inspector = createProcessInspector('linux', 'x64', fake.internals) fake.files.set('/proc/100/task/100/syscall', 'running') fake.files.set('/proc/101/task/101/syscall', '-1 0x0') fake.files.set('/proc/101/task/102/syscall', syscall(0, 0)) - expect(inspector.isStdinWaiting(77)).toBe(true) + expect(inspector.isStdinWaiting(77, 100)).toBe(true) fake.files.set('/proc/101/task/102/syscall', syscall(270, 1, 0x10)) const fdSet = Buffer.alloc(0x11) fdSet[0x10] = 1 fake.memories.set('/proc/101/mem', fdSet) - expect(inspector.isStdinWaiting(77)).toBe(true) + expect(inspector.isStdinWaiting(77, 100)).toBe(true) const poll = Buffer.alloc(8) poll.writeInt32LE(0, 0) poll.writeInt16LE(1, 4) fake.files.set('/proc/101/task/102/syscall', syscall(7, 0x20, 1)) fake.memories.set('/proc/101/mem', Buffer.concat([Buffer.alloc(0x20), poll])) - expect(inspector.isStdinWaiting(77)).toBe(true) + expect(inspector.isStdinWaiting(77, 100)).toBe(true) fake.files.set('/proc/101/task/102/syscall', syscall(232, 5, 0, 1)) fake.files.set('/proc/101/fdinfo/5', 'pos: 0\ntfd: 0 events: 19\n') - expect(inspector.isStdinWaiting(77)).toBe(true) + expect(inspector.isStdinWaiting(77, 100)).toBe(true) + }) + + it('rejects pipeline reads whose fd 0 is not the terminal input', () => { + const fake = fakeInternals() + fake.dirs.set('/proc', ['100']) + fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1')) + fake.dirs.set('/proc/100/task', ['100']) + fake.files.set('/proc/100/task/100/syscall', syscall(0, 0)) + fake.links.set('/proc/99/fd/0', '/dev/pts/1') + fake.links.set('/proc/100/fd/0', 'pipe:[123]') + const inspector = createProcessInspector('linux', 'x64', fake.internals) + + expect(inspector.isStdinWaiting(77, 99)).toBe(false) + fake.links.delete('/proc/100/fd/0') + expect(inspector.isStdinWaiting(77, 99)).toBe(false) + fake.links.set('/proc/100/fd/0', '/dev/pts/1') + expect(inspector.isStdinWaiting(77, 99)).toBe(true) }) it('fails closed on unsupported, malformed, unreadable, or non-stdin waits', () => { @@ -162,30 +187,31 @@ describe('Linux process inspector', () => { fake.dirs.set('/proc', ['100']) fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1')) fake.dirs.set('/proc/100/task', ['100']) + fake.links.set('/proc/100/fd/0', '/dev/pts/1') fake.files.set('/proc/100/task/100/syscall', syscall(0, 2)) - expect(createProcessInspector('linux', 'mips', fake.internals).isStdinWaiting(77)).toBe(false) - expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false) + expect(createProcessInspector('linux', 'mips', fake.internals).isStdinWaiting(77, 100)).toBe(false) + expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false) fake.files.set('/proc/100/task/100/syscall', syscall(270, 1, 0)) - expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false) + expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false) fake.files.set('/proc/100/task/100/syscall', syscall(7, 0, 0)) - expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false) + expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false) fake.files.set('/proc/100/task/100/syscall', syscall(7, 0, 1)) - expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false) + expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false) fake.files.set('/proc/100/task/100/syscall', syscall(7, 0x20, 1)) - expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false) + expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false) fake.files.set('/proc/100/task/100/syscall', syscall(232, 9, 0, 1)) - expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false) + expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false) fake.files.set('/proc/100/task/100/syscall', syscall(999)) - expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false) + expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false) fake.files.set('/proc/100/task/100/syscall', 'not-a-number 0x0') - expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false) + expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false) fake.dirs.delete('/proc/100/task') - expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false) + expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false) fake.dirs.set('/proc', ['100', '200']) fake.files.set('/proc/200/stat', stat(200, 88, 200, 88, '2')) - expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false) + expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false) }) it('contains unreadable syscall, memory, and fdinfo boundaries', () => { @@ -194,19 +220,21 @@ describe('Linux process inspector', () => { fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1')) fake.dirs.set('/proc/100/task', ['100']) const inspector = createProcessInspector('linux', 'x64', fake.internals) - expect(inspector.isStdinWaiting(77)).toBe(false) + expect(inspector.isStdinWaiting(77, 100)).toBe(false) + fake.links.set('/proc/100/fd/0', '/dev/pts/1') + expect(inspector.isStdinWaiting(77, 100)).toBe(false) fake.files.set('/proc/100/task/100/syscall', syscall(270, 1, 0x10)) - expect(inspector.isStdinWaiting(77)).toBe(false) + expect(inspector.isStdinWaiting(77, 100)).toBe(false) fake.files.set('/proc/100/task/100/syscall', syscall(232, 5, 0, 1)) - expect(inspector.isStdinWaiting(77)).toBe(false) + expect(inspector.isStdinWaiting(77, 100)).toBe(false) const noStdinPoll = Buffer.alloc(0x28) noStdinPoll.writeInt32LE(2, 0x20) noStdinPoll.writeInt16LE(1, 0x24) fake.memories.set('/proc/100/mem', noStdinPoll) fake.files.set('/proc/100/task/100/syscall', syscall(7, 0x20, 1)) - expect(inspector.isStdinWaiting(77)).toBe(false) + expect(inspector.isStdinWaiting(77, 100)).toBe(false) }) }) @@ -217,7 +245,7 @@ describe('macOS process inspector', () => { fake.setPs(' 10 1 Mon Jul 21 10:00:00 2026\n 11 10 Mon Jul 21 10:00:01 2026\n 12 11 Mon Jul 21 10:00:02 2026\n 13 99 Mon Jul 21 10:00:03 2026\nmalformed\n') const inspector = createProcessInspector('darwin', 'arm64', fake.internals) expect(inspector.foregroundPgid(10)).toBe(55) - expect(inspector.isStdinWaiting(55)).toBe(false) + expect(inspector.isStdinWaiting(55, 10)).toBe(false) expect(inspector.processTree(10)).toEqual([ { pid: 12, started: 'Mon Jul 21 10:00:02 2026' }, { pid: 11, started: 'Mon Jul 21 10:00:01 2026' }, diff --git a/packages/subprocess/subprocess-local/tests/terminal.spec.ts b/packages/subprocess/subprocess-local/tests/terminal.spec.ts index c2aca88085..330660eda3 100644 --- a/packages/subprocess/subprocess-local/tests/terminal.spec.ts +++ b/packages/subprocess/subprocess-local/tests/terminal.spec.ts @@ -59,12 +59,16 @@ class FakeInspector implements ProcessInspector { readonly alive = new Set() readonly groups: Array<[number, SubprocessTerminalSignal]> = [] readonly processes: Array<[number, 'SIGTERM' | 'SIGKILL']> = [] + readonly stdinChecks: Array<[number, number]> = [] throwGroup = false throwProcess = false removeOnSignal = true foregroundPgid() { return this.pgid } - isStdinWaiting() { return this.waiting } + isStdinWaiting(pgid: number, shellPid: number) { + this.stdinChecks.push([pgid, shellPid]) + return this.waiting + } processTree() { return this.root === undefined ? this.members : [this.root, ...this.members] } processSession() { return this.sessionMembers } isAlive(identity: ProcessIdentity) { return this.alive.has(identity.pid) } @@ -182,6 +186,7 @@ describe('LocalTerminalHandle', () => { await handle.write('input\r') expect(pty.writes).toEqual(['input\r']) expect(await handle.inspectForeground()).toEqual({ processGroupId: 456, inputWaiting: true }) + expect(inspector.stdinChecks).toEqual([[456, 123]]) expect(await handle.signalForeground('SIGINT')).toBe(456) expect(inspector.groups).toEqual([[456, 'SIGINT']]) diff --git a/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts index e00bdeb9e2..5950fd9328 100644 --- a/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts @@ -65,7 +65,7 @@ describe('WindowsProcessInspector (injected internals)', () => { const fake = fakeInternals() const inspector = new WindowsProcessInspector(fake.internals) expect(inspector.foregroundPgid(77)).toBe(77) - expect(inspector.isStdinWaiting(77)).toBe(false) + expect(inspector.isStdinWaiting(77, 10)).toBe(false) expect(inspector.processSession(77)).toEqual([]) }) From 5467685bc1307b305962f5aa4086dbf160a5ae48 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 25 Aug 2026 13:35:53 +0800 Subject: [PATCH 02/12] fix(pty): identify waiting thread terminals --- ...26-07-16-persistent-pty-sessions.i18n.yaml | 4 +- .../2026-07-16-persistent-pty-sessions.md | 8 +-- .../2026-07-16-persistent-pty-sessions.zh.md | 8 +-- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 2 +- .../subprocess/subprocess-local/README.zh.md | 2 +- .../subprocess-local/src/process-inspector.ts | 62 +++++++++++++---- .../tests/process-inspector.spec.ts | 57 +++++++++++++--- .../terminal-bash/tests/local.spec.ts | 21 ++++++ .../notifications.expected.jsonl | 68 +++++++++++-------- snapshots/sdk/persistent-tools/session.jsonl | 64 +++++++++-------- 11 files changed, 206 insertions(+), 94 deletions(-) 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 3633aa06be..663a33d56d 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: 44e87390c9a6e4dc97e9466d3113fdd07cd3c500 -2026-07-16-persistent-pty-sessions.zh.md: 0aad7cc409f951c1d36cf543415c6ac1faf143ba +2026-07-16-persistent-pty-sessions.md: b49301aaef6593730c67c3a727e1e82b3ec25379 +2026-07-16-persistent-pty-sessions.zh.md: d98400255a8282b1fa7f529caf85ee03a0fb451a 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 44e87390c9..b49301aaef 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 @@ -74,7 +74,7 @@ With `run_in_background: true`, `dsh-tool-terminal` registers the in-flight send The local backend first recognizes a private OSC prompt marker emitted by its controlled bash startup, then requires the printable tail after the latest marker to exactly equal the controlled `PS1` before declaring prompt readiness and runs three bounded fallback tiers. Carrying that tail across data callbacks covers delivery where the marker and prompt arrive separately; requiring the exact tail rejects a delayed earlier prompt once echoed input or output follows it, so it cannot settle the current send. The marker is removed before output reaches the model and avoids a fixed silence delay for ordinary shell commands on both platforms. Unpublished startup does not accept zero-output silence as readiness; timeout rejects the spawn. If caller cancellation wins during startup, the backend closes the private session and propagates the exact `AbortSignal.reason`; a foreground PGID that is not observable yet cannot replace cancellation with a lookup error. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, `handoffGraceMs`, and `timeoutMs`. -On Linux, the inspector reads the shell's terminal foreground PGID from `/proc//stat`, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct `read(0)`, a permitted read of a `select`/`pselect6` or `poll`/`ppoll` argument containing fd 0, or an epoll interest list containing fd 0. The waiting process's `/proc//fd/0` must also resolve to the terminal shell's fd 0 target, so a pipeline reader blocked on its pipe remains a running command rather than terminal-input readiness. A wait already present before terminal input is not post-write readiness: the same PGID must be observed outside that wait before re-entering it, while a changed foreground PGID is new evidence. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; unsupported architectures skip Tier 1. +On Linux, the inspector reads the shell's terminal foreground PGID from `/proc//stat`, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct `read(0)`, a permitted read of a `select`/`pselect6` or `poll`/`ppoll` argument containing fd 0, or an epoll interest list containing fd 0. The waiting thread's `/proc//task//fd/0` must identify the shell's controlling terminal device, so a thread-local fd table cannot substitute its leader's terminal descriptor and a pipeline reader blocked on its pipe remains a running command. Direct PTY descriptors use their device number; `/dev/tty` uses the owning process's `tty_nr` because `stat` reports the alias device rather than the selected PTY. A wait already present before terminal input is not post-write readiness: the same PGID must be observed outside that wait before re-entering it, while a changed foreground PGID is new evidence. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; unsupported architectures skip Tier 1. On macOS there is no exact syscall tier. Output silence returns `inferred_idle` for any foreground process group, including Python and `gdb`; `ps`-derived terminal PGID is used for signaling, not as proof that only the shell can be idle. Pure process-inspector logic is injectable and unit-tested on Linux, while a macOS CI job exercises the real PTY and process-table path. @@ -157,9 +157,9 @@ The package ships concise tool guidance explaining persistent state, owner isola ## Verification - Per-file coverage pins owner fencing, concurrent reservations, cancellation during pre-write inspection, unpublished-spawn cancellation and awaited teardown, sandbox-mode change rejection, retriable lifecycle cleanup, readiness tiers, rejection of pre-write stdin waits and delayed earlier prompts, the configured handoff grace holding the idle fallback past one poll and its rejection below `pollIntervalMs`, sanitizer carry state, complete UTF-8 bounds, task integration, schemas, and exact render intents. -- Subprocess process fixtures cover non-leader and non-main-thread stdin waits, rejection of fd 0 backed by a pipe, zombie quiescence, unreadable process state, supported syscall tables, unsupported architectures, and other false-positive rejection; macOS inspector logic is injected into the same unit suite. -- Real `node-pty` and PTY-consumer tests jointly exercise shell state, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT`, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts. -- A Loader-driven `cordis.yml` test mounts the real three-package composition and verifies that delayed pipeline output returns with the completed command instead of being classified as terminal-input readiness. ACP and headless snapshots pin the six schemas, bounded results, and errors through opt-in overlays; TUI snapshots pin terminal and generic card presentation. +- Subprocess process fixtures cover non-leader and non-main-thread stdin waits, thread-local fd tables, the `/dev/tty` alias, rejection of fd 0 backed by a pipe, zombie quiescence, unreadable process state, supported syscall tables, unsupported architectures, and other false-positive rejection; macOS inspector logic is injected into the same unit suite. +- Real `node-pty` and PTY-consumer tests jointly exercise shell state, controlling-terminal input through `/dev/tty`, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT`, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts. +- A Loader-driven `cordis.yml` test mounts the real three-package composition and verifies that delayed pipeline output returns with the completed command instead of being classified as terminal-input readiness. The SDK minimal snapshot pins that output through the persistent Bash tool; ACP and headless snapshots pin the six terminal schemas, bounded results, and errors through opt-in overlays; TUI snapshots pin terminal and generic card presentation. - Package contracts, the architecture map, subsystem pages, generated catalogs, and the website API describe the same shipped surface. ## Consequences 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 0aad7cc409..d98400255a 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 @@ -74,7 +74,7 @@ UI 渲染约定精确且不携带位置信息。`terminal_send` 只为前台发 本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker,并且只有在最近一个 marker 后的可打印尾部与受控 `PS1` 完全相等时才声明 prompt 就绪;除此之外,它还运行 3 个有界 fallback 层级。在 data callback 之间保留该尾部,可以适配 marker 与 prompt 被分开交付的情况;如果回显的输入或输出跟在延迟到达的先前 prompt 之后,要求尾部完全相等会拒绝该 prompt,使其无法完成当前 send。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。尚未发布的 startup 不会把零输出静默视为就绪;timeout 会拒绝 spawn。若调用方取消在 startup 期间胜出,后端会关闭私有会话并原样抛出 `AbortSignal.reason`;尚不可观察的前台 PGID 不会再用查找错误覆盖取消原因。所有时间参数都是经校验的配置字段:`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs`、`handoffGraceMs` 和 `timeoutMs`。 -在 Linux 上,检查器从 `/proc//stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6` 或 `poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。等待进程的 `/proc//fd/0` 还必须与终端 shell 的 fd 0 解析到同一目标,因此阻塞于管道的流水线读取端仍属于正在运行的命令,不构成终端输入就绪。终端输入前就已存在的等待并不代表写入后就绪:必须先观察到同一 PGID 脱离该等待,之后再次进入等待才能使该次 send 完成;前台 PGID 发生变化则构成新的证据。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。架构表只包含对应 Linux UAPI 定义的 syscall number;不支持的架构跳过 Tier 1。 +在 Linux 上,检查器从 `/proc//stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6` 或 `poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。等待线程的 `/proc//task//fd/0` 还必须标识 shell 的控制终端设备,因此线程本地 fd 表无法用 leader 的终端描述符冒充自身 fd,阻塞于管道的流水线读取端仍属于正在运行的命令。直接 PTY 描述符使用其设备号;`/dev/tty` 则使用所属进程的 `tty_nr`,因为 `stat` 报告的是别名设备而非选定的 PTY。终端输入前就已存在的等待并不代表写入后就绪:必须先观察到同一 PGID 脱离该等待,之后再次进入等待才能使该次 send 完成;前台 PGID 发生变化则构成新的证据。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。架构表只包含对应 Linux UAPI 定义的 syscall number;不支持的架构跳过 Tier 1。 macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 `inferred_idle`,包括 Python 和 `gdb`;从 `ps` 推导的终端 PGID 只用于发送信号,不作为「只有 shell 才能 idle」的证明。纯进程检查逻辑可注入,并在 Linux 上经过单元测试,同时由 macOS CI job 驱动真实 PTY 和进程表路径。 @@ -157,9 +157,9 @@ plugins: ## 验证 - 逐文件覆盖测试锁定了 owner 隔离、并发预留、写入前检查期间的取消、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、对写入前 stdin 等待与延迟到达的先前 prompt 的拒绝、配置化交接宽限把 idle fallback 顶过一次轮询以及低于 `pollIntervalMs` 时的拒绝、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。 -- 子进程 fixture(测试前置数据)覆盖非 leader 与非主线程的 stdin 等待、拒绝把指向管道的 fd 0 当作终端输入、僵尸进程完全停稳、不可读进程状态、受支持的 syscall 表、不支持的架构和其他误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。 -- 真实 `node-pty` 与 PTY 消费方测试共同在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、raw mode 前台 `SIGINT`、忽略 `SIGTERM` 的后代进程,以及 dispose 返回后立即完全停稳。 -- Loader 驱动的 `cordis.yml` 测试挂载真实三包组合,并验证延迟到达的流水线输出随已完成命令返回,而不会被归类为终端输入就绪。ACP 与 headless 快照通过 opt-in overlay 固定 6 个 schema、有界结果和错误;TUI 快照固定 terminal 与 generic 卡片展示。 +- 子进程 fixture(测试前置数据)覆盖非 leader 与非主线程的 stdin 等待、线程本地 fd 表、`/dev/tty` 别名、拒绝把指向管道的 fd 0 当作终端输入、僵尸进程完全停稳、不可读进程状态、受支持的 syscall 表、不支持的架构和其他误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。 +- 真实 `node-pty` 与 PTY 消费方测试共同在受支持宿主上覆盖 shell 状态、通过 `/dev/tty` 读取控制终端输入、共享沙箱策略、环境清洗、raw mode 前台 `SIGINT`、忽略 `SIGTERM` 的后代进程,以及 dispose 返回后立即完全停稳。 +- Loader 驱动的 `cordis.yml` 测试挂载真实三包组合,并验证延迟到达的流水线输出随已完成命令返回,而不会被归类为终端输入就绪。SDK minimal 快照通过持久 Bash 工具固定该输出;ACP 与 headless 快照通过 opt-in overlay 固定 6 个终端 schema、有界结果和错误;TUI 快照固定 terminal 与 generic 卡片展示。 - 包约定、架构图、子系统页面、生成目录和 website API 描述同一个已发布接口。 ## 后果 diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 229008e146..5b25e04c1e 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-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/subprocess/subprocess-local/README.md -README.md: 26b23f1e0b9a7efee9a2f20f259c69a832e64d53 -README.zh.md: 797eda99fc131eff93c2eaaf87603588a01b3067 +README.md: deea6b6b291d684c4d3a31bee562a0d99e90cb65 +README.zh.md: be4396df4b85d3a18c5236bc835b50dfd26e5f19 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 26b23f1e0b..deea6b6b29 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -11,7 +11,7 @@ Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA - **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). - **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. - **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative paths containing separators are rejected at the seam, and relative PATH entries resolve from the host process cwd. -- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Linux reports an exact input wait only when the waiting process's fd 0 resolves to the terminal shell's fd 0 target, so a pipeline reader blocked on `pipe:[…]` cannot publish terminal readiness. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes start identities with zero-time process-handle waits for liveness, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's termination because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. +- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Linux reports an exact input wait only when the waiting thread's own fd 0 identifies the shell's controlling terminal, including the `/dev/tty` alias, so a pipeline reader blocked on `pipe:[…]` cannot publish terminal readiness. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes start identities with zero-time process-handle waits for liveness, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's termination because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. - **Terminate-and-join disposal** — the service retains live handles so its own disposal can escalate every running tree and await its exit; quiescent and spawn-failed handles leave the live set after whole-tree or terminal-session cleanup finishes. - **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener force-terminates every ordinary tree and observable terminal session still in the same live sets. The local-only operations send POSIX SIGKILL to the managed group, run Windows `taskkill /T /F`, and synchronously signal captured/current terminal identities around the PTY root kill; they create no promise or timer, preserve the host's exit code and diagnostic, contain each target's failure, and do not claim quiescence. Normal disposal keeps the awaited graceful path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md). diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index 797eda99fc..be4396df4b 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -11,7 +11,7 @@ - **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md)。 - **基于偏移量的读取**:收集模式的读取器按完整流的字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 - **可执行文件查找**:`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该 seam 处被拒绝,相对 PATH 条目从宿主进程 cwd 解析。 -- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。Linux 只有在等待进程的 fd 0 与终端 shell 的 fd 0 解析到同一目标时才报告精确输入等待,因此阻塞于 `pipe:[…]` 的流水线读取端无法发布终端就绪状态。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 启动身份与进程句柄零时等待结合起来判断存活状态,并把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组)。拆卸会验证 shell 已终止,因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 +- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。Linux 只有在等待线程自身的 fd 0 标识 shell 的控制终端(包括 `/dev/tty` 别名)时才报告精确输入等待,因此阻塞于 `pipe:[…]` 的流水线读取端无法发布终端就绪状态。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 启动身份与进程句柄零时等待结合起来判断存活状态,并把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组)。拆卸会验证 shell 已终止,因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 - **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,使自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;完全停稳与 spawn 失败的句柄会在整棵进程树或 terminal session 清理完成后离开存活集合。 - **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会强制终止同一组存活集合中仍存在的每棵普通进程树和可观察 terminal session。这些仅供本地实现使用的操作会向受管 POSIX 进程组发送 SIGKILL、在 Windows 运行 `taskkill /T /F`,并在终止 PTY root 前后同步向已捕获及当前可观察的 terminal 身份发送信号;它们不会创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待温和路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md)。 diff --git a/packages/subprocess/subprocess-local/src/process-inspector.ts b/packages/subprocess/subprocess-local/src/process-inspector.ts index 05b775e5ef..e13ced066f 100644 --- a/packages/subprocess/subprocess-local/src/process-inspector.ts +++ b/packages/subprocess/subprocess-local/src/process-inspector.ts @@ -1,6 +1,6 @@ /** Platform process-table inspection for terminal readiness, signals, and teardown. */ -import { closeSync, openSync, readFileSync, readdirSync, readlinkSync, readSync } from 'node:fs' +import { closeSync, openSync, readFileSync, readdirSync, readlinkSync, readSync, statSync } from 'node:fs' import { execFileSync } from 'node:child_process' import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess' import { createWindowsProcessInspector } from './windows-inspector.ts' @@ -11,6 +11,11 @@ export interface ProcessIdentity { started: string } +interface FileStatus { + readonly rdev: number + isCharacterDevice(): boolean +} + /** Injectable OS process operations used by one local PTY session. */ export interface ProcessInspector { foregroundPgid(shellPid: number): number | undefined @@ -37,6 +42,7 @@ export interface ProcessInspectorInternals { readFile(path: string): string readDir(path: string): string[] readLink(path: string): string + stat(path: string): FileStatus open(path: string): number read(fd: number, buffer: Buffer, length: number, position: number): number close(fd: number): void @@ -49,6 +55,7 @@ const DEFAULT_INTERNALS: ProcessInspectorInternals = { readFile: path => readFileSync(path, 'utf8'), readDir: path => readdirSync(path), readLink: path => readlinkSync(path, 'utf8'), + stat: path => statSync(path), open: path => openSync(path, 'r'), read: (fd, buffer, length, position) => readSync(fd, buffer, 0, length, position), close: closeSync, @@ -63,6 +70,7 @@ interface ProcStat { pgrp: number session: number state: string + ttyDevice: number tpgid: number started: string } @@ -82,11 +90,12 @@ export function parseProcStat(text: string): ProcStat | undefined { const parentPid = Number(rest[1]) const pgrp = Number(rest[2]) const session = Number(rest[3]) + const ttyDevice = Number(rest[4]) const tpgid = Number(rest[5]) const started = rest[19] - if (![pid, parentPid, pgrp, session, tpgid].every(Number.isSafeInteger) + if (![pid, parentPid, pgrp, session, ttyDevice, tpgid].every(Number.isSafeInteger) || state.length !== 1 || started === undefined) return undefined - return { pid, parentPid, pgrp, session, state, tpgid, started } + return { pid, parentPid, pgrp, session, state, ttyDevice, tpgid, started } } function readLinuxStat(internals: ProcessInspectorInternals, pid: number): ProcStat | undefined { @@ -97,10 +106,31 @@ function readLinuxStat(internals: ProcessInspectorInternals, pid: number): ProcS } } -function readLinuxStdinTarget(internals: ProcessInspectorInternals, pid: number): string | undefined { +// `/proc//stat` renders tty_nr as a signed 32-bit device number, while +// Node exposes the same st_rdev bits as a nonnegative number. +function linuxDeviceNumber(value: number): number { + return value >>> 0 +} + +// `/dev/tty` reports the alias device instead of the selected PTY through stat, +// so its owning process's tty_nr is the only comparable terminal identity. +function readLinuxTerminalDevice( + internals: ProcessInspectorInternals, + pid: number, + ttyDevice: number, + tid?: number, +): number | undefined { + const terminalDevice = linuxDeviceNumber(ttyDevice) + if (terminalDevice === 0) return undefined + const path = tid === undefined ? `/proc/${pid}/fd/0` : `/proc/${pid}/task/${tid}/fd/0` try { - return internals.readLink(`/proc/${pid}/fd/0`) - } catch (_unreadableStdinTarget) { + const target = internals.readLink(path) + if (target === '/dev/tty') return terminalDevice + const status = internals.stat(path) + return status.isCharacterDevice() && linuxDeviceNumber(status.rdev) === terminalDevice + ? terminalDevice + : undefined + } catch (_unreadableStdinDevice) { return undefined } } @@ -199,9 +229,9 @@ function pollHasStdin( return false } -function epollHasStdin(internals: ProcessInspectorInternals, pid: number, epfd: number): boolean { +function epollHasStdin(internals: ProcessInspectorInternals, pid: number, tid: number, epfd: number): boolean { try { - return internals.readFile(`/proc/${pid}/fdinfo/${epfd}`) + return internals.readFile(`/proc/${pid}/task/${tid}/fdinfo/${epfd}`) .split('\n') .some(line => /^tfd:\s+0\b/.test(line.trim())) } catch (_unreadableFdInfo) { @@ -227,6 +257,7 @@ const SYSCALLS: Partial> = { function syscallWaitsOnStdin( internals: ProcessInspectorInternals, pid: number, + tid: number, syscall: SyscallInfo, table: SyscallTable, ): boolean { @@ -239,7 +270,7 @@ function syscallWaitsOnStdin( return a1 >= 1 && pollHasStdin(internals, pid, a0, a1) } if (syscall.number === table.epollWait || syscall.number === table.epollPwait) { - return a2 >= 1 && epollHasStdin(internals, pid, a0) + return a2 >= 1 && epollHasStdin(internals, pid, tid, a0) } return false } @@ -304,15 +335,18 @@ class LinuxProcessInspector extends PosixProcessInspector { isStdinWaiting(pgid: number, shellPid: number): boolean { const table = SYSCALLS[this.arch] if (table === undefined) return false - const terminalStdinTarget = readLinuxStdinTarget(this.internals, shellPid) - if (terminalStdinTarget === undefined) return false + const shell = readLinuxStat(this.internals, shellPid) + if (shell === undefined) return false + const terminalDevice = readLinuxTerminalDevice(this.internals, shellPid, shell.ttyDevice) + if (terminalDevice === undefined) return false for (const pid of numericEntries(this.internals, '/proc')) { - if (readLinuxStat(this.internals, pid)?.pgrp !== pgid) continue + const process = readLinuxStat(this.internals, pid) + if (process?.pgrp !== pgid) continue for (const tid of numericEntries(this.internals, `/proc/${pid}/task`)) { const syscall = readSyscall(this.internals, pid, tid) if (syscall !== undefined - && syscallWaitsOnStdin(this.internals, pid, syscall, table) - && readLinuxStdinTarget(this.internals, pid) === terminalStdinTarget) return true + && syscallWaitsOnStdin(this.internals, pid, tid, syscall, table) + && readLinuxTerminalDevice(this.internals, pid, process.ttyDevice, tid) === terminalDevice) return true } } return false diff --git a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts index 5115db1233..a56167bb4d 100644 --- a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts @@ -7,8 +7,17 @@ import { import type { ProcessInspectorInternals } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts' import { WindowsProcessInspector } from '@deepseek-ai/dsh-subprocess-local/src/windows-inspector.ts' -function stat(pid: number, pgrp: number, session: number, tpgid: number, started: string, parentPid = 1, state = 'S'): string { - const rest = [state, String(parentPid), String(pgrp), String(session), '99', String(tpgid)] +function stat( + pid: number, + pgrp: number, + session: number, + tpgid: number, + started: string, + parentPid = 1, + state = 'S', + ttyDevice = 99, +): string { + const rest = [state, String(parentPid), String(pgrp), String(session), String(ttyDevice), String(tpgid)] while (rest.length < 19) rest.push('0') rest.push(started) return `${pid} (command with space) ${rest.join(' ')}` @@ -24,6 +33,7 @@ function fakeInternals() { const files = new Map() const dirs = new Map() const links = new Map() + const devices = new Map() const memories = new Map() const fds = new Map() const kills: Array<[number, NodeJS.Signals]> = [] @@ -46,6 +56,11 @@ function fakeInternals() { if (value === undefined) throw new Error(`missing ${path}`) return value }, + stat(path) { + const value = devices.get(path) + if (value === undefined) throw new Error(`missing ${path}`) + return { rdev: value.rdev, isCharacterDevice: () => value.character } + }, open(path) { if (!memories.has(path)) throw new Error(`missing ${path}`) const fd = nextFd++ @@ -67,7 +82,7 @@ function fakeInternals() { kill(pid, signal) { kills.push([pid, signal]) }, } return { - internals, files, dirs, links, memories, kills, + internals, files, dirs, links, devices, memories, kills, setPs(value: string) { ps = value }, setTpgid(value: string) { tpgid = value }, } @@ -94,7 +109,7 @@ describe('Linux process inspector', () => { expect(parseProcStat('1 () ')).toBeUndefined() expect(parseProcStat('1 () S')).toBeUndefined() expect(parseProcStat(stat(10, 20, 30, 40, '500', 1, 'SS'))).toBeUndefined() - expect(parseProcStat(stat(10, 20, 30, 40, '500'))).toEqual({ pid: 10, parentPid: 1, pgrp: 20, session: 30, state: 'S', tpgid: 40, started: '500' }) + expect(parseProcStat(stat(10, 20, 30, 40, '500'))).toEqual({ pid: 10, parentPid: 1, pgrp: 20, session: 30, state: 'S', ttyDevice: 99, tpgid: 40, started: '500' }) const fake = fakeInternals() fake.dirs.set('/proc', ['x', '10', '11', '12', '13', '14']) @@ -139,7 +154,9 @@ describe('Linux process inspector', () => { fake.dirs.set('/proc/100/task', ['100']) fake.dirs.set('/proc/101/task', ['101', '102']) fake.links.set('/proc/100/fd/0', '/dev/pts/1') - fake.links.set('/proc/101/fd/0', '/dev/pts/1') + fake.devices.set('/proc/100/fd/0', { character: true, rdev: 99 }) + fake.links.set('/proc/101/task/102/fd/0', '/dev/pts/1') + fake.devices.set('/proc/101/task/102/fd/0', { character: true, rdev: 99 }) const inspector = createProcessInspector('linux', 'x64', fake.internals) fake.files.set('/proc/100/task/100/syscall', 'running') @@ -161,25 +178,33 @@ describe('Linux process inspector', () => { expect(inspector.isStdinWaiting(77, 100)).toBe(true) fake.files.set('/proc/101/task/102/syscall', syscall(232, 5, 0, 1)) - fake.files.set('/proc/101/fdinfo/5', 'pos: 0\ntfd: 0 events: 19\n') + fake.files.set('/proc/101/task/102/fdinfo/5', 'pos: 0\ntfd: 0 events: 19\n') expect(inspector.isStdinWaiting(77, 100)).toBe(true) }) - it('rejects pipeline reads whose fd 0 is not the terminal input', () => { + it('uses the waiting thread fd table and recognizes the controlling-terminal alias', () => { const fake = fakeInternals() fake.dirs.set('/proc', ['100']) + fake.files.set('/proc/99/stat', stat(99, 99, 99, 77, '0')) fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1')) fake.dirs.set('/proc/100/task', ['100']) fake.files.set('/proc/100/task/100/syscall', syscall(0, 0)) fake.links.set('/proc/99/fd/0', '/dev/pts/1') - fake.links.set('/proc/100/fd/0', 'pipe:[123]') + fake.devices.set('/proc/99/fd/0', { character: true, rdev: 99 }) + fake.links.set('/proc/100/fd/0', '/dev/pts/1') + fake.devices.set('/proc/100/fd/0', { character: true, rdev: 99 }) + fake.links.set('/proc/100/task/100/fd/0', 'pipe:[123]') + fake.devices.set('/proc/100/task/100/fd/0', { character: false, rdev: 0 }) const inspector = createProcessInspector('linux', 'x64', fake.internals) expect(inspector.isStdinWaiting(77, 99)).toBe(false) - fake.links.delete('/proc/100/fd/0') + fake.links.delete('/proc/100/task/100/fd/0') expect(inspector.isStdinWaiting(77, 99)).toBe(false) - fake.links.set('/proc/100/fd/0', '/dev/pts/1') + fake.links.set('/proc/100/task/100/fd/0', '/dev/tty') expect(inspector.isStdinWaiting(77, 99)).toBe(true) + fake.links.set('/proc/100/task/100/fd/0', '/dev/pts/2') + fake.devices.set('/proc/100/task/100/fd/0', { character: true, rdev: 100 }) + expect(inspector.isStdinWaiting(77, 99)).toBe(false) }) it('fails closed on unsupported, malformed, unreadable, or non-stdin waits', () => { @@ -188,6 +213,9 @@ describe('Linux process inspector', () => { fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1')) fake.dirs.set('/proc/100/task', ['100']) fake.links.set('/proc/100/fd/0', '/dev/pts/1') + fake.devices.set('/proc/100/fd/0', { character: true, rdev: 99 }) + fake.links.set('/proc/100/task/100/fd/0', '/dev/pts/1') + fake.devices.set('/proc/100/task/100/fd/0', { character: true, rdev: 99 }) fake.files.set('/proc/100/task/100/syscall', syscall(0, 2)) expect(createProcessInspector('linux', 'mips', fake.internals).isStdinWaiting(77, 100)).toBe(false) expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false) @@ -220,9 +248,18 @@ describe('Linux process inspector', () => { fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1')) fake.dirs.set('/proc/100/task', ['100']) const inspector = createProcessInspector('linux', 'x64', fake.internals) + fake.files.delete('/proc/100/stat') + expect(inspector.isStdinWaiting(77, 100)).toBe(false) + fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1', 1, 'S', 0)) + expect(inspector.isStdinWaiting(77, 100)).toBe(false) + fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1')) expect(inspector.isStdinWaiting(77, 100)).toBe(false) fake.links.set('/proc/100/fd/0', '/dev/pts/1') expect(inspector.isStdinWaiting(77, 100)).toBe(false) + fake.devices.set('/proc/100/fd/0', { character: true, rdev: 99 }) + fake.links.set('/proc/100/task/100/fd/0', '/dev/pts/1') + fake.devices.set('/proc/100/task/100/fd/0', { character: true, rdev: 99 }) + expect(inspector.isStdinWaiting(77, 100)).toBe(false) fake.files.set('/proc/100/task/100/syscall', syscall(270, 1, 0x10)) expect(inspector.isStdinWaiting(77, 100)).toBe(false) diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts index ed1b6ab15d..4a90049997 100644 --- a/packages/terminal/terminal-bash/tests/local.spec.ts +++ b/packages/terminal/terminal-bash/tests/local.spec.ts @@ -163,6 +163,27 @@ describe.skipIf(process.platform === 'win32')('terminal-bash real shell', () => await ctx.terminals.kill(agent, created.sessionId) }, 20_000) + it.skipIf(process.platform !== 'linux')('recognizes a foreground read opened through /dev/tty', async () => { + const { ctx, agent } = await harness('danger-full-access', { + idleSilenceMs: 5_000, + timeoutMs: 8_000, + }) + const created = await ctx.terminals.spawn(agent, { type: 'shell' }) + + const waiting = ctx.terminals.startSend(agent, created.sessionId, { + text: 'bash -c \'exec { const { ctx, root, agent, fiber, sandbox } = await harness('workspace-write') const created = await ctx.terminals.spawn(agent, { type: 'shell' }) diff --git a/snapshots/sdk/persistent-tools/notifications.expected.jsonl b/snapshots/sdk/persistent-tools/notifications.expected.jsonl index abb7e0307d..3b8e08a361 100644 --- a/snapshots/sdk/persistent-tools/notifications.expected.jsonl +++ b/snapshots/sdk/persistent-tools/notifications.expected.jsonl @@ -1,9 +1,9 @@ -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, replace that literal tab-indented line, and make the persistent shell exit with code 9."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Prove that bash state persists. After the persistence checks, run exactly `{ sleep 0.1; echo delayed; } | cat` as its own bash call and observe `delayed`. Then create {{cwd}}/note.txt with a tab-indented line, view it, replace that literal tab-indented line, and make the persistent shell exit with code 9."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}}} {"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"running"}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":4,"time":0,"data":{"content":[{"type":"text","text":"Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, replace that literal tab-indented line, and make the persistent shell exit with code 9."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":4,"time":0,"data":{"content":[{"type":"text","text":"Prove that bash state persists. After the persistence checks, run exactly `{ sleep 0.1; echo delayed; } | cat` as its own bash call and observe `delayed`. Then create {{cwd}}/note.txt with a tab-indented line, view it, replace that literal tab-indented line, and make the persistent shell exit with code 9."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":5,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":6,"time":0,"data":{"title":"Prove that bash state persists.","messageSeqs":[4],"source":{"kind":"fallback"}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":7,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} @@ -29,51 +29,61 @@ {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":27,"time":0,"data":{"turn":1,"step":2}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":28,"time":0,"data":{"turn":1,"step":3}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-create","name":"str_replace_editor","argumentsDelta":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"bash-delayed-pipeline","name":"bash","argumentsDelta":"{\"command\":\"{ sleep 0.1; echo delayed; } | cat\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-delayed-pipeline","name":"bash","arguments":"{\"command\":\"{ sleep 0.1; echo delayed; } | cat\"}"}}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":34,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":35,"time":0,"data":{"turn":1,"step":3,"callId":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":36,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"editor-create"},"content":[{"type":"tool-result","toolCallId":"editor-create","content":[{"type":"text","text":"New file created successfully at: {{cwd}}/note.txt"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[35],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":34,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-delayed-pipeline","name":"bash","arguments":"{\"command\":\"{ sleep 0.1; echo delayed; } | cat\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":35,"time":0,"data":{"turn":1,"step":3,"callId":"bash-delayed-pipeline","name":"bash","arguments":"{\"command\":\"{ sleep 0.1; echo delayed; } | cat\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":36,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"bash-delayed-pipeline"},"content":[{"type":"tool-result","toolCallId":"bash-delayed-pipeline","content":[{"type":"text","text":"delayed"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[35],"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":37,"time":0,"data":{"turn":1,"step":3}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":38,"time":0,"data":{"turn":1,"step":4}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-view","name":"str_replace_editor","argumentsDelta":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-create","name":"str_replace_editor","argumentsDelta":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":43,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":44,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":45,"time":0,"data":{"turn":1,"step":4,"callId":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":46,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"editor-view"},"content":[{"type":"tool-result","toolCallId":"editor-view","content":[{"type":"text","text":"Here's the content of {{cwd}}/note.txt with line numbers (which has a total of 3 lines):\n 1 target:\n 2 \told\n 3 \n"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[45],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":44,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":45,"time":0,"data":{"turn":1,"step":4,"callId":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":46,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"editor-create"},"content":[{"type":"tool-result","toolCallId":"editor-create","content":[{"type":"text","text":"New file created successfully at: {{cwd}}/note.txt"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[45],"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":47,"time":0,"data":{"turn":1,"step":4}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":48,"time":0,"data":{"turn":1,"step":5}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-replace","name":"str_replace_editor","argumentsDelta":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-view","name":"str_replace_editor","argumentsDelta":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":53,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":54,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":55,"time":0,"data":{"turn":1,"step":5,"callId":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":56,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file {{cwd}}/note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[55],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":54,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":55,"time":0,"data":{"turn":1,"step":5,"callId":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":56,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"editor-view"},"content":[{"type":"tool-result","toolCallId":"editor-view","content":[{"type":"text","text":"Here's the content of {{cwd}}/note.txt with line numbers (which has a total of 3 lines):\n 1 target:\n 2 \told\n 3 \n"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[55],"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":57,"time":0,"data":{"turn":1,"step":5}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":58,"time":0,"data":{"turn":1,"step":6}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":0,"id":"bash-exit","name":"bash","argumentsDelta":"{\"command\":\"exit 9\"}"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-replace","name":"str_replace_editor","argumentsDelta":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":63,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":64,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":65,"time":0,"data":{"turn":1,"step":6,"callId":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":66,"time":0,"data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"bash-exit"},"content":[{"type":"tool-result","toolCallId":"bash-exit","content":[{"type":"text","text":"exit\n[shell exited: code 9]\nThe persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[65],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":64,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":65,"time":0,"data":{"turn":1,"step":6,"callId":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":66,"time":0,"data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file {{cwd}}/note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[65],"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":67,"time":0,"data":{"turn":1,"step":6}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":68,"time":0,"data":{"turn":1,"step":7}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":69,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":70,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":71,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":69,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":70,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":0,"id":"bash-exit","name":"bash","argumentsDelta":"{\"command\":\"exit 9\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":71,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":72,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":73,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":74,"time":0,"data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"PERSISTENT_TOOLS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[69,70,71,72,73],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":75,"time":0,"data":{"turn":1,"step":7}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":76,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":73,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":74,"time":0,"data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[69,70,71,72,73],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":75,"time":0,"data":{"turn":1,"step":7,"callId":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":76,"time":0,"data":{"turn":1,"step":7,"message":{"source":{"kind":"tool","callId":"bash-exit"},"content":[{"type":"tool-result","toolCallId":"bash-exit","content":[{"type":"text","text":"exit\n[shell exited: code 9]\nThe persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[75],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":77,"time":0,"data":{"turn":1,"step":7}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":78,"time":0,"data":{"turn":1,"step":8}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":79,"time":0,"data":{"turn":1,"step":8,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":80,"time":0,"data":{"turn":1,"step":8,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":81,"time":0,"data":{"turn":1,"step":8,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":82,"time":0,"data":{"turn":1,"step":8,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":83,"time":0,"data":{"turn":1,"step":8,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":84,"time":0,"data":{"turn":1,"step":8,"message":{"role":"assistant","content":[{"type":"text","text":"PERSISTENT_TOOLS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[79,80,81,82,83],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":85,"time":0,"data":{"turn":1,"step":8}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":86,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} {"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"idle"}} diff --git a/snapshots/sdk/persistent-tools/session.jsonl b/snapshots/sdk/persistent-tools/session.jsonl index c2310c5668..8024d37199 100644 --- a/snapshots/sdk/persistent-tools/session.jsonl +++ b/snapshots/sdk/persistent-tools/session.jsonl @@ -1,9 +1,9 @@ {"type":"session","version":0,"id":"{{session:1}}","createdAt":1785331618309,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, replace that literal tab-indented line, and make the persistent shell exit with code 9."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Prove that bash state persists. After the persistence checks, run exactly `{ sleep 0.1; echo delayed; } | cat` as its own bash call and observe `delayed`. Then create {{cwd}}/note.txt with a tab-indented line, view it, replace that literal tab-indented line, and make the persistent shell exit with code 9."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, replace that literal tab-indented line, and make the persistent shell exit with code 9."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Prove that bash state persists. After the persistence checks, run exactly `{ sleep 0.1; echo delayed; } | cat` as its own bash call and observe `delayed`. Then create {{cwd}}/note.txt with a tab-indented line, view it, replace that literal tab-indented line, and make the persistent shell exit with code 9."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Prove that bash state persists.","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} @@ -29,50 +29,60 @@ {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-create","name":"str_replace_editor","argumentsDelta":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}}} -{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"bash-delayed-pipeline","name":"bash","argumentsDelta":"{\"command\":\"{ sleep 0.1; echo delayed; } | cat\"}"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-delayed-pipeline","name":"bash","arguments":"{\"command\":\"{ sleep 0.1; echo delayed; } | cat\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":3,"callId":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}} -{"type":"tool/result","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"editor-create"},"content":[{"type":"tool-result","toolCallId":"editor-create","content":[{"type":"text","text":"New file created successfully at: {{cwd}}/note.txt"}],"isError":false}],"role":"user","id":"{{message:8}}"}},"sourceEventSeqs":[35],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-delayed-pipeline","name":"bash","arguments":"{\"command\":\"{ sleep 0.1; echo delayed; } | cat\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":3,"callId":"bash-delayed-pipeline","name":"bash","arguments":"{\"command\":\"{ sleep 0.1; echo delayed; } | cat\"}"}} +{"type":"tool/result","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"bash-delayed-pipeline"},"content":[{"type":"tool-result","toolCallId":"bash-delayed-pipeline","content":[{"type":"text","text":"delayed"}],"isError":false}],"role":"user","id":"{{message:8}}"}},"sourceEventSeqs":[35],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} {"type":"step/start","data":{"turn":1,"step":4}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-view","name":"str_replace_editor","argumentsDelta":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}}} -{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-create","name":"str_replace_editor","argumentsDelta":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:9}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":4,"callId":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}} -{"type":"tool/result","data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"editor-view"},"content":[{"type":"tool-result","toolCallId":"editor-view","content":[{"type":"text","text":"Here's the content of {{cwd}}/note.txt with line numbers (which has a total of 3 lines):\n 1 target:\n 2 \told\n 3 \n"}],"isError":false}],"role":"user","id":"{{message:10}}"}},"sourceEventSeqs":[45],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:9}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":4,"callId":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}} +{"type":"tool/result","data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"editor-create"},"content":[{"type":"tool-result","toolCallId":"editor-create","content":[{"type":"text","text":"New file created successfully at: {{cwd}}/note.txt"}],"isError":false}],"role":"user","id":"{{message:10}}"}},"sourceEventSeqs":[45],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":4}} {"type":"step/start","data":{"turn":1,"step":5}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-replace","name":"str_replace_editor","argumentsDelta":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}}} -{"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-view","name":"str_replace_editor","argumentsDelta":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:11}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":5,"callId":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}} -{"type":"tool/result","data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file {{cwd}}/note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"{{message:12}}"}},"sourceEventSeqs":[55],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:11}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":5,"callId":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}} +{"type":"tool/result","data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"editor-view"},"content":[{"type":"tool-result","toolCallId":"editor-view","content":[{"type":"text","text":"Here's the content of {{cwd}}/note.txt with line numbers (which has a total of 3 lines):\n 1 target:\n 2 \told\n 3 \n"}],"isError":false}],"role":"user","id":"{{message:12}}"}},"sourceEventSeqs":[55],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":5}} {"type":"step/start","data":{"turn":1,"step":6}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":0,"id":"bash-exit","name":"bash","argumentsDelta":"{\"command\":\"exit 9\"}"}}} -{"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-replace","name":"str_replace_editor","argumentsDelta":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:13}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":6,"callId":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}} -{"type":"tool/result","data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"bash-exit"},"content":[{"type":"tool-result","toolCallId":"bash-exit","content":[{"type":"text","text":"exit\n[shell exited: code 9]\nThe persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment."}],"isError":false}],"role":"user","id":"{{message:14}}"}},"sourceEventSeqs":[65],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:13}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":6,"callId":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}} +{"type":"tool/result","data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file {{cwd}}/note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"{{message:14}}"}},"sourceEventSeqs":[65],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":6}} {"type":"step/start","data":{"turn":1,"step":7}} -{"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}} -{"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":0,"id":"bash-exit","name":"bash","argumentsDelta":"{\"command\":\"exit 9\"}"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"PERSISTENT_TOOLS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:15}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[69,70,71,72,73],"surfaceOp":"append"} +{"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:15}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[69,70,71,72,73],"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":7,"callId":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}} +{"type":"tool/result","data":{"turn":1,"step":7,"message":{"source":{"kind":"tool","callId":"bash-exit"},"content":[{"type":"tool-result","toolCallId":"bash-exit","content":[{"type":"text","text":"exit\n[shell exited: code 9]\nThe persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment."}],"isError":false}],"role":"user","id":"{{message:16}}"}},"sourceEventSeqs":[75],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":7}} +{"type":"step/start","data":{"turn":1,"step":8}} +{"type":"assistant/chunk","data":{"turn":1,"step":8,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":8,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":8,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":8,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":8,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","data":{"turn":1,"step":8,"message":{"role":"assistant","content":[{"type":"text","text":"PERSISTENT_TOOLS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:17}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[79,80,81,82,83],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":8}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} From 2338f4ad1450e75b09765366539e1fe1c590e28d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 25 Aug 2026 14:01:04 +0800 Subject: [PATCH 03/12] fix(pty): detect emulated kernel syscall ABI --- ...26-07-16-persistent-pty-sessions.i18n.yaml | 4 +- .../2026-07-16-persistent-pty-sessions.md | 4 +- .../2026-07-16-persistent-pty-sessions.zh.md | 4 +- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 2 +- .../subprocess/subprocess-local/README.zh.md | 2 +- .../subprocess-local/src/process-inspector.ts | 38 ++++++++++++------- .../tests/process-inspector.spec.ts | 4 +- 8 files changed, 38 insertions(+), 24 deletions(-) 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 663a33d56d..84c8a040c3 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: b49301aaef6593730c67c3a727e1e82b3ec25379 -2026-07-16-persistent-pty-sessions.zh.md: d98400255a8282b1fa7f529caf85ee03a0fb451a +2026-07-16-persistent-pty-sessions.md: 8cbec14dae0517a82925d871843a1998f4734709 +2026-07-16-persistent-pty-sessions.zh.md: 91d07c2dcd1f58ffb53a20955740c7205ace5d92 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 b49301aaef..8cbec14dae 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 @@ -74,7 +74,7 @@ With `run_in_background: true`, `dsh-tool-terminal` registers the in-flight send The local backend first recognizes a private OSC prompt marker emitted by its controlled bash startup, then requires the printable tail after the latest marker to exactly equal the controlled `PS1` before declaring prompt readiness and runs three bounded fallback tiers. Carrying that tail across data callbacks covers delivery where the marker and prompt arrive separately; requiring the exact tail rejects a delayed earlier prompt once echoed input or output follows it, so it cannot settle the current send. The marker is removed before output reaches the model and avoids a fixed silence delay for ordinary shell commands on both platforms. Unpublished startup does not accept zero-output silence as readiness; timeout rejects the spawn. If caller cancellation wins during startup, the backend closes the private session and propagates the exact `AbortSignal.reason`; a foreground PGID that is not observable yet cannot replace cancellation with a lookup error. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, `handoffGraceMs`, and `timeoutMs`. -On Linux, the inspector reads the shell's terminal foreground PGID from `/proc//stat`, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct `read(0)`, a permitted read of a `select`/`pselect6` or `poll`/`ppoll` argument containing fd 0, or an epoll interest list containing fd 0. The waiting thread's `/proc//task//fd/0` must identify the shell's controlling terminal device, so a thread-local fd table cannot substitute its leader's terminal descriptor and a pipeline reader blocked on its pipe remains a running command. Direct PTY descriptors use their device number; `/dev/tty` uses the owning process's `tty_nr` because `stat` reports the alias device rather than the selected PTY. A wait already present before terminal input is not post-write readiness: the same PGID must be observed outside that wait before re-entering it, while a changed foreground PGID is new evidence. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; unsupported architectures skip Tier 1. +On Linux, the inspector reads the shell's terminal foreground PGID from `/proc//stat`, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct `read(0)`, a permitted read of a `select`/`pselect6` or `poll`/`ppoll` argument containing fd 0, or an epoll interest list containing fd 0. The waiting thread's `/proc//task//fd/0` must identify the shell's controlling terminal device, so a thread-local fd table cannot substitute its leader's terminal descriptor and a pipeline reader blocked on its pipe remains a running command. Direct PTY descriptors use their device number; `/dev/tty` uses the owning process's `tty_nr` because `stat` reports the alias device rather than the selected PTY. A wait already present before terminal input is not post-write readiness: the same PGID must be observed outside that wait before re-entering it, while a changed foreground PGID is new evidence. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; the inspector admits the runtime architecture, then checks every supported kernel ABI because `/proc` can report a different ABI under user-mode emulation. Unsupported runtime architectures skip Tier 1. On macOS there is no exact syscall tier. Output silence returns `inferred_idle` for any foreground process group, including Python and `gdb`; `ps`-derived terminal PGID is used for signaling, not as proof that only the shell can be idle. Pure process-inspector logic is injectable and unit-tested on Linux, while a macOS CI job exercises the real PTY and process-table path. @@ -157,7 +157,7 @@ The package ships concise tool guidance explaining persistent state, owner isola ## Verification - Per-file coverage pins owner fencing, concurrent reservations, cancellation during pre-write inspection, unpublished-spawn cancellation and awaited teardown, sandbox-mode change rejection, retriable lifecycle cleanup, readiness tiers, rejection of pre-write stdin waits and delayed earlier prompts, the configured handoff grace holding the idle fallback past one poll and its rejection below `pollIntervalMs`, sanitizer carry state, complete UTF-8 bounds, task integration, schemas, and exact render intents. -- Subprocess process fixtures cover non-leader and non-main-thread stdin waits, thread-local fd tables, the `/dev/tty` alias, rejection of fd 0 backed by a pipe, zombie quiescence, unreadable process state, supported syscall tables, unsupported architectures, and other false-positive rejection; macOS inspector logic is injected into the same unit suite. +- Subprocess process fixtures cover non-leader and non-main-thread stdin waits, thread-local fd tables, the `/dev/tty` alias, supported kernel ABIs under user-mode emulation, rejection of fd 0 backed by a pipe, zombie quiescence, unreadable process state, unsupported architectures, and other false-positive rejection; macOS inspector logic is injected into the same unit suite. - Real `node-pty` and PTY-consumer tests jointly exercise shell state, controlling-terminal input through `/dev/tty`, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT`, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts. - A Loader-driven `cordis.yml` test mounts the real three-package composition and verifies that delayed pipeline output returns with the completed command instead of being classified as terminal-input readiness. The SDK minimal snapshot pins that output through the persistent Bash tool; ACP and headless snapshots pin the six terminal schemas, bounded results, and errors through opt-in overlays; TUI snapshots pin terminal and generic card presentation. - Package contracts, the architecture map, subsystem pages, generated catalogs, and the website API describe the same shipped surface. 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 d98400255a..91d07c2dcd 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 @@ -74,7 +74,7 @@ UI 渲染约定精确且不携带位置信息。`terminal_send` 只为前台发 本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker,并且只有在最近一个 marker 后的可打印尾部与受控 `PS1` 完全相等时才声明 prompt 就绪;除此之外,它还运行 3 个有界 fallback 层级。在 data callback 之间保留该尾部,可以适配 marker 与 prompt 被分开交付的情况;如果回显的输入或输出跟在延迟到达的先前 prompt 之后,要求尾部完全相等会拒绝该 prompt,使其无法完成当前 send。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。尚未发布的 startup 不会把零输出静默视为就绪;timeout 会拒绝 spawn。若调用方取消在 startup 期间胜出,后端会关闭私有会话并原样抛出 `AbortSignal.reason`;尚不可观察的前台 PGID 不会再用查找错误覆盖取消原因。所有时间参数都是经校验的配置字段:`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs`、`handoffGraceMs` 和 `timeoutMs`。 -在 Linux 上,检查器从 `/proc//stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6` 或 `poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。等待线程的 `/proc//task//fd/0` 还必须标识 shell 的控制终端设备,因此线程本地 fd 表无法用 leader 的终端描述符冒充自身 fd,阻塞于管道的流水线读取端仍属于正在运行的命令。直接 PTY 描述符使用其设备号;`/dev/tty` 则使用所属进程的 `tty_nr`,因为 `stat` 报告的是别名设备而非选定的 PTY。终端输入前就已存在的等待并不代表写入后就绪:必须先观察到同一 PGID 脱离该等待,之后再次进入等待才能使该次 send 完成;前台 PGID 发生变化则构成新的证据。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。架构表只包含对应 Linux UAPI 定义的 syscall number;不支持的架构跳过 Tier 1。 +在 Linux 上,检查器从 `/proc//stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6` 或 `poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。等待线程的 `/proc//task//fd/0` 还必须标识 shell 的控制终端设备,因此线程本地 fd 表无法用 leader 的终端描述符冒充自身 fd,阻塞于管道的流水线读取端仍属于正在运行的命令。直接 PTY 描述符使用其设备号;`/dev/tty` 则使用所属进程的 `tty_nr`,因为 `stat` 报告的是别名设备而非选定的 PTY。终端输入前就已存在的等待并不代表写入后就绪:必须先观察到同一 PGID 脱离该等待,之后再次进入等待才能使该次 send 完成;前台 PGID 发生变化则构成新的证据。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。架构表只包含对应 Linux UAPI 定义的 syscall number;检查器先准入运行时架构,再检查每个受支持的内核 ABI,因为在用户态模拟下,`/proc` 可能报告不同的 ABI。不受支持的运行时架构会跳过 Tier 1。 macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 `inferred_idle`,包括 Python 和 `gdb`;从 `ps` 推导的终端 PGID 只用于发送信号,不作为「只有 shell 才能 idle」的证明。纯进程检查逻辑可注入,并在 Linux 上经过单元测试,同时由 macOS CI job 驱动真实 PTY 和进程表路径。 @@ -157,7 +157,7 @@ plugins: ## 验证 - 逐文件覆盖测试锁定了 owner 隔离、并发预留、写入前检查期间的取消、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、对写入前 stdin 等待与延迟到达的先前 prompt 的拒绝、配置化交接宽限把 idle fallback 顶过一次轮询以及低于 `pollIntervalMs` 时的拒绝、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。 -- 子进程 fixture(测试前置数据)覆盖非 leader 与非主线程的 stdin 等待、线程本地 fd 表、`/dev/tty` 别名、拒绝把指向管道的 fd 0 当作终端输入、僵尸进程完全停稳、不可读进程状态、受支持的 syscall 表、不支持的架构和其他误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。 +- 子进程 fixture(测试前置数据)覆盖非 leader 与非主线程的 stdin 等待、线程本地 fd 表、`/dev/tty` 别名、用户态模拟下受支持的内核 ABI、拒绝把指向管道的 fd 0 当作终端输入、僵尸进程完全停稳、不可读进程状态、不支持的架构和其他误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。 - 真实 `node-pty` 与 PTY 消费方测试共同在受支持宿主上覆盖 shell 状态、通过 `/dev/tty` 读取控制终端输入、共享沙箱策略、环境清洗、raw mode 前台 `SIGINT`、忽略 `SIGTERM` 的后代进程,以及 dispose 返回后立即完全停稳。 - Loader 驱动的 `cordis.yml` 测试挂载真实三包组合,并验证延迟到达的流水线输出随已完成命令返回,而不会被归类为终端输入就绪。SDK minimal 快照通过持久 Bash 工具固定该输出;ACP 与 headless 快照通过 opt-in overlay 固定 6 个终端 schema、有界结果和错误;TUI 快照固定 terminal 与 generic 卡片展示。 - 包约定、架构图、子系统页面、生成目录和 website API 描述同一个已发布接口。 diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 5b25e04c1e..3e021ac000 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-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/subprocess/subprocess-local/README.md -README.md: deea6b6b291d684c4d3a31bee562a0d99e90cb65 -README.zh.md: be4396df4b85d3a18c5236bc835b50dfd26e5f19 +README.md: b3e74b75b69dc4c726749b4798f24f3058749ea9 +README.zh.md: bdbbc723fd89574425b3391bc6c4d9639b0cfdac diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index deea6b6b29..b3e74b75b6 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -11,7 +11,7 @@ Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA - **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). - **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. - **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative paths containing separators are rejected at the seam, and relative PATH entries resolve from the host process cwd. -- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Linux reports an exact input wait only when the waiting thread's own fd 0 identifies the shell's controlling terminal, including the `/dev/tty` alias, so a pipeline reader blocked on `pipe:[…]` cannot publish terminal readiness. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes start identities with zero-time process-handle waits for liveness, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's termination because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. +- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Linux reports an exact input wait only when the waiting thread's own fd 0 identifies the shell's controlling terminal, including the `/dev/tty` alias, so a pipeline reader blocked on `pipe:[…]` cannot publish terminal readiness. The syscall probe admits supported runtime architectures and matches every supported kernel ABI so user-mode emulation cannot hide the wait. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes start identities with zero-time process-handle waits for liveness, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's termination because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. - **Terminate-and-join disposal** — the service retains live handles so its own disposal can escalate every running tree and await its exit; quiescent and spawn-failed handles leave the live set after whole-tree or terminal-session cleanup finishes. - **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener force-terminates every ordinary tree and observable terminal session still in the same live sets. The local-only operations send POSIX SIGKILL to the managed group, run Windows `taskkill /T /F`, and synchronously signal captured/current terminal identities around the PTY root kill; they create no promise or timer, preserve the host's exit code and diagnostic, contain each target's failure, and do not claim quiescence. Normal disposal keeps the awaited graceful path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md). diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index be4396df4b..bdbbc723fd 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -11,7 +11,7 @@ - **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md)。 - **基于偏移量的读取**:收集模式的读取器按完整流的字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 - **可执行文件查找**:`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该 seam 处被拒绝,相对 PATH 条目从宿主进程 cwd 解析。 -- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。Linux 只有在等待线程自身的 fd 0 标识 shell 的控制终端(包括 `/dev/tty` 别名)时才报告精确输入等待,因此阻塞于 `pipe:[…]` 的流水线读取端无法发布终端就绪状态。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 启动身份与进程句柄零时等待结合起来判断存活状态,并把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组)。拆卸会验证 shell 已终止,因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 +- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。Linux 只有在等待线程自身的 fd 0 标识 shell 的控制终端(包括 `/dev/tty` 别名)时才报告精确输入等待,因此阻塞于 `pipe:[…]` 的流水线读取端无法发布终端就绪状态。syscall 探针会准入受支持的运行时架构并匹配每个受支持的内核 ABI,使用户态模拟无法隐藏该等待。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 启动身份与进程句柄零时等待结合起来判断存活状态,并把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组)。拆卸会验证 shell 已终止,因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 - **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,使自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;完全停稳与 spawn 失败的句柄会在整棵进程树或 terminal session 清理完成后离开存活集合。 - **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会强制终止同一组存活集合中仍存在的每棵普通进程树和可观察 terminal session。这些仅供本地实现使用的操作会向受管 POSIX 进程组发送 SIGKILL、在 Windows 运行 `taskkill /T /F`,并在终止 PTY root 前后同步向已捕获及当前可观察的 terminal 身份发送信号;它们不会创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待温和路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md)。 diff --git a/packages/subprocess/subprocess-local/src/process-inspector.ts b/packages/subprocess/subprocess-local/src/process-inspector.ts index e13ced066f..7a74213baf 100644 --- a/packages/subprocess/subprocess-local/src/process-inspector.ts +++ b/packages/subprocess/subprocess-local/src/process-inspector.ts @@ -254,23 +254,35 @@ const SYSCALLS: Partial> = { arm64: { read: 63, pselect: 72, ppoll: 73, epollPwait: 22 }, } +const SUPPORTED_SYSCALL_TABLES = Object.values(SYSCALLS) + +// `/proc//task//syscall` uses the kernel ABI's numbers. User-mode +// emulation can therefore expose a supported table different from process.arch. +function linuxSyscallTables(arch: NodeJS.Architecture): readonly SyscallTable[] | undefined { + const primary = SYSCALLS[arch] + if (primary === undefined) return undefined + return [primary, ...SUPPORTED_SYSCALL_TABLES.filter(table => table !== primary)] +} + function syscallWaitsOnStdin( internals: ProcessInspectorInternals, pid: number, tid: number, syscall: SyscallInfo, - table: SyscallTable, + tables: readonly SyscallTable[], ): boolean { const [a0 = 0, a1 = 0, a2 = 0] = syscall.args - if (syscall.number === table.read) return a0 === 0 - if (syscall.number === table.select || syscall.number === table.pselect) { - return a0 >= 1 && fdSetHasStdin(internals, pid, a1) - } - if (syscall.number === table.poll || syscall.number === table.ppoll) { - return a1 >= 1 && pollHasStdin(internals, pid, a0, a1) - } - if (syscall.number === table.epollWait || syscall.number === table.epollPwait) { - return a2 >= 1 && epollHasStdin(internals, pid, tid, a0) + for (const table of tables) { + if (syscall.number === table.read) return a0 === 0 + if (syscall.number === table.select || syscall.number === table.pselect) { + return a0 >= 1 && fdSetHasStdin(internals, pid, a1) + } + if (syscall.number === table.poll || syscall.number === table.ppoll) { + return a1 >= 1 && pollHasStdin(internals, pid, a0, a1) + } + if (syscall.number === table.epollWait || syscall.number === table.epollPwait) { + return a2 >= 1 && epollHasStdin(internals, pid, tid, a0) + } } return false } @@ -333,8 +345,8 @@ class LinuxProcessInspector extends PosixProcessInspector { } isStdinWaiting(pgid: number, shellPid: number): boolean { - const table = SYSCALLS[this.arch] - if (table === undefined) return false + const tables = linuxSyscallTables(this.arch) + if (tables === undefined) return false const shell = readLinuxStat(this.internals, shellPid) if (shell === undefined) return false const terminalDevice = readLinuxTerminalDevice(this.internals, shellPid, shell.ttyDevice) @@ -345,7 +357,7 @@ class LinuxProcessInspector extends PosixProcessInspector { for (const tid of numericEntries(this.internals, `/proc/${pid}/task`)) { const syscall = readSyscall(this.internals, pid, tid) if (syscall !== undefined - && syscallWaitsOnStdin(this.internals, pid, tid, syscall, table) + && syscallWaitsOnStdin(this.internals, pid, tid, syscall, tables) && readLinuxTerminalDevice(this.internals, pid, process.ttyDevice, tid) === terminalDevice) return true } } diff --git a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts index a56167bb4d..ab34922576 100644 --- a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts @@ -146,7 +146,7 @@ describe('Linux process inspector', () => { expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']]) }) - it('detects read, select, poll, and epoll waits across non-leader threads', () => { + it('detects supported kernel ABI waits across non-leader threads', () => { const fake = fakeInternals() fake.dirs.set('/proc', ['100', '101']) fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1')) @@ -163,6 +163,8 @@ describe('Linux process inspector', () => { fake.files.set('/proc/101/task/101/syscall', '-1 0x0') fake.files.set('/proc/101/task/102/syscall', syscall(0, 0)) expect(inspector.isStdinWaiting(77, 100)).toBe(true) + fake.files.set('/proc/101/task/102/syscall', syscall(63, 0)) + expect(inspector.isStdinWaiting(77, 100)).toBe(true) fake.files.set('/proc/101/task/102/syscall', syscall(270, 1, 0x10)) const fdSet = Buffer.alloc(0x11) From 133ed2d0f0c86b6c85acf22ac5d21e0ee97a24e8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 25 Aug 2026 14:08:30 +0800 Subject: [PATCH 04/12] test(pty): report proc state on readiness failure --- .../terminal-bash/tests/local.spec.ts | 53 ++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts index 4a90049997..254ea11415 100644 --- a/packages/terminal/terminal-bash/tests/local.spec.ts +++ b/packages/terminal/terminal-bash/tests/local.spec.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync } from 'node:fs' import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -118,6 +118,55 @@ function processIsRunning(pid: number): boolean { } } +function linuxBashProcDiagnostics(): string { + const read = (path: string): string => { + try { + return readFileSync(path, 'utf8').trim() + } catch (error) { + return error instanceof Error ? `${error.name}: ${error.message}` : String(error) + } + } + const ps = spawnSync('/bin/ps', ['-eo', 'pid=,ppid=,pgid=,sid=,stat=,args='], { encoding: 'utf8' }) + const bash = ps.stdout.split('\n').filter(line => /(?:^|\s)(?:\/bin\/)?bash(?:\s|$)/.test(line)) + const processes = bash.map((line) => { + const pid = /^\s*(\d+)/.exec(line)?.[1] + if (pid === undefined) return { line } + let tids: string[] + try { + tids = readdirSync(`/proc/${pid}/task`) + } catch (error) { + return { line, tasks: error instanceof Error ? `${error.name}: ${error.message}` : String(error) } + } + return { + line, + stat: read(`/proc/${pid}/stat`), + tasks: tids.map((tid) => { + const fd = `/proc/${pid}/task/${tid}/fd/0` + let fd0: string + let rdev: string + try { + fd0 = readlinkSync(fd) + } catch (error) { + fd0 = error instanceof Error ? `${error.name}: ${error.message}` : String(error) + } + try { + const device = statSync(fd) + rdev = `${device.rdev} character=${device.isCharacterDevice()}` + } catch (error) { + rdev = error instanceof Error ? `${error.name}: ${error.message}` : String(error) + } + return { tid, syscall: read(`/proc/${pid}/task/${tid}/syscall`), fd0, rdev } + }), + } + }) + return JSON.stringify({ + arch: process.arch, + machine: spawnSync('/bin/uname', ['-m'], { encoding: 'utf8' }).stdout.trim(), + ptraceScope: read('/proc/sys/kernel/yama/ptrace_scope'), + processes, + }, undefined, 2) +} + // The real-shell suite drives a POSIX bash over the actual node-pty terminal; // Windows has no bash, and its pwsh counterpart lives in the describe below. describe.skipIf(process.platform === 'win32')('terminal-bash real shell', () => { @@ -175,7 +224,7 @@ describe.skipIf(process.platform === 'win32')('terminal-bash real shell', () => submit: true, }) await waitForOutput(waiting, 'WAITING') - expect((await waiting.done).waitReason).toBe('stdin_read') + expect((await waiting.done).waitReason, linuxBashProcDiagnostics()).toBe('stdin_read') const answer = ctx.terminals.startSend(agent, created.sessionId, { text: 'accepted', submit: true }) const result = await answer.done From a3f67137bc48437b60029322fd0f3a34ab951697 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 25 Aug 2026 14:19:56 +0800 Subject: [PATCH 05/12] test(pty): cover restricted proc syscall access --- ...26-07-16-persistent-pty-sessions.i18n.yaml | 4 +- .../2026-07-16-persistent-pty-sessions.md | 4 +- .../2026-07-16-persistent-pty-sessions.zh.md | 4 +- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 2 +- .../subprocess/subprocess-local/README.zh.md | 2 +- .../terminal-bash/tests/local.spec.ts | 72 +++++-------------- 7 files changed, 29 insertions(+), 63 deletions(-) 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 84c8a040c3..d4e5b38da7 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: 8cbec14dae0517a82925d871843a1998f4734709 -2026-07-16-persistent-pty-sessions.zh.md: 91d07c2dcd1f58ffb53a20955740c7205ace5d92 +2026-07-16-persistent-pty-sessions.md: 08ccf64586034b5a0524889f33da89213ac7275d +2026-07-16-persistent-pty-sessions.zh.md: 5225d781fc8eba1fb4a6c7f4d3b5d4d87547b71d 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 8cbec14dae..08ccf64586 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 @@ -74,7 +74,7 @@ With `run_in_background: true`, `dsh-tool-terminal` registers the in-flight send The local backend first recognizes a private OSC prompt marker emitted by its controlled bash startup, then requires the printable tail after the latest marker to exactly equal the controlled `PS1` before declaring prompt readiness and runs three bounded fallback tiers. Carrying that tail across data callbacks covers delivery where the marker and prompt arrive separately; requiring the exact tail rejects a delayed earlier prompt once echoed input or output follows it, so it cannot settle the current send. The marker is removed before output reaches the model and avoids a fixed silence delay for ordinary shell commands on both platforms. Unpublished startup does not accept zero-output silence as readiness; timeout rejects the spawn. If caller cancellation wins during startup, the backend closes the private session and propagates the exact `AbortSignal.reason`; a foreground PGID that is not observable yet cannot replace cancellation with a lookup error. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, `handoffGraceMs`, and `timeoutMs`. -On Linux, the inspector reads the shell's terminal foreground PGID from `/proc//stat`, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct `read(0)`, a permitted read of a `select`/`pselect6` or `poll`/`ppoll` argument containing fd 0, or an epoll interest list containing fd 0. The waiting thread's `/proc//task//fd/0` must identify the shell's controlling terminal device, so a thread-local fd table cannot substitute its leader's terminal descriptor and a pipeline reader blocked on its pipe remains a running command. Direct PTY descriptors use their device number; `/dev/tty` uses the owning process's `tty_nr` because `stat` reports the alias device rather than the selected PTY. A wait already present before terminal input is not post-write readiness: the same PGID must be observed outside that wait before re-entering it, while a changed foreground PGID is new evidence. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; the inspector admits the runtime architecture, then checks every supported kernel ABI because `/proc` can report a different ABI under user-mode emulation. Unsupported runtime architectures skip Tier 1. +On Linux, the inspector reads the shell's terminal foreground PGID from `/proc//stat`, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct `read(0)`, a permitted read of a `select`/`pselect6` or `poll`/`ppoll` argument containing fd 0, or an epoll interest list containing fd 0. The waiting thread's `/proc//task//fd/0` must identify the shell's controlling terminal device, so a thread-local fd table cannot substitute its leader's terminal descriptor and a pipeline reader blocked on its pipe remains a running command. Direct PTY descriptors use their device number; `/dev/tty` uses the owning process's `tty_nr` because `stat` reports the alias device rather than the selected PTY. A wait already present before terminal input is not post-write readiness: the same PGID must be observed outside that wait before re-entering it, while a changed foreground PGID is new evidence. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. A host policy that denies `/proc//task//syscall`, including hardened ptrace policy, likewise skips Tier 1 and preserves the bounded Tier 2 idle inference; process sleep state never substitutes for inaccessible syscall evidence. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; the inspector admits the runtime architecture, then checks every supported kernel ABI because `/proc` can report a different ABI under user-mode emulation. Unsupported runtime architectures skip Tier 1. On macOS there is no exact syscall tier. Output silence returns `inferred_idle` for any foreground process group, including Python and `gdb`; `ps`-derived terminal PGID is used for signaling, not as proof that only the shell can be idle. Pure process-inspector logic is injectable and unit-tested on Linux, while a macOS CI job exercises the real PTY and process-table path. @@ -158,7 +158,7 @@ The package ships concise tool guidance explaining persistent state, owner isola - Per-file coverage pins owner fencing, concurrent reservations, cancellation during pre-write inspection, unpublished-spawn cancellation and awaited teardown, sandbox-mode change rejection, retriable lifecycle cleanup, readiness tiers, rejection of pre-write stdin waits and delayed earlier prompts, the configured handoff grace holding the idle fallback past one poll and its rejection below `pollIntervalMs`, sanitizer carry state, complete UTF-8 bounds, task integration, schemas, and exact render intents. - Subprocess process fixtures cover non-leader and non-main-thread stdin waits, thread-local fd tables, the `/dev/tty` alias, supported kernel ABIs under user-mode emulation, rejection of fd 0 backed by a pipe, zombie quiescence, unreadable process state, unsupported architectures, and other false-positive rejection; macOS inspector logic is injected into the same unit suite. -- Real `node-pty` and PTY-consumer tests jointly exercise shell state, controlling-terminal input through `/dev/tty`, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT`, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts. +- Real `node-pty` and PTY-consumer tests jointly exercise shell state, controlling-terminal input through `/dev/tty`, the exact attribution when process syscalls are readable, its bounded idle fallback when host policy denies them, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT`, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts. - A Loader-driven `cordis.yml` test mounts the real three-package composition and verifies that delayed pipeline output returns with the completed command instead of being classified as terminal-input readiness. The SDK minimal snapshot pins that output through the persistent Bash tool; ACP and headless snapshots pin the six terminal schemas, bounded results, and errors through opt-in overlays; TUI snapshots pin terminal and generic card presentation. - Package contracts, the architecture map, subsystem pages, generated catalogs, and the website API describe the same shipped surface. 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 91d07c2dcd..5225d781fc 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 @@ -74,7 +74,7 @@ UI 渲染约定精确且不携带位置信息。`terminal_send` 只为前台发 本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker,并且只有在最近一个 marker 后的可打印尾部与受控 `PS1` 完全相等时才声明 prompt 就绪;除此之外,它还运行 3 个有界 fallback 层级。在 data callback 之间保留该尾部,可以适配 marker 与 prompt 被分开交付的情况;如果回显的输入或输出跟在延迟到达的先前 prompt 之后,要求尾部完全相等会拒绝该 prompt,使其无法完成当前 send。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。尚未发布的 startup 不会把零输出静默视为就绪;timeout 会拒绝 spawn。若调用方取消在 startup 期间胜出,后端会关闭私有会话并原样抛出 `AbortSignal.reason`;尚不可观察的前台 PGID 不会再用查找错误覆盖取消原因。所有时间参数都是经校验的配置字段:`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs`、`handoffGraceMs` 和 `timeoutMs`。 -在 Linux 上,检查器从 `/proc//stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6` 或 `poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。等待线程的 `/proc//task//fd/0` 还必须标识 shell 的控制终端设备,因此线程本地 fd 表无法用 leader 的终端描述符冒充自身 fd,阻塞于管道的流水线读取端仍属于正在运行的命令。直接 PTY 描述符使用其设备号;`/dev/tty` 则使用所属进程的 `tty_nr`,因为 `stat` 报告的是别名设备而非选定的 PTY。终端输入前就已存在的等待并不代表写入后就绪:必须先观察到同一 PGID 脱离该等待,之后再次进入等待才能使该次 send 完成;前台 PGID 发生变化则构成新的证据。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。架构表只包含对应 Linux UAPI 定义的 syscall number;检查器先准入运行时架构,再检查每个受支持的内核 ABI,因为在用户态模拟下,`/proc` 可能报告不同的 ABI。不受支持的运行时架构会跳过 Tier 1。 +在 Linux 上,检查器从 `/proc//stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6` 或 `poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。等待线程的 `/proc//task//fd/0` 还必须标识 shell 的控制终端设备,因此线程本地 fd 表无法用 leader 的终端描述符冒充自身 fd,阻塞于管道的流水线读取端仍属于正在运行的命令。直接 PTY 描述符使用其设备号;`/dev/tty` 则使用所属进程的 `tty_nr`,因为 `stat` 报告的是别名设备而非选定的 PTY。终端输入前就已存在的等待并不代表写入后就绪:必须先观察到同一 PGID 脱离该等待,之后再次进入等待才能使该次 send 完成;前台 PGID 发生变化则构成新的证据。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。宿主策略(包括加固的 ptrace 策略)若拒绝读取 `/proc//task//syscall`,同样会跳过 Tier 1 并保留有界的 Tier 2 idle 推断;进程休眠状态绝不会代替不可访问的 syscall 证据。架构表只包含对应 Linux UAPI 定义的 syscall number;检查器先准入运行时架构,再检查每个受支持的内核 ABI,因为在用户态模拟下,`/proc` 可能报告不同的 ABI。不受支持的运行时架构会跳过 Tier 1。 macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 `inferred_idle`,包括 Python 和 `gdb`;从 `ps` 推导的终端 PGID 只用于发送信号,不作为「只有 shell 才能 idle」的证明。纯进程检查逻辑可注入,并在 Linux 上经过单元测试,同时由 macOS CI job 驱动真实 PTY 和进程表路径。 @@ -158,7 +158,7 @@ plugins: - 逐文件覆盖测试锁定了 owner 隔离、并发预留、写入前检查期间的取消、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、对写入前 stdin 等待与延迟到达的先前 prompt 的拒绝、配置化交接宽限把 idle fallback 顶过一次轮询以及低于 `pollIntervalMs` 时的拒绝、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。 - 子进程 fixture(测试前置数据)覆盖非 leader 与非主线程的 stdin 等待、线程本地 fd 表、`/dev/tty` 别名、用户态模拟下受支持的内核 ABI、拒绝把指向管道的 fd 0 当作终端输入、僵尸进程完全停稳、不可读进程状态、不支持的架构和其他误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。 -- 真实 `node-pty` 与 PTY 消费方测试共同在受支持宿主上覆盖 shell 状态、通过 `/dev/tty` 读取控制终端输入、共享沙箱策略、环境清洗、raw mode 前台 `SIGINT`、忽略 `SIGTERM` 的后代进程,以及 dispose 返回后立即完全停稳。 +- 真实 `node-pty` 与 PTY 消费方测试共同在受支持宿主上覆盖 shell 状态、通过 `/dev/tty` 读取控制终端输入、进程 syscall 可读时的精确归因、宿主策略拒绝读取时的有界 idle fallback、共享沙箱策略、环境清洗、raw mode 前台 `SIGINT`、忽略 `SIGTERM` 的后代进程,以及 dispose 返回后立即完全停稳。 - Loader 驱动的 `cordis.yml` 测试挂载真实三包组合,并验证延迟到达的流水线输出随已完成命令返回,而不会被归类为终端输入就绪。SDK minimal 快照通过持久 Bash 工具固定该输出;ACP 与 headless 快照通过 opt-in overlay 固定 6 个终端 schema、有界结果和错误;TUI 快照固定 terminal 与 generic 卡片展示。 - 包约定、架构图、子系统页面、生成目录和 website API 描述同一个已发布接口。 diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 3e021ac000..2a89608a4e 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-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/subprocess/subprocess-local/README.md -README.md: b3e74b75b69dc4c726749b4798f24f3058749ea9 -README.zh.md: bdbbc723fd89574425b3391bc6c4d9639b0cfdac +README.md: 9dc9efff220f8d1e60766438be39c3a5a0708cfa +README.zh.md: 0090942019a693025f653a0f52fc4f4243fb679a diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index b3e74b75b6..9dc9efff22 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -11,7 +11,7 @@ Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA - **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). - **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. - **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative paths containing separators are rejected at the seam, and relative PATH entries resolve from the host process cwd. -- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Linux reports an exact input wait only when the waiting thread's own fd 0 identifies the shell's controlling terminal, including the `/dev/tty` alias, so a pipeline reader blocked on `pipe:[…]` cannot publish terminal readiness. The syscall probe admits supported runtime architectures and matches every supported kernel ABI so user-mode emulation cannot hide the wait. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes start identities with zero-time process-handle waits for liveness, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's termination because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. +- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Linux reports an exact input wait only when the waiting thread's own fd 0 identifies the shell's controlling terminal, including the `/dev/tty` alias, so a pipeline reader blocked on `pipe:[…]` cannot publish terminal readiness. The syscall probe admits supported runtime architectures and matches every supported kernel ABI so user-mode emulation cannot hide the wait. When Linux denies `/proc//task//syscall`, the inspector reports no exact wait and leaves the higher PTY backend to its configured idle inference; process sleep state never substitutes for syscall evidence. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes start identities with zero-time process-handle waits for liveness, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's termination because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. - **Terminate-and-join disposal** — the service retains live handles so its own disposal can escalate every running tree and await its exit; quiescent and spawn-failed handles leave the live set after whole-tree or terminal-session cleanup finishes. - **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener force-terminates every ordinary tree and observable terminal session still in the same live sets. The local-only operations send POSIX SIGKILL to the managed group, run Windows `taskkill /T /F`, and synchronously signal captured/current terminal identities around the PTY root kill; they create no promise or timer, preserve the host's exit code and diagnostic, contain each target's failure, and do not claim quiescence. Normal disposal keeps the awaited graceful path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md). diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index bdbbc723fd..0090942019 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -11,7 +11,7 @@ - **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md)。 - **基于偏移量的读取**:收集模式的读取器按完整流的字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 - **可执行文件查找**:`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该 seam 处被拒绝,相对 PATH 条目从宿主进程 cwd 解析。 -- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。Linux 只有在等待线程自身的 fd 0 标识 shell 的控制终端(包括 `/dev/tty` 别名)时才报告精确输入等待,因此阻塞于 `pipe:[…]` 的流水线读取端无法发布终端就绪状态。syscall 探针会准入受支持的运行时架构并匹配每个受支持的内核 ABI,使用户态模拟无法隐藏该等待。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 启动身份与进程句柄零时等待结合起来判断存活状态,并把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组)。拆卸会验证 shell 已终止,因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 +- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。Linux 只有在等待线程自身的 fd 0 标识 shell 的控制终端(包括 `/dev/tty` 别名)时才报告精确输入等待,因此阻塞于 `pipe:[…]` 的流水线读取端无法发布终端就绪状态。syscall 探针会准入受支持的运行时架构并匹配每个受支持的内核 ABI,使用户态模拟无法隐藏该等待。当 Linux 拒绝读取 `/proc//task//syscall` 时,检查器不会报告精确等待,而是由上层 PTY 后端按配置执行 idle 推断;进程休眠状态绝不会代替 syscall 证据。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 启动身份与进程句柄零时等待结合起来判断存活状态,并把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组)。拆卸会验证 shell 已终止,因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 - **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,使自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;完全停稳与 spawn 失败的句柄会在整棵进程树或 terminal session 清理完成后离开存活集合。 - **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会强制终止同一组存活集合中仍存在的每棵普通进程树和可观察 terminal session。这些仅供本地实现使用的操作会向受管 POSIX 进程组发送 SIGKILL、在 Windows 运行 `taskkill /T /F`,并在终止 PTY root 前后同步向已捕获及当前可观察的 terminal 身份发送信号;它们不会创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待温和路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md)。 diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts index 254ea11415..ad6dbc010d 100644 --- a/packages/terminal/terminal-bash/tests/local.spec.ts +++ b/packages/terminal/terminal-bash/tests/local.spec.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs' import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -118,53 +118,15 @@ function processIsRunning(pid: number): boolean { } } -function linuxBashProcDiagnostics(): string { - const read = (path: string): string => { - try { - return readFileSync(path, 'utf8').trim() - } catch (error) { - return error instanceof Error ? `${error.name}: ${error.message}` : String(error) - } +function canReadLinuxProcessSyscall(pid: number): boolean { + try { + readFileSync(`/proc/${pid}/task/${pid}/syscall`, 'utf8') + return true + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'EACCES' || code === 'EPERM') return false + throw error } - const ps = spawnSync('/bin/ps', ['-eo', 'pid=,ppid=,pgid=,sid=,stat=,args='], { encoding: 'utf8' }) - const bash = ps.stdout.split('\n').filter(line => /(?:^|\s)(?:\/bin\/)?bash(?:\s|$)/.test(line)) - const processes = bash.map((line) => { - const pid = /^\s*(\d+)/.exec(line)?.[1] - if (pid === undefined) return { line } - let tids: string[] - try { - tids = readdirSync(`/proc/${pid}/task`) - } catch (error) { - return { line, tasks: error instanceof Error ? `${error.name}: ${error.message}` : String(error) } - } - return { - line, - stat: read(`/proc/${pid}/stat`), - tasks: tids.map((tid) => { - const fd = `/proc/${pid}/task/${tid}/fd/0` - let fd0: string - let rdev: string - try { - fd0 = readlinkSync(fd) - } catch (error) { - fd0 = error instanceof Error ? `${error.name}: ${error.message}` : String(error) - } - try { - const device = statSync(fd) - rdev = `${device.rdev} character=${device.isCharacterDevice()}` - } catch (error) { - rdev = error instanceof Error ? `${error.name}: ${error.message}` : String(error) - } - return { tid, syscall: read(`/proc/${pid}/task/${tid}/syscall`), fd0, rdev } - }), - } - }) - return JSON.stringify({ - arch: process.arch, - machine: spawnSync('/bin/uname', ['-m'], { encoding: 'utf8' }).stdout.trim(), - ptraceScope: read('/proc/sys/kernel/yama/ptrace_scope'), - processes, - }, undefined, 2) } // The real-shell suite drives a POSIX bash over the actual node-pty terminal; @@ -213,23 +175,27 @@ describe.skipIf(process.platform === 'win32')('terminal-bash real shell', () => }, 20_000) it.skipIf(process.platform !== 'linux')('recognizes a foreground read opened through /dev/tty', async () => { - const { ctx, agent } = await harness('danger-full-access', { + const { ctx, root, agent } = await harness('danger-full-access', { idleSilenceMs: 5_000, timeoutMs: 8_000, }) const created = await ctx.terminals.spawn(agent, { type: 'shell' }) + const readerPidFile = join(root, 'tty-reader.pid') const waiting = ctx.terminals.startSend(agent, created.sessionId, { - text: 'bash -c \'exec "$1"; printf "WAITING\\n"; read -r answer; printf "ANSWER=%s\\n" "$answer"' dsh "${readerPidFile}"`, submit: true, }) await waitForOutput(waiting, 'WAITING') - expect((await waiting.done).waitReason, linuxBashProcDiagnostics()).toBe('stdin_read') + const result = await waiting.done + const readerPid = Number(readFileSync(readerPidFile, 'utf8')) + expect(readerPid).toBeGreaterThan(0) + expect(result.waitReason).toBe(canReadLinuxProcessSyscall(readerPid) ? 'stdin_read' : 'inferred_idle') const answer = ctx.terminals.startSend(agent, created.sessionId, { text: 'accepted', submit: true }) - const result = await answer.done - expect(result.waitReason).toBe('stdin_read') - expect(result.viewport).toContain('ANSWER=accepted') + const answered = await answer.done + expect(answered.waitReason).toBe('stdin_read') + expect(answered.viewport).toContain('ANSWER=accepted') await ctx.terminals.kill(agent, created.sessionId) }, 20_000) From 3e24087bfaeabe40b58ba2f7b936895b8f93fe27 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:59:04 +0800 Subject: [PATCH 06/12] fix(web): authenticate the browser Host API --- ...07-28-api-browser-trust-boundary.i18n.yaml | 4 +- .../2026-07-28-api-browser-trust-boundary.md | 10 +- ...026-07-28-api-browser-trust-boundary.zh.md | 10 +- ...26-07-30-config-plane-boundaries.i18n.yaml | 4 +- .../2026-07-30-config-plane-boundaries.md | 2 +- .../2026-07-30-config-plane-boundaries.zh.md | 2 +- .../2026-07-30-web-config-plane.i18n.yaml | 4 +- .../2026-07-30-web-config-plane.md | 4 +- .../2026-07-30-web-config-plane.zh.md | 4 +- ...-08-03-per-session-agent-presets.i18n.yaml | 4 +- .../2026-08-03-per-session-agent-presets.md | 2 +- ...2026-08-03-per-session-agent-presets.zh.md | 2 +- ...-provider-endpoint-interrogation.i18n.yaml | 4 +- ...4-draft-provider-endpoint-interrogation.md | 2 +- ...raft-provider-endpoint-interrogation.zh.md | 2 +- ...08-04-websocket-downlink-carrier.i18n.yaml | 4 +- .../2026-08-04-websocket-downlink-carrier.md | 2 +- ...026-08-04-websocket-downlink-carrier.zh.md | 2 +- ...12-plugin-owned-settings-surface.i18n.yaml | 4 +- ...026-08-12-plugin-owned-settings-surface.md | 2 +- ...-08-12-plugin-owned-settings-surface.zh.md | 2 +- ...-24-browser-token-authentication.i18n.yaml | 6 + ...2026-08-24-browser-token-authentication.md | 45 +++ ...6-08-24-browser-token-authentication.zh.md | 45 +++ ...8-06-host-backed-web-preferences.i18n.yaml | 4 +- .../2026-08-06-host-backed-web-preferences.md | 4 +- ...26-08-06-host-backed-web-preferences.zh.md | 4 +- .../2026-07-22-web-bind-address.i18n.yaml | 4 +- .../feature/2026-07-22-web-bind-address.md | 10 +- .../feature/2026-07-22-web-bind-address.zh.md | 10 +- ...-07-28-tool-call-file-open-in-os.i18n.yaml | 4 +- .../2026-07-28-tool-call-file-open-in-os.md | 2 +- ...2026-07-28-tool-call-file-open-in-os.zh.md | 4 +- ...-shared-modal-product-onboarding.i18n.yaml | 4 +- ...6-08-13-shared-modal-product-onboarding.md | 2 +- ...8-13-shared-modal-product-onboarding.zh.md | 2 +- ...08-08-copy-only-preset-authoring.i18n.yaml | 4 +- .../2026-08-08-copy-only-preset-authoring.md | 4 +- ...026-08-08-copy-only-preset-authoring.zh.md | 4 +- ...-unary-apiproxy-remote-migration.i18n.yaml | 4 +- ...6-08-10-unary-apiproxy-remote-migration.md | 19 +- ...8-10-unary-apiproxy-remote-migration.zh.md | 19 +- apps/cli/package.json | 4 +- .../tests/fixtures/web-browser-open/open.mjs | 10 +- apps/cli/tests/github-webhook-real.e2e.ts | 39 ++- apps/cli/tests/web-auth.e2e.ts | 210 +++++++++++++ .../tests/web-browser-open.expected.e2e.ts | 15 +- apps/web/package.json | 4 +- apps/web/tests/smoke-real.e2e.ts | 51 +++- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 16 +- docs/config-catalog.zh.md | 16 +- docs/subsystems/web-server.i18n.yaml | 4 +- docs/subsystems/web-server.md | 4 +- docs/subsystems/web-server.zh.md | 4 +- packages/api/gateway/src/index.ts | 8 +- packages/api/gateway/src/stream-server.ts | 11 +- .../gateway/tests/gateway-stream.host.spec.ts | 61 +++- .../api/gateway/tests/gateway.host.spec.ts | 41 ++- packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/bundle/web-app/src/index.ts | 82 +++--- packages/bundle/web-app/tests/web-app.spec.ts | 43 ++- packages/bundle/web-app/tsconfig.json | 3 + packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 12 +- packages/client/connection/README.zh.md | 12 +- packages/client/connection/package.json | 2 + .../client/connection/src/browser-auth.ts | 276 ++++++++++++++++++ packages/client/connection/src/index.ts | 103 ++----- packages/client/connection/src/invariant.ts | 11 +- packages/client/connection/src/rpc-host.ts | 53 ++-- packages/client/connection/src/rpc.ts | 49 +++- .../tests/browser-auth.host.spec.ts | 231 +++++++++++++++ .../connection/tests/node-half.host.spec.ts | 262 ++++++++++------- packages/client/connection/tsconfig.host.json | 4 + packages/client/locale/README.i18n.yaml | 4 +- packages/client/locale/README.md | 2 +- packages/client/locale/README.zh.md | 2 +- .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 2 +- packages/client/ui-agent-preset/README.zh.md | 2 +- .../src/client/settings-store.ts | 2 +- .../tests/settings-store.client.spec.ts | 4 +- .../src/client/settings-store.ts | 2 +- .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 2 +- .../client/ui-settings-general/README.zh.md | 2 +- .../tests/apply.client.spec.ts | 2 +- .../tests/welcome-store.client.spec.ts | 2 +- packages/client/ui-settings/README.i18n.yaml | 4 +- packages/client/ui-settings/README.md | 2 +- packages/client/ui-settings/README.zh.md | 2 +- .../ui-settings/src/client/settings-mirror.ts | 2 +- .../ui-settings/src/client/settings-scope.ts | 2 +- packages/client/ui-theme/README.i18n.yaml | 4 +- packages/client/ui-theme/README.md | 2 +- packages/client/ui-theme/README.zh.md | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 6 +- packages/host/apiproxy/README.zh.md | 6 +- packages/host/apiproxy/src/api-proxy.ts | 5 +- .../host/apiproxy/src/api/agent-presets.ts | 10 +- packages/host/apiproxy/src/api/host.ts | 4 +- packages/host/apiproxy/src/api/settings.ts | 4 +- .../apiproxy/tests/api-proxy-config.spec.ts | 4 +- .../host/frontend-static/README.i18n.yaml | 4 +- packages/host/frontend-static/README.md | 2 + packages/host/frontend-static/README.zh.md | 2 + packages/host/frontend-static/package.json | 3 + packages/host/frontend-static/src/index.ts | 22 +- .../tests/frontend-static.spec.ts | 46 ++- packages/host/frontend-static/tsconfig.json | 3 + pnpm-lock.yaml | 21 ++ 115 files changed, 1617 insertions(+), 522 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md create mode 100644 .agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md create mode 100644 apps/cli/tests/web-auth.e2e.ts create mode 100644 packages/client/connection/src/browser-auth.ts create mode 100644 packages/client/connection/tests/browser-auth.host.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml index c206e01dba..f2e1a2ed33 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.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-api-browser-trust-boundary.md -2026-07-28-api-browser-trust-boundary.md: 92b76c109aa9b55bc72bb76f8b208ea2d814d8ce -2026-07-28-api-browser-trust-boundary.zh.md: 653ff32f2e62bf0e2982517f82649ff2d06e40d4 +2026-07-28-api-browser-trust-boundary.md: 2997b0f2affaac59be9cd58108bc20086442f6db +2026-07-28-api-browser-trust-boundary.zh.md: ff952ede9adc53c9d590ad2ba24a32b448172f82 diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md index 92b76c109a..2997b0f2af 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md @@ -6,7 +6,7 @@ English | [中文](2026-07-28-api-browser-trust-boundary.zh.md) ## Problem -The web GUI host serves `/api` over plain HTTP (default `127.0.0.1:3080`, `--host 0.0.0.0` supported), and the surface includes remote-code-execution-grade methods — `session.prompt` drives an agent that runs bash. A browser turns the operator into a confused deputy against such a local API in two classic ways: a malicious page fires a "simple" cross-site POST (`text/plain` — sent without a CORS preflight) whose side effects execute even though the response stays unreadable, and a DNS-rebound origin talks to the socket as if same-origin, making CORS inapplicable entirely, with only the `Host` header betraying the attacker's domain. Before this decision the system's only browser-trust check (`isTrustedNativeDialogRequest`: loopback socket + same-origin + loopback Host) guarded exactly one cosmetic route — `host.pickDirectory`, whose native dialog pops on the host's screen — while every consequential method was unguarded. Guarding per-RPC also could not survive the in-app directory browser, whose whole point is serving legitimately remote clients that a loopback rule would refuse. +The web GUI host serves `/api` over plain loopback HTTP (default `127.0.0.1:3080`; the CLI rejects `--host 0.0.0.0`), and the surface includes remote-code-execution-grade methods — `session.prompt` drives an agent that runs bash. A browser turns the operator into a confused deputy against such a local API in two classic ways: a malicious page fires a "simple" cross-site POST (`text/plain` — sent without a CORS preflight) whose side effects execute even though the response stays unreadable, and a DNS-rebound origin talks to the socket as if same-origin, making CORS inapplicable entirely, with only the `Host` header betraying the attacker's domain. Before this decision the system's only browser-trust check (`isTrustedNativeDialogRequest`: loopback socket + same-origin + loopback Host) guarded exactly one cosmetic route — `host.pickDirectory`, whose native dialog pops on the host's screen — while every consequential method was unguarded. Guarding per-RPC also could not survive the in-app directory browser, whose whole point is serving legitimately remote clients that a loopback rule would refuse. ## Decision @@ -15,17 +15,17 @@ Enforce browser trust once, at the carrier, for the entire `/api` prefix — two - **Media-type fence (dsh-host-apiproxy)**: every `/api` POST must declare `application/json`, else 415 before parsing. Cross-site "simple" requests thereby stop existing: any cross-site attempt is forced into a CORS preflight this server never answers. - **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: every request must present a `Host` that is loopback or matches a `trustedHosts` entry (exact on `host:port`, any port on port-less entries, WHATWG-normalized; rebinding defense). Deliberately no shortcut for unmarked requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may be a rebound browser read whose response the page can read, and Host is the one header rebinding cannot forge; non-browser clients pass via loopback, the derived LAN IP literals, or a declared authority. An attached `Origin` must equal the Host authority; `sec-fetch-site: cross-site` is refused outright. A `trustedHosts` entry that is not a bare, canonical authority fails the plugin load — WHATWG parsing would otherwise quietly authorize the hostname inside a typo or broaden an exact-port grant. `host.pickDirectory` loses its bespoke guard and rides the same fence. -Two boundaries stay deliberately out of scope: reachability is the webserver binding's policy (`host: 127.0.0.1 | 0.0.0.0`), and authentication for genuinely remote deployments is deferred work recorded in the connection README — the fence is a confused-deputy defense, not an auth layer. The old guard's loopback-socket check was dropped rather than generalized: with binding expressing reachability and `trustedHosts` naming remote authorities, the socket address adds nothing a header fence does not already cover. +Reachability remains the webserver binding's policy (`host: 127.0.0.1 | 0.0.0.0`), and this fence remains a confused-deputy defense rather than identity. Connection applies the separate [browser token authentication](2026-08-24-browser-token-authentication.md) after the fence. The old guard's loopback-socket check was dropped rather than generalized: with binding expressing reachability and `trustedHosts` naming accepted authorities, the socket address adds nothing the Host/Origin checks need. ## Alternatives considered - **Per-RPC guards (status quo extended).** Rejected: the guard list trails the method list forever, the highest-value methods were already unguarded, and a loopback rule on browse RPCs would break the remote deployments they exist for. - **CORS headers + credential omission.** Rejected: we never want cross-origin reads at all, so answering preflights only widens the surface; refusing them is strictly stronger and simpler. -- **Authentication tokens.** Rejected for this change: token minting/storage/rotation is real product surface; the fence closes the browser-deputy holes without pre-deciding the auth design. +- **Authentication tokens.** Rejected for this change: token minting, storage, and rotation are separate product decisions. The later [browser token authentication](2026-08-24-browser-token-authentication.md) owns them without changing this fence. ## Consequences - Any future `/api` method is covered by construction; there is no per-route trust decision left to forget. -- Non-loopback deployments must have their serving authorities trusted or requests are refused. The dsh CLI keeps its advertised `--host 0.0.0.0` LAN URL working by deriving the machine's LAN IP literals into the connection row (port-less entries — an IP-literal Host cannot be a rebound name, and the bound port may be OS-assigned) and offers `dsh web --trusted-host` for named authorities; compositions the CLI does not boot declare `trustedHosts` themselves. Non-browser automation rides the same fence: loopback, a derived LAN IP, or a declared authority passes; an undeclared DNS alias is refused. +- A custom non-loopback composition must trust its serving authorities or requests are refused, then satisfy browser authentication like every loopback request. The shipped CLI rejects `--host 0.0.0.0`; `--trusted-host` only extends the Host/Origin fence and grants no identity. - Clients must label POST bodies `application/json` (ours always did; raw-fetch tests gained the header). -- The trusted-network assumption of an unauthenticated `0.0.0.0` deployment is now documented instead of implicit. +- Host and Origin remain request-routing evidence only. The process token and signed cookie establish the browser identity used by every Host method. diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md index 653ff32f2e..ff952ede9a 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -Web GUI 宿主以纯 HTTP 提供 `/api`(默认 `127.0.0.1:3080`,支持 `--host 0.0.0.0`),而这个面上有远程代码执行级别的方法——`session.prompt` 驱动的 agent(智能体)可以运行 bash。浏览器会用两种经典方式把操作者变成攻击此类本地 API 的「混淆代理人」:恶意页面发出跨站「简单请求」 POST(`text/plain`——不经 CORS 预检即发出),其副作用照常执行、只是响应不可读;以及 DNS rebinding 后的源以「同源」身份直连 socket,CORS 整体失效,只有 `Host` 头会暴露攻击者的域名。在本决策之前,系统里唯一的浏览器信任检查(`isTrustedNativeDialogRequest`:回环 socket、同源、回环 Host)只守着一个装饰性的路由——`host.pickDirectory`,其原生对话框弹在宿主屏幕上——而所有真正具有严重后果的方法都没有防护。按 RPC 逐个设防也活不过应用内目录浏览器:它存在的意义就是服务合法的远程客户端,回环规则恰恰会拒绝它们。 +Web GUI 宿主以纯 loopback HTTP 提供 `/api`(默认 `127.0.0.1:3080`;CLI 拒绝 `--host 0.0.0.0`),而这个面上有远程代码执行级别的方法——`session.prompt` 驱动的 agent(智能体)可以运行 bash。浏览器会用两种经典方式把操作者变成攻击此类本地 API 的「混淆代理人」:恶意页面发出跨站「简单请求」 POST(`text/plain`——不经 CORS 预检即发出),其副作用照常执行、只是响应不可读;以及 DNS rebinding 后的源以「同源」身份直连 socket,CORS 整体失效,只有 `Host` 头会暴露攻击者的域名。在本决策之前,系统里唯一的浏览器信任检查(`isTrustedNativeDialogRequest`:回环 socket、同源、回环 Host)只守着一个装饰性的路由——`host.pickDirectory`,其原生对话框弹在宿主屏幕上——而所有真正具有严重后果的方法都没有防护。按 RPC 逐个设防也活不过应用内目录浏览器:它存在的意义就是服务合法的远程客户端,回环规则恰恰会拒绝它们。 ## 决策 @@ -15,17 +15,17 @@ Web GUI 宿主以纯 HTTP 提供 `/api`(默认 `127.0.0.1:3080`,支持 `--ho - **媒体类型栅栏(dsh-host-apiproxy)**:每个 `/api` POST 必须声明 `application/json`,否则在解析前以 415 拒绝。跨站「简单请求」由此不复存在:任何跨站尝试都被逼进一次本服务器从不应答的 CORS 预检。 - **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:每个请求的 `Host` 都必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化;rebinding 防御)。刻意不为无标记请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求可能是被重绑页面发起且响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、推导的 LAN IP 字面量或已声明的权威通过。若带 `Origin` 则必须与 Host 权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。不是单纯规范化 authority 的 `trustedHosts` 条目会导致插件加载失败——否则 WHATWG 解析会悄悄授权笔误里的 hostname,或放大精确端口授权。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。 -两条边界刻意留在范围之外:可达性由 webserver 的绑定配置(`host: 127.0.0.1 | 0.0.0.0`)控制;真正远程部署的认证是延期工作,记录在 connection README——这道栅栏是混淆代理人防御,不是认证层。旧守卫的回环 socket 检查被放弃而非泛化:绑定表达可达性、`trustedHosts` 点名远程权威之后,socket 地址提供不了头部栅栏覆盖不到的任何东西。 +可达性仍由 webserver 的绑定配置(`host: 127.0.0.1 | 0.0.0.0`)控制,这道栅栏仍是混淆代理人防御,而不是身份。Connection 在栅栏之后应用独立的[浏览器令牌认证](2026-08-24-browser-token-authentication.zh.md)。旧守卫的回环 socket 检查被放弃而非泛化:绑定表达可达性、`trustedHosts` 点名接受的 authority 之后,socket 地址提供不了 Host/Origin 校验需要的额外信息。 ## 曾考虑的替代方案 - **按 RPC 设防(延续现状)。** 否决:守卫清单永远追着方法清单跑,价值最高的方法本来就没被守住,而 browse RPC 上的回环规则会破坏它们为之存在的远程部署。 - **CORS 头与省略凭据。** 否决:我们根本不想要任何跨源读取,应答预检只会扩大暴露面;拒绝预检严格更强也更简单。 -- **认证令牌。** 在本变更中否决:令牌的签发、存储、轮换是真实的产品面;栅栏能够封死浏览器混淆代理人漏洞,无需预先决定认证设计。 +- **认证令牌。** 在本变更中否决:令牌签发、存储与轮换属于独立产品决策。后续[浏览器令牌认证](2026-08-24-browser-token-authentication.zh.md)持有这些机制,不改变本栅栏。 ## 后果 - 未来任何 `/api` 方法天然在覆盖范围内;不存在会被遗忘的按路由信任决定。 -- 非回环部署的对外服务 authority 必须列入信任范围,否则请求会被拒绝。dsh CLI 通过把本机 LAN IP 字面量推导进 connection 行(不带端口的条目——IP 字面量 Host 不可能是被重绑的域名,且绑定端口可能由操作系统分配)来保住它公布的 `--host 0.0.0.0` LAN URL,并提供 `dsh web --trusted-host` 声明具名权威;并非由 CLI 启动的组合自行声明 `trustedHosts`。非浏览器自动化走同一道栅栏:回环地址、推导的 LAN IP 或已声明的权威可通过;未声明的 DNS 别名会被拒绝。 +- 自定义非 loopback 组合必须信任其服务 authority,否则请求会被拒绝;随后仍像每个 loopback 请求一样满足浏览器认证。随附 CLI 拒绝 `--host 0.0.0.0`;`--trusted-host` 只扩展 Host/Origin 栅栏,绝不授予身份。 - 客户端必须给 POST 体标注 `application/json`(我们自己的客户端一向如此;裸 fetch 测试补上了该头)。 -- 无认证 `0.0.0.0` 部署的「可信网络」假设从隐含变为成文。 +- Host 与 Origin 仍只是请求路由证据。进程令牌与签名 cookie 建立每个 Host 方法使用的浏览器身份。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml index ac8f90ed76..08e1de4e5f 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.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-30-config-plane-boundaries.md -2026-07-30-config-plane-boundaries.md: 7b234ec64669cc1e6f0d5a67437005747edcea98 -2026-07-30-config-plane-boundaries.zh.md: f543c511d9374a50955331911b2eb2d62dab0675 +2026-07-30-config-plane-boundaries.md: d7ae13f08ca5c3957a8a5c1871b0022453e9b562 +2026-07-30-config-plane-boundaries.zh.md: e53a68f91f4d961af5a01fd7bbfb4472b4b17dfe diff --git a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md index 7b234ec646..d7ae13f08c 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md +++ b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md @@ -20,7 +20,7 @@ Three smaller defects sat beside them. `llm/adapters-updated` documented contain ## Decision -**Reading configuration is as privileged as writing it.** `settings.describe` and `credentials.describe` join the loopback-only set, so the whole configuration plane stays same-origin until real authentication exists. The model catalog (`llm.providers`, `llm.models`) deliberately does not: it carries provider ids, display names, and model lists — no endpoints, no key state — and a LAN client's model picker needs it. The boundary is asserted over a real HTTP server rather than a hand-assembled request, because the `Host` header a browser actually sends is what decides it. +**Reading configuration is as privileged as writing it.** `settings.describe`, `credentials.describe`, the model catalog, and every other Host operation require one browser session. The configuration plane still redacts secrets independently of authentication. The boundary is asserted over a real HTTP server rather than a hand-assembled request, proving that a forged loopback `Host` value never establishes identity. **The plane serves exactly the namespaces a registered model provider addresses.** `ctx.llm.listConfigurableProviders()` is the allow-list, so the product boundary is enforced rather than inferred from the installed plugin set, and a future namespace becomes web-configurable only by joining that directory. An unregistered namespace and an unexposed one answer identically (`settings-not-exposed`), so probing cannot enumerate the registry. diff --git a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md index f543c511d9..e53a68f91f 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md @@ -20,7 +20,7 @@ Status: implemented ## 决策 -**读取配置与写入配置同样属于特权操作。**`settings.describe` 与 `credentials.describe` 加入仅限回环的集合,因此在真正的认证层出现之前,整个配置面都保持同源。模型目录(`llm.providers`、`llm.models`)刻意不在其中:它携带的是提供方 id、显示名与模型列表——没有端点、没有密钥状态——而 LAN 客户端的模型选择器正需要它。这条边界由一台真实 HTTP 服务器来断言,而不是手工拼装的请求,因为真正决定它的,是浏览器实际发出的那个 `Host` 头。 +**读取配置与写入配置同样属于特权操作。**`settings.describe`、`credentials.describe`、模型目录及其他所有 Host 操作都要求同一个浏览器会话。配置面仍独立于认证对 secret 脱敏。这条边界由真实 HTTP 服务器而非手工请求来断言,证明伪造 loopback `Host` 值绝不建立身份。 **这个面恰好服务于已注册模型提供方所指向的那些 namespace。**`ctx.llm.listConfigurableProviders()` 就是允许列表,于是产品边界是被执行的,而不是从已安装的插件集合里推断出来的;将来的 namespace 只有加入该目录才会变得可在 Web 上配置。未注册的 namespace 与未暴露的 namespace 得到完全相同的答复(`settings-not-exposed`),因此探测无法枚举注册表。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index ca0cd569c9..a345f69828 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.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-30-web-config-plane.md -2026-07-30-web-config-plane.md: c8397d03cd7b8eb4ea127cdc26124f5e721e822d -2026-07-30-web-config-plane.zh.md: b0724c3a8cb160cbac5fc88fe07d35e79accfc49 +2026-07-30-web-config-plane.md: d5bd9c05e8352536c5c6f8b265db7dbd56a4fb84 +2026-07-30-web-config-plane.zh.md: 3c7f801766b1a6c197cc208a3c4a030b8aaac771 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index c8397d03cd..d5bd9c05e8 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -12,11 +12,11 @@ The request-level configuration seam made LLM adapter configuration restart-free ## Decision -**Wire domains on the compiled RPC map, rejections as codes, owner events forwarded verbatim.** `settings.describe/openDocument/update/replace/mutate`, `credentials.describe/set/unset`, `llm.providers`, and `llm.models` join `RpcMethodMap`, so the compiler-locked wiring sites keep schema, handler, and client in lockstep. Seam rejections fold into `settings-rejected {ns}` / `credential-rejected {ref}` business errors, while clients subscribe to forwarded settings, credentials, and LLM owner events and converge without polling ([forwarded Remote events](2026-08-10-remote-event-delivery.md)). Settings reads, native actions, and writes join `pickDirectory`/`openPath` in the connection guard's privileged set: loopback + same-origin or 403, because a LAN-exposed dsh web must not accept configuration access from another origin. +**Wire domains on the compiled RPC map, rejections as codes, owner events forwarded verbatim.** `settings.describe/openDocument/update/replace/mutate`, `credentials.describe/set/unset`, `llm.providers`, and `llm.models` join `RpcMethodMap`, so the compiler-locked wiring sites keep schema, handler, and client in lockstep. Seam rejections fold into `settings-rejected {ns}` / `credential-rejected {ref}` business errors, while clients subscribe to forwarded settings, credentials, and LLM owner events and converge without polling ([forwarded Remote events](2026-08-10-remote-event-delivery.md)). Connection authenticates settings reads, native actions, writes, `pickDirectory`, `openPath`, and every other Host operation with one browser session; Host/Origin failures still return 403 before identity is checked. **`describe()` grows layers and structural secret redaction.** `SettingsDescriptor` carries `base`/`user` beside the effective value, so the form marks "overridden" by presence in the user layer, not value inequality (an override *equal* to the base is still an override). `describe({ redactSecrets: true })` — mandatory at every wire face — strips `role('secret')` subtrees from all three layers via a pure structural walk of the schema (object/dict/array containers; a secret-role subtree is one opaque leaf) and enumerates the stripped slots as `{path, set}`, so a page can render write-only inputs without ever receiving a value. -**The Host identifies and opens the local settings document.** The settings seam exposes optional `documentPath` provider metadata and a `prepareDocument()` operation; `settings-file` returns its fully resolved custom or `$DSH_HOME/settings.yaml` filename and exclusively creates an absent empty document with owner-only permissions, while non-file providers retain the base `undefined`. The loopback-only `settings.describe` response carries only the boolean `hasDocument` capability beside the redacted namespace views. `ui-settings-general` registers a `settings.action` entry only on loopback pages, shows it only after the metadata confirms that a provider-owned local document can be prepared, and invokes pathless `settings.openDocument`; the Host resolves the provider path again before a text-document handoff (`open -t` on macOS so an arbitrary YAML file association cannot redirect the gesture, `xdg-open` on desktop Linux, `Invoke-Item` on Windows, and `wslpath -w` followed by that Windows handoff on WSL). Generic workspace paths retain the default intent, including its browser preference for browser-renderable documents. The browser neither derives `$DSH_HOME` nor receives a filesystem target; remote pages make no privileged settings read for this action. +**The Host identifies and opens the local settings document.** The settings seam exposes optional `documentPath` provider metadata and a `prepareDocument()` operation; `settings-file` returns its fully resolved custom or `$DSH_HOME/settings.yaml` filename and exclusively creates an absent empty document with owner-only permissions, while non-file providers retain the base `undefined`. The browser-authenticated `settings.describe` response carries only the boolean `hasDocument` capability beside the redacted namespace views. `ui-settings-general` registers a `settings.action` entry only on loopback pages, shows it only after the metadata confirms that a provider-owned local document can be prepared, and invokes pathless `settings.openDocument`; the Host resolves the provider path again before a text-document handoff (`open -t` on macOS so an arbitrary YAML file association cannot redirect the gesture, `xdg-open` on desktop Linux, `Invoke-Item` on Windows, and `wslpath -w` followed by that Windows handoff on WSL). Generic workspace paths retain the default intent, including its browser preference for browser-renderable documents. The browser neither derives `$DSH_HOME` nor receives a filesystem target; non-loopback pages retain the Client policy that makes no Host settings read for this action. **The llm seam declares configurability and announces topology.** `registerConfigurableProviders()` is an all-or-nothing, fiber-scoped directory of `{provider, displayName, settingsNs, settingsPath}` — the addressing a config page needs to open the right settings subtree for a route that may not exist yet; `listConfigurableProviders()` merges with live routes in the wire handler so undeclared live routes still report active. The zero-payload `'llm/adapters-updated'` event fires from all four registration/unregistration commit points with contained listener dispatch (INVARIANT rethrow), following the settings/commands precedent. `llm-deepseek`'s route renamed to `deepseek-official` because the pi-ai catalog legitimately owns `deepseek` as an aggregator entry; pre-release stance, no alias. diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index b0724c3a8c..3c7f801766 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -12,11 +12,11 @@ Status: implemented ## 决策 -**wire 领域挂上编译期 RPC 映射,拒绝落为错误码,owner 事件原样转发。**`settings.describe/openDocument/update/replace/mutate`、`credentials.describe/set/unset`、`llm.providers` 与 `llm.models` 一同加入 `RpcMethodMap`,由编译器锁定的接线位点让 schema、处理器与客户端保持步调一致。seam 侧拒绝折叠为业务错误,客户端则订阅转发的 settings、credentials 与 LLM owner 事件,无需轮询即可收敛(见[转发的 Remote 事件](2026-08-10-remote-event-delivery.zh.md))。settings 读取、原生操作与写入和 `pickDirectory`/`openPath` 一起进入连接守卫的特权集合:回环 + 同源,否则 403,因为暴露在局域网上的 dsh web 绝不能接受来自其他源的配置访问。 +**wire 领域挂上编译期 RPC 映射,拒绝落为错误码,owner 事件原样转发。**`settings.describe/openDocument/update/replace/mutate`、`credentials.describe/set/unset`、`llm.providers` 与 `llm.models` 一同加入 `RpcMethodMap`,由编译器锁定的接线位点让 schema、处理器与客户端保持步调一致。seam 侧拒绝折叠为业务错误,客户端则订阅转发的 settings、credentials 与 LLM owner 事件,无需轮询即可收敛(见[转发的 Remote 事件](2026-08-10-remote-event-delivery.zh.md))。Connection 用一个浏览器会话认证 settings 读取、原生操作、写入、`pickDirectory`、`openPath` 与其他所有 Host 操作;Host/Origin 失败仍会在身份校验前返回 403。 **`describe()` 增加分层与结构化 secret 脱敏。**`SettingsDescriptor` 在生效值之外携带 `base`/`user`,表单据此按「字段是否出现在用户层」来标记「已覆盖」,而非按值是否不等(与 base *相等*的覆盖仍然是覆盖)。`describe({ redactSecrets: true })`——在每个 wire 面都强制启用——经由对 schema 的纯结构遍历(object/dict/array 容器;secret 角色子树整体是一个不透明叶节点)从全部三层剥除 `role('secret')` 子树,并把剥除的槽位枚举为 `{path, set}`,页面因此不必收到任何值就能渲染只写输入框。 -**Host 识别并打开本地设置文档。** settings seam 暴露可选的 `documentPath` 提供方元数据和 `prepareDocument()` 操作;`settings-file` 返回已完全解析的自定义文件名或 `$DSH_HOME/settings.yaml` 文件名,并在文档缺失时以仅属主可访问的权限独占创建空文档,非文件提供方则保留基类的 `undefined`。仅限回环访问的 `settings.describe` 响应会在脱敏 namespace 视图旁只携带布尔型 `hasDocument` 能力。`ui-settings-general` 只在回环页面注册一条 `settings.action` 条目,只有元数据确认可准备好一份由提供方持有的本地文档后才显示,并调用无路径参数的 `settings.openDocument`;Host 会在文本文档交接前再次解析提供方路径(macOS 上使用 `open -t`,使任意 YAML 文件关联无法重定向这次操作;桌面 Linux 上使用 `xdg-open`;Windows 上使用 `Invoke-Item`;WSL 上先执行 `wslpath -w`,再使用同一 Windows 交接)。通用 Workspace 路径仍保留默认意图,包括针对浏览器可渲染文档的浏览器偏好。浏览器既不推导 `$DSH_HOME`,也不会收到文件系统目标;远程页面不会为这项操作发起特权 settings 读取。 +**Host 识别并打开本地设置文档。** settings seam 暴露可选的 `documentPath` 提供方元数据和 `prepareDocument()` 操作;`settings-file` 返回已完全解析的自定义文件名或 `$DSH_HOME/settings.yaml` 文件名,并在文档缺失时以仅属主可访问的权限独占创建空文档,非文件提供方则保留基类的 `undefined`。经浏览器认证的 `settings.describe` 响应会在脱敏 namespace 视图旁只携带布尔型 `hasDocument` 能力。`ui-settings-general` 只在回环页面注册一条 `settings.action` 条目,只有元数据确认可准备好一份由提供方持有的本地文档后才显示,并调用无路径参数的 `settings.openDocument`;Host 会在文本文档交接前再次解析提供方路径(macOS 上使用 `open -t`,使任意 YAML 文件关联无法重定向这次操作;桌面 Linux 上使用 `xdg-open`;Windows 上使用 `Invoke-Item`;WSL 上先执行 `wslpath -w`,再使用同一 Windows 交接)。通用 Workspace 路径仍保留默认意图,包括针对浏览器可渲染文档的浏览器偏好。浏览器既不推导 `$DSH_HOME`,也不会收到文件系统目标;非 loopback 页面保留 Client 策略,不为这项操作发起 Host settings 读取。 **llm seam 声明可配置性并公布拓扑。**`registerConfigurableProviders()` 是一个全有或全无、以 fiber 为作用域的目录,条目为 `{provider, displayName, settingsNs, settingsPath}`——这正是配置页要为一条可能尚不存在的路由打开正确设置子树时所需要的寻址;`listConfigurableProviders()` 在 wire 处理器里与存活路由合并,未声明的存活路由因此仍报告为激活。零负载的 `'llm/adapters-updated'` 事件从全部四个注册/注销提交点触发,listener 派发带异常隔离(INVARIANT 重抛),沿用 settings/commands 的先例。`llm-deepseek` 的路由重命名为 `deepseek-official`,因为 pi-ai catalog 名正言顺地拥有 `deepseek` 这个聚合器条目;依预发布立场,不设别名。 diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index 0a95947eaf..21995b0838 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.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-08-03-per-session-agent-presets.md -2026-08-03-per-session-agent-presets.md: b2195004580e1f4bf4be5527c61a8ee9b816c964 -2026-08-03-per-session-agent-presets.zh.md: 9f689c74dd570d6ec9aaa7cd1274618326a836dd +2026-08-03-per-session-agent-presets.md: 97352a0e3376ce1fe26bac62b88c2c674202adc7 +2026-08-03-per-session-agent-presets.zh.md: b62baf3e3097ba99247c2e86cc3fb5b7e43e2fab diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md index b219500458..97352a0e33 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -53,7 +53,7 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`) **Switching is allowed only while a session is blank.** Once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so `agentPreset.select` answers `agent-preset-locked`. A blank switch keeps the agent and the session and replaces only the subtree, because the host discards the `AgentHandle` it creates and there is no delete RPC — and keeping them is the better outcome anyway, since the session id, its workspace attachment, and its projections all stay put. The swap is unmount-then-mount (two compositions would register the same tool names into one layer), so it resolves the new preset before tearing anything down and restores the previous one when the new mount fails. -**Authoring a preset is an RPC, and a privileged one.** A composition is a file, but "edit it on the filesystem" is not a browser affordance, so the roster gained `read`/`write`/`remove` beside `select`. Those three are loopback-pinned: a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability. `list` and `select` deliberately stay ordinary. The roster carries ids and trust only, and a LAN client's picker needs it; and choosing a preset looked like escalation — one of them mounts the toolset that edits the live runtime — but `session.create` already takes an `agentPreset`, so pinning only the switch would have left the same capability one method over. The capability is not the preset's to grant either: the deployment's own default already carries `bash` and the filesystem tools, so any caller that may start a session at all can already run commands as this process. Containment is a property of the id (`[a-z0-9][a-z0-9-]*`), checked before it becomes a directory name rather than by inspecting the joined path afterwards; the text is parsed with the loader's own schema and dialect, so a save cannot leave a file no session could load. Shipped presets are refused for writes and deletes, because the deployment's copy is what a broken local preset is compared against — which also makes "duplicate, then edit" the authoring path rather than an afterthought. +**Authoring a preset is an RPC, and a privileged one.** A composition is a file, but "edit it on the filesystem" is not a browser affordance, so the roster gained `read`/`write`/`remove` beside `select`. Connection authenticates authoring, `list`, `select`, and the complete Host API with one browser session: a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability, while choosing a preset grants nothing `session.create` with `agentPreset` did not already grant. The capability is not the preset's to grant either: the deployment's own default already carries `bash` and the filesystem tools, so any caller that may start a session at all can already run commands as this process. Containment is a property of the id (`[a-z0-9][a-z0-9-]*`), checked before it becomes a directory name rather than by inspecting the joined path afterwards; the text is parsed with the loader's own schema and dialect, so a save cannot leave a file no session could load. Shipped presets are refused for writes and deletes, because the deployment's copy is what a broken local preset is compared against — which also makes "duplicate, then edit" the authoring path rather than an afterthought. **A service with a consumer outside the agent plane cannot move into a preset.** The aggressive split moved the `subagents` registry and its spawn/fork backends into the delegation group's entry-local realm, and `dsh web` then failed to boot: `dsh-host-apiproxy` is a HOST row that injects `subagents` to answer the browser's cross-session queries (`listChildren`, `followup`), so it waited forever for a service only sessions now provided. A per-session copy is wrong twice over — a provider name registers once, so the second session would have collided anyway. The registry and every shared backend, including the [fixed Codex and Claude Code product providers](2026-08-10-product-subagent-providers-in-shared-host.md), are host-plane; a preset contributes whichever delegation TOOLS its agent should see, and those tools resolve the host registry. `workflows` stays entry-local because nothing outside an agent reads it. Grepping injectors is what should have caught this and did not: the search has to include the host packages, not just the agent-plane ones. diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md index 9f689c74dd..b62baf3e30 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -54,7 +54,7 @@ Status: implemented **只有空白会话才允许切换。** 一旦跑过任何轮次,那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录 tool call,因此 `agentPreset.select` 返回 `agent-preset-locked`。空白期的切换保留 agent 与 session,只替换子树——因为宿主丢弃了它创建的 `AgentHandle`,也没有 delete RPC;而保留它们本身就是更好的结果,会话 id、workspace 挂接与 projections 都原地不动。该替换是"先卸后装"(两份组装会把同名工具注册进同一分层),因此它在拆除任何东西之前先解析新 preset,并在新组装装载失败时恢复原来的那一份。 -**创作 preset 是一次 RPC,而且是特权 RPC。** 组装是一个文件,但“去文件系统里改它”并不是浏览器能提供的操作,因此名单在 `select` 之外新增了 `read`/`write`/`remove`。这三者被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力。`list` 与 `select` 刻意保持为普通方法。名单只携带 id 与信任级别,而局域网客户端的选择器需要它;至于选择本身,它看起来像提权——其中一个 preset 会挂载可编辑活动运行时的工具集——但 `session.create` 本就接受 `agentPreset`,只固定切换会把同一能力留在隔壁一个方法上。这份能力也不由 preset 授予:部署自带的默认 preset 本就带着 `bash` 与文件系统工具,因此任何被允许开启会话的调用方,早已能以本进程的身份执行命令。约束是 id 自身的性质(`[a-z0-9][a-z0-9-]*`),在它成为目录名之前就检查,而不是事后再去审视拼接出的路径;文本使用 loader 自身的 schema 与方言解析,因此保存不会留下任何会话都无法加载的文件。随部署提供的 preset 拒绝写入与删除,因为部署自带的那一份正是用来对照有问题的本地 preset 的——这也让“先复制、再编辑”成为创作路径本身,而非事后补充。 +**创作 preset 是一次 RPC,而且是特权 RPC。** 组装是一个文件,但“去文件系统里改它”并不是浏览器能提供的操作,因此名单在 `select` 之外新增了 `read`/`write`/`remove`。Connection 用一个浏览器会话认证创作操作、`list`、`select` 与完整 Host API:组装指明一个会话所运行的插件,因此读取它是侦察,写入它是任意能力;选择 preset 则没有授予 `session.create` 携带 `agentPreset` 时尚未拥有的能力。这份能力也不由 preset 授予:部署自带的默认 preset 本就带着 `bash` 与文件系统工具,因此任何被允许开启会话的调用方,早已能以本进程的身份执行命令。约束是 id 自身的性质(`[a-z0-9][a-z0-9-]*`),在它成为目录名之前就检查,而不是事后再去审视拼接出的路径;文本使用 loader 自身的 schema 与方言解析,因此保存不会留下任何会话都无法加载的文件。随部署提供的 preset 拒绝写入与删除,因为部署自带的那一份正是用来对照有问题的本地 preset 的——这也让“先复制、再编辑”成为创作路径本身,而非事后补充。 **在 agent 平面之外还有消费方的服务,不能搬进 preset。** 激进拆分把 `subagents` 注册表连同 spawn/fork 后端一起搬进了 delegation 组的 entry-local realm,于是 `dsh web` 直接起不来:`dsh-host-apiproxy` 是宿主行,它注入 `subagents` 来回答浏览器的跨会话查询(`listChildren`、`followup`),因而永远等待一个此刻只有会话才提供的服务。按会话各一份在两个层面上都是错的——provider 名只能注册一次,第二个会话本来也会相撞。注册表与所有共享后端,包括[固定的 Codex 与 Claude Code 产品 provider](2026-08-10-product-subagent-providers-in-shared-host.zh.md),都属于宿主平面;preset 只贡献自己的 agent 应看见的委派**工具**,这些工具解析宿主注册表。`workflows` 保持 entry-local,因为 agent 之外没有任何东西读它。本该拦下它的是「检索注入方」这一步,而它没拦住:检索必须覆盖宿主包,而不只是 agent 平面的包。 diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml index f620af93e3..9647bf2513 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.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-08-04-draft-provider-endpoint-interrogation.md -2026-08-04-draft-provider-endpoint-interrogation.md: 6a545ea5c97ef7e7f0e916c1676c1361f7a9bf3b -2026-08-04-draft-provider-endpoint-interrogation.zh.md: eb8133d0f2ec4a1999770468782db9c47ba170c8 +2026-08-04-draft-provider-endpoint-interrogation.md: 0132dba5888faa1b9e6a4e59b45ee56a259eeef7 +2026-08-04-draft-provider-endpoint-interrogation.zh.md: 8cea5d2809611696606113b2e38578f1b54b4e09 diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md index 6a545ea5c9..0132dba588 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md @@ -19,7 +19,7 @@ Interrogation is keyed by **settings namespace**, not by provider route: - `ctx.llm.registerModelDiscovery(settingsNs, discover)` lets an adapter plugin offer to interrogate endpoints for the namespace it owns, and `ctx.llm.discoverModels(settingsNs, request)` asks. There is no way to enumerate which namespaces registered: a surface that cannot interrogate learns it from the refusal, and a list nothing consumed would be a required wire field doing nothing. The namespace is the right key because a configuration surface already holds it from the configurable-provider directory, and because a provider being added has no route to name. - `LlmModelDiscoveryRequest` carries the draft — an optional `provider`, an optional `baseURL`, an optional `api`, an optional `apiKey`, and a signal — and needs at least one of `provider` or `baseURL` to have anything to answer about. `provider` exists because a route the adapter already describes is answered from its own registry with no network call at all; only a route it does not describe reaches an endpoint. Nothing in this path writes settings or credentials. The one read is the credential of a route the request names: a configuration surface holds a redacted descriptor rather than the stored secret, so the draft's `apiKey` is present only while the user is typing one, and without that read an already-configured route would be interrogated unauthenticated and answer 401. The typed key wins, being the one under test. - `LlmDiscoveredModel` makes every field but `id` optional, because most listings disclose an id and nothing else. The reply is candidates, not a catalog: a surface adopting one still owes the capacities the adapter requires. -- `llm.discoverModels` carries the same draft over the wire. Its `apiKey` is the third and last payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`, and it is never stored or echoed back. It does ride the client's outgoing envelope like every other secret-bearing payload, where a `subscribeEnvelopes()` observer can see it; redacting that tap is a configuration-plane-wide change, not this method's to make alone. The method is loopback-only for a second reason besides the key: it makes the host issue a GET to a caller-chosen URL and reports the outcome, which is a probe an anonymous LAN caller must not have. Every refusal folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. +- `llm.discoverModels` carries the same draft over the wire. Its `apiKey` is the third and last payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`, and it is never stored or echoed back. It does ride the client's outgoing envelope like every other secret-bearing payload, where a `subscribeEnvelopes()` observer can see it; redacting that tap is a configuration-plane-wide change, not this method's to make alone. Connection authenticates the method with the complete Host API: it makes the host issue a GET to a caller-chosen URL and reports the outcome, which an anonymous caller must not receive. Every refusal folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. `dsh-llm-pi-ai` implements the wire path as a plain `GET {baseURL}/models`, reading `openai-completions` and `openai-responses`: their `GET /models` shape with bearer auth is the one a gateway, a self-hosted server, and the official endpoints all agree on. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; both would have reported an authentication failure as a provider with no models. Every other protocol answers `DISCOVERY_UNSUPPORTED`, so the surface falls back to hand-entry rather than reporting a guessed response shape as an empty provider. `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared `content-length` is checked first as a courtesy but never trusted as the bound, matching `dsh-web-fetch`'s two-stage shape for its own caller-supplied URLs. diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md index eb8133d0f2..8cea5d2809 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md @@ -19,7 +19,7 @@ Status: implemented - `ctx.llm.registerModelDiscovery(settingsNs, discover)` 让适配器插件为自己拥有的 namespace 提供「询问端点」的能力,`ctx.llm.discoverModels(settingsNs, request)` 发起询问。没有任何办法枚举哪些 namespace 注册过:询问不了的界面会从那句拒绝里知道,而一份无人消费的列表只会变成一个什么都不做的必填协议字段。以 namespace 为键是对的,因为配置界面已经从可配置提供方目录里拿到了它,也因为正在新增的提供方没有路由可点名。 - `LlmModelDiscoveryRequest` 携带草稿——可选的 `provider`、可选的 `baseURL`、可选的 `api`、可选的 `apiKey`,以及一个 signal——且 `provider` 与 `baseURL` 至少要有一个,才有东西可答。`provider` 之所以存在,是因为适配器已经描述过的路由直接由它自己的注册表作答、完全不联网;只有它未描述的路由才会抵达某个端点。这条路径不写 settings 与 credentials。唯一的读取是请求所点名路由的凭据:配置界面拿到的是脱敏描述符而非已存的机密,因此草稿里的 `apiKey` 只在用户正键入时才存在;没有这次读取,已配置好的路由就会被不带认证地询问,只换回一个 401。键入的密钥优先,因为那正是被测试的那一把。 - `LlmDiscoveredModel` 除 `id` 外每个字段都可选,因为大多数列表只公布 id。回复是候选而非 catalog:采纳其中一条的界面仍要补上适配器所需的容量。 -- `llm.discoverModels` 把同一份草稿送过协议层。它的 `apiKey` 是可承载机密的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。它确实会像其他承载机密的载荷一样随客户端外发信封同行,`subscribeEnvelopes()` 观察者看得到;把那个抽头脱敏是整个配置面的改动,不该由这一个方法独自决定。除密钥之外,将它限制为仅可通过回环访问还有第二个理由:它让宿主向调用方选定的 URL 发起 GET 并回报结果,这是匿名 LAN 调用者不该拥有的探测能力。每一种拒绝都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。 +- `llm.discoverModels` 把同一份草稿送过协议层。它的 `apiKey` 是可承载机密的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。它确实会像其他承载机密的载荷一样随客户端外发信封同行,`subscribeEnvelopes()` 观察者看得到;把那个抽头脱敏是整个配置面的改动,不该由这一个方法独自决定。Connection 用与完整 Host API 相同的会话认证该方法:它让宿主向调用方选定的 URL 发起 GET 并回报结果,匿名调用者绝不能获得这类探测能力。每一种拒绝都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。 `dsh-llm-pi-ai` 的实现只是一次朴素的 `GET {baseURL}/models`,且仅限 OpenAI 兼容协议。它们的列表形状是网关、自建服务与官方端点三方一致认可的那一种,而这正是该动作存在的场景。其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把猜错的响应形状报成一个空提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。回复在四兆字节上限下读取,且上限落在实际收到的字节上——端点是用户自己填的 URL,因此会先看声明的 `content-length` 作为善意提示,但绝不把它当作边界;这与 `dsh-web-fetch` 面对自己的调用方提供 URL 时所用的两段式形状一致。 diff --git a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml index 28a4778928..8ded320f05 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.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-08-04-websocket-downlink-carrier.md -2026-08-04-websocket-downlink-carrier.md: f757487c7b0880cfff25b93644341dc85b5e0e38 -2026-08-04-websocket-downlink-carrier.zh.md: 5fa603b9a8dc66146c777f54dfbdc25cb131debd +2026-08-04-websocket-downlink-carrier.md: 420a0d30f31cca58448adc0afd46a5d6d5e9107f +2026-08-04-websocket-downlink-carrier.zh.md: 5f277a4ed33ffe97b51587fef960746b93eff1c1 diff --git a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md index f757487c7b..420a0d30f3 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md +++ b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md @@ -16,7 +16,7 @@ WebSocket carries only the host→browser downlink. All client→host unary call ## Upgrade and lifecycle boundaries -`dsh-host-webserver` provides an exact upgrade-route registration point alongside ordinary routes, dispatches Node upgrade sockets by pathname only, contains raw-socket errors, and waits for surviving upgraded connections to close during server teardown; it knows nothing about Harness frames or WebSocket messages. `dsh-client-connection` owns the WebSocket handshake, frame output, and stream cancellation, and reuses the `/api` Host/Origin trust fence before upgrade. An untrusted authority or cross-origin Origin is rejected before `ctx.apiProxy.events.*` starts. +`dsh-host-webserver` provides an exact upgrade-route registration point alongside ordinary routes, dispatches Node upgrade sockets by pathname only, contains raw-socket errors, and waits for surviving upgraded connections to close during server teardown; it knows nothing about Harness frames or WebSocket messages. `dsh-client-connection` owns the WebSocket handshake, frame output, and stream cancellation. Before upgrade it applies the `/api` Host/Origin checks followed by the same signed browser-cookie authentication as unary HTTP. An untrusted authority or cross-origin Origin receives 403; a trusted but unauthenticated request receives 401; neither starts a Remote stream. A browser abort or socket close cancels the corresponding host stream; plugin teardown also waits for that source iterator's cleanup. If a host stream throws midway, the carrier sends one existing `stream/error` frame and then closes the socket; the client treats that frame as connection loss rather than delivering it to a business sink. Each WebSocket reports open independently, and the existing readiness handshake still waits until mux and host are both open and the `host.describe` HTTP call has succeeded before publishing connected. diff --git a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md index 5fa603b9a8..5f277a4ed3 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md @@ -16,7 +16,7 @@ WebSocket 只承担 host→browser 下行。所有 client→host unary 调用和 ## Upgrade 与生命周期边界 -`dsh-host-webserver` 提供与普通 route 并列的精确 upgrade-route 注册点,只按 pathname 分发 Node upgrade socket,隔离原始 socket 错误,并在 server teardown 期间等待仍存活的升级连接关闭;它不认识 Harness 帧或 WebSocket 消息。`dsh-client-connection` 拥有 WebSocket handshake、frame 写出和流取消,并在 upgrade 前复用 `/api` 的 Host/Origin 信任栅栏。未受信任的 authority 或跨来源 Origin 在 `ctx.apiProxy.events.*` 启动前即被拒绝。 +`dsh-host-webserver` 提供与普通 route 并列的精确 upgrade-route 注册点,只按 pathname 分发 Node upgrade socket,隔离原始 socket 错误,并在 server teardown 期间等待仍存活的升级连接关闭;它不认识 Harness 帧或 WebSocket 消息。`dsh-client-connection` 拥有 WebSocket handshake、frame 写出和流取消。upgrade 前先执行 `/api` Host/Origin 校验,再执行与一元 HTTP 相同的签名浏览器 cookie 认证。未受信任的 authority 或跨来源 Origin 得到 403;Host 可信但未认证的请求得到 401;两者都不会启动 Remote stream。 浏览器 abort 或 socket close 会取消对应的 host 流;插件 teardown 还会等待该 source iterator 完成清理。host 流中途抛错时,载体发送一个现有的 `stream/error` frame 后关闭 socket;客户端把该 frame 收敛为连接丢失,不投递给业务 sink。每条 WebSocket 独立报告 open,既有 readiness handshake 仍等待 mux、host 都 open 且 `host.describe` HTTP 调用成功后才发布 connected。 diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml index 8ae275a581..822f8fa0e2 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.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-08-12-plugin-owned-settings-surface.md -2026-08-12-plugin-owned-settings-surface.md: daed91b8ac4ce0acb81e19908ec08c9f3f7ac21e -2026-08-12-plugin-owned-settings-surface.zh.md: ba39b84faaf90be413ad8d7b8327c67b2fa27cea +2026-08-12-plugin-owned-settings-surface.md: f1c9b48abd3459ed58e3e10fbdd95558884c9a22 +2026-08-12-plugin-owned-settings-surface.zh.md: ff9f37290a8dd8f1ab9ef1bfef3790a3ef20086b diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md index daed91b8ac..f1c9b48abd 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md @@ -32,7 +32,7 @@ Keying makes absence the signal, and that is what removes the bookkeeping the pr The gate did keep one thing off the wire, and this note states it plainly because the decision has to survive the accurate version: a registered namespace the list did not name never had its resolved, `base`, or `user` values reach the browser at all. The plugin inventory page is not a substitute — `PluginInventoryEntry` carries `entryId`, `moduleName`, `enabled`, and `fiberPhase`, and its "configuration" row renders an enabled/disabled tag, never a stored value. -What the gate was not is the boundary its position suggested. Every `settings.*` method sits in `PRIVILEGED_METHODS` (`packages/client/connection`), so a non-loopback or cross-origin request is refused with 403 before reaching this code; `role('secret')` fields are structurally stripped from every layer of every response; and the document the plane edits is the user's own `settings.yaml`, which the same settings page offers to open. The writes it did not block were also the consequential ones: `permission` (which can widen the approval preset) and `agent-presets` (which decides what a session mounts) were both already served. +What the gate was not is the security boundary its position suggested. Connection authenticates every Host API request before reaching this code; `role('secret')` fields are structurally stripped from every layer of every response; and the document the plane edits is the user's own `settings.yaml`, which the same settings page offers to open. The writes it did not block were also the consequential ones: `permission` (which can widen the approval preset) and `agent-presets` (which decides what a session mounts) were both already served. So the exposure this change actually adds, in this repository, is one namespace: `agent-default-model`, whose two fields name a provider and a model and which no browser half renders. A future namespace whose values genuinely must not cross the wire is answered per field by `role('secret')` — finer than a namespace switch, and already enforced. diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md index ba39b84faa..ff9f37290a 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md @@ -32,7 +32,7 @@ Status: implemented 这道门确实挡住了一样东西,本 note 如实写出,因为这个决策必须在准确版本下也站得住:不在名单上的已注册命名空间,其 resolved、`base` 与 `user` 值根本不会抵达浏览器。插件清单页不能替代它——`PluginInventoryEntry` 携带的是 `entryId`、`moduleName`、`enabled` 与 `fiberPhase`,它那一行「configuration」渲染的是启用/停用标签,从不是任何已存值。 -这道门不是的,是它所处位置暗示的那种边界。每个 `settings.*` 方法都在 `PRIVILEGED_METHODS` 里(`packages/client/connection`),非回环或跨源请求在到达这段代码之前就以 403 被拒;`role('secret')` 字段在每种响应的每一层都被结构性剥离;而这个面所编辑的文档,本就是用户自己的 `settings.yaml`,同一个设置页还提供了打开它的入口。它没有挡住的写入,恰恰是有分量的那些:`permission`(能放宽审批预设)与 `agent-presets`(决定一个会话挂载什么)本来就已被服务。 +这道门不是的,是它所处位置暗示的那种安全边界。Connection 在到达这段代码前认证每个 Host API 请求;`role('secret')` 字段在每种响应的每一层都被结构性剥离;而这个面所编辑的文档,本就是用户自己的 `settings.yaml`,同一个设置页还提供了打开它的入口。它没有挡住的写入,恰恰是有分量的那些:`permission`(能放宽审批预设)与 `agent-presets`(决定一个会话挂载什么)本来就已被服务。 因此本次改动在本仓库实际新增的暴露面是一个命名空间:`agent-default-model`——它的两个字段指明一个提供方与一个模型,且没有任何浏览器半侧渲染它。将来若某个命名空间的值确实不该跨越协议,由 `role('secret')` 逐字段作答:比整命名空间开关更精细,而且已经在执行。 diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml new file mode 100644 index 0000000000..e8c68740aa --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md +2026-08-24-browser-token-authentication.md: fb55a35018b7c8df45213f9b9e59dccc3512c0ab +2026-08-24-browser-token-authentication.zh.md: 6cd77117395ff753d36d39da0ec247e064bdb393 diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md new file mode 100644 index 0000000000..fb55a35018 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md @@ -0,0 +1,45 @@ +# Agent Note: Browser launch-token authentication + +Status: implemented + +English | [中文](2026-08-24-browser-token-authentication.zh.md) + +## Problem + +The Web Host runs tool-capable Sessions with the current operating-system user's authority, but its HTTP interface identified privileged callers from request routing facts. In particular, the method-specific loopback list treated a loopback `Host` value as local authority even though an HTTP client controls that header. A caller that could reach the server could therefore name `localhost`, enter configuration methods, and use Host-side operations such as model discovery to disclose stored credentials. Binding the shipped CLI to loopback limits ordinary reachability but does not authenticate a request forwarded or otherwise delivered to that socket. + +## Decision + +`dsh-client-connection` authenticates the complete Host API before dispatch. Every API Proxy method, Remote unary call, generic Connection channel, and Remote WebSocket stream requires the same browser session; endpoint ownership and method names do not alter authority. The existing Host/Origin checks run first and retain their DNS-rebinding and cross-site-request role, returning 403 when they fail. A trusted Host without a valid browser session receives 401. The browser-trust rules remain owned by the [carrier-level browser trust decision](2026-07-28-api-browser-trust-boundary.md). + +Each Connection process generates a random launch token. `dsh-web-app` prints and opens the normal root URL with that token in the query. `frontend-static` asks Connection to authorize index responses: only `GET /?token=...` exchanges the process token for a cookie, then redirects to clean `/`; the token is not accepted on API paths or in an Authorization header. Missing and invalid credentials receive one minimal 401 response. Static non-index assets remain public. + +The cookie is a signed, authority-bound bearer. Its deterministic name and signed payload both include the normalized hostname plus port, so one Harness home can run independent Web ports without cookie collisions. The payload carries safe-integer issue and expiry times under an absolute lifetime; `cookieMaxAgeDays` defaults to 30. The cookie is host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`. It omits `Secure` because the shipped server uses loopback HTTP. There is no logout operation or reverse-proxy-specific handling. + +The HMAC secret is a versioned `grant` record at `client-connection/browser-session` in `ctx.credentials`; the local provider stores it in `$DSH_HOME/.credentials.yaml`. Connection reads the record for each verification, so deletion or replacement revokes every existing cookie without restarting the process. A missing record is recreated only by a valid process-token exchange. Invalid owner payloads fail loud instead of being replaced. The launch token itself is never persisted and changes on every process start, while an unexpired cookie remains valid across restarts on the same authority. + +The shipped CLI continues to reject `--host 0.0.0.0`. Authentication does not imply supported network deployment, TLS, forwarding-header interpretation, or proxy configuration. + +## Verification + +Unit coverage pins token comparison, cookie attributes, HMAC and payload validation, authority and lifetime checks, persistent-secret reuse, record deletion, and invalid durable records. Host transport suites pin uniform 401/403 behavior for API Proxy, generic RPC, Typert Remote HTTP, and WebSocket upgrade paths. The frontend real-composition test boots credentials, Connection, webserver, and static serving through Loader and proves token exchange before index reads while static assets remain public. A real-CLI test starts `dsh web` twice on one port with a temporary `DSH_HOME`, proves that forged `Host: localhost` is unauthenticated, calls `host.describe` with the exchanged cookie, observes a new process token, and reuses the old cookie after restart. + +## Alternatives considered + +**Determine privileged callers from the TCP peer address.** A direct peer address still identifies a local forwarding process rather than the browser user, retains a second authority model beside the API's command-execution capability, and requires proxy policy to answer who the original caller was. One application credential is the enforceable identity used for every operation. + +**Keep a method-specific privileged list and restrict stored credentials to configured targets.** The list can omit new endpoints and does not constrain callers that already control a tool-capable Session. A `discoverModels` target rule would not form a security boundary because the same authenticated principal can update settings and run commands. Uniform authentication covers the operation that grants process control. + +**Persist or accept the launch token as an API bearer.** A durable launch token would become a second long-lived credential, while Authorization-header support would add a non-browser client contract with no current consumer. The process token performs one browser-cookie exchange only. + +**Rotate the signing secret on every restart.** This prevents an existing browser from reconnecting after an ordinary DSH restart. Persisting only the signing secret keeps that workflow while process-token rotation limits the startup URL to one process lifetime. + +**Add logout, TLS-proxy, and forwarding-header configuration.** None is required by the loopback Web application or the reported authentication gap. Adding them would define deployment contracts without current consumers. Browser site-data controls and credential-record deletion provide the two revocation operations this decision needs. + +## Consequences + +Possession of the browser cookie authorizes the complete tool-capable Host API, matching the authority the Web application already exposes after Session creation. `Host` no longer grants a higher method tier, and a method migration between API Proxy and Typert Remote cannot change its caller set. + +The persistent secret makes cookies survive restarts but gives a stolen cookie up to the configured absolute lifetime; deletion or rotation of the record is the global revocation mechanism. Omitting `Secure` preserves loopback HTTP and permits plaintext transmission if an operator makes the same cookie authority reachable over an unencrypted network. The startup URL contains a process credential and must be treated as sensitive output; runtime diagnostics do not repeat it. + +The decision partially supersedes the authentication deferral and unauthenticated non-loopback consequences in the [browser trust note](2026-07-28-api-browser-trust-boundary.md). That note remains active authority for media-type, Host, Origin, Fetch-Metadata, and configured-authority validation. No active Agent Note is archived: the overlap is partial and both security rules retain future decision value. diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md new file mode 100644 index 0000000000..6cd7711739 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md @@ -0,0 +1,45 @@ +# Agent Note: 浏览器启动令牌认证 + +Status: implemented + +[English](2026-08-24-browser-token-authentication.md) | 中文 + +## 问题 + +Web Host 以当前操作系统用户的权限运行具有工具能力的 Session,但其 HTTP 接口用请求路由事实识别特权调用者。具体而言,按方法维护的 loopback 列表把 loopback `Host` 值视为本地 authority,尽管 HTTP 客户端可以控制该 header。能够到达服务器的调用者因此可以声明 `localhost`、进入配置方法,再利用模型发现等 Host 侧操作披露存储的凭据。随附 CLI 绑定 loopback 可以限制普通可达性,却不能认证被转发或以其他方式送达该 socket 的请求。 + +## 决策 + +`dsh-client-connection` 在分发前认证完整 Host API。每个 API Proxy 方法、Remote 一元调用、通用 Connection channel 和 Remote WebSocket stream 都要求同一个浏览器会话;endpoint 所有权与方法名称不改变 authority。既有 Host/Origin 校验先执行,继续负责 DNS rebinding 和跨站请求防御,失败时返回 403。Host 可信但没有有效浏览器会话时返回 401。浏览器信任规则仍由[载体级浏览器信任决策](2026-07-28-api-browser-trust-boundary.zh.md)持有。 + +每个 Connection 进程生成随机启动令牌。`dsh-web-app` 打印并打开 query 中带该令牌的普通根 URL。`frontend-static` 请求 Connection 授权 index 响应:只有 `GET /?token=...` 会把进程令牌交换为 cookie,再重定向到干净的 `/`;API 路径和 Authorization header 都不接受该令牌。缺失与无效凭据得到同一份最小 401 响应。非 index 静态资产保持公开。 + +cookie 是签名且绑定 authority 的 bearer。确定性名称与签名 payload 都包含规范化 hostname 和 port,因此同一 Harness home 可以在不同 Web port 运行而不发生 cookie 冲突。payload 在绝对有效期内携带安全整数形式的签发与过期时间;`cookieMaxAgeDays` 默认为 30。cookie 是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`。随附服务器使用 loopback HTTP,因此不设置 `Secure`。这里没有 logout 操作或反向代理专用处理。 + +HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` 的版本化 `grant` 记录;本地提供方将其存入 `$DSH_HOME/.credentials.yaml`。Connection 每次校验都读取记录,因此删除或替换记录无需重启进程即可撤销全部既有 cookie。缺失记录只能由有效进程令牌交换重新创建。无效 owner payload 会明确失败,而不是被覆盖。启动令牌本身绝不持久化并在每次进程启动时变化;未过期 cookie 则能在相同 authority 上跨重启继续有效。 + +随附 CLI 继续拒绝 `--host 0.0.0.0`。认证不代表支持网络部署、TLS、转发 header 解释或代理配置。 + +## 验证 + +单元覆盖固定令牌比较、cookie 属性、HMAC 与 payload 校验、authority 与有效期校验、持久密钥复用、记录删除及无效持久记录。Host 传输套件固定 API Proxy、通用 RPC、Typert Remote HTTP 和 WebSocket upgrade 路径上一致的 401/403 行为。frontend 真实组合测试经 Loader 启动 credentials、Connection、webserver 与静态服务,证明读取 index 前完成令牌交换,同时静态资产仍公开。真实 CLI 测试在临时 `DSH_HOME` 上用同一端口两次启动 `dsh web`,证明伪造 `Host: localhost` 仍未认证,以交换所得 cookie 调用 `host.describe`,观测新的进程令牌,并在重启后复用旧 cookie。 + +## 曾考虑的替代方案 + +**从 TCP 对端地址判定特权调用者。** 直接对端地址仍可能只识别本地转发进程,而非浏览器用户;它会在 API 的命令执行能力旁保留第二套 authority 模型,并要求代理策略回答原始调用者是谁。一个应用凭据才是每项操作都能执行的身份。 + +**保留按方法的特权列表,并把存储凭据限制在已配置目标。** 列表可能漏掉新 endpoint,也不能约束已经控制工具型 Session 的调用者。`discoverModels` 目标规则不构成安全边界,因为同一已认证主体可以更新 settings 并运行命令。统一认证覆盖授予进程控制权的操作。 + +**持久化启动令牌或把它作为 API bearer 接受。** 持久启动令牌会成为第二份长期凭据;Authorization header 支持则会增加没有当前 consumer 的非浏览器客户端约定。进程令牌只完成一次浏览器 cookie 交换。 + +**每次重启都轮换签名密钥。** 这会阻止既有浏览器在普通 DSH 重启后重连。只持久化签名密钥既保留该工作流,又由进程令牌轮换把启动 URL 限定在一个进程生命周期。 + +**增加 logout、TLS 代理和转发 header 配置。** loopback Web 应用与已报告认证缺口都不需要这些能力;加入它们会在没有当前 consumer 时定义部署约定。浏览器站点数据控制与凭据记录删除已经提供本决策所需的两种撤销操作。 + +## 后果 + +持有浏览器 cookie 就能调用完整的工具型 Host API,这与 Web 应用在创建 Session 后本就暴露的 authority 一致。`Host` 不再授予更高的方法层级,方法在 API Proxy 与 Typert Remote 之间迁移也不会改变调用者集合。 + +持久密钥使 cookie 跨重启生效,也让被盗 cookie 最多保有配置的绝对有效期;删除或轮换记录是全局撤销机制。不设置 `Secure` 保留 loopback HTTP,但如果操作者让同一 cookie authority 经未加密网络可达,cookie 会以明文传输。启动 URL 含进程凭据,必须视为敏感输出;运行时诊断不会重复它。 + +本决策部分取代[浏览器信任说明](2026-07-28-api-browser-trust-boundary.zh.md)中的认证延期与未认证非 loopback 后果。该说明仍是媒体类型、Host、Origin、Fetch-Metadata 和配置 authority 校验的有效权威。没有 active Agent Note 被归档:重叠只发生在局部,两条安全规则都保有未来决策价值。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml index 26fc02785a..a0c01325bf 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.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/bug-fix/2026-08-06-host-backed-web-preferences.md -2026-08-06-host-backed-web-preferences.md: 2e33d05417bf6c347a57b5c0b6c7281ff1392b5b -2026-08-06-host-backed-web-preferences.zh.md: 79c15af723347b92fb0b0a15ccb8de80841dd3b0 +2026-08-06-host-backed-web-preferences.md: 83d4b87f35e2bb3710ecea60d64a8c800ce5df08 +2026-08-06-host-backed-web-preferences.zh.md: ead4a0d27a3bf94f6ef3ffc2915ba721c8379075 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md index 2e33d05417..83d4b87f35 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md @@ -12,13 +12,13 @@ The first theme implementation moved only Appearance to Host settings but awaite ## Decision -The owning Host halves register three schemas: optional `locale.preference` (`zh` or `en`, where absence delegates to the browser), `ui-theme.preference` (`light`, `dark`, or `system`, default `system`), and `ui-conversation.busyEnter` (`queue` or `steer`, default `queue`). The local settings provider stores explicit choices in `$DSH_HOME/settings.yaml`, which resolves to `~/.dsh/settings.yaml` under the default home. The API proxy serves every registered namespace to a loopback client; field roles still redact secrets. +The owning Host halves register three schemas: optional `locale.preference` (`zh` or `en`, where absence delegates to the browser), `ui-theme.preference` (`light`, `dark`, or `system`, default `system`), and `ui-conversation.busyEnter` (`queue` or `steer`, default `queue`). The local settings provider stores explicit choices in `$DSH_HOME/settings.yaml`, which resolves to `~/.dsh/settings.yaml` under the default home. The API proxy serves every registered namespace behind Connection's browser authentication; field roles still redact secrets. `dsh-client-ui-settings` owns one browser-wide settings describe mirror and provides `ctx.settingsScope.bind(spec)` as a per-namespace selector over it. The mirror installs `settings/document-updated` and `connection/reset` listeners before starting its background read, so no settings transport can block plugin activation and an invalidation cannot fall into a read-before-subscribe gap. Each bound scope publishes a snapshot store (status, section value, revision, writability, host/memory mode) the domain service subscribes to, without adding a wire read or listener of its own. The default decoder validates each incoming section against the namespace's own serialized wire schema, rehydrated through the colocated `ctx.settingsSchema` service, so domains carry no hand-written wire guards. Domain services take the scope as an ordinary constructor collaborator, publish their provisional defaults immediately—browser-derived locale, system theme, and Queue—then adopt an accepted Host section without writing it back; a service constructed without a scope (standalone dictionary or policy fixtures) simply stays process-local. The shared read and invalidation lifecycle is specified by the later [settings describe mirror decision](../architecture/2026-08-17-settings-describe-mirror.md). User changes update the live service synchronously and queue a `settings.mutate` path operation through `scope.set`. The scope serializes gestures, sends the latest known namespace revision as `expectedRevision`, records every successful revision, and lets only the latest write settlement republish live state. A rejected or failed latest write reloads Host state. Disposal rejects new work, skips queued operations, suppresses publication by the in-flight operation, and waits for that operation to settle before the plugin reaches quiescence. -Remote browsers cannot call the loopback-only configuration API, so their preferences remain process-local. Dynamic third-party theme ids remain in-process extensions outside the built-in Host schema; removing one resets the live registry without replacing the last durable built-in preference. +The Client keeps Host persistence disabled on non-loopback pages, so their preferences remain process-local even though Connection authenticates the complete API. Dynamic third-party theme ids remain in-process extensions outside the built-in Host schema; removing one resets the live registry without replacing the last durable built-in preference. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md index 79c15af723..ead4a0d27a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md @@ -12,13 +12,13 @@ Web 的 Appearance、Language 和繁忙态 Enter 偏好原本存在浏览器 `lo ## 决策 -各领域所属的 Host half 注册三份 schema:可选的 `locale.preference`(`zh` 或 `en`,缺失时交由浏览器决定)、`ui-theme.preference`(`light`、`dark` 或 `system`,默认为 `system`),以及 `ui-conversation.busyEnter`(`queue` 或 `steer`,默认为 `queue`)。本地 settings 提供方将显式选择存入 `$DSH_HOME/settings.yaml`,在使用默认 home 时,该路径解析为 `~/.dsh/settings.yaml`。API 代理会向回环客户端服务每一个已注册的 namespace;字段角色仍会脱敏机密值。 +各领域所属的 Host half 注册三份 schema:可选的 `locale.preference`(`zh` 或 `en`,缺失时交由浏览器决定)、`ui-theme.preference`(`light`、`dark` 或 `system`,默认为 `system`),以及 `ui-conversation.busyEnter`(`queue` 或 `steer`,默认为 `queue`)。本地 settings 提供方将显式选择存入 `$DSH_HOME/settings.yaml`,在使用默认 home 时,该路径解析为 `~/.dsh/settings.yaml`。API 代理在 Connection 浏览器认证之后服务每一个已注册的 namespace;字段角色仍会脱敏机密值。 `dsh-client-ui-settings` 持有一个浏览器全局的 settings describe 镜像,并提供 `ctx.settingsScope.bind(spec)` 作为该镜像上的逐 namespace selector。镜像在开始后台读取之前安装 `settings/document-updated` 和 `connection/reset` 监听器,因此任何 settings 传输都不会阻塞插件激活,失效通知也不会掉入先读取、后订阅的空档。每个绑定的 scope 会发布一个供领域服务订阅的快照 store(状态、分节值、revision、可写性、host/内存模式),自身不再增加协议读取或监听器。默认解码器会对照该 namespace 自身的序列化 wire schema(经同包的 `ctx.settingsSchema` 服务还原)校验每个传入分节,因此各领域无需携带手写的 wire 校验器。领域服务把 scope 当作普通的构造函数协作者接收,立即发布各自的暂定默认值:由浏览器派生的 locale、系统主题和 Queue;随后采纳已获接受的 Host 分节,但不将其写回;不带 scope 构造的服务——独立词典或政策 fixture(测试前置数据)——则仅停留在进程本地。共享读取与失效生命周期由后续的 [settings describe 镜像决策](../architecture/2026-08-17-settings-describe-mirror.zh.md)规定。 用户变更会同步更新实时服务,并经 `scope.set` 将一项 `settings.mutate` 路径操作排入队列。scope 会串行处理手势,以最新已知 namespace revision 作为 `expectedRevision` 发送,记录每次成功写入的 revision,并且只允许最新写入的结算结果重新发布实时状态。最新写入被拒或失败时,scope 会重新加载 Host 状态。插件释放会拒绝新工作、跳过已排队操作、抑制运行中操作发布状态,并等待该操作结算后才让插件达到完全停稳。 -远程浏览器无法调用仅限回环请求的配置 API,因此其偏好仅保留在进程内。动态第三方主题 id 仍是内置 Host schema 之外的进程内扩展;移除其中一个会重置实时注册表,但不会替换上一个持久化的内置偏好。 +Client 在非 loopback 页面禁用 Host 持久化,因此这些页面的偏好仍只保留在进程内,尽管 Connection 认证完整 API。动态第三方主题 id 仍是内置 Host schema 之外的进程内扩展;移除其中一个会重置实时注册表,但不会替换上一个持久化的内置偏好。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-bind-address.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.i18n.yaml index ac5e4cb4c6..ec7451e28d 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-bind-address.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.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-22-web-bind-address.md -2026-07-22-web-bind-address.md: 3332176c0cee940648ad334a44edd30879225503 -2026-07-22-web-bind-address.zh.md: 5ad73b8916d9cedce1073f4a2d025f40197b7d65 +2026-07-22-web-bind-address.md: 68275d220f2270ef924efdbc6a72f3dfcbfd10f8 +2026-07-22-web-bind-address.zh.md: 5bb4fdbbc3e58285b0d227c3ec376786be2782da diff --git a/.agents/notes/implemented/feature/2026-07-22-web-bind-address.md b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.md index 3332176c0c..68275d220f 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-bind-address.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.md @@ -6,15 +6,15 @@ English | [中文](2026-07-22-web-bind-address.zh.md) ## Problem -`dsh web` binds every network interface even when its browser runs on the same machine. Local use therefore exposes an unauthenticated development server without an explicit operator choice, while remote-container and LAN-browser use still needs a supported way to accept non-loopback connections. +The Web application can run commands with the Host user's authority. Same-machine use needs only loopback reachability, while an all-interface CLI mode would imply a supported network deployment without TLS or a defined proxy contract. The HTTP carrier also hides the bind address inside `startWebServer()`, so alternate shells cannot state their own network policy at the package boundary. ## Decision -`dsh web` binds `127.0.0.1` by default. The CLI accepts `--host 0.0.0.0` as the explicit all-interface mode and rejects other values so its network modes remain a small, deliberate contract. All-interface mode keeps printing the loopback URL and, when available, the first external IPv4 URL. +`dsh web` binds `127.0.0.1` and rejects `--host 0.0.0.0`; the CLI exposes no network mode. The process-token and browser-cookie authentication does not broaden that deployment contract ([decision](../architecture/2026-08-24-browser-token-authentication.md)). -`WebServerOptions.host` is required. The HTTP carrier passes that value to `node:http` without supplying a fallback, leaving each shell responsible for its bind policy. Programmatic carrier consumers may select another hostname or address directly. +`WebServer` still requires `host: '127.0.0.1' | '0.0.0.0'` and passes it to `node:http` without a fallback. The generic carrier leaves custom composition policy visible at its package interface; the product CLI owns the stricter loopback choice. ## Alternatives considered @@ -22,8 +22,10 @@ The HTTP carrier also hides the bind address inside `startWebServer()`, so alter **Use a boolean exposure flag.** Rejected because `--host 0.0.0.0` names the resulting socket behavior directly and matches the underlying server option without introducing a second term. +**Keep an explicit `--host 0.0.0.0` mode.** Rejected because authentication alone does not supply TLS, forwarding semantics, or a supported remote-deployment contract for the tool-capable Host. + **Default inside `startWebServer()`.** Rejected because the carrier has multiple possible shells and no basis for choosing their deployment policy. Requiring `host` makes the choice visible at every assembly call. ## Consequences -Local `dsh web` starts remain reachable at `http://127.0.0.1:3080`; a browser on another machine must opt in with `dsh web --host 0.0.0.0`. The CLI does not yet expose custom interface addresses or IPv6 modes, while programmatic carrier consumers retain that flexibility. Server tests pin both loopback and all-interface forwarding into the Node listen boundary, and the web smoke continues to exercise the default CLI path. +Local `dsh web` starts remain reachable at `http://127.0.0.1:3080`. The CLI exposes no custom interface, all-interface, or IPv6 mode; custom WebServer compositions retain the carrier's two-address choice and own every consequence. Server tests pin both carrier values into Node listen, while CLI tests pin rejection of the all-interface flag. diff --git a/.agents/notes/implemented/feature/2026-07-22-web-bind-address.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.zh.md index 5ad73b8916..5bb4fdbbc3 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-bind-address.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.zh.md @@ -6,15 +6,15 @@ Status: implemented ## 问题 -即便浏览器与服务器运行在同一台机器上,`dsh web` 也会绑定所有网络接口。因此,本地使用会在操作者未明确选择的情况下暴露一个未经身份验证的开发服务器;另一方面,远程容器和局域网浏览器场景仍需要一种受支持的方式来接受非环回连接。 +Web 应用可以用 Host 用户的 authority 运行命令。同机使用只需要 loopback 可达性;CLI 若提供全接口模式,就会在没有 TLS 或明确代理约定时暗示支持网络部署。 HTTP 承载层还把绑定地址隐藏在 `startWebServer()` 内部,导致其他壳层无法在包边界明确表达自己的网络策略。 ## 决策 -`dsh web` 默认绑定 `127.0.0.1`。CLI(命令行界面)接受 `--host 0.0.0.0` 作为显式启用的全接口模式,并拒绝其他取值,使网络模式保持为一份规模小、经过审慎限定的约定。全接口模式仍然输出本机环回 URL,并在可用时输出第一个外部 IPv4 URL。 +`dsh web` 绑定 `127.0.0.1` 并拒绝 `--host 0.0.0.0`;CLI 不开放网络模式。进程令牌与浏览器 cookie 认证不扩大该部署约定([决策](../architecture/2026-08-24-browser-token-authentication.zh.md))。 -`WebServerOptions.host` 为必填项。HTTP 承载层将该值直接传给 `node:http`,不提供回退值,因此每个壳层负责制定自己的绑定策略。以编程方式使用承载层的消费方可以直接选择其他主机名或地址。 +`WebServer` 仍要求 `host: '127.0.0.1' | '0.0.0.0'`,并在没有 fallback 的情况下传给 `node:http`。通用承载层让自定义组合策略显式留在包接口上;产品 CLI 持有更严格的 loopback 选择。 ## 曾考虑的替代方案 @@ -22,8 +22,10 @@ HTTP 承载层还把绑定地址隐藏在 `startWebServer()` 内部,导致其 **使用布尔型暴露标志。** 不予采纳,因为 `--host 0.0.0.0` 直接说明最终的套接字行为,并与底层服务器选项一致,无需再引入第二套术语。 +**保留显式 `--host 0.0.0.0` 模式。** 不予采纳,因为仅有认证并不能为工具型 Host 提供 TLS、转发语义或受支持的远程部署约定。 + **在 `startWebServer()` 内设置默认值。** 不予采纳,因为承载层可能由多种壳层调用,没有依据替它们选择部署策略。要求传入 `host`,可使每次装配调用都明确作出这一选择。 ## 后果 -`dsh web` 的本地启动仍可通过 `http://127.0.0.1:3080` 访问;其他机器上的浏览器必须使用 `dsh web --host 0.0.0.0` 显式启用。CLI 尚未开放自定义接口地址或 IPv6 模式,而以编程方式使用承载层的消费方仍保留这种灵活性。服务器测试将环回模式和全接口模式向 Node 监听边界的传递固定为约定,Web 冒烟测试继续覆盖默认 CLI 路径。 +`dsh web` 的本地启动仍可通过 `http://127.0.0.1:3080` 访问。CLI 不开放自定义接口、全接口或 IPv6 模式;自定义 WebServer 组合保留承载层的两个地址选择并自行承担全部后果。服务器测试固定两个承载值都会进入 Node listen,CLI 测试则固定全接口 flag 被拒绝。 diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml index 22ba3ccbe8..824846180a 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.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-28-tool-call-file-open-in-os.md -2026-07-28-tool-call-file-open-in-os.md: 08fc51cc3a5d2fb43b67dc158fd1ee789fafdb68 -2026-07-28-tool-call-file-open-in-os.zh.md: c99a99dcd91a8cfabbbc381af79bb3570f4295f6 +2026-07-28-tool-call-file-open-in-os.md: e6e590b2a97654b8b68d5a9842de10818f544088 +2026-07-28-tool-call-file-open-in-os.zh.md: eb600a69cb2a2c3cc0d7463519d3de4dce76047b diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md index 08fc51cc3a..e6e590b2a9 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md @@ -23,7 +23,7 @@ File-tool path summaries (`read` / `write` / `edit` args carrying `path` or `fil ## Consequences -Clicking a file path in a tool row opens that path on the host. Non-file tool rows are inert summaries (expand toggles remain where the row already supported them). Remote or non-loopback clients cannot invoke `host.openPath`. A Host or OS refusal is owned by the chat view: it shows the thrown reason and retries the same path ([file-open failure](../bug-fix/2026-08-18-tool-row-file-open-failure.md)). +Clicking a file path in a tool row opens that path on the host. Non-file tool rows are inert summaries (expand toggles remain where the row already supported them). The Client withholds `host.openPath` on non-loopback pages; every exposed Host invocation still requires the browser session. A Host or OS refusal is owned by the chat view: it shows the thrown reason and retries the same path ([file-open failure](../bug-fix/2026-08-18-tool-row-file-open-failure.md)). ## Risks diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md index c99a99dcd9..eb600a69cb 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md @@ -12,7 +12,7 @@ Status: implemented 文件工具的路径摘要(`read`/`write`/`edit` 参数中的 `path` 或 `file_path`)渲染为静止状态下即带下划线的链接,并使用 pointer 光标。点击路径会经 `WorkspaceRuntime.openPath` 调用 `host.openPath`,相对路径以会话 cwd 为基准解析。带文件链接的行关闭参数展开(左侧图标不可点);工具行(含 bash 与 todo 注册)去掉整行点击、整行悬停底色,以及点击打开 details 的手势。details 面板及其 inject 面仍保留供程序化选择;工具行不再驱动它们。 -`host.openPath` 是特权一元 RPC,仅接受来自回环地址且同源的浏览器请求(与 `host.pickDirectory` 相同的载体守卫)。平台适配器不经 shell 打开:macOS 为 `open`,Windows 为 PowerShell `Invoke-Item`,桌面 Linux 为 `xdg-open`;浏览器可渲染的文档会在 macOS 与桌面 Linux 上优先使用指定的默认浏览器。尽管 Node 将 WSL 报告为 `linux`,WSL 仍是一种独立的宿主形态:适配器根据其环境或 Microsoft 内核 release 识别它,用 `wslpath -w` 转换 Linux 路径,并将所得 Windows/UNC 路径交给同一 PowerShell 交接。打开器的平台信息和命令运行器可在测试中注入。仅含 URL 的 read 参数(`web_fetch`)不是文件链接。 +`host.openPath` 是一元 RPC,与每个 Host API 方法一样要求通过 Host/Origin 校验和浏览器会话认证。平台适配器不经 shell 打开:macOS 为 `open`,Windows 为 PowerShell `Invoke-Item`,桌面 Linux 为 `xdg-open`;浏览器可渲染的文档会在 macOS 与桌面 Linux 上优先使用指定的默认浏览器。尽管 Node 将 WSL 报告为 `linux`,WSL 仍是一种独立的宿主形态:适配器根据其环境或 Microsoft 内核 release 识别它,用 `wslpath -w` 转换 Linux 路径,并将所得 Windows/UNC 路径交给同一 PowerShell 交接。打开器的平台信息和命令运行器可在测试中注入。仅含 URL 的 read 参数(`web_fetch`)不是文件链接。 ## 考虑过的替代方案 @@ -23,7 +23,7 @@ Status: implemented ## 后果 -点击工具行中的文件路径会在宿主上打开该路径。非文件工具行只是不可交互的摘要(行内已有的展开开关仍保留)。远程或非回环客户端无法调用 `host.openPath`。Host 或操作系统拒绝由聊天视图拥有:它展示抛出的原因,并对同一路径提供重试([打开失败](../bug-fix/2026-08-18-tool-row-file-open-failure.zh.md))。 +点击工具行中的文件路径会在宿主上打开该路径。非文件工具行只是不可交互的摘要(行内已有的展开开关仍保留)。Client 在非 loopback 页面不提供 `host.openPath`;每次已暴露的 Host 调用仍要求浏览器会话。Host 或操作系统拒绝由聊天视图拥有:它展示抛出的原因,并对同一路径提供重试([打开失败](../bug-fix/2026-08-18-tool-row-file-open-failure.zh.md))。 ## 风险 diff --git a/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.i18n.yaml b/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.i18n.yaml index fda5e932c9..fac721984b 100644 --- a/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.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-08-13-shared-modal-product-onboarding.md -2026-08-13-shared-modal-product-onboarding.md: ec3d3e5f112b04736a15645c6e62e942367bb4fa -2026-08-13-shared-modal-product-onboarding.zh.md: 78b17d02dba52170722042126cc5616e9272c722 +2026-08-13-shared-modal-product-onboarding.md: 9f72ee5b4e2abd8dd9ad70c819976be73f3f8116 +2026-08-13-shared-modal-product-onboarding.zh.md: 867b23202f2e15701d72dd5e11cbe6ff2346ef11 diff --git a/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.md b/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.md index ec3d3e5f11..9f72ee5b4e 100644 --- a/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.md +++ b/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.md @@ -14,7 +14,7 @@ First-run onboarding mixed two interaction models: a viewport takeover for produ **Both steps share one modal component.** `OnboardingModal` wraps the existing ui-primitives `Modal`, supplies the common title and content geometry, and owns `#root` inert for exactly the visible lifetime. Escape and mask clicks do not silently complete mandatory onboarding; each step exposes only its explicit actions. A step still loading private facts returns `null`, so it paints and blocks nothing. -**The welcome notice reuses the existing durable field.** Its exact copy and version live in `onboarding-copy.ts`. Loopback clients compare and write `ui-onboarding.welcomeNoticeVersion` through the existing settings API, and only Continue acknowledges the current version. Remote clients retain the existing process-local fallback because the settings namespace is loopback-only. No Host schema, API-proxy allowlist, or persistence implementation changes. +**The welcome notice reuses the existing durable field.** Its exact copy and version live in `onboarding-copy.ts`. Loopback clients compare and write `ui-onboarding.welcomeNoticeVersion` through the existing settings API, and only Continue acknowledges the current version. Non-loopback pages retain the existing process-local fallback because the Client keeps Host settings persistence disabled there. No Host schema, API-proxy allowlist, or persistence implementation changes. **The credential dialog reuses the existing editor and write boundary.** The Models join still decides whether any provider is usable. When the official DeepSeek reference is writable and missing, `ProviderEditor` renders in credential-only mode inside the shared modal. It validates the key and calls the existing `credentials.set`; it does not mutate provider settings. Save and continue waits for the write and refreshed readiness, while Configure later completes only the current coordinator pass. diff --git a/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.zh.md b/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.zh.md index 78b17d02db..867b23202f 100644 --- a/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.zh.md +++ b/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.zh.md @@ -14,7 +14,7 @@ Status: implemented **两个步骤共用同一个弹窗组件。** `OnboardingModal` 包装既有 ui-primitives `Modal`,提供统一的标题和内容布局,并只在可见期间持有 `#root` 的 inert 状态。Escape 和遮罩点击不会静默完成强制引导;每个步骤只暴露自己的明确操作。步骤仍在加载私有事实时返回 `null`,因此不会绘制或阻塞界面。 -**欢迎声明复用既有持久化字段。** 完整文案与版本由 `onboarding-copy.ts` 持有。回环客户端通过既有 settings API 比较和写入 `ui-onboarding.welcomeNoticeVersion`,且只有点击「继续」才确认当前版本。远程客户端继续使用既有的进程内回退,因为该 settings namespace 仅限回环访问。不改变 Host schema、API Proxy 允许列表或持久化实现。 +**欢迎声明复用既有持久化字段。** 完整文案与版本由 `onboarding-copy.ts` 持有。回环客户端通过既有 settings API 比较和写入 `ui-onboarding.welcomeNoticeVersion`,且只有点击「继续」才确认当前版本。非 loopback 页面继续使用既有的进程内回退,因为 Client 在那里禁用 Host settings 持久化。不改变 Host schema、API Proxy 允许列表或持久化实现。 **凭据弹窗复用既有编辑器与写入边界。** Models 联接仍负责判断是否已有任意可用提供方。当 DeepSeek 官方引用可写但缺失时,`ProviderEditor` 以仅凭据模式渲染在共用弹窗中。它校验密钥并调用既有 `credentials.set`,不会修改提供方设置。「保存并继续」会等待写入与就绪状态刷新;「稍后配置」只完成协调器当前这一轮。 diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml index 241d95f649..df36182179 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.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/simplification/2026-08-08-copy-only-preset-authoring.md -2026-08-08-copy-only-preset-authoring.md: c16518b087c7acedbee3d89ce5cc8dbcaa0a0cde -2026-08-08-copy-only-preset-authoring.zh.md: 63a184f5825530a2c99a26b8afa606412decab6e +2026-08-08-copy-only-preset-authoring.md: bfe0d49755abf47314a7b4cf56c738537fca3963 +2026-08-08-copy-only-preset-authoring.zh.md: fc2d9ac5c6ace45c46fc920e1f7f28aca508ba9c diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md index c16518b087..bfe0d49755 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md @@ -14,7 +14,7 @@ Authoring is a host-side copy, and files are the editor. `agentPreset.write` bec ## Consequences -- No composition text and no path crosses the browser wire in either authoring direction; the `entryListSchema`/`!!js` concern dissolves with `assertComposition` itself (deleted). The privileged set is now `read`/`copy`/`openDocument`/`remove` — none accepts a filesystem target. +- No composition text and no path crosses the browser wire in either authoring direction; the `entryListSchema`/`!!js` concern dissolves with `assertComposition` itself (deleted). The authoring operations are `read`/`copy`/`openDocument`/`remove` — none accepts a filesystem target, and Connection authenticates them with the complete Host API. - With the editor gone, hand-editing `agent.cordis.yml` is the ONLY composition edit, so the standing-mount layer grew stamp-keyed generations: `ensureStanding` compares the file's mtime+size and starts the next generation for later sessions ([standing-mounts note](../architecture/2026-08-08-per-preset-standing-mounts.md), updated in place). Without this, an edited file would serve stale compositions until process restart. - A copy is a full snapshot that drifts from an upgraded shipped source — accepted; the preset layer has no patch semantics (that is the bundle layer's `cordis.patch.yml`), and the shipped set itself pays the same cost (`cordis`/`code` are full copies of `standard`) for one-file readability. - `read` dropped `writable` (no editor to gate) and builtin directories are never opened (`openDocument` refuses non-`user` trust like `remove`): the install is overwritten by upgrades, and pointing an editor into it invites edits an upgrade silently discards. @@ -22,7 +22,7 @@ Authoring is a host-side copy, and files are the editor. `agentPreset.write` bec ## Load-bearing details - **Copy target refusal is two checks on purpose.** The roster check refuses any id a root supplies — a user directory named like a shipped preset would be shadowed, so "create" would land a file nothing ever lists; the disk check (`PresetExistsError` before `cp` with `errorOnExist` as the race backstop) refuses a directory occupying the name without being a preset, which discovery cannot see. -- **The revealed path is response-direction disclosure, loopback-pinned.** The invariant "no browser payload can select an arbitrary filesystem target" is about the request direction; showing the resolved directory to the loopback user is the fallback the plan requires. It never rides the unprivileged `list`. +- **The revealed path is response-direction disclosure, browser-authenticated.** The invariant "no browser payload can select an arbitrary filesystem target" is about the request direction; showing the resolved directory to the authenticated browser is the fallback the plan requires. It never rides `list`. - **The e2e lane pins `nativeOpen: false`** (`agent-preset-authoring.overlay.yml`) — both so goldens render the same branch on macOS dev and headless Linux CI, and so test runs never pop a real file manager. The revealed directory is tokenized as `{{presetRoot}}` by the lane itself, since `normalizeAria` only knows the workspace cwd. ## Alternatives considered diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md index 63a184f582..fc2d9ac5c6 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md @@ -14,7 +14,7 @@ agent-preset 设置页带着一个网页 YAML 编辑器:`agentPreset.write` ## 后果 -- 创作两个方向都不再有组装文本或路径跨越浏览器传输层;`entryListSchema`/`!!js` 的顾虑随 `assertComposition` 本身(已删除)一并消解。特权集现为 `read`/`copy`/`openDocument`/`remove`——没有一个接收文件系统目标。 +- 创作两个方向都不再有组装文本或路径跨越浏览器传输层;`entryListSchema`/`!!js` 的顾虑随 `assertComposition` 本身(已删除)一并消解。创作操作现为 `read`/`copy`/`openDocument`/`remove`——没有一个接收文件系统目标,且 Connection 用完整 Host API 的同一会话认证它们。 - 编辑器移除后,手改 `agent.cordis.yml` 成为唯一的组装编辑方式,因此常驻挂载层增加了以 stamp 为键的代际:`ensureStanding` 比对文件的 mtime+大小,为后续会话开启下一代际([常驻挂载 note](../architecture/2026-08-08-per-preset-standing-mounts.zh.md),已就地更新)。没有它,改过的文件要等进程重启才生效。 - 副本是完整快照,会随随附来源升级而漂移——接受;preset 层没有 patch 语义(那是 bundle 层 `cordis.patch.yml` 的能力),随附集合自己也为「一个文件读完整份组装」付了同样的代价(`cordis`/`code` 就是 `standard` 的完整副本)。 - `read` 去掉了 `writable`(没有编辑器可门控),内置目录绝不被打开(`openDocument` 与 `remove` 一样拒绝非 `user` 信任):安装目录会被升级覆盖,把编辑器指向它等于招揽会被升级悄悄丢弃的编辑。 @@ -22,7 +22,7 @@ agent-preset 设置页带着一个网页 YAML 编辑器:`agentPreset.write` ## 关键实现细节 - **复制目标的拒绝刻意分两道检查。** roster 检查拒绝任一根目录提供的 id——与随附 preset 同名的用户目录会被遮蔽,「创建」只会落下一个永远不被列出的文件;磁盘检查(`cp` 之前的 `PresetExistsError`,`errorOnExist` 作竞态兜底)拒绝占着名字却不是 preset 的目录,那是 discovery 看不见的。 -- **展示的路径是响应方向的披露,且钉在环回。**「没有任何浏览器载荷能选中任意文件系统目标」这条不变量说的是请求方向;把解析出的目录展示给环回用户正是方案要求的降级。它绝不搭乘非特权的 `list`。 +- **展示的路径是响应方向的披露,且经过浏览器认证。**「没有任何浏览器载荷能选中任意文件系统目标」这条不变量说的是请求方向;把解析出的目录展示给已认证浏览器正是方案要求的降级。它绝不搭乘 `list`。 - **e2e lane 钉死 `nativeOpen: false`**(`agent-preset-authoring.overlay.yml`)——既让 golden 在 macOS 开发机与无头 Linux CI 上渲染同一分支,也让测试运行永不弹出真实文件管理器。揭示的目录由 lane 自己 token 化为 `{{presetRoot}}`,因为 `normalizeAria` 只认识 workspace cwd。 ## 考虑过的替代方案 diff --git a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml index d327972db7..67e708c2ff 100644 --- a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.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/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md -2026-08-10-unary-apiproxy-remote-migration.md: c4a308e16bee994df69f16228e859dd88d90c346 -2026-08-10-unary-apiproxy-remote-migration.zh.md: db263e1a7843963cf38082a88405453ee4b66af7 +2026-08-10-unary-apiproxy-remote-migration.md: cd019ef10c6d584a98b185ac50856a3fe63b8bd0 +2026-08-10-unary-apiproxy-remote-migration.zh.md: e6842cc82cfd7f850bb58e9d640ec23394287427 diff --git a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md index c4a308e16b..cd019ef10c 100644 --- a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md +++ b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md @@ -12,7 +12,7 @@ Moving a method mechanically is not sufficient. Agent-bound API Proxy methods ca The API Proxy also contains BFF operations whose contract is not a business method: Session lifecycle and transcript assembly, model-selection state, live-only input control, configuration filtering, skill presentation, Host composition facts, and native desktop operations. Stateful interactions and streams have different lifecycles again. Treating all unary syntax as evidence that a method is simple would move product policy into arbitrary Service packages or force new packages that have no independent business owner. -Finally, Connection currently applies its loopback-only privileged-method list inside the API Proxy fallback. A Typert interceptor claims its endpoint before that fallback, so migrating credential or preset authoring calls without moving the privilege check would grant trusted-LAN callers operations that are currently loopback-only. +Finally, Connection must authenticate a request before choosing the API Proxy fallback or a Typert interceptor. A migration that authenticates only the fallback would let Remote-owned endpoints bypass the browser identity required by every Host operation. ## Proposal @@ -76,14 +76,9 @@ Generated Remote methods return business values and throw an Error whose `cause` Resolver-owned `session-not-found` and `agent-busy` errors remain stable because the shared resolver raises `TypertLookupFailure`. Ordinary business exceptions become the Gateway's existing `internal` RPC failure. A selected Client consumer may migrate only if it does not branch on a more specific legacy business error code; if implementation finds such a branch, that RPC leaves this set unless the business package gains a transport-independent typed failure. -## Privileged authority +## Browser authentication -Connection must enforce privileged endpoint authority before choosing the Typert interceptor or API Proxy fallback. The check must recognize both legacy dotted names and Remote slash endpoints and keep these migrated operations loopback-only: - -- `agentPresets/readDocument`, `agentPresets/copy`, and `agentPresets/remove`; -- `credentials/describe`, `credentials/set`, and `credentials/unset`. - -The carrier-wide trusted-host and origin checks remain unchanged. This is a non-escalation requirement: endpoint ownership may change, but the set of callers authorized to invoke the operation may not widen. +Connection authenticates the complete `/api` request before choosing the Typert interceptor or API Proxy fallback. Legacy dotted names and Remote slash endpoints therefore use the same process-token-established browser session without an endpoint list. This is a non-escalation requirement: endpoint ownership may change, but an unauthenticated request can reach neither dispatch path. ## Commit boundaries @@ -101,14 +96,14 @@ The final commit generates every `/remote` artifact from a clean state, updates **Preserve every legacy RPC name and response envelope.** That would turn business packages into copies of the old protocol. Service-oriented names and business values let the Client own joins while Connection continues to own the one RPC envelope. -**Trust the API Proxy fallback to enforce privileged methods.** Interceptor selection bypasses that fallback, so this would silently widen authority for migrated methods. +**Trust the API Proxy fallback to authenticate requests.** Interceptor selection bypasses that fallback, so Remote methods would become anonymously callable. ## Acceptance criteria - Every migration-table method is callable through its listed `ctx.remote` Service and has no production legacy API Proxy route, schema, map row, client stub, or invocation. - Existing methods with matching signatures carry `@Remote` directly; every added method performs the adaptation stated in the table and no identity `remote*` wrapper remains. - Agent/Session integration tests prove the shared lookup outcomes, and subagent interrupt tests prove no cold resume occurs. -- Privileged migrated endpoints reject trusted non-loopback callers and accept loopback callers before either dispatch path runs. +- Migrated endpoints reject unauthenticated requests and accept the same valid browser session as legacy endpoints before either dispatch path runs. - Client behavior and immediate state settlement remain equivalent for every migrated call, including cancellation where supported. - Deferred methods remain on the API Proxy with their existing behavior. - A clean generation/build produces and consumes every selected Remote contribution, and focused tests plus final repository gates pass. @@ -119,6 +114,6 @@ Removing legacy schemas also removes their protocol-specific error taxonomy. A h Generated Remote contracts add build ordering and publication entries to each business package. Missing one runtime mount, declaration export, source-map source, package dependency, or Project Reference can pass a narrow source test while failing a clean Client build. -Moving privilege enforcement to composite dispatch changes security-sensitive carrier code. Tests must exercise both a Remote-owned endpoint and a legacy fallback endpoint so neither path can bypass the loopback decision. +Composite dispatch changes security-sensitive carrier code. Tests must exercise both a Remote-owned endpoint and a legacy fallback endpoint so neither path can bypass browser authentication. -This note applies the existing Typert Remote architecture rather than superseding it. It partially supersedes the central unary ownership and five-step extension checklist in the [GUI RPC protocol note](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) and the central wiring inventory in the [Web configuration plane note](../../implemented/architecture/2026-07-30-web-config-plane.md); those notes remain authoritative for Connection envelopes and configuration behavior outside the migrated methods. The title, command, configuration-boundary, subagent-interrupt, and archive notes continue to own their business behavior and require factual transport updates rather than archival. The [browser trust boundary](../../implemented/architecture/2026-07-28-api-browser-trust-boundary.md) and [generated-contract build order](../../implemented/process/2026-08-08-api-remotes-generated-contract-build.md) remain authoritative and require no archival action. +This note applies the existing Typert Remote architecture rather than superseding it. It partially supersedes the central unary ownership and five-step extension checklist in the [GUI RPC protocol note](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) and the central wiring inventory in the [Web configuration plane note](../../implemented/architecture/2026-07-30-web-config-plane.md); those notes remain authoritative for Connection envelopes and configuration behavior outside the migrated methods. The title, command, configuration-boundary, subagent-interrupt, and archive notes continue to own their business behavior and require factual transport updates rather than archival. The [browser trust boundary](../../implemented/architecture/2026-07-28-api-browser-trust-boundary.md), [browser authentication](../../implemented/architecture/2026-08-24-browser-token-authentication.md), and [generated-contract build order](../../implemented/process/2026-08-08-api-remotes-generated-contract-build.md) remain authoritative and require no archival action. diff --git a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md index db263e1a78..e6842cc82c 100644 --- a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md +++ b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md @@ -12,7 +12,7 @@ Host API Proxy 仍承载许多一元方法。这些方法的实现仅执行服 API Proxy 还包含一些不以业务方法为约定的 BFF 操作:Session 生命周期与 transcript(文本记录)组装、模型选择状态、仅限 live 的输入控制、配置过滤、skill(技能)呈现、Host 组合信息和原生桌面操作。有状态交互与流又具有不同的生命周期。若把一元调用的语法一概视为方法简单的依据,就会把产品策略移入任意服务包,或者迫使系统新增没有独立业务所有者的包。 -最后,Connection 目前在 API Proxy 回退路径内执行仅限环回地址的特权方法清单。Typert interceptor 会先于该回退路径认领自己的端点,因此,如果迁移凭据或 preset 创作调用时不一并迁移权限检查,受信任的局域网调用方就会获得目前仅向环回调用方开放的操作权限。 +最后,Connection 必须在选择 API Proxy 回退路径或 Typert interceptor 前认证请求。若迁移只在回退路径执行认证,由 Remote 持有的 endpoint 就能绕过每个 Host 操作都要求的浏览器身份。 ## 提案 @@ -76,14 +76,9 @@ Lookup 策略作用于整个 key,而非特定端点。提示词输入、队列 Resolver 拥有的 `session-not-found` 和 `agent-busy` 错误保持稳定,因为共享 resolver 会抛出 `TypertLookupFailure`。普通业务异常会变成 Gateway 现有的 `internal` RPC 失败。只有在选定的 Client 消费方不根据更具体的旧版业务错误码进行分支时,才能迁移该调用;如果实现过程中发现这种分支,除非业务包新增与传输无关的类型化失败,否则该 RPC 将退出此集合。 -## 特权调用权限 +## 浏览器认证 -Connection 必须在选择 Typert interceptor 或 API Proxy 回退路径之前检查调用方是否有权访问特权端点。该检查必须同时识别旧式点分名称和 Remote 斜杠端点,并保持以下已迁移操作仅限环回地址: - -- `agentPresets/readDocument`、`agentPresets/copy` 和 `agentPresets/remove`; -- `credentials/describe`、`credentials/set` 和 `credentials/unset`。 - -贯穿整个载体的 trusted-host 和 origin 检查保持不变。这是一项非升权要求:端点所有权可以变化,但获准调用该操作的调用方集合不得扩大。 +Connection 在选择 Typert interceptor 或 API Proxy 回退路径前认证完整 `/api` 请求。旧式点分名称和 Remote 斜杠 endpoint 因此无需 endpoint 清单,就能使用同一个由进程令牌建立的浏览器会话。这是一条非提权要求:endpoint 所有权可以变化,但未认证请求不能进入任一分发路径。 ## 提交边界 @@ -101,14 +96,14 @@ Connection 必须在选择 Typert interceptor 或 API Proxy 回退路径之前 **保留每一个旧版 RPC 名称和响应 envelope。** 这会使业务包变成旧协议的副本。面向服务的名称和业务值让 Client 负责关联操作,而 Connection 继续负责统一的 RPC envelope。 -**依赖 API Proxy 回退路径强制执行特权方法权限。** interceptor 选择会绕过该回退路径,因此这会悄然扩大已迁移方法的权限范围。 +**依赖 API Proxy 回退路径认证请求。** interceptor 选择会绕过该回退路径,使 Remote 方法变成匿名可调用。 ## 验收标准 - 迁移表中的每个方法都可通过表中列出的 `ctx.remote` 服务调用,并且不存在生产环境中的旧版 API Proxy 路由、schema、映射表行、客户端 stub 或调用。 - 签名匹配的现有方法直接带有 `@Remote`;每个新增方法都执行表中所述的适配,且不保留只做恒等转发的 `remote*` 包装层。 - Agent/Session 集成测试证明共享 lookup 的各项结果,subagent 中断测试证明不会发生冷恢复。 -- 已迁移的特权端点拒绝受信任的非环回调用方,并接受环回调用方,且该判定在任一分发路径运行前完成。 +- 已迁移 endpoint 拒绝未认证请求,并在任一分发路径运行前接受与旧 endpoint 相同的有效浏览器会话。 - 每项已迁移调用的 Client 行为和立即提交状态的行为保持等价,包括支持取消之处的取消行为。 - 暂缓迁移的方法及其现有行为仍保留在 API Proxy 上。 - 一次从干净状态开始的生成与构建会生成并消费所选的每项 Remote 贡献,且聚焦测试和最终仓库门禁均通过。 @@ -119,6 +114,6 @@ Connection 必须在选择 Typert interceptor 或 API Proxy 回退路径之前 生成的 Remote 约定会为每个业务包引入构建顺序要求和发布条目。如果遗漏运行时挂载、声明导出、source map 来源、包依赖或 Project Reference 中的任何一项,局部源码测试可能仍会通过,但从干净状态开始的 Client 构建会失败。 -将权限强制执行移至复合分发会改变安全敏感的载体代码。测试必须覆盖一个由 Remote 拥有的端点和一个旧版回退端点,确保两条路径都无法绕过环回判定。 +复合分发会改变安全敏感的载体代码。测试必须覆盖一个由 Remote 拥有的 endpoint 和一个旧版回退 endpoint,确保两条路径都无法绕过浏览器认证。 -本文应用现有 Typert Remote 架构,而非取代它。本文部分取代 [GUI RPC 协议笔记](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md)中的中央一元调用所有权和五步扩展检查清单,以及 [Web 配置平面笔记](../../implemented/architecture/2026-07-30-web-config-plane.zh.md)中的中央接线清单;对于已迁移方法之外的 Connection envelope 和配置行为,这些笔记仍具权威性。标题、命令、配置边界、subagent 中断和归档笔记继续负责各自的业务行为,只需如实更新传输相关事实,无需归档。[浏览器信任边界](../../implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)和[生成约定构建顺序](../../implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md)仍具权威性,无需执行归档操作。 +本文应用现有 Typert Remote 架构,而非取代它。本文部分取代 [GUI RPC 协议笔记](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md)中的中央一元调用所有权和五步扩展检查清单,以及 [Web 配置平面笔记](../../implemented/architecture/2026-07-30-web-config-plane.zh.md)中的中央接线清单;对于已迁移方法之外的 Connection envelope 和配置行为,这些笔记仍具权威性。标题、命令、配置边界、subagent 中断和归档笔记继续负责各自的业务行为,只需如实更新传输相关事实,无需归档。[浏览器信任边界](../../implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)、[浏览器认证](../../implemented/architecture/2026-08-24-browser-token-authentication.zh.md)和[生成约定构建顺序](../../implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md)仍具权威性,无需执行归档操作。 diff --git a/apps/cli/package.json b/apps/cli/package.json index 3d785a12cd..d940c56bb4 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -136,6 +136,8 @@ "@deepseek-ai/dsh-tool-subagent-report": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@types/js-yaml": "^4.0.9", - "execa": "^10.0.0" + "@types/ws": "8.18.1", + "execa": "^10.0.0", + "ws": "8.21.0" } } diff --git a/apps/cli/tests/fixtures/web-browser-open/open.mjs b/apps/cli/tests/fixtures/web-browser-open/open.mjs index 0f196f0e3f..85ca246fbd 100644 --- a/apps/cli/tests/fixtures/web-browser-open/open.mjs +++ b/apps/cli/tests/fixtures/web-browser-open/open.mjs @@ -22,7 +22,15 @@ export default async function open(url) { if (process.env.BROWSER_OPEN_TEST_FAILURE !== undefined) { throw new Error(process.env.BROWSER_OPEN_TEST_FAILURE) } - const response = await fetch(url) + const exchange = await fetch(url, { redirect: 'manual' }) + const setCookie = exchange.headers.get('set-cookie') + const location = exchange.headers.get('location') + if (exchange.status !== 303 || setCookie === null || location === null) { + throw new Error(`browser authentication exchange returned HTTP ${exchange.status}`) + } + const response = await fetch(new URL(location, url), { + headers: { cookie: setCookie.split(';', 1)[0] }, + }) const html = await response.text() console.log(`dsh browser-open: ${JSON.stringify({ url, diff --git a/apps/cli/tests/github-webhook-real.e2e.ts b/apps/cli/tests/github-webhook-real.e2e.ts index e16aef8b3e..fd465714d3 100644 --- a/apps/cli/tests/github-webhook-real.e2e.ts +++ b/apps/cli/tests/github-webhook-real.e2e.ts @@ -12,6 +12,7 @@ import { join } from 'node:path' import { setTimeout as delay } from 'node:timers/promises' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' +import WebSocket from 'ws' const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) const BUILT_BIN = join(REPO_ROOT, 'apps/cli/lib/bin.js') @@ -23,6 +24,23 @@ const SECRET = 'github-webhook-real-e2e-secret' const DELIVERY = 'github-webhook-real-e2e-delivery' const MARKER = 'DSH_GITHUB_WEBHOOK_REAL_E2E_OK' const TITLE = 'GitHub webhook real e2e' +const authenticatedCookies = new Map>() + +/** Exchange the printed process token once for Node-side API probes. */ +function authenticatedWeb(launchUrl: string): Promise<{ origin: string; cookie: string }> { + const existing = authenticatedCookies.get(launchUrl) + if (existing !== undefined) return existing + const exchange = (async () => { + const response = await fetch(launchUrl, { redirect: 'manual' }) + const setCookie = response.headers.get('set-cookie') + if (response.status !== 303 || setCookie === null) { + throw new Error(`dsh web authentication returned HTTP ${String(response.status)}`) + } + return { origin: new URL(launchUrl).origin, cookie: setCookie.split(';', 1)[0]! } + })() + authenticatedCookies.set(launchUrl, exchange) + return exchange +} interface SessionList { items: Array<{ @@ -111,9 +129,10 @@ async function freePort(): Promise { /** Invoke one public Remote method over its HTTP carrier. */ async function remoteRpc(baseUrl: string, endpoint: string, args: object): Promise { - const response = await fetch(`${baseUrl}/api/${endpoint}`, { + const authenticated = await authenticatedWeb(baseUrl) + const response = await fetch(`${authenticated.origin}/api/${endpoint}`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', cookie: authenticated.cookie }, body: JSON.stringify({ type: 'client-request', rpcId: `github-webhook-real-${endpoint}-${randomUUID()}`, @@ -140,7 +159,10 @@ async function openingStreamItem( args: object, accepts: (value: unknown) => boolean, ): Promise> { - const socket = new WebSocket(`${baseUrl.replace(/^http/u, 'ws')}/api/remote.mux`) + const authenticated = await authenticatedWeb(baseUrl) + const socket = new WebSocket(`${authenticated.origin.replace(/^http/u, 'ws')}/api/remote.mux`, { + headers: { cookie: authenticated.cookie }, + }) const streamId = `github-webhook-real-${endpoint}-${randomUUID()}` try { await new Promise((resolve, reject) => { @@ -179,10 +201,13 @@ async function openingStreamItem( else if (value === undefined) reject(new Error(`${endpoint} opening item was absent`)) else resolve(value) } - const message = (event: MessageEvent): void => { + const message = (event: WebSocket.MessageEvent): void => { try { - if (typeof event.data !== 'string') throw new Error(`${endpoint} published a non-text frame`) - const frame: unknown = JSON.parse(event.data) + const text = typeof event.data === 'string' + ? event.data + : Buffer.isBuffer(event.data) ? event.data.toString('utf8') : undefined + if (text === undefined) throw new Error(`${endpoint} published a non-text frame`) + const frame: unknown = JSON.parse(text) if (!isRecord(frame) || frame.streamId !== streamId) return if (frame.type === 'error') { finish(new Error(`${endpoint} failed: ${JSON.stringify(frame.error)}`)) @@ -356,7 +381,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('GitHub webhook through the real const webhookOrigin = `http://127.0.0.1:${String(webhookPort)}` expect((await fetch(`${webhookOrigin}/api`)).status).toBe(404) - expect((await sendGitHubDelivery(baseUrl)).status).not.toBe(202) + expect((await sendGitHubDelivery(new URL(baseUrl).origin)).status).not.toBe(202) expect((await sendGitHubDelivery(webhookOrigin)).status).toBe(202) const workspaces = await eventually( diff --git a/apps/cli/tests/web-auth.e2e.ts b/apps/cli/tests/web-auth.e2e.ts new file mode 100644 index 0000000000..b22f611306 --- /dev/null +++ b/apps/cli/tests/web-auth.e2e.ts @@ -0,0 +1,210 @@ +/** Real `dsh web` authentication against a temporary Harness home. */ + +import type { ChildProcess } from 'node:child_process' +import { spawn } from 'node:child_process' +import { stat } from 'node:fs/promises' +import { request as httpRequest } from 'node:http' +import { createRequire } from 'node:module' +import { createServer } from 'node:net' +import type { AddressInfo } from 'node:net' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { describe, expect, it } from 'vitest' + +const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) +const DSH_SOURCE_BIN = join(REPO_ROOT, 'apps/cli/src/bin.ts') +const TSX_LOADER = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href + +interface RunningWeb { + readonly child: ChildProcess + readonly launchUrl: string + readonly output: () => string +} + +interface HttpResult { + readonly status: number + readonly body: string +} + +function redact(output: string): string { + return output.replace(/([?&]token=)[^\s)]+/gu, '$1') +} + +/** Reserve one concrete loopback port, then release it for the CLI process. */ +async function freePort(): Promise { + const server = createServer() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const port = (server.address() as AddressInfo).port + await new Promise((resolve, reject) => { + server.close((error) => { + if (error === undefined) resolve() + else reject(error) + }) + }) + return port +} + +function cleanEnvironment(root: string, dshHome: string): NodeJS.ProcessEnv { + const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => + !/(?:KEY|SECRET|TOKEN|PASSWORD)/iu.test(name))) + return { + ...env, + DSH_AGENTS_HOME: join(root, '.agents'), + DSH_HOME: dshHome, + DSH_TELEMETRY_DISABLED: '1', + NODE_NO_WARNINGS: '1', + SSH_CONNECTION: '', + SSH_TTY: '', + TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'), + } +} + +/** Start the public source CLI and wait for its authenticated readiness URL. */ +async function startWeb(root: string, dshHome: string, port: number): Promise { + const child = spawn(process.execPath, [ + '--import', TSX_LOADER, + DSH_SOURCE_BIN, + 'web', + '--no-open', + '--port', String(port), + ], { + cwd: root, + env: cleanEnvironment(root, dshHome), + stdio: ['ignore', 'pipe', 'pipe'], + }) + let output = '' + const launchUrl = await new Promise((resolve, reject) => { + let settled = false + const fail = (error: Error): void => { + if (settled) return + settled = true + clearTimeout(timer) + reject(error) + } + const timer = setTimeout(() => { + fail(new Error(`dsh web did not become ready:\n${redact(output)}`)) + }, 90_000) + const append = (chunk: Buffer | string): void => { + output = `${output}${String(chunk)}`.slice(-100_000) + const match = /dsh web: (http:\/\/[^\s]+)/u.exec(output) + if (settled || match?.[1] === undefined) return + settled = true + clearTimeout(timer) + resolve(match[1]) + } + child.stdout?.on('data', append) + child.stderr?.on('data', append) + child.once('error', (error) => { + fail(error) + }) + child.once('exit', (code) => { + fail(new Error(`dsh web exited before readiness (${String(code)}):\n${redact(output)}`)) + }) + }) + return { child, launchUrl, output: () => output } +} + +async function stopWeb(running: RunningWeb): Promise { + if (running.child.exitCode !== null) return + const exited = new Promise((resolve) => { running.child.once('exit', () => { resolve() }) }) + running.child.kill('SIGTERM') + const forced = setTimeout(() => { running.child.kill('SIGKILL') }, 10_000) + forced.unref() + await exited + clearTimeout(forced) +} + +/** POST one real API Proxy envelope while controlling the wire Host header. */ +function describeHost(port: number, host: string, cookie?: string): Promise { + const body = JSON.stringify({ + type: 'client-request', + rpcId: 'web-auth-real-cli', + method: 'host.describe', + payload: {}, + }) + return new Promise((resolve, reject) => { + const req = httpRequest({ + hostname: '127.0.0.1', + port, + path: '/api/host.describe', + method: 'POST', + headers: { + host, + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(body), + ...cookie === undefined ? {} : { cookie }, + }, + }, (res) => { + const chunks: Uint8Array[] = [] + res.on('data', (chunk: Buffer) => { chunks.push(chunk) }) + res.on('end', () => { + resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }) + }) + }) + req.once('error', reject) + req.end(body) + }) +} + +describe('dsh web authentication through the real CLI', () => { + it('rejects a forged loopback Host and preserves the browser cookie across restart', { timeout: 180_000 }, async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-web-auth-real-cli-')) + const dshHome = join(root, '.dsh') + const port = await freePort() + let first: RunningWeb | undefined + let second: RunningWeb | undefined + try { + first = await startWeb(root, dshHome, port) + const firstUrl = new URL(first.launchUrl) + expect(firstUrl.origin).toBe(`http://127.0.0.1:${String(port)}`) + expect(firstUrl.pathname).toBe('/') + expect(firstUrl.searchParams.get('token')).toMatch(/^[A-Za-z0-9_-]{43}$/u) + + expect(await describeHost(port, `localhost:${String(port)}`)).toEqual({ + status: 401, + body: 'unauthorized', + }) + + const exchange = await fetch(first.launchUrl, { redirect: 'manual' }) + expect(exchange.status).toBe(303) + expect(exchange.headers.get('location')).toBe('/') + const setCookie = exchange.headers.get('set-cookie') + if (setCookie === null) throw new Error('real CLI token exchange omitted Set-Cookie') + expect(setCookie).toContain('HttpOnly') + expect(setCookie).toContain('SameSite=Strict') + expect(setCookie).not.toContain('Secure') + const cookie = setCookie.split(';', 1)[0]! + + const authenticated = await describeHost(port, firstUrl.host, cookie) + expect(authenticated.status).toBe(200) + const authenticatedBody = JSON.parse(authenticated.body) as unknown + expect(authenticatedBody).toMatchObject({ + type: 'server-response', + rpcId: 'web-auth-real-cli', + result: { ok: true, value: { version: expect.any(String) as unknown } }, + }) + + await stopWeb(first) + first = undefined + second = await startWeb(root, dshHome, port) + const secondUrl = new URL(second.launchUrl) + expect(secondUrl.searchParams.get('token')).not.toBe(firstUrl.searchParams.get('token')) + expect((await describeHost(port, secondUrl.host, cookie)).status).toBe(200) + + const credentialMode = (await stat(join(dshHome, '.credentials.yaml'))).mode & 0o777 + expect(credentialMode).toBe(0o600) + } catch (error) { + const evidence = [first?.output(), second?.output()].filter(value => value !== undefined).join('\n') + throw new Error(`${error instanceof Error ? error.message : String(error)}\n${redact(evidence)}`, { cause: error }) + } finally { + if (second !== undefined) await stopWeb(second) + if (first !== undefined) await stopWeb(first) + await rm(root, { recursive: true, force: true }) + } + }) +}) diff --git a/apps/cli/tests/web-browser-open.expected.e2e.ts b/apps/cli/tests/web-browser-open.expected.e2e.ts index 0441ab6e17..96bbbc0215 100644 --- a/apps/cli/tests/web-browser-open.expected.e2e.ts +++ b/apps/cli/tests/web-browser-open.expected.e2e.ts @@ -32,7 +32,9 @@ interface BrowserOpenRecord { } function normalizeLocalUrl(url: string): string { - return url.replace(/:\d+$/, ':{{port}}') + return url + .replace(/:\d+/u, ':{{port}}') + .replace(/token=[^&]+/u, 'token={{token}}') } describe.skipIf(!builtArtifactsExist)('dsh web browser-open assembled snapshot', () => { @@ -85,9 +87,9 @@ describe.skipIf(!builtArtifactsExist)('dsh web browser-open assembled snapshot', "bootManifest": true, "dshHomePresent": false, "exitCode": 0, - "openedUrl": "http://127.0.0.1:{{port}}", + "openedUrl": "http://127.0.0.1:{{port}}/?token={{token}}", "opening": true, - "readyUrl": "http://127.0.0.1:{{port}}", + "readyUrl": "http://127.0.0.1:{{port}}/?token={{token}}", "status": 200, "stderr": "", } @@ -124,7 +126,6 @@ describe.skipIf(!builtArtifactsExist)('dsh web browser-open assembled snapshot', const readyUrl = /dsh web: (http:\/\/[^\s]+)/u.exec(result.stdout)?.[1] const diagnostic = result.stderr.split(/\r?\n/u) .find(line => line.startsWith('web-app: could not open the default browser because ')) - ?.replace(/http:\/\/127\.0\.0\.1:\d+/u, 'http://127.0.0.1:{{port}}') expect({ diagnostic, @@ -134,11 +135,11 @@ describe.skipIf(!builtArtifactsExist)('dsh web browser-open assembled snapshot', readyUrl: readyUrl === undefined ? undefined : normalizeLocalUrl(readyUrl), }).toMatchInlineSnapshot(` { - "diagnostic": "web-app: could not open the default browser because fixture desktop unavailable; visit http://127.0.0.1:{{port}} manually", + "diagnostic": "web-app: could not open the default browser because fixture desktop unavailable; use the dsh web URL printed at startup", "exitCode": 0, "opened": false, "opening": true, - "readyUrl": "http://127.0.0.1:{{port}}", + "readyUrl": "http://127.0.0.1:{{port}}/?token={{token}}", } `) }) @@ -183,7 +184,7 @@ describe.skipIf(!builtArtifactsExist)('dsh web browser-open assembled snapshot', "exitCode": 0, "opened": false, "opening": false, - "readyUrl": "http://127.0.0.1:{{port}}", + "readyUrl": "http://127.0.0.1:{{port}}/?token={{token}}", "stderr": "", } `) diff --git a/apps/web/package.json b/apps/web/package.json index d1f70bedfc..eeb6693e6b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -43,6 +43,7 @@ "@types/node": "^22.0.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", + "@types/ws": "8.18.1", "@vitejs/plugin-react": "^4.0.0", "http-server": "^14.1.1", "fflate": "^0.8.2", @@ -51,6 +52,7 @@ "react-dom": "^18.2.0", "typescript": "^6.0.3", "vite": "^6.0.0", - "vitest": "^4.1.8" + "vitest": "^4.1.8", + "ws": "8.21.0" } } diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 7271449361..763e3746fd 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -26,9 +26,30 @@ import { fileURLToPath, pathToFileURL } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import WebSocket from 'ws' import { REPO_ROOT, connectFreshWorkspace, newEnglishPage, probeFreePort, requireDist, saveFailureShot } from './support.ts' const WEB_SURFACE_PROMPT = fileURLToPath(new URL('./expected/web-runtime-context/web-surface-prompt.expected.md', import.meta.url)) +const authenticatedCookies = new Map>() + +/** Exchange a printed process token once for Node-side HTTP/WebSocket probes. */ +function authenticatedWeb(launchUrl: string): Promise<{ origin: string; cookie: string }> { + const existing = authenticatedCookies.get(launchUrl) + if (existing !== undefined) return existing + const exchange = (async () => { + const response = await fetch(launchUrl, { redirect: 'manual' }) + const setCookie = response.headers.get('set-cookie') + if (response.status !== 303 || setCookie === null) { + throw new Error(`dsh web authentication returned HTTP ${String(response.status)}`) + } + return { + origin: new URL(launchUrl).origin, + cookie: setCookie.split(';', 1)[0]!, + } + })() + authenticatedCookies.set(launchUrl, exchange) + return exchange +} const comboMapUrl = (url: string): string => url.replace(/\/client\.js(?=,|&rev=)/g, '/client.js.map') @@ -54,9 +75,10 @@ function waitForReadyLine(child: ChildProcess): Promise { } async function remoteRpc(baseUrl: string, endpoint: string, args: object): Promise { - const response = await fetch(`${baseUrl}/api/${endpoint}`, { + const authenticated = await authenticatedWeb(baseUrl) + const response = await fetch(`${authenticated.origin}/api/${endpoint}`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', cookie: authenticated.cookie }, body: JSON.stringify({ type: 'client-request', rpcId: `smoke-${endpoint}`, @@ -74,7 +96,10 @@ async function remoteRpc(baseUrl: string, endpoint: string, args: object): Pr /** Read the explicit page cut from a freshly opened Session follow stream. */ async function sessionCursor(baseUrl: string, sessionId: string): Promise { - const socket = new WebSocket(`${baseUrl.replace(/^http/, 'ws')}/api/remote.mux`) + const authenticated = await authenticatedWeb(baseUrl) + const socket = new WebSocket(`${authenticated.origin.replace(/^http/u, 'ws')}/api/remote.mux`, { + headers: { cookie: authenticated.cookie }, + }) const streamId = `smoke-history-${randomUUID()}` try { await new Promise((resolve, reject) => { @@ -112,10 +137,13 @@ async function sessionCursor(baseUrl: string, sessionId: string): Promise): void => { + const message = (event: WebSocket.MessageEvent): void => { try { - if (typeof event.data !== 'string') throw new Error('session/follow published a non-text frame') - const frame: unknown = JSON.parse(event.data) + const text = typeof event.data === 'string' + ? event.data + : Buffer.isBuffer(event.data) ? event.data.toString('utf8') : undefined + if (text === undefined) throw new Error('session/follow published a non-text frame') + const frame: unknown = JSON.parse(text) if (!isRecord(frame) || frame.streamId !== streamId) return if (frame.type === 'error') { finish(new Error(`session/follow failed: ${JSON.stringify(frame.error)}`)) @@ -278,8 +306,8 @@ describe('dsh web keyless CLI smoke', () => { let browser: Browser | undefined try { const readyUrl = await waitForReadyLine(child) - expect(readyUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/) - expect((await fetch(readyUrl)).status).toBe(200) + expect(readyUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/\?token=[A-Za-z0-9_-]+$/u) + expect((await fetch(readyUrl, { redirect: 'manual' })).status).toBe(303) browser = await chromium.launch({ headless: true }) const page = await newEnglishPage(browser) const pluginScripts: string[] = [] @@ -310,14 +338,15 @@ describe('dsh web keyless CLI smoke', () => { expect(batchPaths).toContainEqual(expect.stringMatching( /^\/plugins\/\?\?@deepseek-ai\/dsh-client-modules\/client\.js&rev=[a-f\d]{12}$/, )) + const readyOrigin = new URL(readyUrl).origin expect([...cacheHeaders.values()]).toEqual([ 'public, max-age=31536000, immutable', 'public, max-age=31536000, immutable', ]) for (const path of batchPaths) { const [scriptResponse, mapResponse] = await Promise.all([ - fetch(`${readyUrl}${path}`), - fetch(`${readyUrl}${comboMapUrl(path)}`), + fetch(`${readyOrigin}${path}`), + fetch(`${readyOrigin}${comboMapUrl(path)}`), ]) expect(scriptResponse.status).toBe(200) expect(mapResponse.status).toBe(200) @@ -421,7 +450,7 @@ describe('dsh web keyless CLI smoke', () => { message.role === 'user' && message.content?.includes('web-workspace-context-probe')) const systemMessage = captured.messages?.find(message => message.role === 'system') const expectedWebSection = readFileSync(WEB_SURFACE_PROMPT, 'utf8').trimEnd() - .replace('{{webUrl}}', baseUrl) + .replace('{{webUrl}}', new URL(baseUrl).origin) expect(systemMessage?.content).toContain(expectedWebSection) expect(workspaceMessage).toMatchInlineSnapshot(` { diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 14ac7c7714..15b0eabc28 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: d6145e898e07614a771f24cf7e7ed5ab31390a17 -config-catalog.zh.md: 28c36a899b452686de2bc2428e1a2246f2519b4e +config-catalog.md: a4eeb7ffbe09253d7f0f979cd8dc681b4ae038ac +config-catalog.zh.md: 8490829a7881e6503fdf7b7652c679e5d3cc0f3c diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d6145e898e..a4eeb7ffbe 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -376,7 +376,7 @@ Source: [`packages/shell/bash-sandbox/src/index.ts:35`](../packages/shell/bash-s ## `@deepseek-ai/dsh-client-connection` -Requires: `webServer` +Requires: `webServer` · `credentials` ```ts config-catalog /** Plugin config: the deployment's non-loopback serving authorities. */ @@ -386,16 +386,18 @@ export interface ConnectionConfig { * port-less `host` matching any port. The /api trust fence refuses any * request whose Host is neither loopback nor listed here, so a * non-loopback (`0.0.0.0`) deployment must declare the names it is reached - * by (the dsh CLI derives the machine's LAN IP literals itself). An entry - * that is not a bare, canonical authority fails the plugin load. + * by; the Web runtime derives LAN IP literals from an active all-interface + * bind. An entry that is not a bare, canonical authority fails plugin load. */ trustedHosts?: string[] + /** Absolute browser-session lifetime in days. Default: 30. */ + cookieMaxAgeDays?: number /** Maximum buffered JSON body for every `/api` request. Default: 300 MiB. */ maxRequestBodyBytes?: number } ``` -Source: [`packages/client/connection/src/index.ts:52`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:55`](../packages/client/connection/src/index.ts) @@ -814,7 +816,7 @@ Source: [`packages/host/directory-picker-browse/src/index.ts:181`](../packages/h ## `@deepseek-ai/dsh-host-frontend-static` -Requires: `webServer` +Requires: `webServer` · `connection` ```ts config-catalog /** Plugin config: the dist anchor. */ @@ -824,7 +826,7 @@ export interface Config { } ``` -Source: [`packages/host/frontend-static/src/index.ts:28`](../packages/host/frontend-static/src/index.ts) +Source: [`packages/host/frontend-static/src/index.ts:30`](../packages/host/frontend-static/src/index.ts) @@ -3141,7 +3143,7 @@ export interface Config { } ``` -Source: [`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts) +Source: [`packages/bundle/web-app/src/index.ts:44`](../packages/bundle/web-app/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 28c36a899b..8490829a78 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -378,7 +378,7 @@ export type Config = LocalConfig ## `@deepseek-ai/dsh-client-connection` -需要:`webServer` +需要:`webServer` · `credentials` ```ts config-catalog /** Plugin config: the deployment's non-loopback serving authorities. */ @@ -388,16 +388,18 @@ export interface ConnectionConfig { * port-less `host` matching any port. The /api trust fence refuses any * request whose Host is neither loopback nor listed here, so a * non-loopback (`0.0.0.0`) deployment must declare the names it is reached - * by (the dsh CLI derives the machine's LAN IP literals itself). An entry - * that is not a bare, canonical authority fails the plugin load. + * by; the Web runtime derives LAN IP literals from an active all-interface + * bind. An entry that is not a bare, canonical authority fails plugin load. */ trustedHosts?: string[] + /** Absolute browser-session lifetime in days. Default: 30. */ + cookieMaxAgeDays?: number /** Maximum buffered JSON body for every `/api` request. Default: 300 MiB. */ maxRequestBodyBytes?: number } ``` -来源:[`packages/client/connection/src/index.ts:52`](../packages/client/connection/src/index.ts) +来源:[`packages/client/connection/src/index.ts:55`](../packages/client/connection/src/index.ts) @@ -816,7 +818,7 @@ export interface Config { ## `@deepseek-ai/dsh-host-frontend-static` -需要:`webServer` +需要:`webServer` · `connection` ```ts config-catalog /** Plugin config: the dist anchor. */ @@ -826,7 +828,7 @@ export interface Config { } ``` -来源:[`packages/host/frontend-static/src/index.ts:28`](../packages/host/frontend-static/src/index.ts) +来源:[`packages/host/frontend-static/src/index.ts:30`](../packages/host/frontend-static/src/index.ts) @@ -3143,7 +3145,7 @@ export interface Config { } ``` -来源:[`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts) +来源:[`packages/bundle/web-app/src/index.ts:44`](../packages/bundle/web-app/src/index.ts) diff --git a/docs/subsystems/web-server.i18n.yaml b/docs/subsystems/web-server.i18n.yaml index 76eb0f109e..87cca8b0b2 100644 --- a/docs/subsystems/web-server.i18n.yaml +++ b/docs/subsystems/web-server.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 docs/subsystems/web-server.md -web-server.md: 9e1e88d6c796e457fa6c185c1927b46cca9fcf52 -web-server.zh.md: 4401ccf628360a9571e77ea14ae6c29bd22af151 +web-server.md: b806f5b40a2f5ddada40752367e3da23e86ea13d +web-server.zh.md: e5f4a8794d46a2b55d4a69e2cdd13c7dc4c2a6dd diff --git a/docs/subsystems/web-server.md b/docs/subsystems/web-server.md index 9e1e88d6c7..b806f5b40a 100644 --- a/docs/subsystems/web-server.md +++ b/docs/subsystems/web-server.md @@ -24,7 +24,7 @@ interface WebRoute { } ``` -Match order is fixed: exact table first, then longest matching prefix, then the registered fallback. Registration order carries no request-facing semantics — named routes are composed to be disjoint, and the fallback seat answers anything no named route claims; one owner only, a second registration throws. The shipped Web composition claims the seat with [`dsh-host-frontend-static`](../../packages/host/frontend-static/src/index.ts), the SPA dist server with locked semantics: non-GET/HEAD is 405, traversal outside the dist root is 403, a readable index renders at the dist root and configured index path, existing files are served directly, absent or non-file targets are empty 404 responses, and unknown extensions ship as octet-stream. +Match order is fixed: exact table first, then longest matching prefix, then the registered fallback. Registration order carries no request-facing semantics — named routes are composed to be disjoint, and the fallback seat answers anything no named route claims; one owner only, a second registration throws. The shipped Web composition claims the seat with [`dsh-host-frontend-static`](../../packages/host/frontend-static/src/index.ts), the SPA dist server with locked semantics: Connection authenticates the dist root and configured index before their HTML is read; non-index assets remain public; non-GET/HEAD is 405, traversal outside the dist root is 403, existing files are served directly, absent or non-file targets are empty 404 responses, and unknown extensions ship as octet-stream. ## Config @@ -44,7 +44,7 @@ interface Config { } ``` -`host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); there is no TLS, auth, or origin policy, so a non-loopback bind exposes the server to that network. `compression` defaults to `none`; the shipped Web bundle selects gzip level 1 with a 1024-byte threshold. The dist location is an assembly fact of the frontend plugin that claims the seat. +`host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure). The carrier itself owns no TLS, authentication, or Origin policy, so a non-loopback bind exposes the server unless the composition supplies those controls. `compression` defaults to `none`; the shipped Web bundle selects gzip level 1 with a 1024-byte threshold. The shipped `dsh web` command selects loopback and rejects `--host 0.0.0.0`; its Connection plugin supplies Host/Origin checks plus browser-session authentication for every Host API route and stream. Other compositions own their bind and route-authentication policy. The dist location is an assembly fact of the frontend plugin that claims the seat. ## The service diff --git a/docs/subsystems/web-server.zh.md b/docs/subsystems/web-server.zh.md index 4401ccf628..e5f4a8794d 100644 --- a/docs/subsystems/web-server.zh.md +++ b/docs/subsystems/web-server.zh.md @@ -24,7 +24,7 @@ interface WebRoute { } ``` -匹配顺序固定:先查 exact 表,再取最长匹配前缀,最后落到已注册的回退。注册顺序不携带任何面向请求的语义:具名路由在组合上互不相交,任何未被具名路由认领的请求都由回退席位应答;席位只有一个所有者,第二次注册会抛出异常。发布的 Web 组合用 [`dsh-host-frontend-static`](../../packages/host/frontend-static/src/index.ts) 认领席位,即遵循固定语义的 SPA dist 服务器:非 GET/HEAD 返回 405,越出 dist 根目录的遍历返回 403,可读的 index 在 dist 根目录和配置的 index 路径渲染,现有文件直接提供,缺失或不是文件的目标返回空的 404,未知扩展名按 octet-stream 发送。 +匹配顺序固定:先查 exact 表,再取最长匹配前缀,最后落到已注册的回退。注册顺序不携带任何面向请求的语义:具名路由在组合上互不相交,任何未被具名路由认领的请求都由回退席位应答;席位只有一个所有者,第二次注册会抛出异常。发布的 Web 组合用 [`dsh-host-frontend-static`](../../packages/host/frontend-static/src/index.ts) 认领席位,即遵循固定语义的 SPA dist 服务器:Connection 在读取 dist 根目录和配置 index 的 HTML 前完成认证;非 index 资产保持公开;非 GET/HEAD 返回 405,越出 dist 根目录的遍历返回 403,现有文件直接提供,缺失或不是文件的目标返回空的 404,未知扩展名按 octet-stream 发送。 ## 配置 @@ -44,7 +44,7 @@ interface Config { } ``` -`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(刻意的网络暴露);没有 TLS、认证或 origin 策略,因此绑定到非回环地址会把服务器暴露给该网络。`compression` 默认为 `none`;随附的 Web 组合选择 gzip level 1 和 1024 字节阈值。dist 位置是认领席位的前端插件的组装事实。 +`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(刻意的网络暴露)。载体本身不拥有 TLS、认证或 Origin 策略,因此绑定到非回环地址会暴露服务器,除非组合层提供这些控制。`compression` 默认为 `none`;随附的 Web 组合选择 gzip level 1 和 1024 字节阈值。随附的 `dsh web` 命令选择 loopback 并拒绝 `--host 0.0.0.0`;其 Connection 插件为每个 Host API route 与 stream 提供 Host/Origin 校验和浏览器会话认证。其他组合自行拥有绑定与路由认证策略。dist 位置是认领席位的前端插件的组装事实。 ## 服务 diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 208090851e..edc3f8697e 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -182,7 +182,6 @@ export class TypertGatewayService extends Service implements TypertGateway { '/api', endpoint => this.claimsEndpoint(endpoint), (endpoint, payload, signal) => this.dispatchRpc(endpoint, payload, signal), - { authority: 'trusted-host' }, ) }) ctx.inject(['connection', 'webServer'], (webCtx) => { @@ -193,9 +192,10 @@ export class TypertGatewayService extends Service implements TypertGateway { webCtx.effect(() => { const route: WebUpgradeRoute = { path: REMOTE_STREAM_MUX_PATH, - handler: (req, socket, head) => { - if (!webCtx.connection.isTrustedRequest(req, 'trusted-host')) { - rejectRemoteStreamUpgrade(socket) + handler: async (req, socket, head) => { + const rejection = await webCtx.connection.requestRejection(req) + if (rejection !== undefined) { + rejectRemoteStreamUpgrade(socket, rejection) return } mux.handleUpgrade(req, socket, head) diff --git a/packages/api/gateway/src/stream-server.ts b/packages/api/gateway/src/stream-server.ts index bf7711b81b..04b0df0427 100644 --- a/packages/api/gateway/src/stream-server.ts +++ b/packages/api/gateway/src/stream-server.ts @@ -175,14 +175,17 @@ function rawText(data: RawData): string { /** * Reject an upgrade without transferring socket ownership to ws. * @param socket - carrier socket that receives the HTTP rejection. + * @param status - authentication or browser-trust rejection status. */ -export function rejectRemoteStreamUpgrade(socket: Duplex): void { +export function rejectRemoteStreamUpgrade(socket: Duplex, status: 401 | 403): void { + const reason = status === 401 ? 'Unauthorized' : 'Forbidden' + const body = reason.toLowerCase() socket.end([ - 'HTTP/1.1 403 Forbidden', + `HTTP/1.1 ${String(status)} ${reason}`, 'Connection: close', 'Content-Type: text/plain; charset=utf-8', - 'Content-Length: 9', + `Content-Length: ${String(Buffer.byteLength(body))}`, '', - 'forbidden', + body, ].join('\r\n')) } diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index cf95c84f27..273d5f9b99 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -14,6 +14,7 @@ import { TypertRemoteFailure, } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import { MemoryCredentials } from '../../../credentials/credentials/tests/memory.ts' import TypertGatewayService, { TypertGatewayError, type TypertRemoteEventDispatch, @@ -32,9 +33,33 @@ vi.mock('node:crypto', async (importOriginal) => { }) const randomUuid = vi.mocked(randomUUID) +const browserCookies = new WeakMap>() type AgentWireId = TypertContextWire const agentId = (value: string): AgentWireId => value as AgentWireId +/** Exchange this test Host's process token for its WebSocket/HTTP Cookie header. */ +function browserCookie(ctx: Context): Promise { + const existing = browserCookies.get(ctx) + if (existing !== undefined) return existing + const exchange = (async () => { + const origin = `http://127.0.0.1:${String(ctx.webServer.port)}` + const target = new URL(ctx.connection.authenticatedUrl(origin)) + let setCookie: string | undefined + await ctx.connection.authorizeIndex({ + method: 'GET', + url: `${target.pathname}${target.search}`, + headers: { host: target.host }, + }, { + writeHead(_status, headers) { setCookie = headers?.['set-cookie'] }, + end() {}, + }) + if (setCookie === undefined) throw new Error('gateway stream fixture did not receive a browser cookie') + return setCookie.split(';', 1)[0]! + })() + browserCookies.set(ctx, exchange) + return exchange +} + class FeedService extends Service { readonly typertRemote = bindTypertRemote(this, 'feed') readonly signals: AbortSignal[] = [] @@ -259,7 +284,9 @@ describe('Typert Remote streams', () => { it('multiplexes independent streams over one WebSocket and propagates cancellation', async () => { const { ctx, service } = await setup(true) - const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`) + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { + headers: { cookie: await browserCookie(ctx) }, + }) await once(socket, 'open') const frames: Record[] = [] socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record) }) @@ -341,7 +368,9 @@ describe('Typert Remote streams', () => { expect(() => { ctx.typertGateway.registerRemoteEvents(source) }) .toThrow('forwarded Remote event source is already registered') - const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`) + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { + headers: { cookie: await browserCookie(ctx) }, + }) await once(socket, 'open') const frames: Record[] = [] socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record) }) @@ -861,7 +890,9 @@ describe('Typert Remote streams', () => { it('validates the internal Remote event request and reports an absent source', async () => { const { ctx } = await setup(true) - const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`) + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { + headers: { cookie: await browserCookie(ctx) }, + }) await once(socket, 'open') const frames: Record[] = [] socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record) }) @@ -920,6 +951,19 @@ describe('Typert Remote streams', () => { rejected.resume() ;(request as { abort(): void }).abort() }) + + it('answers an unauthenticated trusted Host with 401 before opening a stream', async () => { + const { ctx } = await setup(true) + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`) + socket.on('error', () => {}) + const responseEvent: unknown[] = await once(socket, 'unexpected-response') + const request = responseEvent[0] + const response = responseEvent[1] + const rejected = response as { statusCode?: number; resume(): void } + expect(rejected.statusCode).toBe(401) + rejected.resume() + ;(request as { abort(): void }).abort() + }) }) async function setup(transport: boolean): Promise<{ readonly ctx: Context; readonly service: FeedService }> { @@ -927,6 +971,7 @@ async function setup(transport: boolean): Promise<{ readonly ctx: Context; reado roots.push(ctx) if (transport) { await ctx.plugin(WebServer, { host: '127.0.0.1', port: 0 }) + await ctx.plugin(MemoryCredentials) } await ctx.plugin(TypertRegistry) await ctx.plugin(TypertGatewayService) @@ -989,11 +1034,15 @@ interface RemoteEventTestClient { readonly streamId: string readonly clientId: RemoteEventClientId readonly origin: string + readonly cookie: string } async function openEventClient(ctx: Context, streamId: string): Promise { const origin = `http://127.0.0.1:${String(ctx.webServer.port)}` - const socket = new WebSocket(`${origin.replace('http:', 'ws:')}/api/remote.mux`) + const cookie = await browserCookie(ctx) + const socket = new WebSocket(`${origin.replace('http:', 'ws:')}/api/remote.mux`, { + headers: { cookie }, + }) await once(socket, 'open') const frames: Record[] = [] socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record) }) @@ -1010,7 +1059,7 @@ async function openEventClient(ctx: Context, streamId: string): Promise boolean) | undefined handler: FakeRpcHandler | undefined @@ -119,22 +120,23 @@ class FakeConnectionService extends Service { channel: string, matches: (endpoint: string) => boolean, handler: FakeRpcHandler, - options: { readonly authority: string }, ) => owner.effect(() => { this.channel = channel - this.authority = options.authority this.matches = matches this.handler = handler return () => { this.channel = undefined - this.authority = undefined this.matches = undefined this.handler = undefined } }), } } + + requestRejection(): Promise { + return Promise.resolve(undefined) + } } function fakeHttpServer(routes: WebRoute[]): Pick { @@ -168,6 +170,22 @@ async function serveRoute(route: WebRoute): Promise<{ readonly origin: string; c } } +/** Exchange a Connection launch token without mounting the frontend fallback. */ +async function browserCookie(connection: HostConnectionHandle, origin: string): Promise { + const target = new URL(connection.authenticatedUrl(origin)) + let setCookie: string | undefined + await connection.authorizeIndex({ + method: 'GET', + url: `${target.pathname}${target.search}`, + headers: { host: target.host }, + }, { + writeHead(_status, headers) { setCookie = headers?.['set-cookie'] }, + end() {}, + }) + if (setCookie === undefined) throw new Error('gateway fixture did not receive an authentication cookie') + return setCookie.split(';', 1)[0]! +} + class FirstSharedService extends Service { readonly typertRemote = bindTypertRemote(this, 'firstShared', { namespace: 'shared' }) @@ -958,7 +976,7 @@ describe('TypertGatewayService', () => { await gatewayFiber await ctx.plugin(GoalService) const connection = rawConnection(ctx) - expect(connection).toMatchObject({ channel: '/api', authority: 'trusted-host' }) + expect(connection).toMatchObject({ channel: '/api' }) registerAgentLookup(ctx, { id: 'agent-1' }) registerStrict(ctx, [createDescriptor(), maybeDescriptor()]) @@ -1150,6 +1168,7 @@ describe('TypertGatewayService', () => { it('dispatches claimed invocations through /api and leaves unclaimed endpoints to its fallback', async () => { const ctx = new Context().extend({ fixtureScope: 'http-caller' }) const routes: WebRoute[] = [] + await ctx.plugin(MemoryCredentials) ctx.provide('webServer', fakeHttpServer(routes) as WebServer) const connectionFiber = ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) await connectionFiber @@ -1163,11 +1182,12 @@ describe('TypertGatewayService', () => { let strictActive = true expect(routes).toHaveLength(1) const server = await serveRoute(routes[0]!) + const cookie = await browserCookie(ctx.connection, server.origin) try { const response = await fetch(`${server.origin}/api/goals/create`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', cookie }, body: JSON.stringify({ type: 'client-request', rpcId: 'rpc-http', @@ -1187,7 +1207,7 @@ describe('TypertGatewayService', () => { const invalid = await fetch(`${server.origin}/api/goals/create`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', cookie }, body: JSON.stringify({ type: 'client-request', rpcId: 'rpc-invalid', @@ -1211,7 +1231,7 @@ describe('TypertGatewayService', () => { strictActive = false const withdrawn = await fetch(`${server.origin}/api/goals/create`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', cookie }, body: JSON.stringify({ type: 'client-request', rpcId: 'rpc-withdrawn', @@ -1231,7 +1251,10 @@ describe('TypertGatewayService', () => { }) expect(JSON.stringify(withdrawnBody)).toContain('strict definition was withdrawn') - const unclaimed = await fetch(`${server.origin}/api/legacy/list`, { method: 'POST' }) + const unclaimed = await fetch(`${server.origin}/api/legacy/list`, { + method: 'POST', + headers: { cookie }, + }) expect(unclaimed.status).toBe(404) } finally { await server.close() diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index d22a500223..bc9ac5d953 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/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/bundle/web-app/README.md -README.md: c428bbd352f7f58fe4b76cd078e2c1a1993b422a -README.zh.md: 0d5a33fe356d749fa619ad980ab9ee13ed9ba3fc +README.md: c330e5557c2fa70f60ededcd368ec713db11269e +README.zh.md: b2170c762a2e9425417df09e7ba6e7c16f612e96 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index c428bbd352..c330e5557c 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, the always-on client-plugin reload chain ([`dsh-client-hmr`](../../client/hmr/README.md), idle until a rebuild watcher rewrites client bundles), and mounts this package's `web-runtime` glue plugin (config `{openBrowser, printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-web-frontend`'s exports, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, and registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL` runtime variable when `surfaceContext` is true. After its Loader tree settles, it prints the `dsh web:` URL line when `printUrl` is true and opens the canonical host URL in the default browser when `openBrowser` is true and the inherited `SSH_CONNECTION` and `SSH_TTY` are blank or absent. An SSH launch keeps the URL line but suppresses browser handoff because the SSH client or editor owns the local forwarded address. Immediately before a handoff, the runtime prints `dsh web: opening the default browser; pass --no-open to disable`. A short-lived Node helper runs the maintained platform opener with the canonical scrubbed child environment. On Windows it stays alive until the short-lived PowerShell launcher exits, because `open` reports spawn before that launcher has handed the URL to the shell; elsewhere the helper stops after the opener accepts spawn. A helper failure writes a diagnostic with its reason and the manual URL to stderr without stopping the server, and no path waits for the browser to exit. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, repeatable `--trusted-host`, `--no-open`, and the app's `--help`, then provides `webStartup`; browser opening defaults on for local launches, and `--no-open` turns it off for this invocation. It rejects `--host 0.0.0.0` before publishing that service because the CLI intentionally does not support all-interfaces binding yet. Flag-configured rows inject the service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. +The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, the always-on client-plugin reload chain ([`dsh-client-hmr`](../../client/hmr/README.md), idle until a rebuild watcher rewrites client bundles), and mounts this package's `web-runtime` glue plugin (config `{openBrowser, printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-web-frontend`'s exports, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, and registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL` runtime variable when `surfaceContext` is true. After its Loader tree settles and Connection authentication is available, it prints the `dsh web:` root URL with the fresh process token when `printUrl` is true and opens that authenticated URL in the default browser when `openBrowser` is true and the inherited `SSH_CONNECTION` and `SSH_TTY` are blank or absent. The model prompt and `DSH_WEB_URL` retain the clean canonical URL without credentials. An SSH launch keeps the tokenized URL line but suppresses browser handoff because the SSH client or editor owns the local forwarded address. Immediately before a handoff, the runtime prints `dsh web: opening the default browser; pass --no-open to disable`. A short-lived Node helper runs the maintained platform opener with the canonical scrubbed child environment. On Windows it stays alive until the short-lived PowerShell launcher exits, because `open` reports spawn before that launcher has handed the URL to the shell; elsewhere the helper stops after the opener accepts spawn. A helper failure writes a credential-free diagnostic with its reason and points to the startup URL without stopping the server, and no path waits for the browser to exit. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, repeatable `--trusted-host`, `--no-open`, and the app's `--help`, then provides `webStartup`; browser opening defaults on for local launches, and `--no-open` turns it off for this invocation. It rejects `--host 0.0.0.0` before publishing that service because the CLI intentionally does not support all-interfaces binding. Flag-configured rows inject the service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. The base module-HMR row remains disabled. The Web profile's `patchReload: live` lifecycle uses the launcher's config-only watcher; the browser-facing `dsh-client-hmr` reload chain is separate from server module HMR. diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 0d5a33fe35..b2170c762a 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.zh.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)、浏览器插件名录与始终挂载的客户端插件重载链([`dsh-client-hmr`](../../client/hmr/README.zh.md),在重建 watcher 改写客户端 bundle 之前保持空闲),并挂载本包的 `web-runtime` 粘合插件(配置为 `{openBrowser, printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-web-frontend` 的 exports 解析已构建的前端 dist,只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.zh.md) 回退席位所有者,并在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL` 运行时变量。自身 Loader 配置树结算后,它在 `printUrl` 为 true 时打印 `dsh web:` URL 行;`openBrowser` 为 true 且继承的 `SSH_CONNECTION` 与 `SSH_TTY` 均为空或不存在时,才会用默认浏览器打开规范宿主机 URL。SSH 启动仍保留 URL 行,但会跳过浏览器交接,因为本地转发地址由 SSH 客户端或编辑器持有。交接前,运行时会打印英文提示 `dsh web: opening the default browser; pass --no-open to disable`。短生命周期 Node helper 使用规范的脱敏子进程环境运行受维护的平台 opener。在 Windows 上,helper 会保持存活,直至短生命周期的 PowerShell launcher 退出,因为 `open` 会在 launcher 把 URL 交给 shell 之前、仅在 spawn 时返回;其他平台则在 opener 接受 spawn 后结束。helper 失败时会向 stderr 写入包含原因和手动访问 URL 的诊断,不会停止服务器,且任何路径都不会等待浏览器退出。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.zh.md)),解析 `--host`、`--port`、可重复的 `--trusted-host`、`--no-open` 以及应用自己的 `--help`,再提供 `webStartup`;本机启动默认会打开浏览器,`--no-open` 则只对本次调用关闭该行为。它会在发布该服务前拒绝 `--host 0.0.0.0`,因为 CLI 目前有意不支持绑定所有网络接口。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.zh.md) 是同一 base 之上的同级表层,不挂载本组合包。 +dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.zh.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)、浏览器插件名录与始终挂载的客户端插件重载链([`dsh-client-hmr`](../../client/hmr/README.zh.md),在重建 watcher 改写客户端 bundle 之前保持空闲),并挂载本包的 `web-runtime` 粘合插件(配置为 `{openBrowser, printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-web-frontend` 的 exports 解析已构建的前端 dist,只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.zh.md) 回退席位所有者,并在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL` 运行时变量。Loader 配置树结算且 Connection 认证可用后,它在 `printUrl` 为 true 时打印带新进程令牌的 `dsh web:` 根 URL;`openBrowser` 为 true 且继承的 `SSH_CONNECTION` 与 `SSH_TTY` 均为空或不存在时,才会用默认浏览器打开该认证 URL。模型提示词与 `DSH_WEB_URL` 仍携带不含凭据的干净规范 URL。SSH 启动仍保留带令牌的 URL 行,但会跳过浏览器交接,因为本地转发地址由 SSH 客户端或编辑器持有。交接前,运行时会打印英文提示 `dsh web: opening the default browser; pass --no-open to disable`。短生命周期 Node helper 使用规范的脱敏子进程环境运行受维护的平台 opener。在 Windows 上,helper 会保持存活,直至短生命周期的 PowerShell launcher 退出,因为 `open` 会在 launcher 把 URL 交给 shell 之前、仅在 spawn 时返回;其他平台则在 opener 接受 spawn 后结束。helper 失败时会向 stderr 写入不含凭据的原因并指向启动 URL,不会停止服务器,且任何路径都不会等待浏览器退出。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.zh.md)),解析 `--host`、`--port`、可重复的 `--trusted-host`、`--no-open` 以及应用自己的 `--help`,再提供 `webStartup`;本机启动默认会打开浏览器,`--no-open` 则只对本次调用关闭该行为。它会在发布该服务前拒绝 `--host 0.0.0.0`,因为 CLI 不支持绑定所有网络接口。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.zh.md) 是同一 base 之上的同级表层,不挂载本组合包。 base 的模块 HMR 配置项保持禁用。Web profile 的 `patchReload: live` 生命周期使用启动器的仅配置 watcher;面向浏览器的 `dsh-client-hmr` 重载链与服务器模块 HMR 相互独立。 diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index 713f6d10a4..5ec4ae7072 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -5,9 +5,9 @@ * the built frontend dist (workspace knowledge of this bundle, never user * config), mounts the `frontend-static` fallback owner over it, registers the * harness-source and web-surface prompt sections, the bash-visible web runtime - * variable, the URL line, and the default-browser handoff. App command-line - * values arrive through the `webStartup` service expressions in the bundle - * patch. + * variable, the process-token URL line, and the default-browser handoff. The + * model and shell retain the clean URL. App command-line values arrive through + * the `webStartup` service expressions in the bundle patch. * @module @deepseek-ai/dsh-web-app */ @@ -19,6 +19,7 @@ import { fileURLToPath } from 'node:url' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' +import type {} from '@deepseek-ai/dsh-client-connection' import * as FrontendStatic from '@deepseek-ai/dsh-host-frontend-static' import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' @@ -258,41 +259,48 @@ export function apply(ctx: Context, config: Config): void { }) } if (config.printUrl || handoffBrowser) { - // The URL line and browser handoff are readiness signals: supervisors RPC - // as soon as they observe the line, while a browser requests the page as - // soon as it opens. Neither may run while sibling rows such as the /api - // route owner are still mounting. Await Loader settlement first; a - // hand-built tree without a Loader is already the complete tree. - const announceReady = (): void => { - const webUrl = localWebUrl(ctx) - // Reuse the exact LAN snapshot provided to the /api trust fence. - const lanCandidate = runtime.lanAddresses[0] - const port = ctx.webServer.port - if (config.printUrl) { - console.log(`dsh web: ${webUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${String(port)})`}`) + ctx.inject(['connection'], (connectionCtx) => { + // The URL line and browser handoff are readiness signals: supervisors RPC + // as soon as they observe the line, while a browser requests the page as + // soon as it opens. Neither may run while sibling rows such as the /api + // route owner are still mounting. Await Loader settlement first; a + // hand-built tree without a Loader is already the complete tree. + const announceReady = (): void => { + const webUrl = localWebUrl(connectionCtx) + const authenticatedUrl = connectionCtx.connection.authenticatedUrl(webUrl) + // Reuse the exact LAN snapshot provided to the /api trust fence. + const lanCandidate = runtime.lanAddresses[0] + const port = connectionCtx.webServer.port + const lanUrl = lanCandidate === undefined + ? undefined + : connectionCtx.connection.authenticatedUrl(`http://${lanCandidate}:${String(port)}`) + if (config.printUrl) { + console.log(`dsh web: ${authenticatedUrl}${lanUrl === undefined ? '' : ` (LAN: ${lanUrl})`}`) + } + if (handoffBrowser) { + console.log('dsh web: opening the default browser; pass --no-open to disable') + void internals.openBrowser(authenticatedUrl).catch((error: unknown) => { + const reason = error instanceof Error ? error.message : String(error) + console.error(`web-app: could not open the default browser because ${reason}; use the dsh web URL printed at startup`) + }) + } } - if (handoffBrowser) { - console.log('dsh web: opening the default browser; pass --no-open to disable') - void internals.openBrowser(webUrl).catch((error: unknown) => { - const reason = error instanceof Error ? error.message : String(error) - console.error(`web-app: could not open the default browser because ${reason}; visit ${webUrl} manually`) - }) + // This row's own activation can precede a sibling failure. The app owns + // readiness by waiting for its Loader tree, or announces at once in a + // hand-built tree without Loader. + const settled = connectionCtx.get('loader')?.await() + if (settled === undefined) announceReady() + else { + void settled.then(() => { + // The tree can be disposed while the boot was in flight (early + // SIGTERM); a URL line or browser tab for a dead server would only + // mislead, and reading torn-down services would turn a clean shutdown + // into a crash. + if (connectionCtx.get('webServer') !== undefined + && connectionCtx.get('connection') !== undefined) announceReady() + // Loader reports a failed boot; this row only stays quiet. + }, () => {}) } - } - // This row's own activation can precede a sibling failure. The app owns - // readiness by waiting for its Loader tree, or announces at once in a - // hand-built context without Loader. - const settled = ctx.get('loader')?.await() - if (settled === undefined) announceReady() - else { - void settled.then(() => { - // The tree can be disposed while the boot was in flight (early - // SIGTERM); a URL line or browser tab for a dead server would only - // mislead, and reading the torn-down port would turn a clean shutdown - // into a crash. - if (ctx.get('webServer') !== undefined) announceReady() - // Loader reports a failed boot; this row only stays quiet. - }, () => {}) - } + }) } } diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index 5639129362..90f7063bf7 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -83,6 +83,21 @@ function fakeHttpServer(host: '127.0.0.1' | '0.0.0.0' = '127.0.0.1'): { server: return { server, seat: () => fallback } } +/** Deterministic Host Connection face for URL publication and frontend injection. */ +function provideConnection(ctx: Context): void { + ctx.provide('connection', { + authenticatedUrl(baseUrl: string) { + const url = new URL(baseUrl) + url.pathname = '/' + url.searchParams.set('token', 'test-token') + return url.href + }, + authorizeIndex: () => Promise.resolve(true), + requestRejection: () => Promise.resolve(undefined), + rpc: {}, + } as never) +} + /** A fake Loader whose settlement the test controls (the URL line waits on it). */ function provideLoader(ctx: Context, settle: () => Promise = async () => {}): void { ctx.provide('loader', { await: settle } as never) @@ -105,6 +120,7 @@ describe('web-app runtime glue', () => { ])) const { server, seat } = fakeHttpServer('0.0.0.0') ctx.provide('webServer', server) + provideConnection(ctx) const contributions: BashContribution[] = [] ctx.provide('shellEnv', { register: (contribution: BashContribution) => { @@ -127,13 +143,13 @@ describe('web-app runtime glue', () => { lanAddresses: ['192.168.1.5'], trustedHosts: ['192.168.1.5', 'lab.internal'], }) - expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567 (LAN: http://192.168.1.5:4567)') + expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token (LAN: http://192.168.1.5:4567/?token=test-token)') expect(log).toHaveBeenCalledWith('dsh web: opening the default browser; pass --no-open to disable') - expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:4567') + expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:4567/?token=test-token') expect(lifecycle).toEqual([ - 'dsh web: http://127.0.0.1:4567 (LAN: http://192.168.1.5:4567)', + 'dsh web: http://127.0.0.1:4567/?token=test-token (LAN: http://192.168.1.5:4567/?token=test-token)', 'dsh web: opening the default browser; pass --no-open to disable', - 'open:http://127.0.0.1:4567', + 'open:http://127.0.0.1:4567/?token=test-token', ]) const assembly = await ctx.systemPrompt.assemble() expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout') @@ -151,6 +167,7 @@ describe('web-app runtime glue', () => { stageDist() const ctx = new Context() ctx.provide('webServer', fakeHttpServer().server) + provideConnection(ctx) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) const openBrowser = vi.fn(async () => {}) internals.openBrowser = openBrowser @@ -169,6 +186,7 @@ describe('web-app runtime glue', () => { stageDist() const ctx = new Context() ctx.provide('webServer', fakeHttpServer().server) + provideConnection(ctx) const contributions: BashContribution[] = [] ctx.provide('shellEnv', { register: (contribution: BashContribution) => { @@ -190,10 +208,11 @@ describe('web-app runtime glue', () => { stageDist() const ctx = new Context() ctx.provide('webServer', fakeHttpServer().server) + provideConnection(ctx) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) apply(ctx, new Config({ openBrowser: false, printUrl: true, surfaceContext: true, trustedHosts: [] })) await new Promise(resolve => setTimeout(resolve, 0)) - expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') + expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token') await ctx.fiber.dispose() }) @@ -205,12 +224,13 @@ describe('web-app runtime glue', () => { stageDist() const ctx = new Context() ctx.provide('webServer', fakeHttpServer().server) + provideConnection(ctx) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) const openBrowser = vi.fn(async () => {}) internals.openBrowser = openBrowser apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: false, trustedHosts: [] })) await new Promise(resolve => setTimeout(resolve, 0)) - expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') + expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token') expect(openBrowser).not.toHaveBeenCalled() await ctx.fiber.dispose() }) @@ -223,6 +243,7 @@ describe('web-app runtime glue', () => { // can request the complete app immediately. const settled = new Context() settled.provide('webServer', fakeHttpServer().server) + provideConnection(settled) let release: () => void const settlement = new Promise((resolve) => { release = resolve }) provideLoader(settled, () => settlement) @@ -233,8 +254,8 @@ describe('web-app runtime glue', () => { expect(openBrowser).not.toHaveBeenCalled() release!() await new Promise(resolve => setTimeout(resolve, 0)) - expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') - expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:4567') + expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token') + expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:4567/?token=test-token') await settled.fiber.dispose() // Failed path: Loader reports the sibling failure; the app prints no URL @@ -243,6 +264,7 @@ describe('web-app runtime glue', () => { openBrowser.mockClear() const failed = new Context() failed.provide('webServer', fakeHttpServer().server) + provideConnection(failed) provideLoader(failed, async () => { throw new Error('boot failed') }) apply(failed, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: [] })) await new Promise(resolve => setTimeout(resolve, 0)) @@ -257,6 +279,7 @@ describe('web-app runtime glue', () => { const torn = new Context() const child = torn.plugin((childCtx: Context) => { childCtx.provide('webServer', fakeHttpServer().server) + provideConnection(childCtx) }) await child let releaseTorn: () => void @@ -279,6 +302,7 @@ describe('web-app runtime glue', () => { const { server } = fakeHttpServer() Object.defineProperty(server, 'port', { get: () => undefined }) ctx.provide('webServer', server) + provideConnection(ctx) apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: true, trustedHosts: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) @@ -301,6 +325,7 @@ describe('web-app runtime glue', () => { stageDist() const ctx = new Context() ctx.provide('webServer', fakeHttpServer().server) + provideConnection(ctx) internals.openBrowser = vi.fn(async () => { throw failure }) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) const diagnostic = vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -308,7 +333,7 @@ describe('web-app runtime glue', () => { await new Promise(resolve => setTimeout(resolve, 0)) expect(log).toHaveBeenCalledWith('dsh web: opening the default browser; pass --no-open to disable') expect(diagnostic).toHaveBeenCalledWith( - `web-app: could not open the default browser because ${reason}; visit http://127.0.0.1:4567 manually`, + `web-app: could not open the default browser because ${reason}; use the dsh web URL printed at startup`, ) expect(ctx.get('webServer')).toBeDefined() await ctx.fiber.dispose() diff --git a/packages/bundle/web-app/tsconfig.json b/packages/bundle/web-app/tsconfig.json index c00b64f5a9..2f8cc6c3b2 100644 --- a/packages/bundle/web-app/tsconfig.json +++ b/packages/bundle/web-app/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../boot/cmdline" }, + { + "path": "../../client/connection/tsconfig.host.json" + }, { "path": "../../host/frontend-static" }, diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 8f1d179212..83d376368b 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/client/connection/README.md -README.md: d2614515744ee69ca11443a7bc440a589d3f26b3 -README.zh.md: 13df74ddf7bb21455bb5528119bd7c3d5d149b87 +README.md: 293e9f9d6b158e325325f4f741a244031b1d2e02 +README.zh.md: e3cea191ab1c9745a1920b5cfd13fcd8d8b77692 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index d261451574..293e9f9d6b 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -4,13 +4,15 @@ English | [中文](README.zh.md) Protocol and connection-generation layer. The Client plugin mounts `ctx.connection`, containing the shared API client, current-page loopback state, generation-scoped observable `hostDescription`, a generic RPC carrier, and the registration point for one generation source and the connection loop. A generation publishes `hostDescription` and calls `onConnected` only after its source is ready and `host.describe` succeeds; source completion, failure, withdrawal, or an explicit stop clears that value before `ConnectionController` reconnects with backoff. -The browser uses HTTP POST for API Proxy and generic Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; in-process compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The Host half owns the sole `/api` route, Fetch bridge, and trust checks. Typert Gateway claims its Remote endpoints first, and unclaimed requests fall through to API Proxy. Loopback hostname classification remains package-internal: the Host fence and WebSocket upgrade use it directly, while other Client plugins consume `ctx.connection.isLoopback`. +The browser uses HTTP POST for API Proxy and generic Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; in-process compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The Host half owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks. Typert Gateway claims its Remote endpoints first, and unclaimed requests fall through to API Proxy. Loopback hostname classification remains package-internal to the browser-facing Client state. -The Node half keeps privileged methods (`host.pickDirectory`, `host.openPath`, the settings and credentials configuration planes, `llm.discoverModels`, and `agentPreset.read`/`copy`/`openDocument`/`remove`) loopback-only by passing an empty trust list to the fence. `agentPreset.list` and `agentPreset.select` are excluded: the roster carries only ids and trust levels, while `session.create` already selects a preset. Declared `trustedHosts` authorities can reach other methods; privileged operations remain loopback-only until a real authentication layer exists. +## Browser authentication and request trust -## /api browser-trust fence +Every Host RPC method and WebSocket stream requires one browser session; there is no method-specific loopback tier. Each process mints a random launch token. `dsh-web-app` prints and opens the ordinary root URL with `?token=...`; `frontend-static` delegates root and index requests to `ctx.connection.authorizeIndex`, which accepts that token only on `GET /`, writes an authority-bound signed cookie, and redirects to clean `/`. A missing, expired, malformed, or wrong-authority cookie returns 401 before RPC dispatch. Static assets remain public. The HTTP carrier accepts no query token outside the root exchange and no Authorization-header token. -The node half guards every entry under `/api` before bridging or upgrading (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for unmarked HTTP requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to image and navigation reads, so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; a browser WebSocket handshake carries `Origin` and passes the same comparison. Non-browser clients pass the same fence via loopback, deployment-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. HTTP failures answer plain 403 before any RPC dispatch; upgrade failures reject the handshake before any event stream starts. Non-loopback compositions must trust their serving authorities explicitly: the Web runtime derives LAN IP literals from an all-interfaces server config, while `trustedHosts` in cordis.yml and the CLI's `--trusted-host` flag declare named authorities. `dsh web --host 0.0.0.0` is intentionally unsupported until remote access has an authentication layer. The fence is a reachability policy, not authentication; the Web carrier provides no authentication layer. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md). +The cookie signing secret is the owner-scoped `client-connection/browser-session` grant record in `ctx.credentials`. The local provider persists it in `$DSH_HOME/.credentials.yaml`; `BrowserAuth` reads the current record for every verification, so deletion or rotation revokes cookies without restarting the process. Cookies carry an absolute issue/expiry interval, defaulting to 30 days through `cookieMaxAgeDays`, and bind the normalized hostname plus port in both their deterministic name and signed payload. They are host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`; they deliberately omit `Secure` because the shipped server uses loopback HTTP. + +Before authentication, every request still passes `src/api-request-trust.ts`. Its `Host` must be loopback or match a `trustedHosts` entry: exact on `host:port`, any port on port-less entries, both sides WHATWG-normalized. An attached `Origin` must equal that Host and `sec-fetch-site: cross-site` is refused. Malformed configured authorities fail plugin load. These checks defend DNS rebinding and cross-site browser requests; they never establish identity. A failed Host/Origin check returns 403, while a trusted but unauthenticated request returns 401. `dsh web --host 0.0.0.0` remains unsupported. Decision records: [browser request trust](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md) and [browser token authentication](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md). ## Connection generation @@ -29,3 +31,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **The `/api` bridge buffers each request body in memory** — `maxRequestBodyBytes` (default 300 MiB, sized for the default 200 MiB aggregate image limit after base64 expansion plus envelope headroom) is therefore also the per-request resident bound; a streaming body path would be needed to lower it without shrinking the image limits. +- **The browser cookie is not marked `Secure`** — loopback HTTP is the shipped transport, so deployments that make the same authority reachable over plaintext networking can expose the bearer cookie in transit. +- **There is no logout operation** — clearing the browser cookie ends one browser session; deleting the owner credential record revokes every session and the next launch-token exchange creates a new signing secret. diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 13df74ddf7..e3cea191ab 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -4,13 +4,15 @@ 协议与连接世代层:Client 插件挂载 `ctx.connection`,包含共享 API 客户端、当前页面的 loopback 状态、按 generation 生效的可观察 `hostDescription`、通用 RPC carrier,以及单一 generation source 与连接循环的注册面。每个 generation 只在 source 已就绪且 `host.describe` 成功后发布 `hostDescription` 并调用 `onConnected`;source 结束、失败、被撤回或显式 stop 都会清空该值,再由 `ConnectionController` 退避重连。 -浏览器通过 HTTP POST 执行 API Proxy 一元调用与通用 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。进程内组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 拥有唯一 `/api` route、Fetch bridge 和信任校验;Typert Gateway 先认领自己的 Remote endpoint,未认领的请求再回退 API Proxy。Loopback hostname 判定留在包内:Host fence 与 WebSocket upgrade 直接使用它,其他 Client 插件消费 `ctx.connection.isLoopback`。 +浏览器通过 HTTP POST 执行 API Proxy 一元调用与通用 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。进程内组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 拥有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;Typert Gateway 先认领自己的 Remote endpoint,未认领的请求再回退 API Proxy。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。 -node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,整个 settings 与 credentials 配置面,`llm.discoverModels`,以及 `agentPreset.read`/`copy`/`openDocument`/`remove`)以空信任表过 fence,从而钉在回环本机。`agentPreset.list` 与 `agentPreset.select` 不在其中:名单只携带 id 与信任级别,而 `session.create` 已能选择 preset。已声明的 `trustedHosts` 授权可达其余方法;在真正的认证层出现前,特权面始终只限回环。 +## 浏览器认证与请求信任 -## /api 浏览器信任栅栏 +每个 Host RPC 方法和 WebSocket stream 都要求同一个浏览器会话,不再存在按方法区分的 loopback 层。每个进程生成一个随机启动令牌。`dsh-web-app` 打印并打开带 `?token=...` 的普通根 URL;`frontend-static` 把根路径和 index 请求交给 `ctx.connection.authorizeIndex`,后者只在 `GET /` 接受该令牌,写入绑定 authority 的签名 cookie,再重定向到干净的 `/`。缺失、过期、畸形或 authority 不匹配的 cookie 会在 RPC 分发前得到 401。静态资源保持公开。HTTP 载体不在根路径交换之外接受 query token,也不接受 Authorization header token。 -node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的 HTTP 请求开捷径:明文 HTTP 下浏览器的图片与导航读取既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;WebSocket 浏览器握手会带 `Origin` 并通过同一道比较。非浏览器客户端经由回环地址、部署推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,如附带 `Origin`,则它必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载明确报错:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。HTTP 失败在任何 RPC 分发之前以纯 403 应答,upgrade 失败在启动任何事件流前拒绝握手。非回环组合必须显式信任其服务权威:Web 运行时从全接口服务器配置推导 LAN IP 字面量,cordis.yml 中的 `trustedHosts` 与 CLI(命令行界面)的 `--trusted-host` flag 则声明具名权威。`dsh web --host 0.0.0.0` 在远程访问具备认证层之前有意不受支持。这道栅栏是可达性策略,而不是认证;Web 载体不提供认证层。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)。 +cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-session` 拥有的 grant 记录。本地提供方把它持久化到 `$DSH_HOME/.credentials.yaml`;`BrowserAuth` 每次校验都读取当前记录,因此删除或轮换记录无需重启进程即可撤销 cookie。cookie 携带绝对签发与过期区间,`cookieMaxAgeDays` 默认设为 30 天,并在确定性名称与签名 payload 中同时绑定规范化 hostname 和 port。它是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`;随附服务器使用 loopback HTTP,因此刻意不设置 `Secure`。 + +认证之前,每个请求仍经过 `src/api-request-trust.ts`。其 `Host` 必须是 loopback,或与 `trustedHosts` 条目匹配:带端口的 `host:port` 精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化。若附带 `Origin`,它必须等于该 Host;`sec-fetch-site: cross-site` 一律拒绝。畸形配置 authority 会让插件加载失败。这些检查防御 DNS rebinding 与跨站浏览器请求,绝不建立身份。Host/Origin 校验失败返回 403;Host 可信但未认证的请求返回 401。`dsh web --host 0.0.0.0` 仍不受支持。决策记录:[浏览器请求信任](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)与[浏览器令牌认证](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md)。 ## Connection generation @@ -29,3 +31,5 @@ API Gateway Client 把内部 `$events` logical stream 注册为唯一 generation ## 已知限制与暂缓事项 - **`/api` 桥把每个请求体整体缓冲在内存里**:`maxRequestBodyBytes`(默认 300 MiB,按默认 200 MiB 图片总量上限经 base64 膨胀加信封余量得出)因此同时是单请求的驻留内存上界;要降低它而不缩小图片限额,需要流式请求体路径。 +- **浏览器 cookie 不带 `Secure`**:随附载体是 loopback HTTP;若部署把同一 authority 经明文网络暴露,bearer cookie 可能在传输中泄露。 +- **没有 logout 操作**:清除浏览器 cookie 会结束单个浏览器会话;删除 owner 凭据记录会撤销全部会话,下一次启动令牌交换会创建新的签名密钥。 diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 95ce0d1981..5c7e53bb70 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -51,6 +51,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -62,6 +63,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/client/connection/src/browser-auth.ts b/packages/client/connection/src/browser-auth.ts new file mode 100644 index 0000000000..7c57622bb2 --- /dev/null +++ b/packages/client/connection/src/browser-auth.ts @@ -0,0 +1,276 @@ +/** Browser-session authentication for the Host Connection carrier. */ + +import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto' +import { credentialKey } from '@deepseek-ai/dsh-credentials' +import type { CredentialProvider, CredentialRecord } from '@deepseek-ai/dsh-credentials' +import type { + ConnectionIndexRequest, + ConnectionIndexResponse, + ConnectionTrustRequest, +} from './rpc.ts' + +const AUTH_RECORD_KEY = credentialKey('client-connection', 'browser-session') +const DAY_MILLISECONDS = 24 * 60 * 60 * 1000 +const SECRET_BYTES = 32 +const TOKEN_QUERY = 'token' +const COOKIE_PREFIX = 'dsh-auth-' +const COOKIE_PAYLOAD_VERSION = 1 +const STORED_SECRET_VERSION = 1 + +interface StoredSecretPayload { + readonly version: typeof STORED_SECRET_VERSION + readonly secret: string +} + +interface BrowserCookiePayload { + readonly version: typeof COOKIE_PAYLOAD_VERSION + readonly authority: string + readonly issuedAt: number + readonly expiresAt: number +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function header( + headers: ConnectionTrustRequest['headers'], + name: string, +): string | undefined { + if (headers instanceof Headers) return headers.get(name) ?? undefined + const value = headers[name] + return typeof value === 'string' ? value : undefined +} + +/** Canonical request authority used as the cookie name and signed audience. */ +function requestAuthority(headers: ConnectionTrustRequest['headers']): string | undefined { + const host = header(headers, 'host') + if (host === undefined) return undefined + try { + return new URL(`http://${host}`).host + } catch { + return undefined + } +} + +function canonicalSecret(value: unknown): Buffer | undefined { + if (typeof value !== 'string') return undefined + const decoded = Buffer.from(value, 'base64url') + if (decoded.byteLength !== SECRET_BYTES || decoded.toString('base64url') !== value) return undefined + return decoded +} + +function storedSecret(record: CredentialRecord | undefined): Buffer | undefined { + if (record === undefined) return undefined + if (record.kind !== 'grant' || !isRecord(record.payload) + || record.payload.version !== STORED_SECRET_VERSION) { + throw new Error('client-connection: browser-session credential record has an unsupported format') + } + const secret = canonicalSecret(record.payload.secret) + if (secret === undefined) { + throw new Error('client-connection: browser-session credential record has an invalid secret') + } + return secret +} + +function tokenMatches(actual: string, expected: string): boolean { + const actualBytes = Buffer.from(actual, 'utf8') + const expectedBytes = Buffer.from(expected, 'utf8') + return actualBytes.byteLength === expectedBytes.byteLength && timingSafeEqual(actualBytes, expectedBytes) +} + +function cookieName(authority: string): string { + return COOKIE_PREFIX + createHash('sha256').update(authority).digest('base64url') +} + +/** Read the exact generated cookie without implementing general Cookie decoding. */ +function cookieValue(headerValue: string, name: string): string | undefined { + for (const segment of headerValue.split(';')) { + const at = segment.indexOf('=') + if (at === -1 || segment.slice(0, at).trim() !== name) continue + return segment.slice(at + 1).trim() + } + return undefined +} + +/** Serialize the fixed browser-session attributes; generated names and values are cookie-safe base64url. */ +function sessionCookie(name: string, value: string, expiresAt: number, maxAgeSeconds: number): string { + return `${name}=${value}; Max-Age=${String(maxAgeSeconds)}; Path=/; Expires=${new Date(expiresAt).toUTCString()}; HttpOnly; SameSite=Strict` +} + +function signature(secret: Buffer, body: string): Buffer { + return createHmac('sha256', secret).update(body).digest() +} + +function encodeCookie(payload: BrowserCookiePayload, secret: Buffer): string { + const body = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url') + return `v1.${body}.${signature(secret, body).toString('base64url')}` +} + +function decodeCookie(value: string, secret: Buffer): BrowserCookiePayload | undefined { + const parts = value.split('.') + const [version, body, encodedSignature] = parts + if (parts.length !== 3 || version !== 'v1' || body === undefined || encodedSignature === undefined) { + return undefined + } + const actualSignature = Buffer.from(encodedSignature, 'base64url') + if (actualSignature.toString('base64url') !== encodedSignature) return undefined + const expectedSignature = signature(secret, body) + if (actualSignature.byteLength !== expectedSignature.byteLength + || !timingSafeEqual(actualSignature, expectedSignature)) return undefined + let decoded: unknown + try { + decoded = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) + } catch { + return undefined + } + if (!isRecord(decoded) + || decoded.version !== COOKIE_PAYLOAD_VERSION + || typeof decoded.authority !== 'string' + || !Number.isSafeInteger(decoded.issuedAt) + || !Number.isSafeInteger(decoded.expiresAt)) return undefined + return decoded as unknown as BrowserCookiePayload +} + +/** + * Process launch-token exchange and persistent signed-cookie verification. + * The credential provider owns the signing secret; this object reads it for + * each operation so deletion or rotation revokes existing cookies without a + * process restart. + */ +export class BrowserAuth { + private readonly launchToken = randomBytes(SECRET_BYTES).toString('base64url') + private readonly maxAgeMilliseconds: number + + private constructor( + private readonly credentials: CredentialProvider, + maxAgeDays: number, + ) { + this.maxAgeMilliseconds = maxAgeDays * DAY_MILLISECONDS + if (!Number.isSafeInteger(this.maxAgeMilliseconds) + || !Number.isSafeInteger(Date.now() + this.maxAgeMilliseconds)) { + throw new Error('client-connection: cookieMaxAgeDays exceeds the safe timestamp range') + } + } + + /** + * Initialize browser authentication and create its durable signing secret + * when this Harness home has none. + * @param credentials - persistent credential provider for the Web profile. + * @param maxAgeDays - positive absolute browser-cookie lifetime in days. + * @returns initialized authentication owner with a fresh process token. + */ + static async create(credentials: CredentialProvider, maxAgeDays: number): Promise { + const auth = new BrowserAuth(credentials, maxAgeDays) + await auth.ensureSecret() + return auth + } + + /** + * Add this process's launch token to the ordinary application root URL. + * @param baseUrl - canonical browser origin without credentials. + * @returns root URL carrying the process token as its sole authentication input. + */ + authenticatedUrl(baseUrl: string): string { + const url = new URL(baseUrl) + url.pathname = '/' + url.search = '' + url.hash = '' + url.searchParams.set(TOKEN_QUERY, this.launchToken) + return url.href + } + + /** + * Authenticate an index request. A valid root query token mints the cookie + * and redirects to clean `/`; a valid cookie lets the caller serve the + * index; every other request receives the same minimal 401 response. + * @param req - incoming root or configured-index request. + * @param res - response owned when this method returns false. + * @returns true only when the caller may serve index.html. + */ + async authorizeIndex(req: ConnectionIndexRequest, res: ConnectionIndexResponse): Promise { + /* v8 ignore next -- node:http always supplies url on server requests. */ + const url = new URL(req.url ?? '/', 'http://dsh.invalid') + const tokens = url.searchParams.getAll(TOKEN_QUERY) + if (tokens.length > 0) { + const authority = requestAuthority(req.headers) + if (req.method === 'GET' && url.pathname === '/' && tokens.length === 1 + && authority !== undefined && tokenMatches(tokens.join(''), this.launchToken)) { + const issuedAt = Date.now() + const expiresAt = issuedAt + this.maxAgeMilliseconds + const value = encodeCookie({ + version: COOKIE_PAYLOAD_VERSION, + authority, + issuedAt, + expiresAt, + }, await this.ensureSecret()) + res.writeHead(303, { + 'cache-control': 'no-store', + 'location': '/', + 'referrer-policy': 'no-referrer', + 'set-cookie': sessionCookie( + cookieName(authority), value, expiresAt, Math.floor(this.maxAgeMilliseconds / 1000), + ), + }) + res.end() + return false + } + this.writeUnauthorized(req, res) + return false + } + if (await this.isAuthenticated(req)) return true + this.writeUnauthorized(req, res) + return false + } + + /** + * Verify the authority-bound browser cookie on a Host request. + * @param request - request headers carrying Host and Cookie. + * @returns true only for an unexpired cookie signed by the current durable secret. + */ + async isAuthenticated(request: ConnectionTrustRequest): Promise { + const authority = requestAuthority(request.headers) + const rawCookie = header(request.headers, 'cookie') + if (authority === undefined || rawCookie === undefined) return false + const value = cookieValue(rawCookie, cookieName(authority)) + if (value === undefined) return false + const secret = storedSecret(await this.credentials.readRecord(AUTH_RECORD_KEY)) + if (secret === undefined) return false + const payload = decodeCookie(value, secret) + if (payload === undefined || payload.authority !== authority) return false + const now = Date.now() + return payload.issuedAt <= now + && payload.expiresAt > now + && payload.expiresAt > payload.issuedAt + && payload.expiresAt - payload.issuedAt <= this.maxAgeMilliseconds + } + + private async ensureSecret(): Promise { + const generated: StoredSecretPayload = { + version: STORED_SECRET_VERSION, + secret: randomBytes(SECRET_BYTES).toString('base64url'), + } + const record = await this.credentials.modifyRecord(AUTH_RECORD_KEY, (current) => { + if (current !== undefined) { + storedSecret(current) + return Promise.resolve(undefined) + } + return Promise.resolve({ kind: 'grant', payload: generated }) + }) + const secret = storedSecret(record) + if (secret === undefined) { + throw new Error('client-connection: browser-session credential record was not created') + } + return secret + } + + private writeUnauthorized(req: ConnectionIndexRequest, res: ConnectionIndexResponse): void { + res.writeHead(401, { + 'cache-control': 'no-store', + 'content-type': 'text/plain; charset=utf-8', + }) + res.end(req.method === 'HEAD' + ? undefined + : 'dsh web authentication required; reopen the URL printed by dsh web.\n') + } +} diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 1944e09722..0d43a322a3 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -2,20 +2,23 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-attachment' +import type {} from '@deepseek-ai/dsh-credentials' // Activates the webServer Context merge used below. import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' import { API_PATH } from './api-path.ts' import { bridge, DEFAULT_MAX_REQUEST_BODY_BYTES } from './http-bridge.ts' -import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts' +import { assertTrustedAuthority } from './api-request-trust.ts' +import { BrowserAuth } from './browser-auth.ts' import { HostConnectionService } from './rpc-host.ts' export type { - ConnectionRpcAuthority, + ConnectionIndexRequest, + ConnectionIndexResponse, ConnectionRpcEndpointMatcher, ConnectionRpcFailure, ConnectionRpcHandler, - ConnectionRpcHandlerOptions, + ConnectionRequestRejection, ConnectionRpcResult, ConnectionTrustRequest, HostConnectionHandle, @@ -46,7 +49,7 @@ function assertImageBodyCapacity(ctx: Context, maxRequestBodyBytes: number): voi } /** Services required before providing Connection; API Proxy is an optional `/api` fallback. */ -export const inject = ['webServer'] +export const inject = ['webServer', 'credentials'] /** Plugin config: the deployment's non-loopback serving authorities. */ export interface ConnectionConfig { @@ -55,112 +58,58 @@ export interface ConnectionConfig { * port-less `host` matching any port. The /api trust fence refuses any * request whose Host is neither loopback nor listed here, so a * non-loopback (`0.0.0.0`) deployment must declare the names it is reached - * by (the dsh CLI derives the machine's LAN IP literals itself). An entry - * that is not a bare, canonical authority fails the plugin load. + * by; the Web runtime derives LAN IP literals from an active all-interface + * bind. An entry that is not a bare, canonical authority fails plugin load. */ trustedHosts?: string[] + /** Absolute browser-session lifetime in days. Default: 30. */ + cookieMaxAgeDays?: number /** Maximum buffered JSON body for every `/api` request. Default: 300 MiB. */ maxRequestBodyBytes?: number } export const Config: z = z.object({ trustedHosts: z.array(String).default([]), + cookieMaxAgeDays: z.natural().min(1).default(30), maxRequestBodyBytes: z.natural().min(1).default(DEFAULT_MAX_REQUEST_BODY_BYTES), }) -/** - * Methods gated to loopback even on a trusted-host deployment. Native dialogs - * act on the host machine; the settings and credential domains mutate the - * user's configuration and secret store, and READING them is equally - * privileged — `settings.describe` returns every exposed namespace's - * configuration and `credentials.describe` reports whether an arbitrary - * environment-variable name is configured and where from, which is - * reconnaissance no anonymous caller should have. `trustedHosts` is a - * DNS-rebinding fence, explicitly not authentication, so the whole - * configuration plane stays loopback-same-origin until a real authentication - * layer exists. `llm.discoverModels` belongs to that plane on both counts: it - * carries a draft credential, and it makes the HOST issue a GET to a URL the - * caller chose and reports back the status or the parsed body — an anonymous - * LAN caller would have a probe for whatever the host can reach and the - * browser cannot. - * - * The model catalog (`llm.providers`, `llm.models`) is deliberately NOT here: - * it carries provider ids, display names, and model lists — no endpoints, - * keys, or key state — and a LAN client's model picker legitimately needs it. - */ -const PRIVILEGED_METHODS = new Set([ - // A preset composition names the plugins a session runs, so reading one is - // reconnaissance; copy and remove rearrange what the deployment offers, and - // openDocument drives the host desktop — all more than the roster beside - // them. (Authoring is copy-only, so no method here accepts composition text - // or a path; the pin is about who may manage the roster at all.) - // - // CHOOSING one is not pinned, and `agentPreset.list` is not either. Picking a - // preset looks like escalation — one of them mounts the toolset that edits the - // live runtime — but `session.create` already takes an `agentPreset`, so - // pinning only the switch would leave the same capability one method over. - // The deeper reason is that the capability is not the preset's to grant: the - // deployment's own default already carries `bash` and the filesystem tools, so - // any caller that may start a session at all can already run commands as this - // process. Pinning the switch would be a fence beside an open gate. - 'agentPreset.read', - 'agentPreset.copy', - 'agentPreset.openDocument', - 'agentPreset.remove', - 'host.pickDirectory', - 'host.openPath', - 'settings.describe', - 'settings.openDocument', - 'settings.update', - 'settings.replace', - 'settings.mutate', - 'credentials.describe', - 'credentials.set', - 'credentials.unset', - 'llm.discoverModels', -]) - /** * Mounts the API gateway under the browser transport prefix. Every request on - * the prefix passes the browser-trust fence first (DNS-rebinding and - * cross-site defense — [api-request-trust](./api-request-trust.ts)); - * privileged methods additionally pass it with an empty trust list, which - * pins them to loopback. + * the prefix passes the Host/Origin browser-trust fence and persistent browser + * authentication before dispatch. * @param ctx - Host plugin context. * @param config - resolved plugin config (schema defaults applied). */ -export function apply(ctx: Context, config?: ConnectionConfig): void { +export async function apply(ctx: Context, config?: ConnectionConfig): Promise { // The Loader resolves schema defaults; hand-built test contexts may pass none. const trustedHosts = config?.trustedHosts ?? [] + const cookieMaxAgeDays = config?.cookieMaxAgeDays ?? 30 const maxRequestBodyBytes = config?.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES // Config boundary: a malformed entry fails the load loudly here rather than // silently authorizing its hostname prefix at request time. for (const entry of trustedHosts) assertTrustedAuthority(entry) if (ctx.get('apiProxy') !== undefined) assertImageBodyCapacity(ctx, maxRequestBodyBytes) - const connection = new HostConnectionService(ctx, trustedHosts) + const connection = new HostConnectionService( + ctx, + trustedHosts, + await BrowserAuth.create(ctx.credentials, cookieMaxAgeDays), + ) const fetchHandler = connection.createSharedFetchHandler(API_PATH, { async fetch(request) { - const pathname = new URL(request.url).pathname - const method = pathname.startsWith(`${API_PATH}/`) - ? pathname.slice(API_PATH.length + 1) - : undefined - if (method !== undefined - && PRIVILEGED_METHODS.has(method) - && !isTrustedApiRequest(request, [])) { - return new Response('forbidden', { status: 403 }) - } const apiProxy = ctx.get('apiProxy') if (apiProxy === undefined) return new Response('not found', { status: 404 }) - return toFetchHandler(apiProxy).fetch(request) + return await toFetchHandler(apiProxy).fetch(request) }, }) const route: WebRoute = { kind: 'prefix', path: API_PATH, handler: async (req, res) => { - if (!isTrustedApiRequest(req, trustedHosts)) { - res.writeHead(403) - res.end('forbidden') + const rejection = await connection.requestRejection(req) + if (rejection !== undefined) { + res.writeHead(rejection) + res.end(rejection === 401 ? 'unauthorized' : 'forbidden') return } await bridge(req, res, fetchHandler, maxRequestBodyBytes) diff --git a/packages/client/connection/src/invariant.ts b/packages/client/connection/src/invariant.ts index 78394263cf..5a187545b5 100644 --- a/packages/client/connection/src/invariant.ts +++ b/packages/client/connection/src/invariant.ts @@ -15,11 +15,12 @@ export const name = 'client-connection-invariant' export const inject = ['invariants'] /** - * No runtime invariant: the wire layer emits no cordis events and owns no - * mutable cross-plugin relation — stream/reconnect sequencing is exercised - * directly by its behavior specs, rpcId round-trip discipline is owned by the - * apiproxy contract layer, and the node half's single route registration's - * register/dispose symmetry is audited by the webserver package's invariant. + * No runtime invariant: browser-session verification reads the credential + * record asynchronously at the request that authorizes work, while the + * credentials companion owns record commit-event lifetime. Stream/reconnect + * sequencing is exercised directly by behavior specs, rpcId round-trip + * discipline belongs to apiproxy, and route register/dispose symmetry is + * audited by the webserver companion. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index 162045ed0d..5c079e092a 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -13,12 +13,14 @@ import { import { bridge, type FetchHandler } from './http-bridge.ts' import { isTrustedApiRequest } from './api-request-trust.ts' import { API_PATH } from './api-path.ts' +import type { BrowserAuth } from './browser-auth.ts' import type { + ConnectionIndexRequest, + ConnectionIndexResponse, ConnectionRpcEndpointMatcher, ConnectionRpcHandler, - ConnectionRpcHandlerOptions, ConnectionRpcResult, - ConnectionRpcAuthority, + ConnectionRequestRejection, ConnectionTrustRequest, HostConnectionHandle, HostConnectionRpc, @@ -31,7 +33,6 @@ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ interface ConnectionRpcInterceptor { readonly matches: ConnectionRpcEndpointMatcher readonly fetchHandler: FetchHandler - readonly options: ConnectionRpcHandlerOptions } interface ConnectionServerResponse { @@ -54,9 +55,14 @@ export class HostConnectionService extends Service implements HostConnectionHand /** * Provide the Host half over the active HTTP server. * @param ctx - owning Connection plugin context. - * @param trustedHosts - deployment authorities accepted by trusted-host channels. + * @param trustedHosts - deployment authorities accepted by the Host/Origin fence. + * @param browserAuth - process token and persistent browser-session owner. */ - constructor(ctx: Context, private readonly trustedHosts: readonly string[]) { + constructor( + ctx: Context, + private readonly trustedHosts: readonly string[], + private readonly browserAuth: BrowserAuth, + ) { super(ctx, 'connection') } @@ -64,15 +70,26 @@ export class HostConnectionService extends Service implements HostConnectionHand get rpc(): HostConnectionRpc { const owner = this.ctx return { - handle: (channel, handler, options) => this.register(owner, channel, handler, options), - intercept: (channel, matches, handler, options) => - this.registerInterceptor(owner, channel, matches, handler, options), + handle: (channel, handler) => this.register(owner, channel, handler), + intercept: (channel, matches, handler) => + this.registerInterceptor(owner, channel, matches, handler), } } - /** Apply the existing configured request trust policy to a sibling Web route. */ - isTrustedRequest(request: ConnectionTrustRequest, authority: ConnectionRpcAuthority): boolean { - return isTrustedApiRequest(request, authority === 'loopback' ? [] : this.trustedHosts) + /** Apply the configured Host/Origin fence, then browser authentication. */ + async requestRejection(request: ConnectionTrustRequest): Promise { + if (!isTrustedApiRequest(request, this.trustedHosts)) return 403 + return await this.browserAuth.isAuthenticated(request) ? undefined : 401 + } + + /** Authenticate an index request through the process-token exchange or cookie. */ + authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): Promise { + return this.browserAuth.authorizeIndex(request, response) + } + + /** Add this process's launch token to the clean application URL. */ + authenticatedUrl(baseUrl: string): string { + return this.browserAuth.authenticatedUrl(baseUrl) } /** @@ -92,9 +109,6 @@ export class HostConnectionService extends Service implements HostConnectionHand if (endpoint === undefined || interceptor === undefined || !interceptor.matches(endpoint)) { return fallback.fetch(request) } - if (interceptor.options.authority === 'loopback' && !isTrustedApiRequest(request, [])) { - return Promise.resolve(new Response('forbidden', { status: 403 })) - } return interceptor.fetchHandler.fetch(request) }, } @@ -104,18 +118,17 @@ export class HostConnectionService extends Service implements HostConnectionHand owner: Context, channel: string, handler: ConnectionRpcHandler, - options: ConnectionRpcHandlerOptions, ): () => Promise { assertChannel(channel) - const trustedHosts = options.authority === 'loopback' ? [] : this.trustedHosts const fetchHandler = rpcFetchHandler(channel, handler) const route: WebRoute = { kind: 'prefix', path: channel, handler: async (req, res) => { - if (!isTrustedApiRequest(req, trustedHosts)) { - res.writeHead(403) - res.end('forbidden') + const rejection = await this.requestRejection(req) + if (rejection !== undefined) { + res.writeHead(rejection) + res.end(rejection === 401 ? 'unauthorized' : 'forbidden') return } await bridge(req, res, fetchHandler) @@ -132,7 +145,6 @@ export class HostConnectionService extends Service implements HostConnectionHand channel: string, matches: ConnectionRpcEndpointMatcher, handler: ConnectionRpcHandler, - options: ConnectionRpcHandlerOptions, ): () => Promise { if (channel !== API_PATH) { throw new Error(`connection: invalid shared RPC channel ${JSON.stringify(channel)}`) @@ -140,7 +152,6 @@ export class HostConnectionService extends Service implements HostConnectionHand const interceptor: ConnectionRpcInterceptor = { matches, fetchHandler: rpcFetchHandler(channel, handler), - options, } return owner.effect(() => { if (this.interceptors.has(channel)) { diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index e8dc585d38..d05e2f0b81 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -12,19 +12,25 @@ export type ConnectionRpcResult = | { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: ConnectionRpcFailure } -/** HTTP request facts consumed by the existing browser trust fence. */ +/** HTTP request facts consumed by browser trust and authentication. */ export interface ConnectionTrustRequest { /** Request headers supplied by either the Fetch or node:http representation. */ readonly headers: Headers | Readonly> } -/** Trust fence applied before a Host RPC channel reaches its handler. */ -export type ConnectionRpcAuthority = 'trusted-host' | 'loopback' +/** HTTP status returned before dispatch, or undefined when the request may proceed. */ +export type ConnectionRequestRejection = 401 | 403 | undefined -/** Registration policy for one logical RPC channel. */ -export interface ConnectionRpcHandlerOptions { - /** Browser authority accepted by every endpoint in this channel. */ - readonly authority: ConnectionRpcAuthority +/** Root/index request facts used by the browser-token exchange. */ +export interface ConnectionIndexRequest extends ConnectionTrustRequest { + readonly method?: string | undefined + readonly url?: string | undefined +} + +/** Root/index response operations owned by the browser-token exchange. */ +export interface ConnectionIndexResponse { + writeHead(status: number, headers?: Readonly>): unknown + end(body?: string): unknown } /** Handler invoked after Connection has decoded the transport envelope. */ @@ -40,16 +46,14 @@ export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean /** Host registry for logical RPC channels carried by the current transport. */ export interface HostConnectionRpc { /** - * Register one absolute channel prefix and its trust policy. + * Register one authenticated absolute channel prefix. * @param channel - absolute logical channel such as `/rpc`. * @param handler - decoded endpoint handler returning the existing RPC result shape. - * @param options - channel trust policy. * @returns asynchronous disposer removing the channel and its physical route. */ handle( channel: string, handler: ConnectionRpcHandler, - options: ConnectionRpcHandlerOptions, ): () => Promise /** @@ -57,14 +61,12 @@ export interface HostConnectionRpc { * @param channel - reserved shared channel; currently `/api`. * @param matches - synchronous endpoint ownership test. * @param handler - decoded endpoint handler returning the existing RPC result shape. - * @param options - trust policy for every endpoint claimed by this interceptor. * @returns asynchronous disposer removing the interceptor. */ intercept( channel: '/api', matches: ConnectionRpcEndpointMatcher, handler: ConnectionRpcHandler, - options: ConnectionRpcHandlerOptions, ): () => Promise } @@ -74,12 +76,27 @@ export interface HostConnectionHandle { readonly rpc: HostConnectionRpc /** - * Apply Connection's configured browser trust policy to another Web route. + * Apply Connection's Host/Origin checks and browser authentication to + * another Web route. * @param request - request headers from the HTTP or upgrade request. - * @param authority - configured trusted hosts or loopback-only policy. - * @returns whether the route may accept the request. + * @returns rejection status, or undefined when the route may accept the request. */ - isTrustedRequest(request: ConnectionTrustRequest, authority: ConnectionRpcAuthority): boolean + requestRejection(request: ConnectionTrustRequest): Promise + + /** + * Authenticate one frontend index request, owning a token redirect or 401. + * @param request - root or configured-index HTTP request. + * @param response - response owned when the result is false. + * @returns true only when the frontend may serve index.html. + */ + authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): Promise + + /** + * Add the fresh process token to an ordinary Web application URL. + * @param baseUrl - clean canonical browser origin. + * @returns root URL accepted by {@link authorizeIndex} for initial login. + */ + authenticatedUrl(baseUrl: string): string } /** Client caller for logical RPC channels carried by the current transport. */ diff --git a/packages/client/connection/tests/browser-auth.host.spec.ts b/packages/client/connection/tests/browser-auth.host.spec.ts new file mode 100644 index 0000000000..21ce8f592b --- /dev/null +++ b/packages/client/connection/tests/browser-auth.host.spec.ts @@ -0,0 +1,231 @@ +/** Browser launch-token and persistent-cookie behavior. */ + +import { createHmac } from 'node:crypto' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { CredentialProvider, CredentialRecord } from '@deepseek-ai/dsh-credentials' +import { BrowserAuth } from '../src/browser-auth.ts' +import type { ConnectionIndexRequest, ConnectionIndexResponse } from '../src/rpc.ts' + +class RecordCredentials { + record: CredentialRecord | undefined + discardWrites = false + + readRecord(): Promise { + return Promise.resolve(this.record) + } + + async modifyRecord( + _key: unknown, + mutate: (current: CredentialRecord | undefined) => Promise, + ): Promise { + const next = await mutate(this.record) + if (this.discardWrites) return undefined + if (next !== undefined) this.record = next + return this.record + } + + deleteRecord(): Promise { + this.record = undefined + return Promise.resolve() + } +} + +function signedCookie(store: RecordCredentials, name: string, payload: unknown): string { + const record = store.record + if (record?.kind !== 'grant' || typeof record.payload !== 'object' || record.payload === null) { + throw new Error('test credential store has no signing secret') + } + const secret: unknown = Reflect.get(record.payload, 'secret') + if (typeof secret !== 'string') throw new Error('test credential record has no string secret') + const body = typeof payload === 'string' + ? Buffer.from(payload, 'utf8').toString('base64url') + : Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url') + const signature = createHmac('sha256', Buffer.from(secret, 'base64url')).update(body).digest('base64url') + return `${name}=v1.${body}.${signature}` +} + +interface ResponseState { + status?: number + headers?: Readonly> + body?: string +} + +function response(): { value: ConnectionIndexResponse; state: ResponseState } { + const state: ResponseState = {} + return { + value: { + writeHead(status, headers) { + state.status = status + if (headers !== undefined) state.headers = headers + }, + end(body) { + if (body !== undefined) state.body = body + }, + }, + state, + } +} + +function credentials(store: RecordCredentials): CredentialProvider { + return store as unknown as CredentialProvider +} + +function request(url: string, authority = '127.0.0.1:3080', init?: { + cookie?: string + method?: string +}): ConnectionIndexRequest { + return { + method: init?.method ?? 'GET', + url, + headers: { + host: authority, + ...init?.cookie === undefined ? {} : { cookie: init.cookie }, + }, + } +} + +async function exchange( + auth: BrowserAuth, + authority = '127.0.0.1:3080', +): Promise<{ cookie: string; launchUrl: string; state: ResponseState }> { + const launchUrl = auth.authenticatedUrl(`http://${authority}`) + const target = new URL(launchUrl) + const res = response() + expect(await auth.authorizeIndex(request(`${target.pathname}${target.search}`, authority), res.value)).toBe(false) + const setCookie = res.state.headers?.['set-cookie'] + if (setCookie === undefined) throw new Error('token exchange did not set a cookie') + return { cookie: setCookie.split(';', 1)[0]!, launchUrl, state: res.state } +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('BrowserAuth', () => { + it('mints one process token and a persistent authority-bound cookie', async () => { + const store = new RecordCredentials() + const first = await BrowserAuth.create(credentials(store), 30) + const login = await exchange(first) + + expect(login.state).toMatchObject({ + status: 303, + headers: { + 'cache-control': 'no-store', + 'location': '/', + 'referrer-policy': 'no-referrer', + }, + }) + expect(login.state.headers?.['set-cookie']).toMatch(/; Max-Age=2592000; Path=\/; Expires=.*; HttpOnly; SameSite=Strict$/u) + expect(login.state.headers?.['set-cookie']).not.toContain('Secure') + expect(await first.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: login.cookie }))).toBe(true) + expect(await first.isAuthenticated({ + headers: new Headers({ host: '127.0.0.1:3080', cookie: login.cookie }), + })).toBe(true) + expect(await first.isAuthenticated({ headers: new Headers() })).toBe(false) + expect(await first.isAuthenticated(request('/', 'localhost:3080', { cookie: login.cookie }))).toBe(false) + expect(await first.isAuthenticated(request('/', '127.0.0.1:3081', { cookie: login.cookie }))).toBe(false) + + const restarted = await BrowserAuth.create(credentials(store), 30) + expect(new URL(restarted.authenticatedUrl('http://127.0.0.1:3080')).searchParams.get('token')) + .not.toBe(new URL(login.launchUrl).searchParams.get('token')) + expect(await restarted.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: login.cookie }))).toBe(true) + }) + + it('accepts the cookie for index serving and gives every unauthenticated request one response', async () => { + const auth = await BrowserAuth.create(credentials(new RecordCredentials()), 30) + const { cookie } = await exchange(auth) + const allowed = response() + expect(await auth.authorizeIndex(request('/index.html', '127.0.0.1:3080', { cookie }), allowed.value)).toBe(true) + expect(allowed.state).toEqual({}) + + for (const candidate of [ + request('/'), + request('/?token=wrong'), + request('/?token=wrong&token=again'), + request('/index.html?token=wrong'), + request(auth.authenticatedUrl('http://127.0.0.1:3080'), '127.0.0.1:3080', { method: 'HEAD' }), + ]) { + const denied = response() + expect(await auth.authorizeIndex(candidate, denied.value)).toBe(false) + expect(denied.state.status).toBe(401) + expect(denied.state.headers).toEqual({ + 'cache-control': 'no-store', + 'content-type': 'text/plain; charset=utf-8', + }) + expect(denied.state.body).toBe(candidate.method === 'HEAD' + ? undefined + : 'dsh web authentication required; reopen the URL printed by dsh web.\n') + } + }) + + it('rejects tampering, expiry, future issuance, and a longer lifetime than configured', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-24T00:00:00.000Z')) + const store = new RecordCredentials() + const auth = await BrowserAuth.create(credentials(store), 30) + const { cookie } = await exchange(auth) + const [name, value] = cookie.split('=') as [string, string] + + expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=broken` }))).toBe(false) + expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=${value.slice(0, -1)}x` }))).toBe(false) + expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=%` }))).toBe(false) + expect(await auth.isAuthenticated({ headers: {} })).toBe(false) + expect(await auth.isAuthenticated({ headers: { host: 'bad host', cookie } })).toBe(false) + expect(await auth.isAuthenticated({ headers: { host: '127.0.0.1:3080' } })).toBe(false) + + const invalidPayloads: unknown[] = [ + 'not json', + null, + { version: 2, authority: '127.0.0.1:3080', issuedAt: Date.now(), expiresAt: Date.now() + 1000 }, + { version: 1, authority: 42, issuedAt: Date.now(), expiresAt: Date.now() + 1000 }, + { version: 1, authority: '127.0.0.1:3080', issuedAt: 'now', expiresAt: Date.now() + 1000 }, + { version: 1, authority: '127.0.0.1:3080', issuedAt: Date.now(), expiresAt: 'later' }, + ] + for (const payload of invalidPayloads) { + expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { + cookie: signedCookie(store, name, payload), + }))).toBe(false) + } + + const shorter = await BrowserAuth.create(credentials(store), 1) + expect(await shorter.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false) + vi.setSystemTime(new Date('2026-09-24T00:00:00.000Z')) + expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false) + vi.setSystemTime(new Date('2026-08-23T00:00:00.000Z')) + expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false) + }) + + it('revokes on record deletion and creates a new secret on the next token exchange', async () => { + const store = new RecordCredentials() + const auth = await BrowserAuth.create(credentials(store), 30) + const first = await exchange(auth) + await store.deleteRecord() + expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: first.cookie }))).toBe(false) + + const second = await exchange(auth) + expect(second.cookie).not.toBe(first.cookie) + expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: first.cookie }))).toBe(false) + expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: second.cookie }))).toBe(true) + }) + + it('fails loud on an invalid owner record instead of replacing it', async () => { + const unsupported = new RecordCredentials() + unsupported.record = { kind: 'api-key', key: 'not-a-cookie-secret' } + await expect(BrowserAuth.create(credentials(unsupported), 30)).rejects.toThrow(/unsupported format/u) + + const malformed = new RecordCredentials() + malformed.record = { kind: 'grant', payload: { version: 1, secret: 'short' } } + await expect(BrowserAuth.create(credentials(malformed), 30)).rejects.toThrow(/invalid secret/u) + + const nonString = new RecordCredentials() + nonString.record = { kind: 'grant', payload: { version: 1, secret: 42 } } + await expect(BrowserAuth.create(credentials(nonString), 30)).rejects.toThrow(/invalid secret/u) + + const discarded = new RecordCredentials() + discarded.discardWrites = true + await expect(BrowserAuth.create(credentials(discarded), 30)).rejects.toThrow(/was not created/u) + + await expect(BrowserAuth.create(credentials(new RecordCredentials()), Number.MAX_SAFE_INTEGER)) + .rejects.toThrow(/safe timestamp range/u) + }) +}) diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index 504e50d9fd..a175a7f005 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -12,6 +12,7 @@ import { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api' import type { WebServer, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' import { API_PATH, apply, inject, type HostConnectionHandle } from '../src/index.ts' import { DEFAULT_MAX_REQUEST_BODY_BYTES } from '../src/http-bridge.ts' +import { MemoryCredentials } from '../../../credentials/credentials/tests/memory.ts' /** Structural webServer fake recording both route registries. */ function fakeHttpServer( @@ -57,12 +58,19 @@ function fakeRawPost(headers: Record, url: string, body: string) } /** Response recorder compatible with both the fence's short-circuit and the bridge. */ -function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } { - const state: { status?: number; body?: unknown } = {} +function fakeResponse(): { + response: ServerResponse + state: { status?: number; headers?: Record; body?: unknown } +} { + const state: { status?: number; headers?: Record; body?: unknown } = {} const chunks: Buffer[] = [] const response = Object.assign(new EventEmitter(), { writableEnded: false, - writeHead(value: number) { state.status = value; return this }, + writeHead(value: number, headers?: Record) { + state.status = value + if (headers !== undefined) state.headers = headers + return this + }, write(value: string | Uint8Array) { chunks.push(Buffer.from(value)); return true }, end(this: { writableEnded: boolean }, value?: unknown) { if (typeof value === 'string' || value instanceof Uint8Array) chunks.push(Buffer.from(value)) @@ -84,6 +92,7 @@ async function mounted(config?: { trustedHosts?: string[] }): Promise<{ const ctx = new Context() const routes: WebRoute[] = [] const upgrades: WebUpgradeRoute[] = [] + await ctx.plugin(MemoryCredentials) ctx.provide('webServer', fakeHttpServer(routes, upgrades) as WebServer) ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, config) @@ -96,13 +105,26 @@ async function mounted(config?: { trustedHosts?: string[] }): Promise<{ } } +/** Exchange a service's process token for one authority-bound Cookie header. */ +async function browserCookie(connection: HostConnectionHandle, authority: string): Promise { + const url = new URL(connection.authenticatedUrl(`http://${authority}`)) + const exchanged = fakeResponse() + await connection.authorizeIndex( + fakeRequest({ host: authority }, `${url.pathname}${url.search}`), + exchanged.response, + ) + const setCookie = exchanged.state.headers?.['set-cookie'] + if (setCookie === undefined) throw new Error('browser token exchange did not set a cookie') + return setCookie.split(';', 1)[0]! +} + describe('connection node half', () => { it('reserves enough default carrier capacity for the 200 MiB image batch', () => { expect(DEFAULT_MAX_REQUEST_BODY_BYTES).toBe(300 * 1024 * 1024) expect(DEFAULT_MAX_REQUEST_BODY_BYTES).toBeGreaterThan(Math.ceil(200 * 1024 * 1024 * 4 / 3) + 1024 * 1024) }) - it('fails loud when the carrier cap cannot hold the configured image batch', () => { + it('fails loud when the carrier cap cannot hold the configured image batch', async () => { const ctx = new Context() const routes: WebRoute[] = [] ctx.provide('webServer', fakeHttpServer(routes, []) as WebServer) @@ -110,8 +132,8 @@ describe('connection node half', () => { imageLimits: { maxMessageImageBytes: 20 * 1024 * 1024 }, } as AttachmentStore) ctx.provide('apiProxy', {} as ApiProxy) - expect(() => { apply(ctx, { maxRequestBodyBytes: 1024 }) }) - .toThrow(/must be at least .* aggregate image limit/) + await expect(apply(ctx, { maxRequestBodyBytes: 1024 })) + .rejects.toThrow(/must be at least .* aggregate image limit/) expect(routes).toHaveLength(0) }) @@ -119,6 +141,7 @@ describe('connection node half', () => { const routes: WebRoute[] = [] const upgrades: WebUpgradeRoute[] = [] const ctx = new Context() + await ctx.plugin(MemoryCredentials) ctx.provide('webServer', fakeHttpServer(routes, upgrades) as WebServer) ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] }) @@ -148,72 +171,83 @@ describe('connection node half', () => { await dispose() }) - it('pins privileged methods to loopback even for a declared trusted authority', async () => { - const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] }) - // The privileged set: native dialogs plus the whole settings/credential - // configuration plane, reads included, plus the one method that makes the - // host fetch a caller-chosen URL. The same declared authority reaches - // ordinary reads (carrier-level 404 from the empty proxy proves the fence - // passed), but each privileged method stays loopback-only and 403s. - for (const method of [ + it('requires the same browser session for every method on every trusted authority', async () => { + const { routes, connection, dispose } = await mounted({ trustedHosts: ['harness.example'] }) + const methods = [ 'host.pickDirectory', 'host.openPath', - 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', - 'credentials.describe', 'credentials.set', 'credentials.unset', - 'llm.discoverModels', - // A composition names the plugins a session runs: reading one is - // reconnaissance, and copy/remove/openDocument manage the roster and - // drive the host desktop. - 'agentPreset.read', 'agentPreset.copy', 'agentPreset.openDocument', 'agentPreset.remove', - ]) { + 'settings.describe', 'settings.update', 'credentials.describe', 'credentials.set', + 'llm.discoverModels', 'llm.models', 'agentPreset.read', 'agentPreset.list', + ] + for (const method of methods) { const denied = fakeResponse() - await routes[0]!.handler( - fakeRequest({ host: 'harness.example' }, `${API_PATH}/${method}`), - denied.response, - ) - expect(denied.state.status).toBe(403) - expect(denied.state.body).toBe('forbidden') + await routes[0]!.handler(fakeRequest({ host: 'harness.example' }, `${API_PATH}/${method}`), denied.response) + expect([method, denied.state.status, denied.state.body]).toEqual([method, 401, 'unauthorized']) } - const read = fakeResponse() - await routes[0]!.handler(fakeRequest({ host: 'harness.example' }), read.response) - expect(read.state.status).not.toBe(403) + + const cookie = await browserCookie(connection, 'harness.example') + for (const method of methods) { + const allowed = fakeResponse() + await routes[0]!.handler( + fakeRequest({ host: 'harness.example', cookie }, `${API_PATH}/${method}`), + allowed.response, + ) + expect([method, allowed.state.status]).toEqual([method, 404]) + } + + const forged = fakeResponse() + await routes[0]!.handler(fakeRequest({ host: 'localhost:3080' }), forged.response) + expect(forged.state).toMatchObject({ status: 401, body: 'unauthorized' }) await dispose() }) it('passes loopback and declared-authority requests through to the bridge', async () => { - const { routes, dispose } = await mounted({ trustedHosts: ['harness.example:3080', '192.168.1.5'] }) + const { routes, connection, dispose } = await mounted({ trustedHosts: ['harness.example:3080', '192.168.1.5'] }) // Loopback, no browser markers (curl shape): the fence passes; the carrier // answers 404 for a GET unary path — proof the bridge ran. const loopback = fakeResponse() - await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }), loopback.response) + await routes[0]!.handler(fakeRequest({ + host: '127.0.0.1:3080', + cookie: await browserCookie(connection, '127.0.0.1:3080'), + }), loopback.response) expect(loopback.state.status).toBe(404) // An all-interfaces composition derives port-less LAN IP literals, which // pass markerless curl on any port. const lan = fakeResponse() - await routes[0]!.handler(fakeRequest({ host: '192.168.1.5:3080' }), lan.response) + await routes[0]!.handler(fakeRequest({ + host: '192.168.1.5:3080', + cookie: await browserCookie(connection, '192.168.1.5:3080'), + }), lan.response) expect(lan.state.status).toBe(404) // Declared public authority, same-origin browser shape. const declared = fakeResponse() await routes[0]!.handler(fakeRequest({ - host: 'harness.example:3080', origin: 'http://harness.example:3080', 'sec-fetch-site': 'same-origin', + host: 'harness.example:3080', + origin: 'http://harness.example:3080', + 'sec-fetch-site': 'same-origin', + cookie: await browserCookie(connection, 'harness.example:3080'), }), declared.response) expect(declared.state.status).toBe(404) await dispose() }) - it('shares its configured trust policy with sibling routes', async () => { + it('shares its configured trust and authentication policy with sibling routes', async () => { const { connection, dispose } = await mounted({ trustedHosts: ['harness.example'] }) const loopback = fakeRequest({ host: '127.0.0.1:3080' }) const declared = fakeRequest({ host: 'harness.example' }) - expect(connection.isTrustedRequest(loopback, 'loopback')).toBe(true) - expect(connection.isTrustedRequest(declared, 'loopback')).toBe(false) - expect(connection.isTrustedRequest(declared, 'trusted-host')).toBe(true) + expect(await connection.requestRejection(loopback)).toBe(401) + expect(await connection.requestRejection(declared)).toBe(401) + expect(await connection.requestRejection(fakeRequest({ + host: 'harness.example', + cookie: await browserCookie(connection, 'harness.example'), + }))).toBeUndefined() await dispose() }) it('provides a disposable dedicated RPC channel without requiring apiProxy', async () => { const ctx = new Context() const routes: WebRoute[] = [] + await ctx.plugin(MemoryCredentials) ctx.provide('webServer', fakeHttpServer(routes, []) as WebServer) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() @@ -225,7 +259,7 @@ describe('connection node half', () => { const remove = connection.rpc.handle('/rpc', async (endpoint, payload) => { calls.push({ endpoint, payload }) return { ok: true, value: { accepted: true } } - }, { authority: 'trusted-host' }) + }) const route = routes.find(candidate => candidate.path === '/rpc') expect(route).toBeDefined() @@ -236,7 +270,10 @@ describe('connection node half', () => { payload: { args: { agentId: 'agent-1' } }, } const result = fakeResponse() - await route!.handler(fakePost({ host: '127.0.0.1:3080' }, '/rpc/goals/create', request), result.response) + await route!.handler(fakePost({ + host: '127.0.0.1:3080', + cookie: await browserCookie(connection, '127.0.0.1:3080'), + }, '/rpc/goals/create', request), result.response) expect(result.state.status).toBe(200) expect(JSON.parse(String(result.state.body))).toEqual({ type: 'server-response', @@ -248,9 +285,8 @@ describe('connection node half', () => { payload: { args: { agentId: 'agent-1' } }, }]) - expect(() => connection.rpc.handle('/rpc', async () => ({ ok: true, value: null }), { - authority: 'trusted-host', - })).toThrow(/duplicate route/) + expect(() => connection.rpc.handle('/rpc', async () => ({ ok: true, value: null }))) + .toThrow(/duplicate route/) await remove() expect(routes.map(candidate => candidate.path)).toEqual([API_PATH]) await fiber.dispose() @@ -260,6 +296,7 @@ describe('connection node half', () => { it('dispatches claimed /api endpoints before the API Proxy fallback and withdraws the claim', async () => { const ctx = new Context() const routes: WebRoute[] = [] + await ctx.plugin(MemoryCredentials) ctx.provide('webServer', fakeHttpServer(routes, []) as WebServer) ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] }) @@ -273,19 +310,16 @@ describe('connection node half', () => { calls.push({ endpoint, payload }) return { ok: true, value: { accepted: true } } }, - { authority: 'trusted-host' }, ) expect(() => connection.rpc.intercept( '/api', () => true, async () => ({ ok: true, value: null }), - { authority: 'trusted-host' }, )).toThrow('already has an interceptor') expect(() => connection.rpc.intercept( '/rpc' as '/api', () => true, async () => ({ ok: true, value: null }), - { authority: 'trusted-host' }, )).toThrow('invalid shared RPC channel') const route = routes.find(candidate => candidate.path === API_PATH)! const request: ClientRequest = { @@ -296,7 +330,10 @@ describe('connection node half', () => { } const claimed = fakeResponse() - await route.handler(fakePost({ host: '127.0.0.1:3080' }, '/api/goals/create', request), claimed.response) + const loopbackCookie = await browserCookie(connection, '127.0.0.1:3080') + await route.handler(fakePost({ + host: '127.0.0.1:3080', cookie: loopbackCookie, + }, '/api/goals/create', request), claimed.response) expect(JSON.parse(String(claimed.state.body))).toEqual({ type: 'server-response', rpcId: 'rpc-shared', @@ -313,31 +350,38 @@ describe('connection node half', () => { expect(calls).toHaveLength(1) const unclaimed = fakeResponse() - await route.handler(fakeRequest({ host: '127.0.0.1:3080' }, '/api/session.list'), unclaimed.response) + await route.handler(fakeRequest({ + host: '127.0.0.1:3080', cookie: loopbackCookie, + }, '/api/session.list'), unclaimed.response) expect(unclaimed.state.status).toBe(404) await remove() const withdrawn = fakeResponse() - await route.handler(fakePost({ host: '127.0.0.1:3080' }, '/api/goals/create', request), withdrawn.response) + await route.handler(fakePost({ + host: '127.0.0.1:3080', cookie: loopbackCookie, + }, '/api/goals/create', request), withdrawn.response) expect(withdrawn.state.status).toBe(404) expect(calls).toHaveLength(1) - const removeLoopback = connection.rpc.intercept( + const removeAuthenticated = connection.rpc.intercept( '/api', endpoint => endpoint === 'goals/create', async () => ({ ok: true, value: null }), - { authority: 'loopback' }, ) - const loopbackOnly = fakeResponse() - await route.handler(fakePost({ host: 'harness.example' }, '/api/goals/create', request), loopbackOnly.response) - expect(loopbackOnly.state.status).toBe(403) - await removeLoopback() + const declared = fakeResponse() + await route.handler(fakePost({ + host: 'harness.example', + cookie: await browserCookie(connection, 'harness.example'), + }, '/api/goals/create', request), declared.response) + expect(declared.state.status).toBe(200) + await removeAuthenticated() await fiber.dispose() }) it('applies the configured trust fence and JSON envelope checks to generic channels', async () => { const ctx = new Context() const routes: WebRoute[] = [] + await ctx.plugin(MemoryCredentials) ctx.provide('webServer', fakeHttpServer(routes, []) as WebServer) const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] }) await fiber.await() @@ -345,17 +389,23 @@ describe('connection node half', () => { const remove = connection.rpc.handle('/rpc', async (endpoint) => { if (endpoint === 'fail') throw new Error('handler broke') return { ok: true, value: null } - }, { - authority: 'trusted-host', }) const route = routes.find(candidate => candidate.path === '/rpc')! + const harnessHeaders = { + host: 'harness.example', + cookie: await browserCookie(connection, 'harness.example'), + } const denied = fakeResponse() await route.handler(fakePost({ host: 'other.example' }, '/rpc/goals/create', {}), denied.response) expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' }) + const unauthenticated = fakeResponse() + await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', {}), unauthenticated.response) + expect(unauthenticated.state).toMatchObject({ status: 401, body: 'unauthorized' }) + const methodMismatch = fakeResponse() - await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', { + await route.handler(fakePost(harnessHeaders, '/rpc/goals/create', { type: 'client-request', rpcId: 'rpc-bad', method: 'other', payload: {}, }), methodMismatch.response) expect(JSON.parse(String(methodMismatch.state.body))).toMatchObject({ @@ -364,12 +414,12 @@ describe('connection node half', () => { }) for (const [request, status] of [ - [fakeRequest({ host: 'harness.example' }, '/rpc/goals/create'), 404], - [fakePost({ host: 'harness.example' }, '/outside/goals/create', {}), 404], - [fakePost({ host: 'harness.example' }, '/rpc/goals//create', {}), 404], - [fakeRawPost({ host: 'harness.example' }, '/rpc/goals/create', '{}'), 415], - [fakeRawPost({ host: 'harness.example', 'content-type': 'text/plain' }, '/rpc/goals/create', '{}'), 415], - [fakeRawPost({ host: 'harness.example', 'content-type': 'application/json; charset=utf-8' }, '/rpc/goals/create', '{'), 400], + [fakeRequest(harnessHeaders, '/rpc/goals/create'), 404], + [fakePost(harnessHeaders, '/outside/goals/create', {}), 404], + [fakePost(harnessHeaders, '/rpc/goals//create', {}), 404], + [fakeRawPost(harnessHeaders, '/rpc/goals/create', '{}'), 415], + [fakeRawPost({ ...harnessHeaders, 'content-type': 'text/plain' }, '/rpc/goals/create', '{}'), 415], + [fakeRawPost({ ...harnessHeaders, 'content-type': 'application/json; charset=utf-8' }, '/rpc/goals/create', '{'), 400], ] as const) { const response = fakeResponse() await route.handler(request, response.response) @@ -382,7 +432,7 @@ describe('connection node half', () => { [null, 'invalid-request'], ] as const) { const response = fakeResponse() - await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', body), response.response) + await route.handler(fakePost(harnessHeaders, '/rpc/goals/create', body), response.response) expect(JSON.parse(String(response.state.body))).toMatchObject({ rpcId, result: { ok: false, error: { code: 'bad-request' } }, @@ -390,28 +440,15 @@ describe('connection node half', () => { } const failed = fakeResponse() - await route.handler(fakePost({ host: 'harness.example' }, '/rpc/fail', { + await route.handler(fakePost(harnessHeaders, '/rpc/fail', { type: 'client-request', rpcId: 'rpc-fail', method: 'fail', payload: {}, }), failed.response) expect(failed.state).toMatchObject({ status: 500, body: 'handler failure: Error: handler broke' }) - expect(() => connection.rpc.handle('/api', async () => ({ ok: true, value: null }), { - authority: 'loopback', - })).toThrow('invalid or reserved RPC channel') - expect(() => connection.rpc.handle('api3', async () => ({ ok: true, value: null }), { - authority: 'loopback', - })).toThrow('invalid or reserved RPC channel') - - const removeLoopback = connection.rpc.handle('/loopback', async () => ({ ok: true, value: null }), { - authority: 'loopback', - }) - const loopbackRoute = routes.find(candidate => candidate.path === '/loopback')! - const publicResponse = fakeResponse() - await loopbackRoute.handler(fakePost({ host: 'harness.example' }, '/loopback/read', { - type: 'client-request', rpcId: 'rpc-public', method: 'read', payload: {}, - }), publicResponse.response) - expect(publicResponse.state.status).toBe(403) - await removeLoopback() + expect(() => connection.rpc.handle('/api', async () => ({ ok: true, value: null }))) + .toThrow('invalid or reserved RPC channel') + expect(() => connection.rpc.handle('api3', async () => ({ ok: true, value: null }))) + .toThrow('invalid or reserved RPC channel') await remove() await fiber.dispose() }) @@ -437,10 +474,16 @@ describe('connection node half over a real HTTP server', () => { } /** One real request; `host` spoofs the authority the way a LAN client's browser would send it. */ - function call(port: number, method: string, host: string): Promise { + function call(port: number, method: string, host: string, cookie?: string): Promise { return new Promise((resolve, reject) => { const request = httpRequest( - { host: '127.0.0.1', port, path: `${API_PATH}/${method}`, method: 'GET', headers: { host } }, + { + host: '127.0.0.1', + port, + path: `${API_PATH}/${method}`, + method: 'GET', + headers: { host, ...cookie === undefined ? {} : { cookie } }, + }, (response) => { response.resume() response.on('end', () => { resolve(response.statusCode ?? 0) }) @@ -451,40 +494,37 @@ describe('connection node half over a real HTTP server', () => { }) } - it('answers a declared LAN authority with 403 on every configuration method, over real HTTP', async () => { - // The fence's input is a real IncomingMessage parsed by Node from the - // wire, not a hand-assembled object: the Host header a LAN browser sends - // is exactly what decides loopback-only here, so the boundary is asserted - // against the parse the server actually performs. - const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] }) + it('requires authentication uniformly over a real HTTP request', async () => { + // A real IncomingMessage pins the exploit boundary: a client-controlled + // Host naming loopback passes the rebinding fence but never authenticates. + const { routes, connection, dispose } = await mounted({ trustedHosts: ['harness.example'] }) const { port, close } = await serve(routes) try { - // Reads are as privileged as writes: describe returns the exposed - // configuration, and credentials.describe probes arbitrary env-var names. - for (const method of [ + const methods = [ 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', 'credentials.describe', 'credentials.set', 'credentials.unset', 'host.pickDirectory', 'host.openPath', - // Carries a draft credential and turns the host into a fetcher for a - // URL the caller picked: an anonymous LAN caller must not reach it. 'llm.discoverModels', 'agentPreset.read', 'agentPreset.copy', 'agentPreset.openDocument', 'agentPreset.remove', - ]) { - expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403]) + 'llm.providers', 'llm.models', 'agentPreset.list', 'agentPreset.select', + ] + for (const method of methods) { + expect([method, await call(port, method, 'localhost')]).toEqual([method, 401]) + expect([method, await call(port, method, 'harness.example')]).toEqual([method, 401]) } - // The model catalog stays reachable for the same authority: a LAN - // client's model picker needs it, and it carries no key or endpoint - // state (404 is the empty proxy's carrier answer — the fence passed). - // `agentPreset.list` joins the model catalog for the same reason: ids and - // trust only, and a LAN client's preset picker needs it. `select` is - // reachable too: `session.create` already takes an `agentPreset`, and the - // deployment's own default already carries bash, so pinning the switch - // would be a fence beside an open gate. - for (const method of ['llm.providers', 'llm.models', 'agentPreset.list', 'agentPreset.select']) { - expect([method, await call(port, method, 'harness.example')]).toEqual([method, 404]) + expect(await call(port, 'settings.describe', 'other.example')).toBe(403) + + const declaredCookie = await browserCookie(connection, 'harness.example') + for (const method of methods) { + expect([method, await call(port, method, 'harness.example', declaredCookie)]).toEqual([method, 404]) } - // Loopback reaches everything, configuration included. - expect(await call(port, 'settings.describe', `127.0.0.1:${String(port)}`)).toBe(404) + const loopbackAuthority = `127.0.0.1:${String(port)}` + expect(await call( + port, + 'settings.describe', + loopbackAuthority, + await browserCookie(connection, loopbackAuthority), + )).toBe(404) } finally { await close() await dispose() diff --git a/packages/client/connection/tsconfig.host.json b/packages/client/connection/tsconfig.host.json index ed5305797d..ad9bacc704 100644 --- a/packages/client/connection/tsconfig.host.json +++ b/packages/client/connection/tsconfig.host.json @@ -8,6 +8,7 @@ "files": [ "src/api-path.ts", "src/api-request-trust.ts", + "src/browser-auth.ts", "src/http-bridge.ts", "src/index.ts", "src/invariant.ts", @@ -19,6 +20,9 @@ { "path": "../../attachment/attachment" }, + { + "path": "../../credentials/credentials" + }, { "path": "../../host/apiproxy" }, diff --git a/packages/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml index 096e4ab66a..dca2ac21ab 100644 --- a/packages/client/locale/README.i18n.yaml +++ b/packages/client/locale/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/client/locale/README.md -README.md: 4f54a9a2c5aa9d39ee3c93a4d8f2c6deb538b792 -README.zh.md: d4c4833b2334c1b21f31e9b6f4d5116bbbb8591d +README.md: 1fa0262e7c1e8aa50f12fd2b7533d97ea4737199 +README.zh.md: 45cf657d5f8455fe128f378c2a2b77d919a441da diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md index 4f54a9a2c5..1fa0262e7c 100644 --- a/packages/client/locale/README.md +++ b/packages/client/locale/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Locale plugin: LocaleRuntime — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `en` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. Remote browsers retain only a process-local selection because the settings API is loopback-only. `locale/change` fires on switches, and the plugin points `` at the active locale (`zh-CN`/`en`) on activation and on every switch. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS`; lookup chain ns → common → en → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). Product-authored Client UI text must enter through these typed dictionaries or an already-localized primitive prop; `verify-client-ui-i18n` enforces that source ownership ([decision](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. +Locale plugin: LocaleRuntime — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `en` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. The Client keeps Host settings persistence disabled on non-loopback pages, so their locale selection remains process-local even though Connection authenticates every API method. `locale/change` fires on switches, and the plugin points `` at the active locale (`zh-CN`/`en`) on activation and on every switch. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS`; lookup chain ns → common → en → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). Product-authored Client UI text must enter through these typed dictionaries or an already-localized primitive prop; `verify-client-ui-i18n` enforces that source ownership ([decision](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. ## Model Experience diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md index d4c4833b23..45cf657d5f 100644 --- a/packages/client/locale/README.zh.md +++ b/packages/client/locale/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -locale 插件:LocaleRuntime——`zh`/`en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `en`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。settings API 仅限回环请求,因此远程浏览器的选择仅保留在进程内。`locale/change` 仅在切换语言时触发;插件会在激活时以及每次切换时把 `` 指向当前 locale(`zh-CN`/`en`)。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS`;查找链 ns → common → en → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。产品编写的 Client UI 文本必须经这些 typed 字典或已本地化原子组件 prop 进入展示;`verify-client-ui-i18n` 会强制这项源码归属([决策](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md))。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md)拥有。 +locale 插件:LocaleRuntime——`zh`/`en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `en`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。Client 在非 loopback 页面禁用 Host settings 持久化,因此这些页面的 locale 选择仍只保留在进程内,尽管 Connection 会认证每个 API 方法。`locale/change` 仅在切换语言时触发;插件会在激活时以及每次切换时把 `` 指向当前 locale(`zh-CN`/`en`)。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS`;查找链 ns → common → en → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。产品编写的 Client UI 文本必须经这些 typed 字典或已本地化原子组件 prop 进入展示;`verify-client-ui-i18n` 会强制这项源码归属([决策](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md))。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md)拥有。 ## 模型体验 diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 15b1810f04..64d8176b13 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/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/client/ui-agent-preset/README.md -README.md: 35b667a1f02cfb56567c71350c117194e08aad6b -README.zh.md: 03420776bd3af6d0c7b6be7a9f88c1db8933901c +README.md: b7cd57fed5ea08e272c97df5bc9e96d481ff6a3a +README.zh.md: 4f828c3eec977a57ea34b20ee21ef23c0c7fc922 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 35b667a1f0..b7cd57fed5 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -50,7 +50,7 @@ A roster row carrying `broken` (the host's shape check found the composition mis Setting the default writes the `agent-presets` settings namespace, which the host exposes to configuration clients ([`dsh-apiproxy`](../../host/apiproxy/README.md) keeps an explicit allowlist — a namespace outside it makes a picker move and then silently forget). -`agentPreset.read`, `copy`, `openDocument`, and `remove` are loopback-pinned ([`dsh-client-connection`](../connection/README.md)): a composition names the plugins a session runs, so reading one is reconnaissance, and the rest manage the roster and drive the host desktop. `agentPreset.list` is not — it carries ids, trust, and the two path-free capability flags, and a LAN client's picker needs it. +[`dsh-client-connection`](../connection/README.md) authenticates `agentPreset.read`, `copy`, `openDocument`, `remove`, `list`, and every other Host API method with the same browser session. A composition still names the plugins a session runs, so reading one is reconnaissance, while copy/remove/openDocument manage the roster and drive the host desktop. ## When the surfaces are absent diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index 03420776bd..4f828c3eec 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -50,7 +50,7 @@ preset 自行发布描述,长度不限,而网格让每一行卡片等高— 设置默认值写入的是 `agent-presets` settings 命名空间,宿主需将其暴露给配置客户端([`dsh-apiproxy`](../../host/apiproxy/README.zh.md) 维护一份显式白名单——不在其中的命名空间会让选择器动一下然后悄悄忘记)。 -`agentPreset.read`、`copy`、`openDocument` 与 `remove` 被固定在环回地址(见 [`dsh-client-connection`](../connection/README.zh.md)):组装指明了一个会话所运行的插件,因此读取它是侦察,其余几个则管理名单并驱动宿主桌面。`agentPreset.list` 不在其中——它携带 id、信任级别与两个不含路径的能力标志,而局域网客户端的选择器需要它。 +[`dsh-client-connection`](../connection/README.zh.md) 用同一浏览器会话认证 `agentPreset.read`、`copy`、`openDocument`、`remove`、`list` 及其他所有 Host API 方法。组装仍指明一个会话所运行的插件,因此读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面。 ## 何时不显示这些表层 diff --git a/packages/client/ui-agent-preset/src/client/settings-store.ts b/packages/client/ui-agent-preset/src/client/settings-store.ts index d0a89f358b..8c7e7444f1 100644 --- a/packages/client/ui-agent-preset/src/client/settings-store.ts +++ b/packages/client/ui-agent-preset/src/client/settings-store.ts @@ -168,7 +168,7 @@ export interface AgentPresetSettingsState { error: string | null /** * Whether this browser may persist the choice at all. `settings.describe` is - * loopback-only and reports a read-only provider as `writable: false`; the + * enabled Host settings path reports a read-only provider as `writable: false`; the * row then shows the current default and disables the control rather than * offering a write the gateway will refuse. */ diff --git a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts index 9d7baa26d1..6d66b4ee6d 100644 --- a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts @@ -42,7 +42,7 @@ function fakeApi( : { rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failList, details: {} } } }), }, settings: { - // Loopback-only in production; a read-only provider answers writable:false + // Host persistence is enabled in production only on the selected client path; a read-only provider answers writable:false // and the row disables its control instead of offering a refused write. describe: () => Promise.resolve({ rpcId: 'r', @@ -75,7 +75,7 @@ describe('the agent-preset settings controller', () => { await controller.load() - // `settings.describe` is loopback-only and reports a read-only provider; + // The enabled `settings.describe` path reports a read-only provider; // offering a control whose write answers `settings-rejected` would promise // a switch the host refuses. expect(controller.store.getSnapshot().writable).toBe(false) diff --git a/packages/client/ui-permission-presets/src/client/settings-store.ts b/packages/client/ui-permission-presets/src/client/settings-store.ts index 2f78d7c4b3..2015cfe0b9 100644 --- a/packages/client/ui-permission-presets/src/client/settings-store.ts +++ b/packages/client/ui-permission-presets/src/client/settings-store.ts @@ -168,7 +168,7 @@ export class PermissionPresetSettingsController { if (this.disposed || this.saving) return const mirrored = this.describeFace.getSnapshot() if (mirrored.status === 'unavailable') { - // The terminal non-loopback state: settings RPCs are loopback-only, so + // The terminal non-loopback state: this client keeps Host persistence disabled, so // the row hides itself exactly like an unserved namespace. this.store.update((state) => { state.status = 'unavailable' diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 77b5c4e2d3..e3ddfd3faf 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/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/client/ui-settings-general/README.md -README.md: c59b77617cfd5848553ba340e97bafe37b7b2a2f -README.zh.md: ea4896284b677a7b430cceb949b53aa6e3a5a242 +README.md: b625f4259869de1449ff18cceeac7f0c6f7c7f0d +README.zh.md: 9381bec1cc0b2a33dd4e8afac20ff6edcfc38dd9 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index c59b77617c..b625f42598 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -6,7 +6,7 @@ Settings shell, ownerless copy, and durable product-onboarding namespace. It occ The shell ships no onboarding copy of its own — all text arrives from registrants. Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency). The onboarding ledger projects in ascending order and mounts exactly one step at a time. Visible steps own their dialog chrome and app-root `inert` lifecycle; a mounted step still resolving private facts renders null, so nothing paints or blocks while it decides. The active registrant receives its id, `complete()`, and an `openSection(id)` callback; completing or skipping transfers ownership to the next entry. Registrants own durable completion, capability readiness, copy, mutations, and their visible wrapper, so independently registered flows cannot stack and the shell does not become a second configuration fact source. -A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read. +On a loopback page, the Client loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, browser-authenticated `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Non-loopback pages retain the Client policy that withholds this native action and its settings read. The Host half registers `ui-onboarding` in the user-settings seam. The welcome step contributed by `ui-settings-models` reads and writes its `welcomeNoticeVersion` through the existing public settings boundary; the shell itself remains policy-free. diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index ea4896284b..9381bec1cc 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -6,7 +6,7 @@ 外壳不自带引导文案:所有文本都来自注册方。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。首次使用引导记录按升序投影,每次只挂载一个步骤;可见步骤自行持有弹窗框架和应用根节点 `inert` 生命周期。已挂载但仍在判定私有事实的步骤渲染 null,因此判定期间不绘制也不阻塞任何内容。当前注册方会收到该条目的 id、`complete()` 和 `openSection(id)` 回调;完成或跳过当前步骤后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案、变更操作以及可见包装均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。 -回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联;WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权设置读取。 +在 loopback 页面上,Client 通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且经浏览器认证的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联;WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。非 loopback 页面保留 Client 策略,不提供该原生操作及其 settings 读取。 宿主端在用户设置 seam 中注册 `ui-onboarding`。`ui-settings-models` 提供的欢迎步骤通过既有公开 settings 边界读写其中的 `welcomeNoticeVersion`;外壳本身仍不持有产品策略。 diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index c91b608509..d26af3c58a 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -168,7 +168,7 @@ describe('ui-settings-general apply', () => { await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) }) }) - it('withholds the loopback-only document action off-loopback', async () => { + it('withholds the Host document action off-loopback', async () => { const b = await bench(false) declare(b.slots) const fiber = b.ctx.plugin({ inject: [...inject], apply }) diff --git a/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts b/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts index 52d2771b0b..efcb2aefdb 100644 --- a/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts @@ -49,7 +49,7 @@ function buildWelcome( } describe('WelcomeNoticeStore', () => { - it('acknowledges in memory without calling loopback-only settings APIs', async () => { + it('acknowledges in memory while Host settings persistence is disabled', async () => { const describeCall = vi.fn() const mutate = vi.fn() const { controller } = buildWelcome({ describe: describeCall, mutate }, 'memory') diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index c4188a0eaf..98fc9433d0 100644 --- a/packages/client/ui-settings/README.i18n.yaml +++ b/packages/client/ui-settings/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/client/ui-settings/README.md -README.md: 990469573309b3a92b5eeb8bc41d89b226dbd0d4 -README.zh.md: a0ce9d5cf6d4633cec9fbf466083c7a11a947e8e +README.md: 7e4f3e20ad84e05ee8ec17e37d6922c6d7cf9fce +README.zh.md: cc1c0c6bd94cf770a39d6833ef6efc5d2bd227df diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index 9904695733..7e4f3e20ad 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -15,5 +15,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Remote browsers get no durable settings** — the settings RPCs are loopback-only, so a scope bound in a non-loopback browser starts `unavailable` and never crosses the wire; every row it backs is inert there. +- **Non-loopback pages get no durable settings** — this Client keeps Host persistence disabled there, so a scope starts `unavailable` and never crosses the wire; every row it backs is inert even though Connection authentication covers the API. - **One field per write** — `set` sends a single `set` op, so a row that must move two fields together has no transaction and publishes two revisions. diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index a0ce9d5cf6..cc1c0c6bd9 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/packages/client/ui-settings/README.zh.md @@ -15,5 +15,5 @@ ## 已知限制与暂缓事项 -- **远程浏览器没有持久化设置**:设置 RPC 仅限 loopback,因此在非 loopback 浏览器中绑定的 scope 以 `unavailable` 起步且从不跨线路,它支撑的每一行在那里都是无效的。 +- **非 loopback 页面没有持久化设置**:本 Client 在那里禁用 Host 持久化,因此 scope 以 `unavailable` 起步且从不跨线路;尽管 Connection 认证覆盖 API,它支撑的每一行仍在那里无效。 - **每次写入仅一个字段**:`set` 只发送单个 `set` op,因此需要同时改动两个字段的行没有事务可用,会发布两个 revision。 diff --git a/packages/client/ui-settings/src/client/settings-mirror.ts b/packages/client/ui-settings/src/client/settings-mirror.ts index 81b190e034..bd4aedfc16 100644 --- a/packages/client/ui-settings/src/client/settings-mirror.ts +++ b/packages/client/ui-settings/src/client/settings-mirror.ts @@ -79,7 +79,7 @@ export class SettingsDescribeMirror implements SettingsDescribeFace { /** * @param api - settings wire face. - * @param persistence - remote browsers stay process-local because settings RPCs are loopback-only. + * @param persistence - client-selected Host persistence; non-loopback pages may remain process-local. */ constructor( private readonly api: SettingsFace, diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index a49b8ba707..1b06940577 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -57,7 +57,7 @@ export class SettingsScopeController implements SettingsScope { * @param api - settings wire face (writes only; reads ride the mirror). * @param spec - namespace identity and optional narrowing decoder. * @param mirror - the shared describe mirror this scope derives from. - * @param persistence - remote browsers remain process-local because settings RPCs are loopback-only. + * @param persistence - client-selected Host persistence; non-loopback pages may remain process-local. * @param schema - settings-owned schema operations. */ constructor( diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index 9d080a036f..cdc4c96fa1 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/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/client/ui-theme/README.md -README.md: a9f0eb428789bc117e2fcbbdd2366066e0b994cc -README.zh.md: 1447536c3e12a415c3ca241e4fe04a636ecaa6fd +README.md: 0c9bdf3ee3d99aee1e05453e8f455c1309deb284 +README.zh.md: 033bf7867ac19c664e6303377cae545876ade6f1 diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index a9f0eb4287..0c9bdf3ee3 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Theme plugin: ThemeRuntime over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the live theme preference (`light`/`dark`/`system`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). A loopback browser provides the service immediately with `system`, then loads `ui-theme.preference` in the background and writes each built-in selection through the Host settings API, whose local provider stores it in `$DSH_HOME/settings.yaml` by default; pushed settings changes and reconnects refetch it, rapid selections are serialized in gesture order with namespace revisions, and a rejected latest write reloads the durable value. A remote browser cannot access the privileged settings API, so its selection remains process-local. Third-party registered theme ids remain an in-process extension and do not cross the built-in settings schema; removing one never overwrites the last durable built-in preference. The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. +Theme plugin: ThemeRuntime over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the live theme preference (`light`/`dark`/`system`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). A loopback browser provides the service immediately with `system`, then loads `ui-theme.preference` in the background and writes each built-in selection through the Host settings API, whose local provider stores it in `$DSH_HOME/settings.yaml` by default; pushed settings changes and reconnects refetch it, rapid selections are serialized in gesture order with namespace revisions, and a rejected latest write reloads the durable value. The Client keeps Host settings persistence disabled on non-loopback pages, so their selections remain process-local even though Connection authentication applies to every API method. Third-party registered theme ids remain an in-process extension and do not cross the built-in settings schema; removing one never overwrites the last durable built-in preference. The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. When the host composition includes an HTTP server, the host half injects a synchronous bootstrap immediately after the opening `` tag. Each index response embeds the registered Host setting for `ui-theme.preference`, or `system` when no settings provider is present; the browser resolves `system` from the OS scheme, then sets `color-scheme` and `body[data-ds-dark-theme]` before the shell loading page renders. Compositions without an HTTP server remain unaffected, and ThemeRuntime and ui-layout remain authoritative for client state and subsequent DOM updates after the plugin tree activates. diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index 1447536c3e..033bf7867a 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeRuntime。该服务拥有实时主题偏好(`light`/`dark`/`system`),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。来自回环地址的浏览器会先以 `system` 立即提供该服务,随后在后台加载 `ui-theme.preference`,并将每次内置主题选择通过 Host settings API 写入;其本地提供方默认将设置存入 `$DSH_HOME/settings.yaml`。收到推送的 settings 变更时或重连后,浏览器都会重新拉取该设置;连续快速选择会按操作顺序携带 namespace revision 串行写入,最新写入被拒时则重新加载持久化值。远程浏览器无法访问特权 settings API,因此它的选择仅保留在进程内。已注册的第三方主题 id 仍是进程内扩展,不会跨越内置 settings schema;移除其中任意一个都绝不会覆盖最后一个持久化的内置偏好。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md)拥有。 +主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeRuntime。该服务拥有实时主题偏好(`light`/`dark`/`system`),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。来自回环地址的浏览器会先以 `system` 立即提供该服务,随后在后台加载 `ui-theme.preference`,并将每次内置主题选择通过 Host settings API 写入;其本地提供方默认将设置存入 `$DSH_HOME/settings.yaml`。收到推送的 settings 变更时或重连后,浏览器都会重新拉取该设置;连续快速选择会按操作顺序携带 namespace revision 串行写入,最新写入被拒时则重新加载持久化值。Client 在非 loopback 页面禁用 Host settings 持久化,因此这些页面的选择仍只保留在进程内,尽管 Connection 会认证每个 API 方法。已注册的第三方主题 id 仍是进程内扩展,不会跨越内置 settings schema;移除其中任意一个都绝不会覆盖最后一个持久化的内置偏好。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md)拥有。 当主机组合包含 HTTP 服务器时,主机侧紧接 `` 起始标签注入同步引导代码。每份 index 响应会嵌入已注册的 Host 设置 `ui-theme.preference`,没有 settings provider 时则嵌入 `system`;浏览器按操作系统配色解析 `system`,随后在外壳加载页面渲染前设置 `color-scheme` 和 `body[data-ds-dark-theme]`。不含 HTTP 服务器的组合不受影响,插件树激活后,ThemeRuntime 与 ui-layout 仍分别是客户端状态和后续 DOM 更新的权威来源。 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index a369fe508f..c07785d98f 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/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/host/apiproxy/README.md -README.md: 4cf11c7ffe55484fd2c27e597931f22f8f861a14 -README.zh.md: 934a00f36ab764f13336e26aea5aa7a274de17e5 +README.md: 69826d76b437ea482655d91a930310185079414c +README.zh.md: c7f5d5b579e20236fe7a9f79ed1db8417b641c64 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 4cf11c7ffe..69826d76b4 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -50,15 +50,15 @@ A stale continuation discards every partial result, deduplication entry, and cur Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method does not use the default 30-second unary timeout, while caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request. -`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). For `.html`, `.htm`, `.xhtml`, and `.svg`, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item`, including browser-renderable documents, instead of assuming a Linux desktop association. `host.describe.home` is the host account home directory. The Web client uses it to display POSIX home-rooted paths as `~`; Windows values are still reported and are not abbreviated. `host.describe.canOpenPath` advertises whether that handoff can reach a user-visible desktop: explicit gateway `nativeOpen` wins, an injected opener is usable by definition, and platform detection otherwise accepts macOS, Windows, WSL, or Linux with a display while rejecting headless/container Linux. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`; clients combine both facts before presenting a native action. +`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). For `.html`, `.htm`, `.xhtml`, and `.svg`, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item`, including browser-renderable documents, instead of assuming a Linux desktop association. `host.describe.home` is the host account home directory. The Web client uses it to display POSIX home-rooted paths as `~`; Windows values are still reported and are not abbreviated. `host.describe.canOpenPath` advertises whether that handoff can reach a user-visible desktop: explicit gateway `nativeOpen` wins, an injected opener is usable by definition, and platform detection otherwise accepts macOS, Windows, WSL, or Linux with a display while rejecting headless/container Linux. The browser carrier applies the same Host/Origin checks and signed-cookie authentication as every Host API method; clients combine the capability facts before presenting a native action. The `agentPreset.list` domain exposes the deployment's preset roster so a browser can offer a choice when starting a session; each row carries its `trust` (a `user` preset is exactly as privileged as the plugins it names), whether it is the current default, and — when the preset cannot compose a session — a `broken` reason, because a damaged directory still occupies its id and a surface must be able to show and delete it rather than offer it and fail the session start. A deployment composing no presets answers with an empty roster rather than an error, because sharing the host composition is a valid deployment. `agentPreset.select` recomposes one session's agent from a different preset, and is allowed only while the session is blank: once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so the attempt answers `agent-preset-locked`. The agent and the session survive — only the composition is swapped, and a failed swap restores the previous one. -`agentPreset.read`, `copy`, `openDocument`, and `remove` manage the compositions themselves. `read` reports the text with its `trust`, for the read-only viewer. Authoring is copy-only: `copy` takes `{ from, agentPreset, name? }` — two ids the Host resolves against its own roots plus an optional display name — and copies the source's whole directory, so no composition text crosses the wire and a copy is exactly as loadable as its source; an uncontainable or already-taken id answers `agent-preset-invalid`, and `remove` refuses a shipped preset as `agent-preset-read-only`. `openDocument` hands one locally authored preset's DIRECTORY to the platform opener — the request carries an id, never a path, so no browser payload can select an arbitrary filesystem target; where the deployment has no native opener the reply is `{ opened: false, path }` for the surface to show as text, a shipped preset is refused like `remove`, and the gateway's `nativeOpen` config pins the capability where platform detection (`canOpenNativePath`) would mislead. These four are loopback-pinned in [`dsh-client-connection`](../../client/connection/README.md): a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop. `list` and `select` stay ordinary — the roster carries ids and trust and every preset picker needs it, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash. `list` reports two path-free capability flags: `authorable`, whether the deployment configures a root a new preset could be copied to, and `hasDocument`, whether `openDocument` would open natively rather than answer a path. +`agentPreset.read`, `copy`, `openDocument`, and `remove` manage the compositions themselves. `read` reports the text with its `trust`, for the read-only viewer. Authoring is copy-only: `copy` takes `{ from, agentPreset, name? }` — two ids the Host resolves against its own roots plus an optional display name — and copies the source's whole directory, so no composition text crosses the wire and a copy is exactly as loadable as its source; an uncontainable or already-taken id answers `agent-preset-invalid`, and `remove` refuses a shipped preset as `agent-preset-read-only`. `openDocument` hands one locally authored preset's DIRECTORY to the platform opener — the request carries an id, never a path, so no browser payload can select an arbitrary filesystem target; where the deployment has no native opener the reply is `{ opened: false, path }` for the surface to show as text, a shipped preset is refused like `remove`, and the gateway's `nativeOpen` config pins the capability where platform detection (`canOpenNativePath`) would mislead. [`dsh-client-connection`](../../client/connection/README.md) authenticates these methods with `list`, `select`, and the complete Host API. A composition still names the plugins a session runs, so reading one is reconnaissance and copy/remove/openDocument manage the roster and drive the host desktop. `list` reports two path-free capability flags: `authorable`, whether the deployment configures a root a new preset could be copied to, and `hasDocument`, whether `openDocument` would open natively rather than answer a path. The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only invocation path is the slash gesture. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `` context, so every entry point (Web, TUI, and ACP) shares one deterministic path—including for hand-typed text—with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `commands/change` rides the forwarded-event frame as the registry-wide catalog invalidation signal: clients refetch `command.list` instead of diffing. Forwarded `agent-preset/selected` is its per-session counterpart, emitted from the logged selection commit: recomposing a blank session's agent re-parents its scope without registering anything, so both catalogs that session's composition decides (`command.list`, `skill.list`) go stale with no registry change to announce it. -The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves every registered namespace: a plugin distributed outside this repository becomes browser-configurable by registering its section, with no change here, and this proxy adds no boundary of its own — a name no registration answers folds into the seam's own `settings-rejected`. Which surface renders a namespace is the browser's decision (the plugin configuration page keys its cards on the namespace), never this proxy's. `settings.describe` returns each namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Invalidations keep every surface converged without polling. `settings/document-updated` and `credentials/reference-updated` ride the verbatim forwarded-event frame (see below), so a raw settings change whose resolved value is unchanged still reaches clients, and a credential invalidation still carries reference names only, never values. `llm/adapters-updated` is forwarded beside `settings/document-updated`; concrete model consumers subscribe to both owner events directly because topology commits and settings documents can independently change their directories. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. +The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves every registered namespace: a plugin distributed outside this repository becomes browser-configurable by registering its section, with no change here, and this proxy adds no boundary of its own — a name no registration answers folds into the seam's own `settings-rejected`. Which surface renders a namespace is the browser's decision (the plugin configuration page keys its cards on the namespace), never this proxy's. `settings.describe` returns each namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Invalidations keep every surface converged without polling. `settings/document-updated` and `credentials/reference-updated` ride the verbatim forwarded-event frame (see below), so a raw settings change whose resolved value is unchanged still reaches clients, and a credential invalidation still carries reference names only, never values. `llm/adapters-updated` is forwarded beside `settings/document-updated`; concrete model consumers subscribe to both owner events directly because topology commits and settings documents can independently change their directories. Connection authenticates the whole configuration plane, reads and native actions included, with the same browser session as every Host API method. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. ## Carrier layer (`/client` + root) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 934a00f36a..c7f5d5b579 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -50,15 +50,15 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr 目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.zh.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,不使用默认的 30 秒一元调用超时,而调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable`/`directory-exists`/`directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。 -`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。对于 `.html`、`.htm`、`.xhtml` 与 `.svg`,macOS 和桌面 Linux 会优先使用能够确定的默认浏览器;无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。`host.describe.home` 是宿主账户的家目录。Web 客户端用它把 POSIX 家目录路径显示为 `~`;Windows 值仍会上报,但不会缩写。`host.describe.canOpenPath` 会宣告这次交接能否抵达用户可见的桌面:网关显式配置的 `nativeOpen` 优先,注入的 opener 按定义可用,否则平台检测接受 macOS、Windows、WSL 或带 display 的 Linux,并拒绝 headless/容器 Linux。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制;客户端会组合这两个事实后再呈现原生操作。 +`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。对于 `.html`、`.htm`、`.xhtml` 与 `.svg`,macOS 和桌面 Linux 会优先使用能够确定的默认浏览器;无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。`host.describe.home` 是宿主账户的家目录。Web 客户端用它把 POSIX 家目录路径显示为 `~`;Windows 值仍会上报,但不会缩写。`host.describe.canOpenPath` 会宣告这次交接能否抵达用户可见的桌面:网关显式配置的 `nativeOpen` 优先,注入的 opener 按定义可用,否则平台检测接受 macOS、Windows、WSL 或带 display 的 Linux,并拒绝 headless/容器 Linux。浏览器载体对其施加与每个 Host API 方法相同的 Host/Origin 校验和签名 cookie 认证;客户端会组合能力事实后再呈现原生操作。 `agentPreset.list` 领域向浏览器暴露部署的 preset 名单,使其在开启会话时能够提供选择;每一行携带它的 `trust`(`user` preset 的权限恰好等于它所引用的插件)、它是否为当前默认值,以及——当该 preset 无法组装会话时——一条 `broken` 原因:损坏的目录仍占着它的 id,界面必须能展示并删除它,而不是把它端出来然后在会话启动时失败。未组装任何 preset 的部署返回空名单而非错误,因为共用宿主组装本身就是一种有效部署。`agentPreset.select` 用另一个 preset 重组某个会话的 agent,且仅在会话空白时允许:一旦跑过任何轮次,那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录的工具调用,此时返回 `agent-preset-locked`。agent 与会话都不销毁——只替换组装,且替换失败会恢复原来的组装。 -`agentPreset.read`、`copy`、`openDocument` 与 `remove` 负责管理组装本身。`read` 返回文本连同它的 `trust`,供只读查看器使用。创作只有复制一种写入:`copy` 接收 `{ from, agentPreset, name? }`——两个由 Host 对照自身根目录解析的 id 加一个可选显示名——并整目录复制来源,因此组装文本不经过传输层,副本与其来源同等可加载;不可约束或已被占用的 id 回答 `agent-preset-invalid`,`remove` 对随附 preset 回答 `agent-preset-read-only`。`openDocument` 把一个本地创作 preset 的**目录**交给平台打开器——请求只携带 id、绝不携带路径,因此没有任何浏览器载荷能选中任意文件系统目标;部署没有原生打开器时回答 `{ opened: false, path }` 供界面以文本展示,随附 preset 与 `remove` 一样被拒绝,而网关的 `nativeOpen` 配置可在平台探测(`canOpenNativePath`)失真处钉死该能力。这四个方法在 [`dsh-client-connection`](../../client/connection/README.zh.md) 中被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面。`list` 与 `select` 保持为普通方法——名单只携带 id 与信任级别,每个 preset 选择器都需要它;而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash。`list` 报告两个不含路径的能力标志:`authorable`,即部署是否配置了可供复制新 preset 的根目录;`hasDocument`,即 `openDocument` 会原生打开、还是回答一个路径。 +`agentPreset.read`、`copy`、`openDocument` 与 `remove` 负责管理组装本身。`read` 返回文本连同它的 `trust`,供只读查看器使用。创作只有复制一种写入:`copy` 接收 `{ from, agentPreset, name? }`——两个由 Host 对照自身根目录解析的 id 加一个可选显示名——并整目录复制来源,因此组装文本不经过传输层,副本与其来源同等可加载;不可约束或已被占用的 id 回答 `agent-preset-invalid`,`remove` 对随附 preset 回答 `agent-preset-read-only`。`openDocument` 把一个本地创作 preset 的**目录**交给平台打开器——请求只携带 id、绝不携带路径,因此没有任何浏览器载荷能选中任意文件系统目标;部署没有原生打开器时回答 `{ opened: false, path }` 供界面以文本展示,随附 preset 与 `remove` 一样被拒绝,而网关的 `nativeOpen` 配置可在平台探测(`canOpenNativePath`)失真处钉死该能力。[`dsh-client-connection`](../../client/connection/README.zh.md) 用与 `list`、`select` 及完整 Host API 相同的浏览器会话认证这些方法。组装仍指明一个会话所运行的插件,因此读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面。`list` 报告两个不含路径的能力标志:`authorable`,即部署是否配置了可供复制新 preset 的根目录;`hasDocument`,即 `openDocument` 会原生打开、还是回答一个路径。 `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的调用路径。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `` 上下文作答,因此所有入口(Web、TUI 与 ACP(Agent Client Protocol))共享同一条确定性路径,手动键入的文本也走该路径,且没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`commands/change` 搭乘转发事件帧作为注册表级目录失效信号:客户端重新拉取 `command.list` 而不是做差分。转发的 `agent-preset/selected` 是它按会话粒度的对应物,由落账的选择提交点发出:重组空会话的 agent 只是重新挂接其 scope,不产生任何注册,因此该会话组成所决定的两份目录(`command.list`、`skill.list`)都会失效,却没有任何注册表变化来宣告它。 -`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于每一个已注册 namespace:在本仓库之外分发的插件只要注册自己的分节即可变得可从浏览器配置,无需改动这里;本代理也不再自设边界——没有任何注册应答的名字会折叠为 seam 自己的 `settings-rejected`。由哪个界面渲染某个 namespace 是浏览器的决定(插件配置页按 namespace 为其卡片编键),从不由本代理决定。`settings.describe` 为每个 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。失效通知让每个面无需轮询即保持收敛。`settings/document-updated` 与 `credentials/reference-updated` 搭乘原样转发事件帧(见下),因此解析值未变的原始设置变更同样能到达客户端,凭据失效通知也仍然只带引用名、绝不带值。`llm/adapters-updated` 与 `settings/document-updated` 一并原样转发;具体模型消费方直接订阅这两个 owner 事件,因为拓扑提交和设置文档都能独立改变其目录。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据提供方的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 +`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于每一个已注册 namespace:在本仓库之外分发的插件只要注册自己的分节即可变得可从浏览器配置,无需改动这里;本代理也不再自设边界——没有任何注册应答的名字会折叠为 seam 自己的 `settings-rejected`。由哪个界面渲染某个 namespace 是浏览器的决定(插件配置页按 namespace 为其卡片编键),从不由本代理决定。`settings.describe` 为每个 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。失效通知让每个面无需轮询即保持收敛。`settings/document-updated` 与 `credentials/reference-updated` 搭乘原样转发事件帧(见下),因此解析值未变的原始设置变更同样能到达客户端,凭据失效通知也仍然只带引用名、绝不带值。`llm/adapters-updated` 与 `settings/document-updated` 一并原样转发;具体模型消费方直接订阅这两个 owner 事件,因为拓扑提交和设置文档都能独立改变其目录。Connection 用与每个 Host API 方法相同的浏览器会话认证整个配置面,包括读取与原生操作。未装 settings 或凭据提供方的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 ## 载体层(`/client` + 根路径) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index a236782544..1a8835383a 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -745,10 +745,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } }, - // Authoring is privileged (see PRIVILEGED_METHODS in dsh-client-connection): - // a composition names the plugins a session runs, so reading one is + // A composition names the plugins a session runs, so reading one is // reconnaissance, and copy/remove/openDocument manage the roster and - // drive the host desktop. + // drive the host desktop. Connection authenticates the complete API. async read(request) { const { agentPreset } = request.payload const presets = ctx.get('agentPresets') diff --git a/packages/host/apiproxy/src/api/agent-presets.ts b/packages/host/apiproxy/src/api/agent-presets.ts index fe33062df6..3918de5894 100644 --- a/packages/host/apiproxy/src/api/agent-presets.ts +++ b/packages/host/apiproxy/src/api/agent-presets.ts @@ -2,11 +2,11 @@ * agent-presets domain contract: the roster a browser offers when starting a * session, plus the authoring calls behind it. * - * `list` is ordinary: it carries ids and trust, and every preset picker needs - * it. The authoring calls are privileged and loopback-pinned — a composition - * names the plugins a session runs, so reading one is reconnaissance, and - * although authoring is copy-only (no caller supplies composition text or a - * path), copying and deleting still rearrange what the deployment offers. + * A composition names the plugins a session runs, so reading one is + * reconnaissance; although authoring is copy-only (no caller supplies + * composition text or a path), copying and deleting still rearrange what the + * deployment offers. Connection authenticates these calls with the complete + * Host API rather than assigning a separate method tier. */ import type { SessionId } from '@deepseek-ai/dsh-session/types' diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index 5e45fcf3a5..e700b86db3 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -88,8 +88,8 @@ export interface HostApi { /** * Open a filesystem path with the operating system's default application * (Finder / Explorer / xdg-open hand-off). The browser carrier's - * prefix-wide trust fence covers this privileged method like every other - * `/api` request. + * prefix-wide trust and authentication checks cover this method like every + * other `/api` request. */ openPath( request: RpcRequest<{ path: string }>, diff --git a/packages/host/apiproxy/src/api/settings.ts b/packages/host/apiproxy/src/api/settings.ts index 5bbc6d56d9..ec7017eef7 100644 --- a/packages/host/apiproxy/src/api/settings.ts +++ b/packages/host/apiproxy/src/api/settings.ts @@ -55,8 +55,8 @@ export interface SettingsApi { * Describe every registered namespace: redacted layered values plus the * serialized schema a client renders its form from. `hasDocument` reports * whether a file-backed provider owns a local document without exposing its - * Host path. This method is loopback-only; `writable: false` (read-only - * provider) tells the client to disable every write control. + * Host path. Connection requires the browser session used by every Host API + * method; `writable: false` tells the client to disable every write control. */ describe(request: RpcRequest<{}>): Promise { it('serves every registered namespace, including one this repository never named', async () => { // Registering IS the exposure: a plugin distributed outside this // repository configures itself from the browser without a change here. - // The plane stays loopback-only and secret-redacted, and which surface - // renders a namespace is the browser's decision, not this proxy's. + // The plane stays browser-authenticated and secret-redacted, and which + // surface renders a namespace is the browser's decision, not this proxy's. const ctx = await harness() ctx.settings.register(NS, AdapterConfig) ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() })) diff --git a/packages/host/frontend-static/README.i18n.yaml b/packages/host/frontend-static/README.i18n.yaml index 771455e06a..f21081f544 100644 --- a/packages/host/frontend-static/README.i18n.yaml +++ b/packages/host/frontend-static/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/host/frontend-static/README.md -README.md: e4f3765a0471566dfb2f6780b1072d3e79788dc5 -README.zh.md: 7672478480f07b760634fef0c8640fcbdc0e6e7e +README.md: c541791e667dc07995a91263aa219afb84ee488a +README.zh.md: 76aeaff93db4eb3eb4b098e865acee46776f85c6 diff --git a/packages/host/frontend-static/README.md b/packages/host/frontend-static/README.md index e4f3765a04..c541791e66 100644 --- a/packages/host/frontend-static/README.md +++ b/packages/host/frontend-static/README.md @@ -4,6 +4,8 @@ English | [中文](README.zh.md) SPA dist server for the Web shell: a function plugin (config `{distIndex}`) that claims the [webserver](../webserver/README.md)'s single fallback seat and serves the built frontend directory with explicit index entry points. While `distIndex` is readable, the dist root and configured index path render `index.html` with HTTP 200; other existing files are served directly. An absent or non-file target inside the dist root, including a missing configured index, returns an empty 404; traversal outside the dist root returns 403, unknown extensions ship as `application/octet-stream`, and non-GET/HEAD without a matching named route returns 405. Every successful index response is rendered through the webserver's `renderIndex` — structured injection rows first, then the raw index taps — which is how the boot manifest reaches the page. `distIndex` is an assembly fact of the composing application: [`dsh-web-app`](../../bundle/web-app/README.md) resolves it through the frontend package's exports and mounts this plugin; a deployment never hardcodes it. +Root and configured-index responses require `ctx.connection.authorizeIndex` before reading the HTML bytes. A valid process token receives a 303 redirect plus the persistent browser cookie; an existing valid cookie serves the index; every other index request receives the Connection-owned 401 response. Non-index files remain public static assets. Connection owns all token, cookie, expiry, and signing-record semantics; this package only places that decision before the HTML read. + The fallback seat is single-owner (a second claim throws) and effect-scoped: disposing the plugin's fiber releases the seat, after which the unclaimed webserver answers 404. ## Model Experience diff --git a/packages/host/frontend-static/README.zh.md b/packages/host/frontend-static/README.zh.md index 7672478480..76aeaff93d 100644 --- a/packages/host/frontend-static/README.zh.md +++ b/packages/host/frontend-static/README.zh.md @@ -4,6 +4,8 @@ Web 壳的 SPA dist 服务器:一个函数插件(配置为 `{distIndex}`),占据 [webserver](../webserver/README.zh.md) 的唯一回退席位,并通过显式 index 入口服务已构建的前端目录。`distIndex` 可读时,dist 根目录和配置的 index 路径以 HTTP 200 渲染 `index.html`;其他现有文件直接提供。dist 根目录内缺失或不是文件的目标——包括缺失的配置 index——返回空的 404;越出 dist 根目录的遍历返回 403,未知扩展名按 `application/octet-stream` 提供,GET/HEAD 之外的方法在没有匹配的具名路由时返回 405。每个成功的 index 响应都经 webserver 的 `renderIndex` 渲染——先结构化注入行、后原始 index 转换器——启动 manifest(元数据清单)就是经这条路径送达页面的。`distIndex` 是组合应用的组装事实:[`dsh-web-app`](../../bundle/web-app/README.zh.md) 通过前端包的 exports 解析它并挂载本插件;部署绝不硬编码它。 +根路径与配置 index 响应在读取 HTML 字节前必须通过 `ctx.connection.authorizeIndex`。有效进程令牌得到带持久浏览器 cookie 的 303 重定向;既有有效 cookie 允许提供 index;其他 index 请求都得到 Connection 持有的 401 响应。非 index 文件保持公开静态资源。令牌、cookie、有效期和签名记录的全部语义归 Connection;本包只把这项判定放在 HTML 读取之前。 + 回退席位只有单一所有者(第二次占据会抛错),并受 effect 作用域约束:dispose(资源释放)插件的 fiber 会释放席位,此后无人占据的 webserver 回答 404。 ## 模型体验 diff --git a/packages/host/frontend-static/package.json b/packages/host/frontend-static/package.json index 2fcc012873..067548dca2 100644 --- a/packages/host/frontend-static/package.json +++ b/packages/host/frontend-static/package.json @@ -34,6 +34,7 @@ "peerDependencies": { "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { @@ -41,6 +42,8 @@ }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-credentials-local": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/host/frontend-static/src/index.ts b/packages/host/frontend-static/src/index.ts index 1227299362..6855a21190 100644 --- a/packages/host/frontend-static/src/index.ts +++ b/packages/host/frontend-static/src/index.ts @@ -4,8 +4,9 @@ * entry points. A readable index renders at the dist root and configured index * path; missing paths return 404, traversal outside the dist root is 403, * unknown extensions ship as octet-stream, and non-GET/HEAD is 405. Every - * index response runs through the webserver's index render (structured - * injection rows, then raw taps). The dist location is workspace knowledge of + * index response first passes Connection's browser authentication, then the + * webserver's index render (structured injection rows, then raw taps). + * Non-index assets stay public. The dist location is workspace knowledge of * the composing application, so `distIndex` is typically supplied through a * `!!js` expression, never hardcoded by a deployment. * @module @deepseek-ai/dsh-host-frontend-static @@ -16,13 +17,14 @@ import { readFile } from 'node:fs/promises' import { dirname, extname, join, normalize, resolve, sep } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import type {} from '@deepseek-ai/dsh-client-connection' import type {} from '@deepseek-ai/dsh-host-webserver' /** Stable Cordis plugin name. */ export const name = 'frontend-static' -/** Service required before the fallback seat can be claimed. */ -export const inject = ['webServer'] +/** Services required before the authenticated fallback seat can be claimed. */ +export const inject = ['webServer', 'connection'] /** Plugin config: the dist anchor. */ export interface Config { @@ -62,11 +64,13 @@ const STATIC_MISS_CODES: ReadonlySet = new Set([ * @param res - the node:http response to write. * @param distRoot - absolute dist root directory (resolved by the caller). * @param distIndex - absolute path of index.html inside distRoot. + * @param authorizeIndex - authenticates an index response before its bytes are read. * @param renderIndex - produces the index.html body (structured injection * rendering) for the dist root and configured index path. */ export async function serveStatic( pathname: string, res: ServerResponse, distRoot: string, distIndex: string, + authorizeIndex: () => Promise, renderIndex: () => Promise, ): Promise { const target = resolve(normalize(join(distRoot, pathname))) @@ -82,6 +86,7 @@ export async function serveStatic( let type: string try { if (target === distRoot || target === distIndex) { + if (!await authorizeIndex()) return body = await renderIndex() type = HTML_MIME } else { @@ -126,6 +131,13 @@ export function apply(ctx: Context, config: Config): void { } /* v8 ignore next -- node:http always sets url on server requests */ const rawPath = new URL(req.url ?? '/', 'http://x').pathname - await serveStatic(decodeURIComponent(rawPath), res, distRoot, distIndex, renderIndex) + await serveStatic( + decodeURIComponent(rawPath), + res, + distRoot, + distIndex, + () => ctx.connection.authorizeIndex(req, res), + renderIndex, + ) }), 'frontend-static: fallback seat') } diff --git a/packages/host/frontend-static/tests/frontend-static.spec.ts b/packages/host/frontend-static/tests/frontend-static.spec.ts index 93989857b9..a503777254 100644 --- a/packages/host/frontend-static/tests/frontend-static.spec.ts +++ b/packages/host/frontend-static/tests/frontend-static.spec.ts @@ -14,6 +14,8 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' +import * as Connection from '@deepseek-ai/dsh-client-connection' +import LocalCredentials from '@deepseek-ai/dsh-credentials-local' import HttpServer from '@deepseek-ai/dsh-host-webserver' import * as FrontendStatic from '../src/index.ts' @@ -27,7 +29,7 @@ afterEach(async () => { root = undefined }) -/** Write a dist fixture and a two-row cordis.yml, then boot it through the real Loader. */ +/** Write a dist fixture and the authenticated Web rows, then boot them through the real Loader. */ async function loadComposition(): Promise { root = await mkdtemp(join(tmpdir(), 'dsh-frontend-static-')) const dist = join(root, 'dist') @@ -40,10 +42,15 @@ async function loadComposition(): Promise { await mkdir(join(dist, 'empty')) const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-credentials-local'", + ' config:', + ` path: '${join(root, '.credentials.yaml')}'`, + ' watch: false', "- name: '@deepseek-ai/dsh-host-webserver'", ' config:', " host: '127.0.0.1'", ' port: 0', + "- name: '@deepseek-ai/dsh-client-connection'", '- id: frontend', " name: '@deepseek-ai/dsh-host-frontend-static'", ' config:', @@ -56,7 +63,9 @@ async function loadComposition(): Promise { await context.plugin(Loader) context.loader.builtins.include = Include const modules = new Map([ + ['@deepseek-ai/dsh-credentials-local', LocalCredentials], ['@deepseek-ai/dsh-host-webserver', HttpServer], + ['@deepseek-ai/dsh-client-connection', Connection], ['@deepseek-ai/dsh-host-frontend-static', FrontendStatic], ]) context.loader.internal = { @@ -95,6 +104,24 @@ describe('real Loader composition', () => { expect(unloaded).toEqual([]) const server = loaded.webServer const port = server.port + const launchUrl = loaded.connection.authenticatedUrl(`http://127.0.0.1:${String(port)}`) + const exchange = await fetch(launchUrl, { redirect: 'manual' }) + expect(exchange.status).toBe(303) + expect(exchange.headers.get('location')).toBe('/') + const setCookie = exchange.headers.get('set-cookie') + if (setCookie === null) throw new Error('authenticated frontend did not set a cookie') + const cookie = setCookie.split(';', 1)[0]! + const authenticated = (init?: RequestInit): RequestInit => { + const headers = new Headers(init?.headers) + headers.set('cookie', cookie) + return { ...init, headers } + } + + expect(await request(port, '/')).toMatchObject({ + status: 401, + type: 'text/plain; charset=utf-8', + body: 'dsh web authentication required; reopen the URL printed by dsh web.\n', + }) // Real assets with their MIME types; a live rebuild is served on the next read. expect(await request(port, '/app.js')).toMatchObject({ status: 200, type: 'text/javascript; charset=utf-8', body: 'export {}' }) @@ -117,33 +144,33 @@ describe('real Loader composition', () => { // Only the root and index path render index.html through registered taps. const untap = server.tapIndex(html => html.replace('', '')) for (const path of ['/', '/index.html', '/?fixture']) { - const got = await request(port, path) + const got = await request(port, path, authenticated()) expect(got.status).toBe(200) expect(got.type).toBe('text/html; charset=utf-8') expect(got.body).toContain('__T__') expect(got.body).toContain('shell') } - expect(await request(port, '/', { method: 'HEAD' })).toEqual({ + expect(await request(port, '/', authenticated({ method: 'HEAD' }))).toEqual({ status: 200, type: 'text/html; charset=utf-8', body: '', }) untap() - expect((await request(port, '/')).body).not.toContain('__T__') + expect((await request(port, '/', authenticated())).body).not.toContain('__T__') // A missing configured index follows the same empty-404 contract for both // of its public entry paths and for both supported methods. await rm(join(root!, 'dist', 'index.html')) for (const path of ['/', '/index.html']) { - const get = await request(port, path) - const head = await request(port, path, { method: 'HEAD' }) + const get = await request(port, path, authenticated()) + const head = await request(port, path, authenticated({ method: 'HEAD' })) expect(get).toEqual({ status: 404, type: null, body: '' }) expect(head).toEqual(get) } // Ordinary unknown paths and static-resource misses are empty 404s for // both GET and HEAD; neither class can be mistaken for the HTML shell. - const ordinaryMisses = ['/no/such/route', '/api/no/such/route', '/empty', '/app.js/child'] + const ordinaryMisses = ['/no/such/route', '/empty', '/app.js/child'] const assetMisses = [ '/missing.js', '/missing.css', @@ -158,6 +185,11 @@ describe('real Loader composition', () => { expect(get).toEqual({ status: 404, type: null, body: '' }) expect(head).toEqual(get) } + expect(await request(port, '/api/no/such/route', authenticated())).toEqual({ + status: 404, + type: 'text/plain;charset=UTF-8', + body: 'not found', + }) // Traversal outside the dist root is 403, non-GET/HEAD is 405, and a // malformed filesystem target still reaches the webserver's 400 guard. diff --git a/packages/host/frontend-static/tsconfig.json b/packages/host/frontend-static/tsconfig.json index 6c09296ed9..5eda998b9b 100644 --- a/packages/host/frontend-static/tsconfig.json +++ b/packages/host/frontend-static/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../client/connection/tsconfig.host.json" + }, { "path": "../webserver" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e95ecdff6..86359634bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -454,9 +454,15 @@ importers: '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 + '@types/ws': + specifier: 8.18.1 + version: 8.18.1 execa: specifier: ^10.0.0 version: 10.0.0 + ws: + specifier: 8.21.0 + version: 8.21.0 apps/web: devDependencies: @@ -499,6 +505,9 @@ importers: '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) + '@types/ws': + specifier: 8.18.1 + version: 8.18.1 '@vitejs/plugin-react': specifier: ^4.0.0 version: 4.7.0(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -526,6 +535,9 @@ importers: vitest: specifier: ^4.1.8 version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + ws: + specifier: 8.21.0 + version: 8.21.0 native/landlock-run: devDependencies: @@ -1579,6 +1591,9 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment + '@deepseek-ai/dsh-credentials': + specifier: workspace:^ + version: link:../../credentials/credentials '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands @@ -5692,6 +5707,12 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection + '@deepseek-ai/dsh-credentials-local': + specifier: workspace:^ + version: link:../../credentials/credentials-local '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../webserver From ce031ddd1696d95b4602292f0a7caa589a7dd776 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:07:56 +0800 Subject: [PATCH 07/12] fix(web): cover authenticated host runtimes --- ...-24-browser-token-authentication.i18n.yaml | 4 +- ...2026-08-24-browser-token-authentication.md | 4 +- ...6-08-24-browser-token-authentication.zh.md | 4 +- apps/web/tests/access-confirmation.e2e.ts | 2 +- apps/web/tests/agent-preset-authoring.e2e.ts | 4 +- apps/web/tests/agent-preset-selection.e2e.ts | 12 +++--- apps/web/tests/approval-composer.e2e.ts | 2 +- apps/web/tests/background-job-list.e2e.ts | 2 +- apps/web/tests/bash-abort-row.e2e.ts | 2 +- .../tests/chat-continuous-conversation.e2e.ts | 2 +- apps/web/tests/chat-long-interactions.e2e.ts | 2 +- apps/web/tests/chat-scroll-contract.e2e.ts | 2 +- apps/web/tests/code-mode-round.e2e.ts | 2 +- apps/web/tests/cold-blank-session.e2e.ts | 2 +- apps/web/tests/complex-history.perf.ts | 4 +- apps/web/tests/composer-draft-scroll.e2e.ts | 2 +- apps/web/tests/composer-tab-geometry.e2e.ts | 2 +- .../tests/conversation-column-overflow.e2e.ts | 2 +- apps/web/tests/cordis-tool-round.e2e.ts | 2 +- apps/web/tests/declared-reasoning.e2e.ts | 2 +- apps/web/tests/default-model.e2e.ts | 2 +- .../tests/details-session-lifecycle.e2e.ts | 2 +- apps/web/tests/feedback-command.e2e.ts | 2 +- apps/web/tests/github-ready-review.e2e.ts | 2 +- apps/web/tests/goal-bar.e2e.ts | 2 + .../tests/goal-command-presentation.e2e.ts | 2 +- apps/web/tests/goal-multi-turn-actions.e2e.ts | 2 +- apps/web/tests/lifecycle-chrome.e2e.ts | 4 +- apps/web/tests/live-interactions.e2e.ts | 2 +- apps/web/tests/markdown-cjk-strong.e2e.ts | 2 +- apps/web/tests/markdown-images.e2e.ts | 2 +- .../tests/markdown-inline-code-links.e2e.ts | 2 +- apps/web/tests/markdown-wide-table.e2e.ts | 4 +- apps/web/tests/math-rendering.e2e.ts | 2 +- apps/web/tests/message-actions.e2e.ts | 2 +- apps/web/tests/message-feedback-layout.e2e.ts | 2 +- .../message-feedback-protocol.snapshot.ts | 2 +- apps/web/tests/message-feedback.e2e.ts | 2 +- apps/web/tests/models-settings.e2e.ts | 2 +- apps/web/tests/navigation-panes.e2e.ts | 4 +- .../tests/onboarding-deepseek-config.e2e.ts | 2 +- .../tests/onboarding-usable-provider.e2e.ts | 2 +- .../tests/permission-policy-context.e2e.ts | 2 +- apps/web/tests/plan-control-row.e2e.ts | 2 +- apps/web/tests/plan-review.e2e.ts | 2 +- apps/web/tests/plugin-config.e2e.ts | 2 +- apps/web/tests/produced-file-mentions.e2e.ts | 2 +- apps/web/tests/produced-files.e2e.ts | 2 +- apps/web/tests/pwsh-terminal.e2e.ts | 2 +- apps/web/tests/question-composer.e2e.ts | 2 +- apps/web/tests/queue-actions.e2e.ts | 4 +- apps/web/tests/rail-search-expand.e2e.ts | 2 +- apps/web/tests/reference-composer.e2e.ts | 2 +- apps/web/tests/remote-welcome.e2e.ts | 2 +- apps/web/tests/replay-round-trip.e2e.ts | 2 +- apps/web/tests/scaffold.ts | 26 ++++++++++++- apps/web/tests/schedule-after.e2e.ts | 2 +- apps/web/tests/seeded-history.e2e.ts | 6 ++- apps/web/tests/settings-chrome.e2e.ts | 12 +++--- apps/web/tests/sidebar-scrollbar.e2e.ts | 2 +- .../tests/sidebar-subagent-activity.e2e.ts | 2 +- apps/web/tests/skill-invocation-policy.e2e.ts | 2 +- apps/web/tests/skill-tool-row.e2e.ts | 2 +- apps/web/tests/skill-user-invoke.e2e.ts | 2 +- apps/web/tests/startup-auto-selection.e2e.ts | 2 +- apps/web/tests/startup-rpc-budget.e2e.ts | 2 +- apps/web/tests/stats-paged-history.e2e.ts | 2 +- apps/web/tests/steering.e2e.ts | 8 ++-- apps/web/tests/subagent-conversation.e2e.ts | 2 +- apps/web/tests/subagent-interrupt-ui.e2e.ts | 2 +- apps/web/tests/subagent-interrupt.e2e.ts | 16 ++++---- .../tests/trajectory-virtualization.e2e.ts | 2 +- apps/web/tests/turn-tail-actions.e2e.ts | 2 +- apps/web/tests/web-search-round.e2e.ts | 2 +- apps/web/tests/workflow-run.e2e.ts | 2 +- apps/web/tests/workspace-management.e2e.ts | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 10 +++-- docs/module-graph.zh.md | 10 +++-- packages/api/remotes/tests/built-lib.e2e.ts | 33 ++++++++++++++++- .../bundle/web-app/tests/browser-open.spec.ts | 27 +++++++++++++- packages/bundle/web-app/tests/web-app.spec.ts | 1 + .../client/connection/src/browser-auth.ts | 37 ++++++++++++++----- .../tests/api-request-trust.host.spec.ts | 7 ++++ .../tests/browser-auth.host.spec.ts | 13 +++++-- .../webworker-runtime/src/transport/tunnel.ts | 25 ++++++------- .../webworker-runtime/src/worker-host.ts | 6 +-- .../tests/transport/tunnel-server.spec.ts | 36 ++++++++++++++++++ 88 files changed, 303 insertions(+), 144 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml index e8c68740aa..0691ce60ab 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.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-08-24-browser-token-authentication.md -2026-08-24-browser-token-authentication.md: fb55a35018b7c8df45213f9b9e59dccc3512c0ab -2026-08-24-browser-token-authentication.zh.md: 6cd77117395ff753d36d39da0ec247e064bdb393 +2026-08-24-browser-token-authentication.md: 515990b81f3c92b7f3c422dcc40acfed1e5996ac +2026-08-24-browser-token-authentication.zh.md: 8066e2c3f62245779c6a03b5160c41e4bbc94ba5 diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md index fb55a35018..515990b81f 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md @@ -18,11 +18,13 @@ The cookie is a signed, authority-bound bearer. Its deterministic name and signe The HMAC secret is a versioned `grant` record at `client-connection/browser-session` in `ctx.credentials`; the local provider stores it in `$DSH_HOME/.credentials.yaml`. Connection reads the record for each verification, so deletion or replacement revokes every existing cookie without restarting the process. A missing record is recreated only by a valid process-token exchange. Invalid owner payloads fail loud instead of being replaced. The launch token itself is never persisted and changes on every process start, while an unexpired cookie remains valid across restarts on the same authority. +The in-page Web Worker preview exposes no network socket. Its page-owned `postMessage` tunnel enters the real route first, then retries a 401 or 403 through the worker-local fetch handler. This keeps Connection interceptors while limiting the authentication bypass to the page that created the Host worker. + The shipped CLI continues to reject `--host 0.0.0.0`. Authentication does not imply supported network deployment, TLS, forwarding-header interpretation, or proxy configuration. ## Verification -Unit coverage pins token comparison, cookie attributes, HMAC and payload validation, authority and lifetime checks, persistent-secret reuse, record deletion, and invalid durable records. Host transport suites pin uniform 401/403 behavior for API Proxy, generic RPC, Typert Remote HTTP, and WebSocket upgrade paths. The frontend real-composition test boots credentials, Connection, webserver, and static serving through Loader and proves token exchange before index reads while static assets remain public. A real-CLI test starts `dsh web` twice on one port with a temporary `DSH_HOME`, proves that forged `Host: localhost` is unauthenticated, calls `host.describe` with the exchanged cookie, observes a new process token, and reuses the old cookie after restart. +Unit coverage pins token comparison, cookie attributes, HMAC and payload validation, authority and lifetime checks, persistent-secret reuse, record deletion, and invalid durable records. Host transport suites pin uniform 401/403 behavior for API Proxy, generic RPC, Typert Remote HTTP, and WebSocket upgrade paths. The frontend real-composition test boots credentials, Connection, webserver, and static serving through Loader and proves token exchange before index reads while static assets remain public. Packed-worker tests prove portable cookie encoding and worker-local retry for both authentication and trust rejection. A real-CLI test starts `dsh web` twice on one port with a temporary `DSH_HOME`, proves that forged `Host: localhost` is unauthenticated, calls `host.describe` with the exchanged cookie, observes a new process token, and reuses the old cookie after restart. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md index 6cd7711739..8066e2c3f6 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md @@ -18,11 +18,13 @@ cookie 是签名且绑定 authority 的 bearer。确定性名称与签名 payloa HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` 的版本化 `grant` 记录;本地提供方将其存入 `$DSH_HOME/.credentials.yaml`。Connection 每次校验都读取记录,因此删除或替换记录无需重启进程即可撤销全部既有 cookie。缺失记录只能由有效进程令牌交换重新创建。无效 owner payload 会明确失败,而不是被覆盖。启动令牌本身绝不持久化并在每次进程启动时变化;未过期 cookie 则能在相同 authority 上跨重启继续有效。 +页内 Web Worker preview 不暴露网络 socket。其由页面持有的 `postMessage` tunnel 先进入真实 route,收到 401 或 403 后再经 worker 本地 fetch handler 重试。这样既保留 Connection interceptor,又把认证绕过限制在创建 Host worker 的页面内。 + 随附 CLI 继续拒绝 `--host 0.0.0.0`。认证不代表支持网络部署、TLS、转发 header 解释或代理配置。 ## 验证 -单元覆盖固定令牌比较、cookie 属性、HMAC 与 payload 校验、authority 与有效期校验、持久密钥复用、记录删除及无效持久记录。Host 传输套件固定 API Proxy、通用 RPC、Typert Remote HTTP 和 WebSocket upgrade 路径上一致的 401/403 行为。frontend 真实组合测试经 Loader 启动 credentials、Connection、webserver 与静态服务,证明读取 index 前完成令牌交换,同时静态资产仍公开。真实 CLI 测试在临时 `DSH_HOME` 上用同一端口两次启动 `dsh web`,证明伪造 `Host: localhost` 仍未认证,以交换所得 cookie 调用 `host.describe`,观测新的进程令牌,并在重启后复用旧 cookie。 +单元覆盖固定令牌比较、cookie 属性、HMAC 与 payload 校验、authority 与有效期校验、持久密钥复用、记录删除及无效持久记录。Host 传输套件固定 API Proxy、通用 RPC、Typert Remote HTTP 和 WebSocket upgrade 路径上一致的 401/403 行为。frontend 真实组合测试经 Loader 启动 credentials、Connection、webserver 与静态服务,证明读取 index 前完成令牌交换,同时静态资产仍公开。打包 worker 测试证明 cookie 编码可移植,并覆盖认证与信任拒绝后的 worker 本地重试。真实 CLI 测试在临时 `DSH_HOME` 上用同一端口两次启动 `dsh web`,证明伪造 `Host: localhost` 仍未认证,以交换所得 cookie 调用 `host.describe`,观测新的进程令牌,并在重启后复用旧 cookie。 ## 曾考虑的替代方案 diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts index 87578885ce..26aa2d3d3d 100644 --- a/apps/web/tests/access-confirmation.e2e.ts +++ b/apps/web/tests/access-confirmation.e2e.ts @@ -35,7 +35,7 @@ describe('web e2e: Full access confirmation', () => { // callback. page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) }, 120_000) diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index d338b43b55..72b8317fe4 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -69,7 +69,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { // The scenario asserts the shipped Chinese copy, so the browser asks for it. page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) @@ -257,7 +257,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { await dialog.waitFor({ state: 'detached', timeout: 10_000 }) await page.getByRole('button', { name: '创造模式' }).waitFor({ timeout: 10_000 }) await expect.poll(async () => { - const response = await fetch(`${scaffold.baseUrl}/api/session/list`, { + const response = await scaffold.hostFetch('/api/session/list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index ea7ee009d5..a1f198ca93 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -137,11 +137,11 @@ async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise * produced. Addressed by id rather than by scanning the serialized list: the * seeded session records `minimal` too, so a substring match over the whole * list answers before the switch has landed. - * @param baseUrl - the scaffold's origin. + * @param scaffold - authenticated Web Host scaffold. * @returns the live session's preset, or undefined before it is listed. */ -async function livePreset(baseUrl: string): Promise { - const response = await fetch(`${baseUrl}/api/session/list`, { +async function livePreset(scaffold: WebScaffold): Promise { + const response = await scaffold.hostFetch('/api/session/list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ @@ -190,7 +190,7 @@ describe('web e2e: agent-preset selection', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) @@ -234,7 +234,7 @@ describe('web e2e: agent-preset selection', () => { // The chip stages; the blank session the workspace connect produced is // what the stage lands on. The host's own answer is what comes back. - await expect.poll(() => livePreset(scaffold.baseUrl), { timeout: 15_000 }).toBe('minimal') + await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal') }) it('re-reads the slash catalog through the composition the switch installed', async () => { @@ -264,7 +264,7 @@ describe('web e2e: agent-preset selection', () => { // instead of leaving the session reading the narrower composition. await page.getByRole('button', { name: 'Minimal mode' }).click() await page.getByRole('menuitem', { name: /^Standard mode/ }).first().click() - await expect.poll(() => livePreset(scaffold.baseUrl), { timeout: 15_000 }).toBe('standard') + await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('standard') await composer.fill('/') await expect.poll(() => menuOptions(page), { timeout: 15_000 }) diff --git a/apps/web/tests/approval-composer.e2e.ts b/apps/web/tests/approval-composer.e2e.ts index 0bbc3fa210..d8fe3935c8 100644 --- a/apps/web/tests/approval-composer.e2e.ts +++ b/apps/web/tests/approval-composer.e2e.ts @@ -43,7 +43,7 @@ describe('web e2e: approval takeover keeps its actions reachable', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) diff --git a/apps/web/tests/background-job-list.e2e.ts b/apps/web/tests/background-job-list.e2e.ts index fcf02290fd..cad3d05aa0 100644 --- a/apps/web/tests/background-job-list.e2e.ts +++ b/apps/web/tests/background-job-list.e2e.ts @@ -56,7 +56,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) const groupRow = page.locator('[role="treeitem"]').first() diff --git a/apps/web/tests/bash-abort-row.e2e.ts b/apps/web/tests/bash-abort-row.e2e.ts index dcd4f93cf9..63d8d729e0 100644 --- a/apps/web/tests/bash-abort-row.e2e.ts +++ b/apps/web/tests/bash-abort-row.e2e.ts @@ -34,7 +34,7 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', () browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) const groupRow = page.locator('[role="treeitem"]').first() diff --git a/apps/web/tests/chat-continuous-conversation.e2e.ts b/apps/web/tests/chat-continuous-conversation.e2e.ts index cd15f2e054..8130e70308 100644 --- a/apps/web/tests/chat-continuous-conversation.e2e.ts +++ b/apps/web/tests/chat-continuous-conversation.e2e.ts @@ -197,7 +197,7 @@ describe('web e2e: continuous conversation grown through the composer', () => { page.on('console', (message) => { if (message.type() === 'warning') consoleWarnings.push(message.text()) }) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd, 'continuous-chat-e2e') }, 120_000) diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts index 03198a23a8..c7d6c7de50 100644 --- a/apps/web/tests/chat-long-interactions.e2e.ts +++ b/apps/web/tests/chat-long-interactions.e2e.ts @@ -155,7 +155,7 @@ describe('web e2e: long Chat interaction contract', () => { browser = await chromium.launch() page = await newEnglishPage(browser, 900) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await openSeed(page) }, 120_000) diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index 7c70741296..287e3a61f8 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -166,7 +166,7 @@ async function launchScrollWorld(options: ScrollWorldOptions): Promise { events.push(event) }) page = await newEnglishPage(browser, 900) const tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) // Session-list bootstrap can replace the controlled search state. Wait // for the seeded baseline before openSeed starts the lazy content query diff --git a/apps/web/tests/code-mode-round.e2e.ts b/apps/web/tests/code-mode-round.e2e.ts index fcdbb721a6..58210a6437 100644 --- a/apps/web/tests/code-mode-round.e2e.ts +++ b/apps/web/tests/code-mode-round.e2e.ts @@ -38,7 +38,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) diff --git a/apps/web/tests/cold-blank-session.e2e.ts b/apps/web/tests/cold-blank-session.e2e.ts index 87eaf848db..b9e64960d3 100644 --- a/apps/web/tests/cold-blank-session.e2e.ts +++ b/apps/web/tests/cold-blank-session.e2e.ts @@ -39,7 +39,7 @@ describe('web e2e: cold blank Session visibility', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) diff --git a/apps/web/tests/complex-history.perf.ts b/apps/web/tests/complex-history.perf.ts index eeb9931473..0913d82e90 100644 --- a/apps/web/tests/complex-history.perf.ts +++ b/apps/web/tests/complex-history.perf.ts @@ -907,7 +907,7 @@ async function openPerformancePage( world: PerformanceWorld, expectedSessions: number, ): Promise { - await world.page.goto(world.scaffold.baseUrl, { waitUntil: 'load' }) + await world.page.goto(world.scaffold.authenticatedUrl, { waitUntil: 'load' }) await world.page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) const group = world.page.getByRole('treeitem').first() await expect.poll(() => group.textContent(), { timeout: 30_000 }) @@ -1389,7 +1389,7 @@ describe('manual web performance: complex workspace and history', () => { }) let testFailure: unknown try { - await world.page.goto(world.scaffold.baseUrl, { waitUntil: 'load' }) + await world.page.goto(world.scaffold.authenticatedUrl, { waitUntil: 'load' }) await world.page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(world.page, world.scaffold.workspaceCwd, 'continuous-conversation-perf') const cdp = await world.page.context().newCDPSession(world.page) diff --git a/apps/web/tests/composer-draft-scroll.e2e.ts b/apps/web/tests/composer-draft-scroll.e2e.ts index dd6ae8085e..04b13e206e 100644 --- a/apps/web/tests/composer-draft-scroll.e2e.ts +++ b/apps/web/tests/composer-draft-scroll.e2e.ts @@ -195,7 +195,7 @@ describe('web e2e: composer draft scrolling', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd, 'composer-draft-scroll') await page.locator('textarea:enabled').first().fill(DRAFT) diff --git a/apps/web/tests/composer-tab-geometry.e2e.ts b/apps/web/tests/composer-tab-geometry.e2e.ts index 99b746d97b..a4905b86e0 100644 --- a/apps/web/tests/composer-tab-geometry.e2e.ts +++ b/apps/web/tests/composer-tab-geometry.e2e.ts @@ -248,7 +248,7 @@ describe('web e2e: input card position across view tabs', () => { browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] }) page = await newEnglishPage(browser, WIDE_VIEWPORT.height) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await openSeededSession(page) await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 }) diff --git a/apps/web/tests/conversation-column-overflow.e2e.ts b/apps/web/tests/conversation-column-overflow.e2e.ts index c9805a4df8..460acf9163 100644 --- a/apps/web/tests/conversation-column-overflow.e2e.ts +++ b/apps/web/tests/conversation-column-overflow.e2e.ts @@ -208,7 +208,7 @@ describe('web e2e: the conversation column scrolls on one axis', () => { browser = await chromium.launch() page = await newEnglishPage(browser, 900) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[data-conversation-scroll] [class*="heroGlow"]', { timeout: 30_000 }) }, 180_000) diff --git a/apps/web/tests/cordis-tool-round.e2e.ts b/apps/web/tests/cordis-tool-round.e2e.ts index e6e3fe6f5e..6dfebd0e98 100644 --- a/apps/web/tests/cordis-tool-round.e2e.ts +++ b/apps/web/tests/cordis-tool-round.e2e.ts @@ -91,7 +91,7 @@ describe('web e2e: Cordis tools use their owned cards', () => { }) }) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) diff --git a/apps/web/tests/declared-reasoning.e2e.ts b/apps/web/tests/declared-reasoning.e2e.ts index 8d35d0d1ea..2310e392e0 100644 --- a/apps/web/tests/declared-reasoning.e2e.ts +++ b/apps/web/tests/declared-reasoning.e2e.ts @@ -51,7 +51,7 @@ describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach th browser = await chromium.launch() page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) }, 120_000) diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index 34a9fab2f9..d095448a79 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -81,7 +81,7 @@ describe('web e2e: the composer model switch is the default for later sessions', browser = await chromium.launch() page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) // The composer's seats only exist once a workspace is connected: without // one the input is the locked placeholder and no session scope is open. diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index 9ff90d2dca..d53338e1a1 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -78,7 +78,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await appFrame(page).waitFor({ timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) diff --git a/apps/web/tests/feedback-command.e2e.ts b/apps/web/tests/feedback-command.e2e.ts index f017d9289d..242b28188c 100644 --- a/apps/web/tests/feedback-command.e2e.ts +++ b/apps/web/tests/feedback-command.e2e.ts @@ -45,7 +45,7 @@ describe('web e2e: /feedback command acknowledgement', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) // Fresh world: connecting a workspace births the blank session whose // live composer accepts the slash line. diff --git a/apps/web/tests/github-ready-review.e2e.ts b/apps/web/tests/github-ready-review.e2e.ts index af3af1c0f2..4d473ddf13 100644 --- a/apps/web/tests/github-ready-review.e2e.ts +++ b/apps/web/tests/github-ready-review.e2e.ts @@ -98,7 +98,7 @@ describe.skipIf(MODE === 'record')('web e2e: GitHub ready-for-review', () => { page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'en-US' }) await page.addInitScript(() => { localStorage.setItem('dsh.locale', 'en') }) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 60_000) diff --git a/apps/web/tests/goal-bar.e2e.ts b/apps/web/tests/goal-bar.e2e.ts index a775143da6..631367886b 100644 --- a/apps/web/tests/goal-bar.e2e.ts +++ b/apps/web/tests/goal-bar.e2e.ts @@ -30,6 +30,8 @@ describe('web e2e: goal bar clear convergence', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) + const login = await page.context().request.get(scaffold.authenticatedUrl, { maxRedirects: 0 }) + expect(login.status()).toBe(303) await page.goto(`${scaffold.baseUrl}?fixture`, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) diff --git a/apps/web/tests/goal-command-presentation.e2e.ts b/apps/web/tests/goal-command-presentation.e2e.ts index 537ab8095b..605c45e1bb 100644 --- a/apps/web/tests/goal-command-presentation.e2e.ts +++ b/apps/web/tests/goal-command-presentation.e2e.ts @@ -33,7 +33,7 @@ describe('web e2e: /goal human transcript presentation', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) diff --git a/apps/web/tests/goal-multi-turn-actions.e2e.ts b/apps/web/tests/goal-multi-turn-actions.e2e.ts index f73c100827..346593529b 100644 --- a/apps/web/tests/goal-multi-turn-actions.e2e.ts +++ b/apps/web/tests/goal-multi-turn-actions.e2e.ts @@ -117,7 +117,7 @@ describe('web e2e: Goal keeps one assistant action row per completed turn', () = browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) } diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 35bea3b085..e1b62632aa 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -53,7 +53,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) // Fresh world: connect a Workspace so the composer scenarios start live. await connectFreshWorkspace(page, scaffold.workspaceCwd) @@ -103,7 +103,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () const activePage = await newEnglishPage(browser) const activeTripwire = watchConsole(activePage) try { - await activePage.goto(activeScaffold.baseUrl, { waitUntil: 'load' }) + await activePage.goto(activeScaffold.authenticatedUrl, { waitUntil: 'load' }) await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(activePage, activeScaffold.workspaceCwd) const input = activePage.locator('textarea').first() diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index ced590a45d..553b1565cf 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -104,7 +104,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) // Fresh world: connect a Workspace so the composer scenarios start live. await connectFreshWorkspace(page, scaffold.workspaceCwd) diff --git a/apps/web/tests/markdown-cjk-strong.e2e.ts b/apps/web/tests/markdown-cjk-strong.e2e.ts index 83963ea36e..b99f7f90d4 100644 --- a/apps/web/tests/markdown-cjk-strong.e2e.ts +++ b/apps/web/tests/markdown-cjk-strong.e2e.ts @@ -96,7 +96,7 @@ describe('web e2e: CJK-adjacent Markdown strong emphasis', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts index 57e5910813..54783b48a5 100644 --- a/apps/web/tests/markdown-images.e2e.ts +++ b/apps/web/tests/markdown-images.e2e.ts @@ -153,7 +153,7 @@ describe('web e2e: remote Markdown image rendering', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) diff --git a/apps/web/tests/markdown-inline-code-links.e2e.ts b/apps/web/tests/markdown-inline-code-links.e2e.ts index e216e5b6d0..be3e30a380 100644 --- a/apps/web/tests/markdown-inline-code-links.e2e.ts +++ b/apps/web/tests/markdown-inline-code-links.e2e.ts @@ -95,7 +95,7 @@ describe('web e2e: Markdown inline-code links', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) diff --git a/apps/web/tests/markdown-wide-table.e2e.ts b/apps/web/tests/markdown-wide-table.e2e.ts index 901a425f43..77e583bf45 100644 --- a/apps/web/tests/markdown-wide-table.e2e.ts +++ b/apps/web/tests/markdown-wide-table.e2e.ts @@ -253,7 +253,7 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) const groupRow = page.locator('[role="treeitem"]').first() await groupRow.waitFor({ timeout: 15_000 }) @@ -423,7 +423,7 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro const hidpiTripwire = watchConsole(hidpiPage) try { onTestFailed(() => saveFailureShot(hidpiPage, 'web-e2e-markdown-wide-table-hidpi')) - await hidpiPage.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await hidpiPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await hidpiPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) const groupRow = hidpiPage.locator('[role="treeitem"]').first() await groupRow.waitFor({ timeout: 15_000 }) diff --git a/apps/web/tests/math-rendering.e2e.ts b/apps/web/tests/math-rendering.e2e.ts index 08f4ca2705..c5e3e22a60 100644 --- a/apps/web/tests/math-rendering.e2e.ts +++ b/apps/web/tests/math-rendering.e2e.ts @@ -97,7 +97,7 @@ describe('web e2e: settled Markdown math rendering', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index 58e54965fb..de849baea3 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -87,7 +87,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) diff --git a/apps/web/tests/message-feedback-layout.e2e.ts b/apps/web/tests/message-feedback-layout.e2e.ts index ed03d14f8d..5ab5cb0102 100644 --- a/apps/web/tests/message-feedback-layout.e2e.ts +++ b/apps/web/tests/message-feedback-layout.e2e.ts @@ -207,7 +207,7 @@ describe('web e2e: the feedback note editor floats above the column', () => { browser = await chromium.launch() page = await newEnglishPage(browser, 900) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 180_000) diff --git a/apps/web/tests/message-feedback-protocol.snapshot.ts b/apps/web/tests/message-feedback-protocol.snapshot.ts index 02cebabcc0..e9dfa197b3 100644 --- a/apps/web/tests/message-feedback-protocol.snapshot.ts +++ b/apps/web/tests/message-feedback-protocol.snapshot.ts @@ -65,7 +65,7 @@ describe('message feedback Host Remote protocol', () => { const exchanges: ProtocolExchange[] = [] const invoke = async (rpcId: string, endpoint: string, request: unknown): Promise => { const payload = { args: { request } } - const response = await fetch(`${scaffold.baseUrl}/api/${endpoint}`, { + const response = await scaffold.hostFetch(`/api/${endpoint}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ diff --git a/apps/web/tests/message-feedback.e2e.ts b/apps/web/tests/message-feedback.e2e.ts index f6c3262c5f..02e4b62951 100644 --- a/apps/web/tests/message-feedback.e2e.ts +++ b/apps/web/tests/message-feedback.e2e.ts @@ -32,7 +32,7 @@ describe('web e2e: durable per-message feedback', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index f727c619f9..e802b981bf 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -47,7 +47,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { // The scenario asserts the shipped Chinese copy, so the browser asks for it. page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 1da479e432..6ba5df86c9 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -114,7 +114,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { // Workspace stream settles through the user-visible Ungrouped barrier. const sessionBaseline = baselineResponse(page) const [, sessionResponse] = await Promise.all([ - page.goto(scaffold.baseUrl, { waitUntil: 'load' }), + page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }), sessionBaseline, ]) await assertBaselineSucceeded(sessionResponse, 'session.list') @@ -327,7 +327,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { }) const observerSessionBaseline = baselineResponse(observer) const [, observerSessionResponse] = await Promise.all([ - observer.goto(scaffold.baseUrl, { waitUntil: 'load' }), + observer.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }), observerSessionBaseline, ]) await assertBaselineSucceeded(observerSessionResponse, 'observer session.list') diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 59b7e3c987..82079543d3 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -38,7 +38,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE }) tripwire = watchConsole(page) page.on('console', message => browserConsole.push(message.text())) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) diff --git a/apps/web/tests/onboarding-usable-provider.e2e.ts b/apps/web/tests/onboarding-usable-provider.e2e.ts index a1d97993b7..d16ed51822 100644 --- a/apps/web/tests/onboarding-usable-provider.e2e.ts +++ b/apps/web/tests/onboarding-usable-provider.e2e.ts @@ -33,7 +33,7 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first- // The scenario asserts the shipped Chinese copy, so the browser asks for it. page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE }) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) diff --git a/apps/web/tests/permission-policy-context.e2e.ts b/apps/web/tests/permission-policy-context.e2e.ts index 06817d7df5..5ff6141af8 100644 --- a/apps/web/tests/permission-policy-context.e2e.ts +++ b/apps/web/tests/permission-policy-context.e2e.ts @@ -77,7 +77,7 @@ describe('web e2e: current sandbox policy reaches the model before tools', () => browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) diff --git a/apps/web/tests/plan-control-row.e2e.ts b/apps/web/tests/plan-control-row.e2e.ts index 49a3ce06dc..dd55656d61 100644 --- a/apps/web/tests/plan-control-row.e2e.ts +++ b/apps/web/tests/plan-control-row.e2e.ts @@ -64,7 +64,7 @@ describe('web e2e: plan chip click area at the narrow viewport', () => { browser = await chromium.launch() page = await newEnglishPage(browser, VIEWPORT.height) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) await page.setViewportSize(VIEWPORT) diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index 5c4082747f..8b4dc3afd4 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/apps/web/tests/plan-review.e2e.ts @@ -53,7 +53,7 @@ describe('web e2e: plan review takeover round trip', () => { // golden pins one language. page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index 1877d71e7c..0235f687de 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -33,7 +33,7 @@ describe('web e2e: plugin configuration section', () => { // derives from it, as the rest of the settings surface does. page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) diff --git a/apps/web/tests/produced-file-mentions.e2e.ts b/apps/web/tests/produced-file-mentions.e2e.ts index badd9b845e..f89cfe53b3 100644 --- a/apps/web/tests/produced-file-mentions.e2e.ts +++ b/apps/web/tests/produced-file-mentions.e2e.ts @@ -127,7 +127,7 @@ describe('web e2e: inline-code mentions of produced files', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) diff --git a/apps/web/tests/produced-files.e2e.ts b/apps/web/tests/produced-files.e2e.ts index 2dcc92f65e..3c6ebbf526 100644 --- a/apps/web/tests/produced-files.e2e.ts +++ b/apps/web/tests/produced-files.e2e.ts @@ -118,7 +118,7 @@ describe('web e2e: a finished turn ends with the files it produced', () => { // the assertion itself narrows the conversation after navigation. await page.setViewportSize({ width: 1280, height: 900 }) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) diff --git a/apps/web/tests/pwsh-terminal.e2e.ts b/apps/web/tests/pwsh-terminal.e2e.ts index 4f56a422a9..85ce829904 100644 --- a/apps/web/tests/pwsh-terminal.e2e.ts +++ b/apps/web/tests/pwsh-terminal.e2e.ts @@ -55,7 +55,7 @@ describe.skipIf(MODE === 'record' || !HAS_PWSH)('web e2e: pwsh calls use the bas await seedSession(scaffold, fixture, SEED_ID) browser = await chromium.launch() page = await newEnglishPage(browser) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index f0325a9e63..b63815975a 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -75,7 +75,7 @@ describe('web e2e: resident question composer round trip', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) // Fresh world: connect a Workspace so the composer scenarios start live. await connectFreshWorkspace(page, scaffold.workspaceCwd) diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index 3dfeee4f4a..d026aa1110 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -81,7 +81,7 @@ describe('web e2e: queue row actions', () => { browser = await chromium.launch() page = await newEnglishPage(browser) const tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) onTestFailed(() => saveFailureShot(page, 'web-e2e-queue-actions')) @@ -191,7 +191,7 @@ describe('web e2e: queue row actions', () => { browser = await chromium.launch() page = await newEnglishPage(browser) const tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) onTestFailed(() => saveFailureShot(page, 'web-e2e-context-layout')) diff --git a/apps/web/tests/rail-search-expand.e2e.ts b/apps/web/tests/rail-search-expand.e2e.ts index 74f7cb12c9..bf520f3ebb 100644 --- a/apps/web/tests/rail-search-expand.e2e.ts +++ b/apps/web/tests/rail-search-expand.e2e.ts @@ -30,7 +30,7 @@ describe('web e2e: rail search click survives its own document-level bubble', () browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts index c6d8bfb60f..1e052462f5 100644 --- a/apps/web/tests/reference-composer.e2e.ts +++ b/apps/web/tests/reference-composer.e2e.ts @@ -147,7 +147,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) await writeFile(join(scaffold.workspaceCwd, 'workspace', 'reference.txt'), 'reference fixture\n') diff --git a/apps/web/tests/remote-welcome.e2e.ts b/apps/web/tests/remote-welcome.e2e.ts index 256afaeede..b7b013dc2d 100644 --- a/apps/web/tests/remote-welcome.e2e.ts +++ b/apps/web/tests/remote-welcome.e2e.ts @@ -29,7 +29,7 @@ describe.skipIf(MODE === 'record')('web e2e: remote welcome notice', () => { locale: ZH_BROWSER_LOCALE, }) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('#root', { timeout: 30_000 }) }, 120_000) diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index fa1ee79079..30644607f5 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -50,7 +50,7 @@ describe('web e2e: fresh round trip through the real assembly', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) // Fresh world: connect a Workspace so the composer scenarios start live. await connectFreshWorkspace(page, scaffold.workspaceCwd) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index a4e1adc07b..bde659deec 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -205,6 +205,8 @@ export interface WebScaffold { mode: WebSnapshotMode /** Browser-facing origin for the bound test server. */ baseUrl: string + /** Process-token URL that establishes this scaffold's browser session. */ + authenticatedUrl: string /** Settled root context (the in-process readiness barrier; headless event subscription is its sanctioned use). */ ctx: Context /** Temp project directory sessions run in (shell/fs tool cwd). */ @@ -213,6 +215,8 @@ export interface WebScaffold { persistenceRoot: string /** Isolated harness home the settings/credentials rows write ($DSH_HOME double). */ harnessHome: string + /** Send a browser-equivalent Host request with this scaffold's authenticated cookie. */ + hostFetch(path: string, init?: RequestInit): Promise /** Await a settled turn end: in-process turn/end, then the agent's idle flip (which follows the persistence flush). */ whenTurnSettled(timeoutMs?: number): Promise /** @@ -559,6 +563,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { + const headers = new Headers(init.headers) + headers.set('cookie', cookieHeader) + return fetch(new URL(path, baseUrl), { ...init, headers }) + }, // Barrier stack: the in-process turn/end identifies the session, its // explicit flush makes the transcript durable, and the caller's browser // settled-poll comes last because host completion strictly precedes render. diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 65192533c5..9b0b407464 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -235,7 +235,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { }) await page.addInitScript(() => { localStorage.setItem('dsh.locale', 'en') }) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) expect(await page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone)) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index ad4af3b3da..07d5810185 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -208,7 +208,7 @@ describe('web e2e: seeded history renders through cold resume', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) @@ -288,6 +288,10 @@ describe('web e2e: seeded history renders through cold resume', () => { // only — the prompt and full tool output must stay on screen. expect(await page.getByText(PROMPT, { exact: true }).count()).toBe(1) + await expect.poll( + () => scaffold.ctx.agents.get(SessionId(SEED_ID)) !== undefined, + { timeout: 10_000 }, + ).toBe(true) const agent = scaffold.ctx.agents.get(SessionId(SEED_ID)) if (agent === undefined) throw new Error('seeded session did not attach an agent') agent.session.append('user/message', createUserMessage({ diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 35de5ac4f2..27521b8a57 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -42,7 +42,7 @@ describe('web e2e: settings modal and General preferences', () => { // the client derives from it (the English default has its own spec below). page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) @@ -314,7 +314,7 @@ describe('web e2e: settings modal and General preferences', () => { try { expect(second.baseUrl).not.toBe(scaffold.baseUrl) await secondPage.emulateMedia({ colorScheme: 'light' }) - await secondPage.goto(second.baseUrl, { waitUntil: 'load' }) + await secondPage.goto(second.authenticatedUrl, { waitUntil: 'load' }) await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await expect.poll(async () => (await readState(secondPage)).attr, { timeout: 5_000 }).toBe(true) const secondState = await readState(secondPage) @@ -372,7 +372,7 @@ describe('web e2e: settings modal and General preferences', () => { const secondTripwire = watchConsole(secondPage) try { expect(second.baseUrl).not.toBe(scaffold.baseUrl) - await secondPage.goto(second.baseUrl, { waitUntil: 'load' }) + await secondPage.goto(second.authenticatedUrl, { waitUntil: 'load' }) await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await secondPage.getByRole('button', { name: '设置', exact: true }).click() await secondPage.getByRole('dialog', { name: '设置' }) @@ -438,7 +438,7 @@ describe('web e2e: settings modal and General preferences', () => { const secondTripwire = watchConsole(secondPage) try { expect(second.baseUrl).not.toBe(scaffold.baseUrl) - await secondPage.goto(second.baseUrl, { waitUntil: 'load' }) + await secondPage.goto(second.authenticatedUrl, { waitUntil: 'load' }) await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await secondPage.getByRole('button', { name: 'Settings', exact: true }).click() await secondPage.getByRole('dialog', { name: 'Settings' }) @@ -472,7 +472,7 @@ describe('web e2e: settings modal and General preferences', () => { const enTripwire = watchConsole(enPage) onTestFailed(() => saveFailureShot(enPage, 'web-e2e-settings-browser-language')) try { - await enPage.goto(fresh.baseUrl, { waitUntil: 'load' }) + await enPage.goto(fresh.authenticatedUrl, { waitUntil: 'load' }) await enPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) expect(await enPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull() await enPage.getByRole('button', { name: 'Settings', exact: true }).click() @@ -498,7 +498,7 @@ describe('web e2e: settings modal and General preferences', () => { const frTripwire = watchConsole(frPage) onTestFailed(() => saveFailureShot(frPage, 'web-e2e-settings-unshipped-language')) try { - await frPage.goto(fresh.baseUrl, { waitUntil: 'load' }) + await frPage.goto(fresh.authenticatedUrl, { waitUntil: 'load' }) await frPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) expect(await frPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull() await frPage.getByRole('button', { name: 'Settings', exact: true }).click() diff --git a/apps/web/tests/sidebar-scrollbar.e2e.ts b/apps/web/tests/sidebar-scrollbar.e2e.ts index 3c2761e72d..899bf57e19 100644 --- a/apps/web/tests/sidebar-scrollbar.e2e.ts +++ b/apps/web/tests/sidebar-scrollbar.e2e.ts @@ -292,7 +292,7 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum // the list with room to spare. page = await newEnglishPage(browser, 800) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await expandSeededSessions(page) // Every assertion about a thumb colour needs a drawn thumb, and the column diff --git a/apps/web/tests/sidebar-subagent-activity.e2e.ts b/apps/web/tests/sidebar-subagent-activity.e2e.ts index ed087f0b8f..43fbd22f05 100644 --- a/apps/web/tests/sidebar-subagent-activity.e2e.ts +++ b/apps/web/tests/sidebar-subagent-activity.e2e.ts @@ -111,7 +111,7 @@ describe('web e2e: sidebar subagent activity', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) const workspace = await scaffold.ctx.workspaceRegistry.resolveByPath(cwd) diff --git a/apps/web/tests/skill-invocation-policy.e2e.ts b/apps/web/tests/skill-invocation-policy.e2e.ts index a938ed65d8..de0a838417 100644 --- a/apps/web/tests/skill-invocation-policy.e2e.ts +++ b/apps/web/tests/skill-invocation-policy.e2e.ts @@ -83,7 +83,7 @@ describe('web e2e: skill invocation policy through the real host', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) diff --git a/apps/web/tests/skill-tool-row.e2e.ts b/apps/web/tests/skill-tool-row.e2e.ts index 6e41ba9035..25914d5fa0 100644 --- a/apps/web/tests/skill-tool-row.e2e.ts +++ b/apps/web/tests/skill-tool-row.e2e.ts @@ -33,7 +33,7 @@ describe.skipIf(MODE === 'record')('web e2e: dedicated Skill tool row', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) const groupRow = page.locator('[role="treeitem"]').first() diff --git a/apps/web/tests/skill-user-invoke.e2e.ts b/apps/web/tests/skill-user-invoke.e2e.ts index 767146d2b9..04c7d6880c 100644 --- a/apps/web/tests/skill-user-invoke.e2e.ts +++ b/apps/web/tests/skill-user-invoke.e2e.ts @@ -79,7 +79,7 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) diff --git a/apps/web/tests/startup-auto-selection.e2e.ts b/apps/web/tests/startup-auto-selection.e2e.ts index d85343d7f9..7cfc3e68b2 100644 --- a/apps/web/tests/startup-auto-selection.e2e.ts +++ b/apps/web/tests/startup-auto-selection.e2e.ts @@ -29,7 +29,7 @@ describe('web e2e: startup auto-selection', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 180_000) diff --git a/apps/web/tests/startup-rpc-budget.e2e.ts b/apps/web/tests/startup-rpc-budget.e2e.ts index b9e06fcb3b..59faec6512 100644 --- a/apps/web/tests/startup-rpc-budget.e2e.ts +++ b/apps/web/tests/startup-rpc-budget.e2e.ts @@ -33,7 +33,7 @@ describe('startup RPC budget', () => { const url = new URL(request.url()) if (url.pathname.startsWith('/api/')) calls.push(url.pathname.slice('/api/'.length)) }) - await page.goto(scaffold.baseUrl) + await page.goto(scaffold.authenticatedUrl) // Boot settles when the workspace picker is interactive; the trailing wait // absorbs the first-connection reset wave the budget must include. await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: 30_000 }) diff --git a/apps/web/tests/stats-paged-history.e2e.ts b/apps/web/tests/stats-paged-history.e2e.ts index 845b9a3593..1e6682489d 100644 --- a/apps/web/tests/stats-paged-history.e2e.ts +++ b/apps/web/tests/stats-paged-history.e2e.ts @@ -83,7 +83,7 @@ describe('web e2e: whole-session stats survive history paging', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 111b49f705..30a4afb832 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -78,7 +78,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) // Fresh world: connect a Workspace so the composer scenarios start live. await connectFreshWorkspace(page, scaffold.workspaceCwd) @@ -191,7 +191,7 @@ describe('web e2e: composer shortcut steers directly', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) @@ -248,7 +248,7 @@ describe('web e2e: composer shortcut follows the swapped busy behavior', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) @@ -314,7 +314,7 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) await page.getByText('Standard mode', { exact: true }).waitFor({ timeout: 10_000 }) diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 0be7e1f05e..a786b2c6cf 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -88,7 +88,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = if (path.startsWith('/api/')) apiCalls.push(path) }) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) diff --git a/apps/web/tests/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts index aa985f6601..e9ef24ca4f 100644 --- a/apps/web/tests/subagent-interrupt-ui.e2e.ts +++ b/apps/web/tests/subagent-interrupt-ui.e2e.ts @@ -134,7 +134,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co if (path.startsWith('/api/')) apiCalls.push(path) }) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) diff --git a/apps/web/tests/subagent-interrupt.e2e.ts b/apps/web/tests/subagent-interrupt.e2e.ts index b3b9a53037..6edb81e404 100644 --- a/apps/web/tests/subagent-interrupt.e2e.ts +++ b/apps/web/tests/subagent-interrupt.e2e.ts @@ -23,8 +23,8 @@ const WAKING = 'And add one concrete example.' type RpcResult = { ok: true; value: T } | { ok: false; error: { code: string; message: string } } /** POST one API Proxy unary RPC through the real HTTP carrier and unwrap its result. */ -async function rpc(baseUrl: string, method: string, payload: unknown): Promise> { - const response = await fetch(`${baseUrl}/api/${method}`, { +async function rpc(scaffold: WebScaffold, method: string, payload: unknown): Promise> { + const response = await scaffold.hostFetch(`/api/${method}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ @@ -39,9 +39,9 @@ async function rpc(baseUrl: string, method: string, payload: unknown): Promis } /** POST one generated Session Remote unary through the API Gateway carrier. */ -async function sessionRemote(baseUrl: string, method: string, request: unknown): Promise> { +async function sessionRemote(scaffold: WebScaffold, method: string, request: unknown): Promise> { const endpoint = `session/${method}` - const response = await fetch(`${baseUrl}/api/${endpoint}`, { + const response = await scaffold.hostFetch(`/api/${endpoint}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ @@ -108,7 +108,7 @@ describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real co }) // A live parent Agent through the real API; no workspace or browser. - const created = await sessionRemote<{ sessionId: string }>(scaffold.baseUrl, 'create', { + const created = await sessionRemote<{ sessionId: string }>(scaffold, 'create', { cwd: scaffold.workspaceCwd, }) if (!created.ok) throw new Error(`session.create failed: ${created.error.code}`) @@ -138,7 +138,7 @@ describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real co it('parks a queued follow-up on interrupt and resumes it FIFO on a waking send', async () => { // Queue the follow-up while the turn is still open, then interrupt. - const queued = await rpc<{ messageId: string }>(scaffold.baseUrl, 'subagent.prompt', { + const queued = await rpc<{ messageId: string }>(scaffold, 'subagent.prompt', { parentSessionId: parentId, childSessionId: childId, mode: 'continuable', @@ -147,7 +147,7 @@ describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real co expect(queued).toMatchObject({ ok: true }) const settled = scaffold.whenTurnSettled() - const interrupted = await rpc<{ accepted: true }>(scaffold.baseUrl, 'subagent.interrupt', { + const interrupted = await rpc<{ accepted: true }>(scaffold, 'subagent.interrupt', { parentSessionId: parentId, childSessionId: childId, mode: 'continuable', @@ -169,7 +169,7 @@ describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real co // Only an explicit waking send resumes the parked queue, FIFO, then the // child runs both turns to completion and settles. - const waking = await rpc<{ messageId: string }>(scaffold.baseUrl, 'subagent.prompt', { + const waking = await rpc<{ messageId: string }>(scaffold, 'subagent.prompt', { parentSessionId: parentId, childSessionId: childId, mode: 'continuable', diff --git a/apps/web/tests/trajectory-virtualization.e2e.ts b/apps/web/tests/trajectory-virtualization.e2e.ts index 6629bb0b5d..e0f2b5bb66 100644 --- a/apps/web/tests/trajectory-virtualization.e2e.ts +++ b/apps/web/tests/trajectory-virtualization.e2e.ts @@ -196,7 +196,7 @@ describe('web e2e: Trajectory virtualization over tail-paged history', () => { browser = await chromium.launch() page = await newEnglishPage(browser, 900) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) // The compact layout dropped group session counts; the seeded baseline is // the Ungrouped bucket once cold summaries load. diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts index ce848fa96b..b2ea2baf88 100644 --- a/apps/web/tests/turn-tail-actions.e2e.ts +++ b/apps/web/tests/turn-tail-actions.e2e.ts @@ -80,7 +80,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) } diff --git a/apps/web/tests/web-search-round.e2e.ts b/apps/web/tests/web-search-round.e2e.ts index 3299b1b163..7237670f0b 100644 --- a/apps/web/tests/web-search-round.e2e.ts +++ b/apps/web/tests/web-search-round.e2e.ts @@ -160,7 +160,7 @@ describe('web e2e: shipped default web search', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) diff --git a/apps/web/tests/workflow-run.e2e.ts b/apps/web/tests/workflow-run.e2e.ts index cf5999b626..f3d9e2c8b3 100644 --- a/apps/web/tests/workflow-run.e2e.ts +++ b/apps/web/tests/workflow-run.e2e.ts @@ -59,7 +59,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 2dbd8cd1f4..a8345c45d3 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -124,7 +124,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index d23a564580..e32509f544 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: 3fce90069441b683f449b4b16def95d8d9acc323 -module-graph.zh.md: f7431bd5a417ff6c5de4efe22a1d711611b1ffee +module-graph.md: cc8eaf8b49dc95568d34a4d57e9cbff8d30656c1 +module-graph.zh.md: 73ba9081455a265194aae943fb96efc0ec95d38f diff --git a/docs/module-graph.md b/docs/module-graph.md index 3fce900694..cc8eaf8b49 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -399,8 +399,6 @@ flowchart TD pkg_subprocess_e2b --> pkg_invariants pkg_subprocess_e2b --> pkg_subprocess pkg_subprocess_e2b --> pkg_timeout - pkg_host_frontend_static --> pkg_host_webserver - pkg_host_frontend_static --> pkg_invariants pkg_host_plugin_inventory --> pkg_brand pkg_host_plugin_inventory --> pkg_invariants pkg_host_plugin_inventory --> pkg_typert_protocol @@ -1076,6 +1074,7 @@ flowchart TD pkg_tool_session_query --> pkg_tools pkg_client_connection --> pkg_attachment pkg_client_connection --> pkg_commands + pkg_client_connection --> pkg_credentials pkg_client_connection --> pkg_host_apiproxy pkg_client_connection --> pkg_host_webserver pkg_client_connection --> pkg_invariants @@ -1195,6 +1194,9 @@ flowchart TD pkg_experimental_agent_team --> pkg_session pkg_experimental_agent_team --> pkg_session_persistence pkg_experimental_agent_team --> pkg_subagent + pkg_host_frontend_static --> pkg_client_connection + pkg_host_frontend_static --> pkg_host_webserver + pkg_host_frontend_static --> pkg_invariants pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -1696,7 +1698,6 @@ flowchart TD | [`client-modules`](../packages/client/modules) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1829,7 +1830,7 @@ flowchart TD | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | @@ -1845,6 +1846,7 @@ flowchart TD | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) | +| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index f7431bd5a4..73ba908145 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -401,8 +401,6 @@ flowchart TD pkg_subprocess_e2b --> pkg_invariants pkg_subprocess_e2b --> pkg_subprocess pkg_subprocess_e2b --> pkg_timeout - pkg_host_frontend_static --> pkg_host_webserver - pkg_host_frontend_static --> pkg_invariants pkg_host_plugin_inventory --> pkg_brand pkg_host_plugin_inventory --> pkg_invariants pkg_host_plugin_inventory --> pkg_typert_protocol @@ -1078,6 +1076,7 @@ flowchart TD pkg_tool_session_query --> pkg_tools pkg_client_connection --> pkg_attachment pkg_client_connection --> pkg_commands + pkg_client_connection --> pkg_credentials pkg_client_connection --> pkg_host_apiproxy pkg_client_connection --> pkg_host_webserver pkg_client_connection --> pkg_invariants @@ -1197,6 +1196,9 @@ flowchart TD pkg_experimental_agent_team --> pkg_session pkg_experimental_agent_team --> pkg_session_persistence pkg_experimental_agent_team --> pkg_subagent + pkg_host_frontend_static --> pkg_client_connection + pkg_host_frontend_static --> pkg_host_webserver + pkg_host_frontend_static --> pkg_invariants pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -1698,7 +1700,6 @@ flowchart TD | [`client-modules`](../packages/client/modules) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1831,7 +1832,7 @@ flowchart TD | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | @@ -1847,6 +1848,7 @@ flowchart TD | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) | +| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/packages/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts index 232cf9b2f6..92c5c7c621 100644 --- a/packages/api/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -58,6 +58,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { const { Session, SessionId } = await import(urls.session) const routes = [] + const credentialRecords = new Map() const host = new Context() host.provide('webServer', { register(route) { @@ -67,6 +68,15 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { tapIndex() { return () => {} }, port: 0, }) + host.provide('credentials', { + readRecord(key) { return Promise.resolve(credentialRecords.get(key)) }, + async modifyRecord(key, mutate) { + const current = credentialRecords.get(key) + const next = await mutate(current) + if (next !== undefined) credentialRecords.set(key, next) + return next ?? current + }, + }) await host.plugin({ inject: connectionHost.inject, apply: connectionHost.apply }) await host.plugin(TypertRegistry) await host.plugin(AgentRegistry) @@ -101,11 +111,32 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { if (routes.length !== 1 || routes[0].path !== '/api') { throw new Error('Connection did not register exactly one /api route') } - const server = createServer((request, response) => { void routes[0].handler(request, response) }) + const server = createServer((request, response) => { + if ((request.url ?? '/').startsWith('/?')) { + void host.connection.authorizeIndex(request, response).then(authorized => { + if (authorized) { + response.writeHead(200, { 'content-type': 'text/html' }) + response.end('shell') + } + }) + return + } + void routes[0].handler(request, response) + }) await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen)) const address = server.address() if (address === null || typeof address === 'string') throw new Error('HTTP server has no TCP address') const origin = 'http://127.0.0.1:' + String(address.port) + const login = await fetch(host.connection.authenticatedUrl(origin), { redirect: 'manual' }) + const setCookie = login.headers.get('set-cookie') + if (login.status !== 303 || setCookie === null) throw new Error('browser token exchange failed') + const cookie = setCookie.split(';', 1)[0] + const hostFetch = globalThis.fetch + globalThis.fetch = (input, init = {}) => { + const headers = new Headers(init.headers) + headers.set('cookie', cookie) + return hostFetch(input, { ...init, headers }) + } const handoffs = new Map() globalThis.window = { diff --git a/packages/bundle/web-app/tests/browser-open.spec.ts b/packages/bundle/web-app/tests/browser-open.spec.ts index 0e2e22649d..5c1560c445 100644 --- a/packages/bundle/web-app/tests/browser-open.spec.ts +++ b/packages/bundle/web-app/tests/browser-open.spec.ts @@ -29,6 +29,7 @@ afterEach(async () => { vi.unstubAllEnvs() Reflect.deleteProperty(globalThis, '__dshWebAppApply') Reflect.deleteProperty(globalThis, '__dshWebServer') + Reflect.deleteProperty(globalThis, '__dshConnection') }) describe('web app browser startup', () => { @@ -42,8 +43,14 @@ describe('web app browser startup', () => { internals.resolveDistIndex = () => index const webserverModule = join(root, 'webserver.mjs') + const connectionModule = join(root, 'connection.mjs') const webAppModule = join(root, 'web-app.mjs') writeFileSync(webserverModule, 'export default globalThis.__dshWebServer\n') + writeFileSync(connectionModule, [ + "export const inject = ['webServer']", + "export const apply = ctx => ctx.provide('connection', globalThis.__dshConnection)", + '', + ].join('\n')) writeFileSync(webAppModule, [ "export const name = 'fixture-web-app'", "export const inject = ['webServer']", @@ -57,6 +64,8 @@ describe('web app browser startup', () => { ' config:', ' host: 127.0.0.1', ' port: 0', + '- id: connection', + ` name: ${pathToFileURL(connectionModule).href}`, '- id: web-app', ` name: ${pathToFileURL(webAppModule).href}`, ' config:', @@ -70,9 +79,25 @@ describe('web app browser startup', () => { const globals = globalThis as unknown as { __dshWebAppApply: typeof apply __dshWebServer: typeof WebServer + __dshConnection: { + authenticatedUrl(baseUrl: string): string + authorizeIndex(): Promise + requestRejection(): Promise + rpc: object + } } globals.__dshWebAppApply = apply globals.__dshWebServer = WebServer + globals.__dshConnection = { + authenticatedUrl: (baseUrl) => { + const url = new URL(baseUrl) + url.searchParams.set('token', 'fixture-token') + return url.href + }, + authorizeIndex: () => Promise.resolve(true), + requestRejection: () => Promise.resolve(undefined), + rpc: {}, + } let openedUrl: string | undefined let openedStatus: number | undefined @@ -95,7 +120,7 @@ describe('web app browser startup', () => { await ctx.loader.await() await opened - expect(openedUrl).toBe(`http://127.0.0.1:${String(ctx.webServer.port)}`) + expect(openedUrl).toBe(`http://127.0.0.1:${String(ctx.webServer.port)}/?token=fixture-token`) expect(openedStatus).toBe(200) }) }) diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index 90f7063bf7..4376147e47 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -286,6 +286,7 @@ describe('web-app runtime glue', () => { const tornSettlement = new Promise((resolve) => { releaseTorn = resolve }) provideLoader(torn, () => tornSettlement) apply(torn, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: [] })) + await new Promise(resolve => setTimeout(resolve, 0)) await child.dispose() // the webServer service goes away releaseTorn!() await new Promise(resolve => setTimeout(resolve, 0)) diff --git a/packages/client/connection/src/browser-auth.ts b/packages/client/connection/src/browser-auth.ts index 7c57622bb2..1f2bd8768a 100644 --- a/packages/client/connection/src/browser-auth.ts +++ b/packages/client/connection/src/browser-auth.ts @@ -16,6 +16,7 @@ const TOKEN_QUERY = 'token' const COOKIE_PREFIX = 'dsh-auth-' const COOKIE_PAYLOAD_VERSION = 1 const STORED_SECRET_VERSION = 1 +const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/ interface StoredSecretPayload { readonly version: typeof STORED_SECRET_VERSION @@ -33,6 +34,20 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } +function encodeBase64Url(value: Uint8Array): string { + return Buffer.from(value).toString('base64') + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/u, '') +} + +function decodeBase64Url(value: string): Buffer | undefined { + if (!BASE64URL_PATTERN.test(value) || value.length % 4 === 1) return undefined + const padding = '='.repeat((4 - value.length % 4) % 4) + const decoded = Buffer.from(value.replaceAll('-', '+').replaceAll('_', '/') + padding, 'base64') + return encodeBase64Url(decoded) === value ? decoded : undefined +} + function header( headers: ConnectionTrustRequest['headers'], name: string, @@ -55,8 +70,8 @@ function requestAuthority(headers: ConnectionTrustRequest['headers']): string | function canonicalSecret(value: unknown): Buffer | undefined { if (typeof value !== 'string') return undefined - const decoded = Buffer.from(value, 'base64url') - if (decoded.byteLength !== SECRET_BYTES || decoded.toString('base64url') !== value) return undefined + const decoded = decodeBase64Url(value) + if (decoded === undefined || decoded.byteLength !== SECRET_BYTES) return undefined return decoded } @@ -80,7 +95,7 @@ function tokenMatches(actual: string, expected: string): boolean { } function cookieName(authority: string): string { - return COOKIE_PREFIX + createHash('sha256').update(authority).digest('base64url') + return COOKIE_PREFIX + encodeBase64Url(createHash('sha256').update(authority).digest()) } /** Read the exact generated cookie without implementing general Cookie decoding. */ @@ -103,8 +118,8 @@ function signature(secret: Buffer, body: string): Buffer { } function encodeCookie(payload: BrowserCookiePayload, secret: Buffer): string { - const body = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url') - return `v1.${body}.${signature(secret, body).toString('base64url')}` + const body = encodeBase64Url(Buffer.from(JSON.stringify(payload), 'utf8')) + return `v1.${body}.${encodeBase64Url(signature(secret, body))}` } function decodeCookie(value: string, secret: Buffer): BrowserCookiePayload | undefined { @@ -113,14 +128,16 @@ function decodeCookie(value: string, secret: Buffer): BrowserCookiePayload | und if (parts.length !== 3 || version !== 'v1' || body === undefined || encodedSignature === undefined) { return undefined } - const actualSignature = Buffer.from(encodedSignature, 'base64url') - if (actualSignature.toString('base64url') !== encodedSignature) return undefined + const actualSignature = decodeBase64Url(encodedSignature) + if (actualSignature === undefined) return undefined const expectedSignature = signature(secret, body) if (actualSignature.byteLength !== expectedSignature.byteLength || !timingSafeEqual(actualSignature, expectedSignature)) return undefined let decoded: unknown try { - decoded = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) + const bodyBytes = decodeBase64Url(body) + if (bodyBytes === undefined) return undefined + decoded = JSON.parse(bodyBytes.toString('utf8')) } catch { return undefined } @@ -139,7 +156,7 @@ function decodeCookie(value: string, secret: Buffer): BrowserCookiePayload | und * process restart. */ export class BrowserAuth { - private readonly launchToken = randomBytes(SECRET_BYTES).toString('base64url') + private readonly launchToken = encodeBase64Url(randomBytes(SECRET_BYTES)) private readonly maxAgeMilliseconds: number private constructor( @@ -248,7 +265,7 @@ export class BrowserAuth { private async ensureSecret(): Promise { const generated: StoredSecretPayload = { version: STORED_SECRET_VERSION, - secret: randomBytes(SECRET_BYTES).toString('base64url'), + secret: encodeBase64Url(randomBytes(SECRET_BYTES)), } const record = await this.credentials.modifyRecord(AUTH_RECORD_KEY, (current) => { if (current !== undefined) { diff --git a/packages/client/connection/tests/api-request-trust.host.spec.ts b/packages/client/connection/tests/api-request-trust.host.spec.ts index f145230a8b..359a461e94 100644 --- a/packages/client/connection/tests/api-request-trust.host.spec.ts +++ b/packages/client/connection/tests/api-request-trust.host.spec.ts @@ -68,6 +68,13 @@ describe('isTrustedApiRequest', () => { expect(isTrustedApiRequest(request({ host: 'localhost:3080', 'sec-fetch-site': 'same-origin' }), [])).toBe(true) }) + it('reads Fetch Headers while preserving absent browser markers', () => { + expect(isTrustedApiRequest({ headers: new Headers({ host: '127.0.0.1:3080' }) }, [])).toBe(true) + expect(isTrustedApiRequest({ + headers: new Headers({ host: '127.0.0.1:3080', origin: 'http://evil.example' }), + }, [])).toBe(false) + }) + it('assertTrustedAuthority accepts bare authorities and throws on anything more', () => { for (const entry of ['harness.internal', 'harness.internal:3080', 'HARNESS.internal:80', '10.0.0.9', '[::1]:3080']) { expect(() => { assertTrustedAuthority(entry) }).not.toThrow() diff --git a/packages/client/connection/tests/browser-auth.host.spec.ts b/packages/client/connection/tests/browser-auth.host.spec.ts index 21ce8f592b..6a62d6401c 100644 --- a/packages/client/connection/tests/browser-auth.host.spec.ts +++ b/packages/client/connection/tests/browser-auth.host.spec.ts @@ -31,15 +31,19 @@ class RecordCredentials { } function signedCookie(store: RecordCredentials, name: string, payload: unknown): string { + const body = typeof payload === 'string' + ? Buffer.from(payload, 'utf8').toString('base64url') + : Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url') + return signedBodyCookie(store, name, body) +} + +function signedBodyCookie(store: RecordCredentials, name: string, body: string): string { const record = store.record if (record?.kind !== 'grant' || typeof record.payload !== 'object' || record.payload === null) { throw new Error('test credential store has no signing secret') } const secret: unknown = Reflect.get(record.payload, 'secret') if (typeof secret !== 'string') throw new Error('test credential record has no string secret') - const body = typeof payload === 'string' - ? Buffer.from(payload, 'utf8').toString('base64url') - : Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url') const signature = createHmac('sha256', Buffer.from(secret, 'base64url')).update(body).digest('base64url') return `${name}=v1.${body}.${signature}` } @@ -169,6 +173,9 @@ describe('BrowserAuth', () => { expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=broken` }))).toBe(false) expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=${value.slice(0, -1)}x` }))).toBe(false) expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=%` }))).toBe(false) + expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { + cookie: signedBodyCookie(store, name, 'a'), + }))).toBe(false) expect(await auth.isAuthenticated({ headers: {} })).toBe(false) expect(await auth.isAuthenticated({ headers: { host: 'bad host', cookie } })).toBe(false) expect(await auth.isAuthenticated({ headers: { host: '127.0.0.1:3080' } })).toBe(false) diff --git a/packages/experimental/webworker-runtime/src/transport/tunnel.ts b/packages/experimental/webworker-runtime/src/transport/tunnel.ts index b181e89687..dc6608f975 100644 --- a/packages/experimental/webworker-runtime/src/transport/tunnel.ts +++ b/packages/experimental/webworker-runtime/src/transport/tunnel.ts @@ -4,11 +4,9 @@ * * - `GET /__boot__` answers from tunnel glue, never from the host API surface, * because the page needs the boot payload before its Cordis tree exists. - * - Privileged `/api` methods take that same direct entry: the browser strips the - * `host` header from the WHATWG `Request` the route lane rebuilds, so the - * privileged fence would answer 403 for every one of them. The method set is - * not restated here — an unexpected 403 from the route lane is retried on the - * direct lane, which keeps the split honest without a copied list. + * - Privileged `/api` methods take that same direct entry. The method set is not + * restated here: a 401 or 403 from the route lane is retried on the direct + * lane because the page owns the worker and needs no network authentication. * - Everything else is fed into the real webserver route table through the * request listener the app's fake `node:http` captured, keeping the trust * fences, byte limits, and status semantics intact. @@ -88,7 +86,7 @@ export interface TunnelPort { export interface TunnelSeams { /** * Direct entry to the API fetch handler for privileged methods and any unary - * call the route lane refused with 403. + * call the route lane refused with 401 or 403. */ readonly directFetch: (request: Request) => Promise /** Boot payload for `GET /__boot__`: the structured index injection table. */ @@ -118,12 +116,12 @@ export interface TunnelServerOptions { readonly requestListener: () => Promise /** * Methods that skip the route lane outright. Supply the host's own privileged - * set when it is reachable; omitting it leaves the 403 retry as the mechanism. + * set when it is reachable; omitting it leaves the 401/403 retry as the mechanism. */ readonly privilegedMethods?: ReadonlySet /** * Escape hatch for the unary `/api` lane. `route` (default) keeps every fence - * and byte limit with a 403 retry on the direct lane; `direct` sends every + * and byte limit with a 401/403 retry on the direct lane; `direct` sends every * unary `/api` call straight to the fetch handler. */ readonly unaryApiLane?: 'route' | 'direct' @@ -135,7 +133,7 @@ interface InFlight { type QueuedFrame = TunnelRequestFrame | TunnelStreamOpenFrame -/** Recorded response frames, so a 403 from the route lane can be discarded. */ +/** Recorded response frames, so a route-lane authentication refusal can be discarded. */ class BufferedSink { private readonly calls: Array<() => void> = [] private target: ResponseSink | undefined @@ -228,7 +226,7 @@ export class TunnelServer { */ serve(seams: TunnelSeams): void { this.seams = seams - console.info(`webworker tunnel: serving (unary /api lane=${this.unaryApiLane}${this.unaryApiLane === 'route' ? ' with 403 retry' : ''}, privileged set=${this.privilegedMethods === undefined ? 'none' : String(this.privilegedMethods.size)}, queued=${String(this.queue.length)})`) + console.info(`webworker tunnel: serving (unary /api lane=${this.unaryApiLane}${this.unaryApiLane === 'route' ? ' with 401/403 retry' : ''}, privileged set=${this.privilegedMethods === undefined ? 'none' : String(this.privilegedMethods.size)}, queued=${String(this.queue.length)})`) for (const frame of this.queue.splice(0)) this.dispatchFrame(frame) } @@ -380,7 +378,7 @@ export class TunnelServer { /** * Unary `/api`: keep the route lane's fences, but fall back to the direct lane - * when the privileged fence refuses a request the page is entitled to make. + * when network authentication or trust rejects the worker-owning page. */ private async serveApi( original: TunnelRequestFrame, @@ -405,9 +403,8 @@ export class TunnelServer { if (outcome === 'aborted' || exchange.aborted) return // The decision happens at the first frame, before anything reaches the page: // the route lane streams its answers, so a refusal can carry a body too. - if (outcome.status === 403) { - // The privileged fence read a Request the browser stripped `host` from. - console.debug(`webworker tunnel: route lane refused ${method} with 403; answering on the direct lane`) + if (outcome.status === 401 || outcome.status === 403) { + console.debug(`webworker tunnel: route lane refused ${method} with ${String(outcome.status)}; answering on the direct lane`) await this.serveDirect(original, sink) return } diff --git a/packages/experimental/webworker-runtime/src/worker-host.ts b/packages/experimental/webworker-runtime/src/worker-host.ts index bbbf6a3900..27ffb27214 100644 --- a/packages/experimental/webworker-runtime/src/worker-host.ts +++ b/packages/experimental/webworker-runtime/src/worker-host.ts @@ -356,9 +356,9 @@ function requireLoweredImage(vfs: MemoryVfs, path: string): void { * endpoints (`/api//`) are served by an interceptor the gateway * registers on the Connection service, and answer 404 from the core routes. The * Connection service composes both halves in `createSharedFetchHandler`, whose - * fallback — not the composition — carries the privileged fence, so composing it - * here keeps every interceptor while leaving out the fence the direct lane exists - * to bypass. + * fallback — not the composition — carries network authentication and trust, so + * composing it here keeps every interceptor while leaving out the fences the + * worker-local direct lane exists to bypass. * @param ctx - Booted host context. * @param core - Fetch handler over the API surface. * @returns Handler covering interceptors and the core surface. diff --git a/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts b/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts index f9107e1037..0a32e77753 100644 --- a/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts +++ b/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts @@ -24,6 +24,42 @@ function seams(openStream: TunnelSeams['openStream']): TunnelSeams { } } +describe('worker tunnel unary authentication', () => { + it.each([401, 403])('retries a route-lane HTTP %s through the worker-local direct lane', async (status) => { + const frames: TunnelOutboundFrame[] = [] + const directFetch = vi.fn(async () => new Response('direct answer', { + status: 200, + headers: { 'content-type': 'text/plain' }, + })) + const server = new TunnelServer({ + port: { postMessage: (frame) => { frames.push(frame) } }, + requestListener: () => Promise.resolve((_req, response) => { + const res = response as { + writeHead(status: number, headers: Record): void + end(body: string): void + } + res.writeHead(status, { 'content-type': 'text/plain' }) + res.end('network request rejected') + }), + }) + server.serve({ + ...seams(async () => (async function *(): AsyncGenerator { yield undefined })()), + directFetch, + }) + + server.handleMessage({ + t: 'req', id: status, method: 'POST', url: 'http://localhost/api/session/list', headers: {}, + }) + + await vi.waitFor(() => { expect(frames).toHaveLength(1) }) + const [frame] = frames + expect(frame).toMatchObject({ t: 'res', id: status, status: 200 }) + if (frame?.t !== 'res' || frame.body === undefined) throw new Error('direct retry did not return one body') + expect(new TextDecoder().decode(frame.body)).toBe('direct answer') + expect(directFetch).toHaveBeenCalledOnce() + }) +}) + describe('worker tunnel logical streams', () => { it('drains a pre-boot open through the worker-local Gateway seam', async () => { const { server, frames } = harness() From 3b3b493a9607a2cbbebd3b01b89c8b328d15edeb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:21:42 +0800 Subject: [PATCH 08/12] fix(web): retain launch token across reloads --- ...-24-browser-token-authentication.i18n.yaml | 4 +- ...2026-08-24-browser-token-authentication.md | 6 +-- ...6-08-24-browser-token-authentication.zh.md | 6 +-- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- packages/bundle/web-app/src/index.ts | 3 ++ packages/bundle/web-app/tests/web-app.spec.ts | 18 +++++++ .../client/connection/src/browser-auth.ts | 31 ++++++++++-- packages/client/connection/src/index.ts | 2 +- .../tests/browser-auth.host.spec.ts | 50 +++++++++++++++---- 11 files changed, 101 insertions(+), 27 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml index 0691ce60ab..126c669ced 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.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-08-24-browser-token-authentication.md -2026-08-24-browser-token-authentication.md: 515990b81f3c92b7f3c422dcc40acfed1e5996ac -2026-08-24-browser-token-authentication.zh.md: 8066e2c3f62245779c6a03b5160c41e4bbc94ba5 +2026-08-24-browser-token-authentication.md: 69f24c148a1054f99447f1f9f2a6d2e5540c83db +2026-08-24-browser-token-authentication.zh.md: e9b96af21e913c1c3e482940c9f65d0d37506c81 diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md index 515990b81f..69f24c148a 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md @@ -12,11 +12,11 @@ The Web Host runs tool-capable Sessions with the current operating-system user's `dsh-client-connection` authenticates the complete Host API before dispatch. Every API Proxy method, Remote unary call, generic Connection channel, and Remote WebSocket stream requires the same browser session; endpoint ownership and method names do not alter authority. The existing Host/Origin checks run first and retain their DNS-rebinding and cross-site-request role, returning 403 when they fail. A trusted Host without a valid browser session receives 401. The browser-trust rules remain owned by the [carrier-level browser trust decision](2026-07-28-api-browser-trust-boundary.md). -Each Connection process generates a random launch token. `dsh-web-app` prints and opens the normal root URL with that token in the query. `frontend-static` asks Connection to authorize index responses: only `GET /?token=...` exchanges the process token for a cookie, then redirects to clean `/`; the token is not accepted on API paths or in an Authorization header. Missing and invalid credentials receive one minimal 401 response. Static non-index assets remain public. +Each Host process generates a random launch token, retained by the root application context across Connection hot reloads. `dsh-web-app` prints and opens the normal root URL with that token in the query once per process. `frontend-static` asks Connection to authorize index responses: only `GET /?token=...` exchanges the process token for a cookie, then redirects to clean `/`; the token is not accepted on API paths or in an Authorization header. An obsolete token paired with a valid cookie redirects to clean `/`. Missing and invalid credentials receive one minimal 401 response. Static non-index assets remain public. The cookie is a signed, authority-bound bearer. Its deterministic name and signed payload both include the normalized hostname plus port, so one Harness home can run independent Web ports without cookie collisions. The payload carries safe-integer issue and expiry times under an absolute lifetime; `cookieMaxAgeDays` defaults to 30. The cookie is host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`. It omits `Secure` because the shipped server uses loopback HTTP. There is no logout operation or reverse-proxy-specific handling. -The HMAC secret is a versioned `grant` record at `client-connection/browser-session` in `ctx.credentials`; the local provider stores it in `$DSH_HOME/.credentials.yaml`. Connection reads the record for each verification, so deletion or replacement revokes every existing cookie without restarting the process. A missing record is recreated only by a valid process-token exchange. Invalid owner payloads fail loud instead of being replaced. The launch token itself is never persisted and changes on every process start, while an unexpired cookie remains valid across restarts on the same authority. +The HMAC secret is a versioned `grant` record at `client-connection/browser-session` in `ctx.credentials`; the local provider stores it in `$DSH_HOME/.credentials.yaml`. Connection reads the record for each verification, so deletion or replacement revokes every existing cookie without restarting the process. A missing record is created when Connection starts; after runtime deletion, the next valid process-token exchange or process start recreates it. Invalid owner payloads fail loud instead of being replaced. The launch token itself is never persisted and changes on every process start, while an unexpired cookie remains valid across restarts on the same authority. The in-page Web Worker preview exposes no network socket. Its page-owned `postMessage` tunnel enters the real route first, then retries a 401 or 403 through the worker-local fetch handler. This keeps Connection interceptors while limiting the authentication bypass to the page that created the Host worker. @@ -24,7 +24,7 @@ The shipped CLI continues to reject `--host 0.0.0.0`. Authentication does not im ## Verification -Unit coverage pins token comparison, cookie attributes, HMAC and payload validation, authority and lifetime checks, persistent-secret reuse, record deletion, and invalid durable records. Host transport suites pin uniform 401/403 behavior for API Proxy, generic RPC, Typert Remote HTTP, and WebSocket upgrade paths. The frontend real-composition test boots credentials, Connection, webserver, and static serving through Loader and proves token exchange before index reads while static assets remain public. Packed-worker tests prove portable cookie encoding and worker-local retry for both authentication and trust rejection. A real-CLI test starts `dsh web` twice on one port with a temporary `DSH_HOME`, proves that forged `Host: localhost` is unauthenticated, calls `host.describe` with the exchanged cookie, observes a new process token, and reuses the old cookie after restart. +Unit coverage pins process-token retention across Connection reloads, cookie attributes, HMAC and payload validation, authority and lifetime checks, persistent-secret reuse, record deletion, invalid durable records, and cleanup of obsolete token URLs backed by valid cookies. Host transport suites pin uniform 401/403 behavior for API Proxy, generic RPC, Typert Remote HTTP, and WebSocket upgrade paths. The frontend real-composition test boots credentials, Connection, webserver, and static serving through Loader and proves token exchange before index reads while static assets remain public. Packed-worker tests prove portable cookie encoding and worker-local retry for both authentication and trust rejection. A real-CLI test starts `dsh web` twice on one port with a temporary `DSH_HOME`, proves that forged `Host: localhost` is unauthenticated, calls `host.describe` with the exchanged cookie, observes a new process token, and reuses the old cookie after restart. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md index 8066e2c3f6..e9b96af21e 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md @@ -12,11 +12,11 @@ Web Host 以当前操作系统用户的权限运行具有工具能力的 Session `dsh-client-connection` 在分发前认证完整 Host API。每个 API Proxy 方法、Remote 一元调用、通用 Connection channel 和 Remote WebSocket stream 都要求同一个浏览器会话;endpoint 所有权与方法名称不改变 authority。既有 Host/Origin 校验先执行,继续负责 DNS rebinding 和跨站请求防御,失败时返回 403。Host 可信但没有有效浏览器会话时返回 401。浏览器信任规则仍由[载体级浏览器信任决策](2026-07-28-api-browser-trust-boundary.zh.md)持有。 -每个 Connection 进程生成随机启动令牌。`dsh-web-app` 打印并打开 query 中带该令牌的普通根 URL。`frontend-static` 请求 Connection 授权 index 响应:只有 `GET /?token=...` 会把进程令牌交换为 cookie,再重定向到干净的 `/`;API 路径和 Authorization header 都不接受该令牌。缺失与无效凭据得到同一份最小 401 响应。非 index 静态资产保持公开。 +每个 Host 进程生成随机启动令牌,并由应用根 context 跨 Connection 热重载保留。`dsh-web-app` 每个进程只打印并打开一次 query 中带该令牌的普通根 URL。`frontend-static` 请求 Connection 授权 index 响应:只有 `GET /?token=...` 会把进程令牌交换为 cookie,再重定向到干净的 `/`;API 路径和 Authorization header 都不接受该令牌。过时令牌如果同时带有有效 cookie,会重定向到干净的 `/`。缺失与无效凭据得到同一份最小 401 响应。非 index 静态资产保持公开。 cookie 是签名且绑定 authority 的 bearer。确定性名称与签名 payload 都包含规范化 hostname 和 port,因此同一 Harness home 可以在不同 Web port 运行而不发生 cookie 冲突。payload 在绝对有效期内携带安全整数形式的签发与过期时间;`cookieMaxAgeDays` 默认为 30。cookie 是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`。随附服务器使用 loopback HTTP,因此不设置 `Secure`。这里没有 logout 操作或反向代理专用处理。 -HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` 的版本化 `grant` 记录;本地提供方将其存入 `$DSH_HOME/.credentials.yaml`。Connection 每次校验都读取记录,因此删除或替换记录无需重启进程即可撤销全部既有 cookie。缺失记录只能由有效进程令牌交换重新创建。无效 owner payload 会明确失败,而不是被覆盖。启动令牌本身绝不持久化并在每次进程启动时变化;未过期 cookie 则能在相同 authority 上跨重启继续有效。 +HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` 的版本化 `grant` 记录;本地提供方将其存入 `$DSH_HOME/.credentials.yaml`。Connection 每次校验都读取记录,因此删除或替换记录无需重启进程即可撤销全部既有 cookie。缺失记录在 Connection 启动时创建;运行期删除后,由下一次有效进程令牌交换或进程启动重新创建。无效 owner payload 会明确失败,而不是被覆盖。启动令牌本身绝不持久化并在每次进程启动时变化;未过期 cookie 则能在相同 authority 上跨重启继续有效。 页内 Web Worker preview 不暴露网络 socket。其由页面持有的 `postMessage` tunnel 先进入真实 route,收到 401 或 403 后再经 worker 本地 fetch handler 重试。这样既保留 Connection interceptor,又把认证绕过限制在创建 Host worker 的页面内。 @@ -24,7 +24,7 @@ HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` ## 验证 -单元覆盖固定令牌比较、cookie 属性、HMAC 与 payload 校验、authority 与有效期校验、持久密钥复用、记录删除及无效持久记录。Host 传输套件固定 API Proxy、通用 RPC、Typert Remote HTTP 和 WebSocket upgrade 路径上一致的 401/403 行为。frontend 真实组合测试经 Loader 启动 credentials、Connection、webserver 与静态服务,证明读取 index 前完成令牌交换,同时静态资产仍公开。打包 worker 测试证明 cookie 编码可移植,并覆盖认证与信任拒绝后的 worker 本地重试。真实 CLI 测试在临时 `DSH_HOME` 上用同一端口两次启动 `dsh web`,证明伪造 `Host: localhost` 仍未认证,以交换所得 cookie 调用 `host.describe`,观测新的进程令牌,并在重启后复用旧 cookie。 +单元覆盖 Connection 重载时保留进程令牌、cookie 属性、HMAC 与 payload 校验、authority 与有效期校验、持久密钥复用、记录删除、无效持久记录,以及用有效 cookie 清理过时令牌 URL。Host 传输套件固定 API Proxy、通用 RPC、Typert Remote HTTP 和 WebSocket upgrade 路径上一致的 401/403 行为。frontend 真实组合测试经 Loader 启动 credentials、Connection、webserver 与静态服务,证明读取 index 前完成令牌交换,同时静态资产仍公开。打包 worker 测试证明 cookie 编码可移植,并覆盖认证与信任拒绝后的 worker 本地重试。真实 CLI 测试在临时 `DSH_HOME` 上用同一端口两次启动 `dsh web`,证明伪造 `Host: localhost` 仍未认证,以交换所得 cookie 调用 `host.describe`,观测新的进程令牌,并在重启后复用旧 cookie。 ## 曾考虑的替代方案 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 15b0eabc28..222437150c 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: a4eeb7ffbe09253d7f0f979cd8dc681b4ae038ac -config-catalog.zh.md: 8490829a7881e6503fdf7b7652c679e5d3cc0f3c +config-catalog.md: aaeb30309288b2864c510cf7ebf6674988c568d1 +config-catalog.zh.md: ec649715bc74f2eb11c366b2eeca5026afdcdd6f diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a4eeb7ffbe..aaeb303092 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3143,7 +3143,7 @@ export interface Config { } ``` -Source: [`packages/bundle/web-app/src/index.ts:44`](../packages/bundle/web-app/src/index.ts) +Source: [`packages/bundle/web-app/src/index.ts:45`](../packages/bundle/web-app/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 8490829a78..ec649715bc 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3145,7 +3145,7 @@ export interface Config { } ``` -来源:[`packages/bundle/web-app/src/index.ts:44`](../packages/bundle/web-app/src/index.ts) +来源:[`packages/bundle/web-app/src/index.ts:45`](../packages/bundle/web-app/src/index.ts) diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index 5ec4ae7072..d0b2635f8e 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -33,6 +33,7 @@ export const name = 'web-app' /** This dsh installation's root, from either this package's source or built entry. */ const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url)) +const ANNOUNCED_ROOTS = new WeakSet() /** Runtime service that releases Web rows after bind-dependent values resolve. */ const WEB_RUNTIME_SERVICE = 'webRuntime' @@ -266,6 +267,7 @@ export function apply(ctx: Context, config: Config): void { // route owner are still mounting. Await Loader settlement first; a // hand-built tree without a Loader is already the complete tree. const announceReady = (): void => { + if (ANNOUNCED_ROOTS.has(connectionCtx.root)) return const webUrl = localWebUrl(connectionCtx) const authenticatedUrl = connectionCtx.connection.authenticatedUrl(webUrl) // Reuse the exact LAN snapshot provided to the /api trust fence. @@ -274,6 +276,7 @@ export function apply(ctx: Context, config: Config): void { const lanUrl = lanCandidate === undefined ? undefined : connectionCtx.connection.authenticatedUrl(`http://${lanCandidate}:${String(port)}`) + ANNOUNCED_ROOTS.add(connectionCtx.root) if (config.printUrl) { console.log(`dsh web: ${authenticatedUrl}${lanUrl === undefined ? '' : ` (LAN: ${lanUrl})`}`) } diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index 4376147e47..6a6f8c8c15 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -216,6 +216,24 @@ describe('web-app runtime glue', () => { await ctx.fiber.dispose() }) + it('does not publish readiness again when Connection reloads', async () => { + stageDist() + const ctx = new Context() + ctx.provide('webServer', fakeHttpServer().server) + const first = ctx.plugin((connectionCtx: Context) => { provideConnection(connectionCtx) }) + await first + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + apply(ctx, new Config({ openBrowser: false, printUrl: true, surfaceContext: true, trustedHosts: [] })) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(log).toHaveBeenCalledTimes(1) + + await first.dispose() + await ctx.plugin((connectionCtx: Context) => { provideConnection(connectionCtx) }) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(log).toHaveBeenCalledTimes(1) + await ctx.fiber.dispose() + }) + it.each([ ['SSH_CONNECTION', '10.0.0.2 55000 10.0.0.9 22'], ['SSH_TTY', '/dev/pts/3'], diff --git a/packages/client/connection/src/browser-auth.ts b/packages/client/connection/src/browser-auth.ts index 1f2bd8768a..3316b746bc 100644 --- a/packages/client/connection/src/browser-auth.ts +++ b/packages/client/connection/src/browser-auth.ts @@ -17,6 +17,7 @@ const COOKIE_PREFIX = 'dsh-auth-' const COOKIE_PAYLOAD_VERSION = 1 const STORED_SECRET_VERSION = 1 const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/ +const PROCESS_LAUNCH_TOKENS = new WeakMap() interface StoredSecretPayload { readonly version: typeof STORED_SECRET_VERSION @@ -48,6 +49,14 @@ function decodeBase64Url(value: string): Buffer | undefined { return encodeBase64Url(decoded) === value ? decoded : undefined } +function processLaunchToken(owner: object): string { + const existing = PROCESS_LAUNCH_TOKENS.get(owner) + if (existing !== undefined) return existing + const created = encodeBase64Url(randomBytes(SECRET_BYTES)) + PROCESS_LAUNCH_TOKENS.set(owner, created) + return created +} + function header( headers: ConnectionTrustRequest['headers'], name: string, @@ -156,13 +165,15 @@ function decodeCookie(value: string, secret: Buffer): BrowserCookiePayload | und * process restart. */ export class BrowserAuth { - private readonly launchToken = encodeBase64Url(randomBytes(SECRET_BYTES)) + private readonly launchToken: string private readonly maxAgeMilliseconds: number private constructor( + processOwner: object, private readonly credentials: CredentialProvider, maxAgeDays: number, ) { + this.launchToken = processLaunchToken(processOwner) this.maxAgeMilliseconds = maxAgeDays * DAY_MILLISECONDS if (!Number.isSafeInteger(this.maxAgeMilliseconds) || !Number.isSafeInteger(Date.now() + this.maxAgeMilliseconds)) { @@ -173,12 +184,17 @@ export class BrowserAuth { /** * Initialize browser authentication and create its durable signing secret * when this Harness home has none. + * @param processOwner - root application context retaining one token across Connection reloads. * @param credentials - persistent credential provider for the Web profile. * @param maxAgeDays - positive absolute browser-cookie lifetime in days. * @returns initialized authentication owner with a fresh process token. */ - static async create(credentials: CredentialProvider, maxAgeDays: number): Promise { - const auth = new BrowserAuth(credentials, maxAgeDays) + static async create( + processOwner: object, + credentials: CredentialProvider, + maxAgeDays: number, + ): Promise { + const auth = new BrowserAuth(processOwner, credentials, maxAgeDays) await auth.ensureSecret() return auth } @@ -232,6 +248,15 @@ export class BrowserAuth { res.end() return false } + if (req.method === 'GET' && url.pathname === '/' && await this.isAuthenticated(req)) { + res.writeHead(303, { + 'cache-control': 'no-store', + 'location': '/', + 'referrer-policy': 'no-referrer', + }) + res.end() + return false + } this.writeUnauthorized(req, res) return false } diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 0d43a322a3..ff4d3ba1c1 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -93,7 +93,7 @@ export async function apply(ctx: Context, config?: ConnectionConfig): Promise { + return BrowserAuth.create(processOwner, credentials(store), maxAgeDays) +} + function request(url: string, authority = '127.0.0.1:3080', init?: { cookie?: string method?: string @@ -108,7 +116,8 @@ afterEach(() => { describe('BrowserAuth', () => { it('mints one process token and a persistent authority-bound cookie', async () => { const store = new RecordCredentials() - const first = await BrowserAuth.create(credentials(store), 30) + const processOwner = {} + const first = await createAuth(store, 30, processOwner) const login = await exchange(first) expect(login.state).toMatchObject({ @@ -129,14 +138,33 @@ describe('BrowserAuth', () => { expect(await first.isAuthenticated(request('/', 'localhost:3080', { cookie: login.cookie }))).toBe(false) expect(await first.isAuthenticated(request('/', '127.0.0.1:3081', { cookie: login.cookie }))).toBe(false) - const restarted = await BrowserAuth.create(credentials(store), 30) + const reloaded = await createAuth(store, 30, processOwner) + expect(reloaded.authenticatedUrl('http://127.0.0.1:3080')).toBe(login.launchUrl) + expect(await reloaded.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: login.cookie }))).toBe(true) + + const restarted = await createAuth(store) expect(new URL(restarted.authenticatedUrl('http://127.0.0.1:3080')).searchParams.get('token')) .not.toBe(new URL(login.launchUrl).searchParams.get('token')) expect(await restarted.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: login.cookie }))).toBe(true) + const staleUrl = new URL(login.launchUrl) + const redirected = response() + expect(await restarted.authorizeIndex(request( + `${staleUrl.pathname}${staleUrl.search}`, + '127.0.0.1:3080', + { cookie: login.cookie }, + ), redirected.value)).toBe(false) + expect(redirected.state).toEqual({ + status: 303, + headers: { + 'cache-control': 'no-store', + 'location': '/', + 'referrer-policy': 'no-referrer', + }, + }) }) it('accepts the cookie for index serving and gives every unauthenticated request one response', async () => { - const auth = await BrowserAuth.create(credentials(new RecordCredentials()), 30) + const auth = await createAuth(new RecordCredentials()) const { cookie } = await exchange(auth) const allowed = response() expect(await auth.authorizeIndex(request('/index.html', '127.0.0.1:3080', { cookie }), allowed.value)).toBe(true) @@ -166,7 +194,7 @@ describe('BrowserAuth', () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-08-24T00:00:00.000Z')) const store = new RecordCredentials() - const auth = await BrowserAuth.create(credentials(store), 30) + const auth = await createAuth(store) const { cookie } = await exchange(auth) const [name, value] = cookie.split('=') as [string, string] @@ -194,7 +222,7 @@ describe('BrowserAuth', () => { }))).toBe(false) } - const shorter = await BrowserAuth.create(credentials(store), 1) + const shorter = await createAuth(store, 1) expect(await shorter.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false) vi.setSystemTime(new Date('2026-09-24T00:00:00.000Z')) expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false) @@ -204,7 +232,7 @@ describe('BrowserAuth', () => { it('revokes on record deletion and creates a new secret on the next token exchange', async () => { const store = new RecordCredentials() - const auth = await BrowserAuth.create(credentials(store), 30) + const auth = await createAuth(store) const first = await exchange(auth) await store.deleteRecord() expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: first.cookie }))).toBe(false) @@ -218,21 +246,21 @@ describe('BrowserAuth', () => { it('fails loud on an invalid owner record instead of replacing it', async () => { const unsupported = new RecordCredentials() unsupported.record = { kind: 'api-key', key: 'not-a-cookie-secret' } - await expect(BrowserAuth.create(credentials(unsupported), 30)).rejects.toThrow(/unsupported format/u) + await expect(createAuth(unsupported)).rejects.toThrow(/unsupported format/u) const malformed = new RecordCredentials() malformed.record = { kind: 'grant', payload: { version: 1, secret: 'short' } } - await expect(BrowserAuth.create(credentials(malformed), 30)).rejects.toThrow(/invalid secret/u) + await expect(createAuth(malformed)).rejects.toThrow(/invalid secret/u) const nonString = new RecordCredentials() nonString.record = { kind: 'grant', payload: { version: 1, secret: 42 } } - await expect(BrowserAuth.create(credentials(nonString), 30)).rejects.toThrow(/invalid secret/u) + await expect(createAuth(nonString)).rejects.toThrow(/invalid secret/u) const discarded = new RecordCredentials() discarded.discardWrites = true - await expect(BrowserAuth.create(credentials(discarded), 30)).rejects.toThrow(/was not created/u) + await expect(createAuth(discarded)).rejects.toThrow(/was not created/u) - await expect(BrowserAuth.create(credentials(new RecordCredentials()), Number.MAX_SAFE_INTEGER)) + await expect(createAuth(new RecordCredentials(), Number.MAX_SAFE_INTEGER)) .rejects.toThrow(/safe timestamp range/u) }) }) From 5595d593d123e17390bc357b7e7ebb02b9b0110a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:35:02 +0800 Subject: [PATCH 09/12] docs(web): state authentication contracts directly --- .../2026-07-28-api-browser-trust-boundary.i18n.yaml | 4 ++-- .../architecture/2026-07-28-api-browser-trust-boundary.md | 2 +- .../architecture/2026-07-28-api-browser-trust-boundary.zh.md | 2 +- .../2026-08-24-browser-token-authentication.i18n.yaml | 4 ++-- .../architecture/2026-08-24-browser-token-authentication.md | 2 +- .../2026-08-24-browser-token-authentication.zh.md | 2 +- .../2026-08-08-copy-only-preset-authoring.i18n.yaml | 2 +- .../2026-08-08-copy-only-preset-authoring.zh.md | 2 +- packages/client/connection/README.i18n.yaml | 2 +- packages/client/connection/README.zh.md | 2 +- packages/client/connection/src/browser-auth.ts | 2 +- packages/host/apiproxy/README.i18n.yaml | 2 +- packages/host/apiproxy/README.zh.md | 2 +- 13 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml index f2e1a2ed33..5d8fa99508 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.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-api-browser-trust-boundary.md -2026-07-28-api-browser-trust-boundary.md: 2997b0f2affaac59be9cd58108bc20086442f6db -2026-07-28-api-browser-trust-boundary.zh.md: ff952ede9adc53c9d590ad2ba24a32b448172f82 +2026-07-28-api-browser-trust-boundary.md: 4401dc8e361281b1239eb84319fc5353948bc217 +2026-07-28-api-browser-trust-boundary.zh.md: c1db2632a6019585ab6a948bcb0f4f2ef853b8a7 diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md index 2997b0f2af..4401dc8e36 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md @@ -15,7 +15,7 @@ Enforce browser trust once, at the carrier, for the entire `/api` prefix — two - **Media-type fence (dsh-host-apiproxy)**: every `/api` POST must declare `application/json`, else 415 before parsing. Cross-site "simple" requests thereby stop existing: any cross-site attempt is forced into a CORS preflight this server never answers. - **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: every request must present a `Host` that is loopback or matches a `trustedHosts` entry (exact on `host:port`, any port on port-less entries, WHATWG-normalized; rebinding defense). Deliberately no shortcut for unmarked requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may be a rebound browser read whose response the page can read, and Host is the one header rebinding cannot forge; non-browser clients pass via loopback, the derived LAN IP literals, or a declared authority. An attached `Origin` must equal the Host authority; `sec-fetch-site: cross-site` is refused outright. A `trustedHosts` entry that is not a bare, canonical authority fails the plugin load — WHATWG parsing would otherwise quietly authorize the hostname inside a typo or broaden an exact-port grant. `host.pickDirectory` loses its bespoke guard and rides the same fence. -Reachability remains the webserver binding's policy (`host: 127.0.0.1 | 0.0.0.0`), and this fence remains a confused-deputy defense rather than identity. Connection applies the separate [browser token authentication](2026-08-24-browser-token-authentication.md) after the fence. The old guard's loopback-socket check was dropped rather than generalized: with binding expressing reachability and `trustedHosts` naming accepted authorities, the socket address adds nothing the Host/Origin checks need. +Reachability is the webserver binding's policy (`host: 127.0.0.1 | 0.0.0.0`), and this fence is a confused-deputy defense rather than identity. Connection applies the separate [browser token authentication](2026-08-24-browser-token-authentication.md) after the fence. The fence does not inspect peer socket addresses: binding expresses reachability, `trustedHosts` names accepted authorities, and the socket address adds nothing the Host/Origin checks need. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md index ff952ede9a..c1db2632a6 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md @@ -15,7 +15,7 @@ Web GUI 宿主以纯 loopback HTTP 提供 `/api`(默认 `127.0.0.1:3080`;CLI - **媒体类型栅栏(dsh-host-apiproxy)**:每个 `/api` POST 必须声明 `application/json`,否则在解析前以 415 拒绝。跨站「简单请求」由此不复存在:任何跨站尝试都被逼进一次本服务器从不应答的 CORS 预检。 - **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:每个请求的 `Host` 都必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化;rebinding 防御)。刻意不为无标记请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求可能是被重绑页面发起且响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、推导的 LAN IP 字面量或已声明的权威通过。若带 `Origin` 则必须与 Host 权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。不是单纯规范化 authority 的 `trustedHosts` 条目会导致插件加载失败——否则 WHATWG 解析会悄悄授权笔误里的 hostname,或放大精确端口授权。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。 -可达性仍由 webserver 的绑定配置(`host: 127.0.0.1 | 0.0.0.0`)控制,这道栅栏仍是混淆代理人防御,而不是身份。Connection 在栅栏之后应用独立的[浏览器令牌认证](2026-08-24-browser-token-authentication.zh.md)。旧守卫的回环 socket 检查被放弃而非泛化:绑定表达可达性、`trustedHosts` 点名接受的 authority 之后,socket 地址提供不了 Host/Origin 校验需要的额外信息。 +可达性由 webserver 的绑定配置(`host: 127.0.0.1 | 0.0.0.0`)控制,这道栅栏是混淆代理人防御,而不是身份。Connection 在栅栏之后应用独立的[浏览器令牌认证](2026-08-24-browser-token-authentication.zh.md)。栅栏不检查对端 socket 地址:绑定表达可达性,`trustedHosts` 点名接受的 authority,socket 地址提供不了 Host/Origin 校验需要的额外信息。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml index 126c669ced..8fec25a1a2 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.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-08-24-browser-token-authentication.md -2026-08-24-browser-token-authentication.md: 69f24c148a1054f99447f1f9f2a6d2e5540c83db -2026-08-24-browser-token-authentication.zh.md: e9b96af21e913c1c3e482940c9f65d0d37506c81 +2026-08-24-browser-token-authentication.md: d04e655a9e4f25623e45f7c85922352db602da36 +2026-08-24-browser-token-authentication.zh.md: ff66630e9fbb77f7cba2c0af16693af724a85c70 diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md index 69f24c148a..d04e655a9e 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md @@ -40,7 +40,7 @@ Unit coverage pins process-token retention across Connection reloads, cookie att ## Consequences -Possession of the browser cookie authorizes the complete tool-capable Host API, matching the authority the Web application already exposes after Session creation. `Host` no longer grants a higher method tier, and a method migration between API Proxy and Typert Remote cannot change its caller set. +Possession of the browser cookie authorizes the complete tool-capable Host API, matching the authority the Web application exposes after Session creation. `Host` does not grant a higher method tier, and a method migration between API Proxy and Typert Remote cannot change its caller set. The persistent secret makes cookies survive restarts but gives a stolen cookie up to the configured absolute lifetime; deletion or rotation of the record is the global revocation mechanism. Omitting `Secure` preserves loopback HTTP and permits plaintext transmission if an operator makes the same cookie authority reachable over an unencrypted network. The startup URL contains a process credential and must be treated as sensitive output; runtime diagnostics do not repeat it. diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md index e9b96af21e..ff66630e9f 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md @@ -40,7 +40,7 @@ HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` ## 后果 -持有浏览器 cookie 就能调用完整的工具型 Host API,这与 Web 应用在创建 Session 后本就暴露的 authority 一致。`Host` 不再授予更高的方法层级,方法在 API Proxy 与 Typert Remote 之间迁移也不会改变调用者集合。 +持有浏览器 cookie 就能调用完整的工具型 Host API,这与 Web 应用在创建 Session 后暴露的 authority 一致。`Host` 不授予更高的方法层级,方法在 API Proxy 与 Typert Remote 之间迁移也不会改变调用者集合。 持久密钥使 cookie 跨重启生效,也让被盗 cookie 最多保有配置的绝对有效期;删除或轮换记录是全局撤销机制。不设置 `Secure` 保留 loopback HTTP,但如果操作者让同一 cookie authority 经未加密网络可达,cookie 会以明文传输。启动 URL 含进程凭据,必须视为敏感输出;运行时诊断不会重复它。 diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml index df36182179..d98333c159 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md 2026-08-08-copy-only-preset-authoring.md: bfe0d49755abf47314a7b4cf56c738537fca3963 -2026-08-08-copy-only-preset-authoring.zh.md: fc2d9ac5c6ace45c46fc920e1f7f28aca508ba9c +2026-08-08-copy-only-preset-authoring.zh.md: 71e83d5e17c56e53ce4f4678b5a22baaed02be31 diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md index fc2d9ac5c6..71e83d5e17 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md @@ -14,7 +14,7 @@ agent-preset 设置页带着一个网页 YAML 编辑器:`agentPreset.write` ## 后果 -- 创作两个方向都不再有组装文本或路径跨越浏览器传输层;`entryListSchema`/`!!js` 的顾虑随 `assertComposition` 本身(已删除)一并消解。创作操作现为 `read`/`copy`/`openDocument`/`remove`——没有一个接收文件系统目标,且 Connection 用完整 Host API 的同一会话认证它们。 +- 创作两个方向都没有组装文本或路径跨越浏览器传输层;`entryListSchema`/`!!js` 的顾虑随 `assertComposition` 本身(已删除)一并消解。创作操作是 `read`/`copy`/`openDocument`/`remove`——没有一个接收文件系统目标,且 Connection 用完整 Host API 的同一会话认证它们。 - 编辑器移除后,手改 `agent.cordis.yml` 成为唯一的组装编辑方式,因此常驻挂载层增加了以 stamp 为键的代际:`ensureStanding` 比对文件的 mtime+大小,为后续会话开启下一代际([常驻挂载 note](../architecture/2026-08-08-per-preset-standing-mounts.zh.md),已就地更新)。没有它,改过的文件要等进程重启才生效。 - 副本是完整快照,会随随附来源升级而漂移——接受;preset 层没有 patch 语义(那是 bundle 层 `cordis.patch.yml` 的能力),随附集合自己也为「一个文件读完整份组装」付了同样的代价(`cordis`/`code` 就是 `standard` 的完整副本)。 - `read` 去掉了 `writable`(没有编辑器可门控),内置目录绝不被打开(`openDocument` 与 `remove` 一样拒绝非 `user` 信任):安装目录会被升级覆盖,把编辑器指向它等于招揽会被升级悄悄丢弃的编辑。 diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 83d376368b..9e2f611ae3 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/connection/README.md README.md: 293e9f9d6b158e325325f4f741a244031b1d2e02 -README.zh.md: e3cea191ab1c9745a1920b5cfd13fcd8d8b77692 +README.zh.md: 1acc4e1748b8067bd18a626dfddfabc2cdcc242d diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index e3cea191ab..1acc4e1748 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -8,7 +8,7 @@ ## 浏览器认证与请求信任 -每个 Host RPC 方法和 WebSocket stream 都要求同一个浏览器会话,不再存在按方法区分的 loopback 层。每个进程生成一个随机启动令牌。`dsh-web-app` 打印并打开带 `?token=...` 的普通根 URL;`frontend-static` 把根路径和 index 请求交给 `ctx.connection.authorizeIndex`,后者只在 `GET /` 接受该令牌,写入绑定 authority 的签名 cookie,再重定向到干净的 `/`。缺失、过期、畸形或 authority 不匹配的 cookie 会在 RPC 分发前得到 401。静态资源保持公开。HTTP 载体不在根路径交换之外接受 query token,也不接受 Authorization header token。 +每个 Host RPC 方法和 WebSocket stream 都要求同一个浏览器会话,不存在按方法区分的 loopback 层。每个进程生成一个随机启动令牌。`dsh-web-app` 打印并打开带 `?token=...` 的普通根 URL;`frontend-static` 把根路径和 index 请求交给 `ctx.connection.authorizeIndex`,后者只在 `GET /` 接受该令牌,写入绑定 authority 的签名 cookie,再重定向到干净的 `/`。缺失、过期、畸形或 authority 不匹配的 cookie 会在 RPC 分发前得到 401。静态资源保持公开。HTTP 载体不在根路径交换之外接受 query token,也不接受 Authorization header token。 cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-session` 拥有的 grant 记录。本地提供方把它持久化到 `$DSH_HOME/.credentials.yaml`;`BrowserAuth` 每次校验都读取当前记录,因此删除或轮换记录无需重启进程即可撤销 cookie。cookie 携带绝对签发与过期区间,`cookieMaxAgeDays` 默认设为 30 天,并在确定性名称与签名 payload 中同时绑定规范化 hostname 和 port。它是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`;随附服务器使用 loopback HTTP,因此刻意不设置 `Secure`。 diff --git a/packages/client/connection/src/browser-auth.ts b/packages/client/connection/src/browser-auth.ts index 3316b746bc..77194e16f0 100644 --- a/packages/client/connection/src/browser-auth.ts +++ b/packages/client/connection/src/browser-auth.ts @@ -187,7 +187,7 @@ export class BrowserAuth { * @param processOwner - root application context retaining one token across Connection reloads. * @param credentials - persistent credential provider for the Web profile. * @param maxAgeDays - positive absolute browser-cookie lifetime in days. - * @returns initialized authentication owner with a fresh process token. + * @returns initialized authentication owner with the process owner's launch token. */ static async create( processOwner: object, diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index c07785d98f..103060ad44 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md README.md: 69826d76b437ea482655d91a930310185079414c -README.zh.md: c7f5d5b579e20236fe7a9f79ed1db8417b641c64 +README.zh.md: 26d132a9a8e48c0833e6145b139226d765cb9709 diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index c7f5d5b579..26d132a9a8 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -58,7 +58,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的调用路径。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `` 上下文作答,因此所有入口(Web、TUI 与 ACP(Agent Client Protocol))共享同一条确定性路径,手动键入的文本也走该路径,且没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`commands/change` 搭乘转发事件帧作为注册表级目录失效信号:客户端重新拉取 `command.list` 而不是做差分。转发的 `agent-preset/selected` 是它按会话粒度的对应物,由落账的选择提交点发出:重组空会话的 agent 只是重新挂接其 scope,不产生任何注册,因此该会话组成所决定的两份目录(`command.list`、`skill.list`)都会失效,却没有任何注册表变化来宣告它。 -`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于每一个已注册 namespace:在本仓库之外分发的插件只要注册自己的分节即可变得可从浏览器配置,无需改动这里;本代理也不再自设边界——没有任何注册应答的名字会折叠为 seam 自己的 `settings-rejected`。由哪个界面渲染某个 namespace 是浏览器的决定(插件配置页按 namespace 为其卡片编键),从不由本代理决定。`settings.describe` 为每个 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。失效通知让每个面无需轮询即保持收敛。`settings/document-updated` 与 `credentials/reference-updated` 搭乘原样转发事件帧(见下),因此解析值未变的原始设置变更同样能到达客户端,凭据失效通知也仍然只带引用名、绝不带值。`llm/adapters-updated` 与 `settings/document-updated` 一并原样转发;具体模型消费方直接订阅这两个 owner 事件,因为拓扑提交和设置文档都能独立改变其目录。Connection 用与每个 Host API 方法相同的浏览器会话认证整个配置面,包括读取与原生操作。未装 settings 或凭据提供方的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 +`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于每一个已注册 namespace:在本仓库之外分发的插件只要注册自己的分节即可变得可从浏览器配置,无需改动这里;本代理不自设边界——没有任何注册应答的名字会折叠为 seam 自己的 `settings-rejected`。由哪个界面渲染某个 namespace 是浏览器的决定(插件配置页按 namespace 为其卡片编键),从不由本代理决定。`settings.describe` 为每个 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。失效通知让每个面无需轮询即保持收敛。`settings/document-updated` 与 `credentials/reference-updated` 搭乘原样转发事件帧(见下),因此解析值未变的原始设置变更同样能到达客户端,凭据失效通知也仍然只带引用名、绝不带值。`llm/adapters-updated` 与 `settings/document-updated` 一并原样转发;具体模型消费方直接订阅这两个 owner 事件,因为拓扑提交和设置文档都能独立改变其目录。Connection 用与每个 Host API 方法相同的浏览器会话认证整个配置面,包括读取与原生操作。未装 settings 或凭据提供方的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 ## 载体层(`/client` + 根路径) From 9c964848cd54d6e9a7d5f01f1745936673cd1910 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:15:34 +0800 Subject: [PATCH 10/12] fix(web): keep browser authentication synchronous --- ...-24-browser-token-authentication.i18n.yaml | 4 +- ...2026-08-24-browser-token-authentication.md | 8 +- ...6-08-24-browser-token-authentication.zh.md | 8 +- packages/api/gateway/src/index.ts | 4 +- .../gateway/tests/gateway-stream.host.spec.ts | 44 +++++------ .../api/gateway/tests/gateway.host.spec.ts | 10 +-- packages/api/remotes/tests/built-lib.e2e.ts | 10 +-- .../bundle/web-app/tests/browser-open.spec.ts | 8 +- packages/bundle/web-app/tests/web-app.spec.ts | 4 +- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 4 +- packages/client/connection/README.zh.md | 4 +- .../client/connection/src/browser-auth.ts | 65 +++++++-------- packages/client/connection/src/index.ts | 2 +- packages/client/connection/src/rpc-host.ts | 8 +- packages/client/connection/src/rpc.ts | 4 +- .../tests/browser-auth.host.spec.ts | 79 +++++++++++-------- .../connection/tests/node-half.host.spec.ts | 32 ++++---- packages/host/frontend-static/src/index.ts | 4 +- 19 files changed, 154 insertions(+), 152 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml index 8fec25a1a2..6756a4d842 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.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-08-24-browser-token-authentication.md -2026-08-24-browser-token-authentication.md: d04e655a9e4f25623e45f7c85922352db602da36 -2026-08-24-browser-token-authentication.zh.md: ff66630e9fbb77f7cba2c0af16693af724a85c70 +2026-08-24-browser-token-authentication.md: c75f561d9529296ff668791c29453e522f309cc3 +2026-08-24-browser-token-authentication.zh.md: d4a5059619fefda9d9060e9879d10c0a2197f8f3 diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md index d04e655a9e..c75f561d95 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md @@ -16,7 +16,7 @@ Each Host process generates a random launch token, retained by the root applicat The cookie is a signed, authority-bound bearer. Its deterministic name and signed payload both include the normalized hostname plus port, so one Harness home can run independent Web ports without cookie collisions. The payload carries safe-integer issue and expiry times under an absolute lifetime; `cookieMaxAgeDays` defaults to 30. The cookie is host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`. It omits `Secure` because the shipped server uses loopback HTTP. There is no logout operation or reverse-proxy-specific handling. -The HMAC secret is a versioned `grant` record at `client-connection/browser-session` in `ctx.credentials`; the local provider stores it in `$DSH_HOME/.credentials.yaml`. Connection reads the record for each verification, so deletion or replacement revokes every existing cookie without restarting the process. A missing record is created when Connection starts; after runtime deletion, the next valid process-token exchange or process start recreates it. Invalid owner payloads fail loud instead of being replaced. The launch token itself is never persisted and changes on every process start, while an unexpired cookie remains valid across restarts on the same authority. +The HMAC secret is a versioned `grant` record at `client-connection/browser-session` in `ctx.credentials`; the local provider stores it in `$DSH_HOME/.credentials.yaml`. Connection loads or creates the record during activation and retains the secret for synchronous request verification. An active Connection continues using its loaded secret if the durable record changes; the next activation loads the replacement or creates a missing record, so deleting the record and restarting the process revokes every existing cookie. Invalid owner payloads fail loud instead of being replaced. The launch token itself is never persisted and changes on every process start, while an unexpired cookie remains valid across restarts on the same authority. The in-page Web Worker preview exposes no network socket. Its page-owned `postMessage` tunnel enters the real route first, then retries a 401 or 403 through the worker-local fetch handler. This keeps Connection interceptors while limiting the authentication bypass to the page that created the Host worker. @@ -24,7 +24,7 @@ The shipped CLI continues to reject `--host 0.0.0.0`. Authentication does not im ## Verification -Unit coverage pins process-token retention across Connection reloads, cookie attributes, HMAC and payload validation, authority and lifetime checks, persistent-secret reuse, record deletion, invalid durable records, and cleanup of obsolete token URLs backed by valid cookies. Host transport suites pin uniform 401/403 behavior for API Proxy, generic RPC, Typert Remote HTTP, and WebSocket upgrade paths. The frontend real-composition test boots credentials, Connection, webserver, and static serving through Loader and proves token exchange before index reads while static assets remain public. Packed-worker tests prove portable cookie encoding and worker-local retry for both authentication and trust rejection. A real-CLI test starts `dsh web` twice on one port with a temporary `DSH_HOME`, proves that forged `Host: localhost` is unauthenticated, calls `host.describe` with the exchanged cookie, observes a new process token, and reuses the old cookie after restart. +Unit coverage pins process-token retention across Connection reloads, one secret load per activation, synchronous verification without credential-provider reads, cookie attributes, HMAC and payload validation, authority and lifetime checks, record deletion taking effect on the next activation, invalid durable records, and cleanup of obsolete token URLs backed by valid cookies. Host transport suites pin uniform 401/403 behavior for API Proxy, generic RPC, Typert Remote HTTP, and WebSocket upgrade paths. The frontend real-composition test boots credentials, Connection, webserver, and static serving through Loader and proves token exchange before index reads while static assets remain public. Packed-worker tests prove portable cookie encoding and worker-local retry for both authentication and trust rejection. A real-CLI test starts `dsh web` twice on one port with a temporary `DSH_HOME`, proves that forged `Host: localhost` is unauthenticated, calls `host.describe` with the exchanged cookie, observes a new process token, and reuses the old cookie after restart. ## Alternatives considered @@ -36,12 +36,12 @@ Unit coverage pins process-token retention across Connection reloads, cookie att **Rotate the signing secret on every restart.** This prevents an existing browser from reconnecting after an ordinary DSH restart. Persisting only the signing secret keeps that workflow while process-token rotation limits the startup URL to one process lifetime. -**Add logout, TLS-proxy, and forwarding-header configuration.** None is required by the loopback Web application or the reported authentication gap. Adding them would define deployment contracts without current consumers. Browser site-data controls and credential-record deletion provide the two revocation operations this decision needs. +**Add logout, TLS-proxy, and forwarding-header configuration.** None is required by the loopback Web application or the reported authentication gap. Adding them would define deployment contracts without current consumers. Browser site-data controls revoke one browser session; deleting the credential record and restarting the process revokes all sessions. ## Consequences Possession of the browser cookie authorizes the complete tool-capable Host API, matching the authority the Web application exposes after Session creation. `Host` does not grant a higher method tier, and a method migration between API Proxy and Typert Remote cannot change its caller set. -The persistent secret makes cookies survive restarts but gives a stolen cookie up to the configured absolute lifetime; deletion or rotation of the record is the global revocation mechanism. Omitting `Secure` preserves loopback HTTP and permits plaintext transmission if an operator makes the same cookie authority reachable over an unencrypted network. The startup URL contains a process credential and must be treated as sensitive output; runtime diagnostics do not repeat it. +The persistent secret makes cookies survive restarts but gives a stolen cookie up to the configured absolute lifetime. Deleting the record and restarting the process is the global revocation mechanism; the active Connection intentionally avoids credential-provider work on each request. Omitting `Secure` preserves loopback HTTP and permits plaintext transmission if an operator makes the same cookie authority reachable over an unencrypted network. The startup URL contains a process credential and must be treated as sensitive output; runtime diagnostics do not repeat it. The decision partially supersedes the authentication deferral and unauthenticated non-loopback consequences in the [browser trust note](2026-07-28-api-browser-trust-boundary.md). That note remains active authority for media-type, Host, Origin, Fetch-Metadata, and configured-authority validation. No active Agent Note is archived: the overlap is partial and both security rules retain future decision value. diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md index ff66630e9f..d4a5059619 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md @@ -16,7 +16,7 @@ Web Host 以当前操作系统用户的权限运行具有工具能力的 Session cookie 是签名且绑定 authority 的 bearer。确定性名称与签名 payload 都包含规范化 hostname 和 port,因此同一 Harness home 可以在不同 Web port 运行而不发生 cookie 冲突。payload 在绝对有效期内携带安全整数形式的签发与过期时间;`cookieMaxAgeDays` 默认为 30。cookie 是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`。随附服务器使用 loopback HTTP,因此不设置 `Secure`。这里没有 logout 操作或反向代理专用处理。 -HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` 的版本化 `grant` 记录;本地提供方将其存入 `$DSH_HOME/.credentials.yaml`。Connection 每次校验都读取记录,因此删除或替换记录无需重启进程即可撤销全部既有 cookie。缺失记录在 Connection 启动时创建;运行期删除后,由下一次有效进程令牌交换或进程启动重新创建。无效 owner payload 会明确失败,而不是被覆盖。启动令牌本身绝不持久化并在每次进程启动时变化;未过期 cookie 则能在相同 authority 上跨重启继续有效。 +HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` 的版本化 `grant` 记录;本地提供方将其存入 `$DSH_HOME/.credentials.yaml`。Connection 在激活期间加载或创建该记录,并保留密钥以同步校验请求。持久记录发生变化后,当前 Connection 继续使用已加载的密钥;下一次激活会加载替换记录或创建缺失记录,因此删除记录并重启进程会撤销全部既有 cookie。无效 owner payload 会明确失败,而不是被覆盖。启动令牌本身绝不持久化并在每次进程启动时变化;未过期 cookie 则能在相同 authority 上跨重启继续有效。 页内 Web Worker preview 不暴露网络 socket。其由页面持有的 `postMessage` tunnel 先进入真实 route,收到 401 或 403 后再经 worker 本地 fetch handler 重试。这样既保留 Connection interceptor,又把认证绕过限制在创建 Host worker 的页面内。 @@ -24,7 +24,7 @@ HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` ## 验证 -单元覆盖 Connection 重载时保留进程令牌、cookie 属性、HMAC 与 payload 校验、authority 与有效期校验、持久密钥复用、记录删除、无效持久记录,以及用有效 cookie 清理过时令牌 URL。Host 传输套件固定 API Proxy、通用 RPC、Typert Remote HTTP 和 WebSocket upgrade 路径上一致的 401/403 行为。frontend 真实组合测试经 Loader 启动 credentials、Connection、webserver 与静态服务,证明读取 index 前完成令牌交换,同时静态资产仍公开。打包 worker 测试证明 cookie 编码可移植,并覆盖认证与信任拒绝后的 worker 本地重试。真实 CLI 测试在临时 `DSH_HOME` 上用同一端口两次启动 `dsh web`,证明伪造 `Host: localhost` 仍未认证,以交换所得 cookie 调用 `host.describe`,观测新的进程令牌,并在重启后复用旧 cookie。 +单元覆盖 Connection 重载时保留进程令牌、每次激活只加载一次密钥、无需读取凭据提供方的同步校验、cookie 属性、HMAC 与 payload 校验、authority 与有效期校验、记录删除在下一次激活时生效、无效持久记录,以及用有效 cookie 清理过时令牌 URL。Host 传输套件固定 API Proxy、通用 RPC、Typert Remote HTTP 和 WebSocket upgrade 路径上一致的 401/403 行为。frontend 真实组合测试经 Loader 启动 credentials、Connection、webserver 与静态服务,证明读取 index 前完成令牌交换,同时静态资产仍公开。打包 worker 测试证明 cookie 编码可移植,并覆盖认证与信任拒绝后的 worker 本地重试。真实 CLI 测试在临时 `DSH_HOME` 上用同一端口两次启动 `dsh web`,证明伪造 `Host: localhost` 仍未认证,以交换所得 cookie 调用 `host.describe`,观测新的进程令牌,并在重启后复用旧 cookie。 ## 曾考虑的替代方案 @@ -36,12 +36,12 @@ HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` **每次重启都轮换签名密钥。** 这会阻止既有浏览器在普通 DSH 重启后重连。只持久化签名密钥既保留该工作流,又由进程令牌轮换把启动 URL 限定在一个进程生命周期。 -**增加 logout、TLS 代理和转发 header 配置。** loopback Web 应用与已报告认证缺口都不需要这些能力;加入它们会在没有当前 consumer 时定义部署约定。浏览器站点数据控制与凭据记录删除已经提供本决策所需的两种撤销操作。 +**增加 logout、TLS 代理和转发 header 配置。** loopback Web 应用与已报告认证缺口都不需要这些能力;加入它们会在没有当前 consumer 时定义部署约定。浏览器站点数据控制会撤销单个浏览器会话;删除凭据记录并重启进程会撤销全部会话。 ## 后果 持有浏览器 cookie 就能调用完整的工具型 Host API,这与 Web 应用在创建 Session 后暴露的 authority 一致。`Host` 不授予更高的方法层级,方法在 API Proxy 与 Typert Remote 之间迁移也不会改变调用者集合。 -持久密钥使 cookie 跨重启生效,也让被盗 cookie 最多保有配置的绝对有效期;删除或轮换记录是全局撤销机制。不设置 `Secure` 保留 loopback HTTP,但如果操作者让同一 cookie authority 经未加密网络可达,cookie 会以明文传输。启动 URL 含进程凭据,必须视为敏感输出;运行时诊断不会重复它。 +持久密钥使 cookie 跨重启生效,也让被盗 cookie 最多保有配置的绝对有效期。删除记录并重启进程是全局撤销机制;当前 Connection 刻意避免在每个请求上访问凭据提供方。不设置 `Secure` 保留 loopback HTTP,但如果操作者让同一 cookie authority 经未加密网络可达,cookie 会以明文传输。启动 URL 含进程凭据,必须视为敏感输出;运行时诊断不会重复它。 本决策部分取代[浏览器信任说明](2026-07-28-api-browser-trust-boundary.zh.md)中的认证延期与未认证非 loopback 后果。该说明仍是媒体类型、Host、Origin、Fetch-Metadata 和配置 authority 校验的有效权威。没有 active Agent Note 被归档:重叠只发生在局部,两条安全规则都保有未来决策价值。 diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index edc3f8697e..86a71d834a 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -192,8 +192,8 @@ export class TypertGatewayService extends Service implements TypertGateway { webCtx.effect(() => { const route: WebUpgradeRoute = { path: REMOTE_STREAM_MUX_PATH, - handler: async (req, socket, head) => { - const rejection = await webCtx.connection.requestRejection(req) + handler: (req, socket, head) => { + const rejection = webCtx.connection.requestRejection(req) if (rejection !== undefined) { rejectRemoteStreamUpgrade(socket, rejection) return diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index 273d5f9b99..01fcc81b0f 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -33,31 +33,29 @@ vi.mock('node:crypto', async (importOriginal) => { }) const randomUuid = vi.mocked(randomUUID) -const browserCookies = new WeakMap>() +const browserCookies = new WeakMap() type AgentWireId = TypertContextWire const agentId = (value: string): AgentWireId => value as AgentWireId /** Exchange this test Host's process token for its WebSocket/HTTP Cookie header. */ -function browserCookie(ctx: Context): Promise { +function browserCookie(ctx: Context): string { const existing = browserCookies.get(ctx) if (existing !== undefined) return existing - const exchange = (async () => { - const origin = `http://127.0.0.1:${String(ctx.webServer.port)}` - const target = new URL(ctx.connection.authenticatedUrl(origin)) - let setCookie: string | undefined - await ctx.connection.authorizeIndex({ - method: 'GET', - url: `${target.pathname}${target.search}`, - headers: { host: target.host }, - }, { - writeHead(_status, headers) { setCookie = headers?.['set-cookie'] }, - end() {}, - }) - if (setCookie === undefined) throw new Error('gateway stream fixture did not receive a browser cookie') - return setCookie.split(';', 1)[0]! - })() - browserCookies.set(ctx, exchange) - return exchange + const origin = `http://127.0.0.1:${String(ctx.webServer.port)}` + const target = new URL(ctx.connection.authenticatedUrl(origin)) + let setCookie: string | undefined + ctx.connection.authorizeIndex({ + method: 'GET', + url: `${target.pathname}${target.search}`, + headers: { host: target.host }, + }, { + writeHead(_status, headers) { setCookie = headers?.['set-cookie'] }, + end() {}, + }) + if (setCookie === undefined) throw new Error('gateway stream fixture did not receive a browser cookie') + const cookie = setCookie.split(';', 1)[0]! + browserCookies.set(ctx, cookie) + return cookie } class FeedService extends Service { @@ -285,7 +283,7 @@ describe('Typert Remote streams', () => { it('multiplexes independent streams over one WebSocket and propagates cancellation', async () => { const { ctx, service } = await setup(true) const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { - headers: { cookie: await browserCookie(ctx) }, + headers: { cookie: browserCookie(ctx) }, }) await once(socket, 'open') const frames: Record[] = [] @@ -369,7 +367,7 @@ describe('Typert Remote streams', () => { .toThrow('forwarded Remote event source is already registered') const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { - headers: { cookie: await browserCookie(ctx) }, + headers: { cookie: browserCookie(ctx) }, }) await once(socket, 'open') const frames: Record[] = [] @@ -891,7 +889,7 @@ describe('Typert Remote streams', () => { it('validates the internal Remote event request and reports an absent source', async () => { const { ctx } = await setup(true) const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { - headers: { cookie: await browserCookie(ctx) }, + headers: { cookie: browserCookie(ctx) }, }) await once(socket, 'open') const frames: Record[] = [] @@ -1039,7 +1037,7 @@ interface RemoteEventTestClient { async function openEventClient(ctx: Context, streamId: string): Promise { const origin = `http://127.0.0.1:${String(ctx.webServer.port)}` - const cookie = await browserCookie(ctx) + const cookie = browserCookie(ctx) const socket = new WebSocket(`${origin.replace('http:', 'ws:')}/api/remote.mux`, { headers: { cookie }, }) diff --git a/packages/api/gateway/tests/gateway.host.spec.ts b/packages/api/gateway/tests/gateway.host.spec.ts index 36f23a62df..0798765e98 100644 --- a/packages/api/gateway/tests/gateway.host.spec.ts +++ b/packages/api/gateway/tests/gateway.host.spec.ts @@ -134,8 +134,8 @@ class FakeConnectionService extends Service { } } - requestRejection(): Promise { - return Promise.resolve(undefined) + requestRejection(): undefined { + return undefined } } @@ -171,10 +171,10 @@ async function serveRoute(route: WebRoute): Promise<{ readonly origin: string; c } /** Exchange a Connection launch token without mounting the frontend fallback. */ -async function browserCookie(connection: HostConnectionHandle, origin: string): Promise { +function browserCookie(connection: HostConnectionHandle, origin: string): string { const target = new URL(connection.authenticatedUrl(origin)) let setCookie: string | undefined - await connection.authorizeIndex({ + connection.authorizeIndex({ method: 'GET', url: `${target.pathname}${target.search}`, headers: { host: target.host }, @@ -1182,7 +1182,7 @@ describe('TypertGatewayService', () => { let strictActive = true expect(routes).toHaveLength(1) const server = await serveRoute(routes[0]!) - const cookie = await browserCookie(ctx.connection, server.origin) + const cookie = browserCookie(ctx.connection, server.origin) try { const response = await fetch(`${server.origin}/api/goals/create`, { diff --git a/packages/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts index 92c5c7c621..ef7b4ae0ed 100644 --- a/packages/api/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -113,12 +113,10 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { } const server = createServer((request, response) => { if ((request.url ?? '/').startsWith('/?')) { - void host.connection.authorizeIndex(request, response).then(authorized => { - if (authorized) { - response.writeHead(200, { 'content-type': 'text/html' }) - response.end('shell') - } - }) + if (host.connection.authorizeIndex(request, response)) { + response.writeHead(200, { 'content-type': 'text/html' }) + response.end('shell') + } return } void routes[0].handler(request, response) diff --git a/packages/bundle/web-app/tests/browser-open.spec.ts b/packages/bundle/web-app/tests/browser-open.spec.ts index 5c1560c445..c9bc0c31cf 100644 --- a/packages/bundle/web-app/tests/browser-open.spec.ts +++ b/packages/bundle/web-app/tests/browser-open.spec.ts @@ -81,8 +81,8 @@ describe('web app browser startup', () => { __dshWebServer: typeof WebServer __dshConnection: { authenticatedUrl(baseUrl: string): string - authorizeIndex(): Promise - requestRejection(): Promise + authorizeIndex(): boolean + requestRejection(): undefined rpc: object } } @@ -94,8 +94,8 @@ describe('web app browser startup', () => { url.searchParams.set('token', 'fixture-token') return url.href }, - authorizeIndex: () => Promise.resolve(true), - requestRejection: () => Promise.resolve(undefined), + authorizeIndex: () => true, + requestRejection: () => undefined, rpc: {}, } diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index 6a6f8c8c15..40e47132ca 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -92,8 +92,8 @@ function provideConnection(ctx: Context): void { url.searchParams.set('token', 'test-token') return url.href }, - authorizeIndex: () => Promise.resolve(true), - requestRejection: () => Promise.resolve(undefined), + authorizeIndex: () => true, + requestRejection: () => undefined, rpc: {}, } as never) } diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 9e2f611ae3..34c03db722 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/client/connection/README.md -README.md: 293e9f9d6b158e325325f4f741a244031b1d2e02 -README.zh.md: 1acc4e1748b8067bd18a626dfddfabc2cdcc242d +README.md: 4aa85765b009bd9231687976b43bc923ba41177a +README.zh.md: da784c1ce2d966fe8f650d9ebb3b9c6313271829 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 293e9f9d6b..4aa85765b0 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -10,7 +10,7 @@ The browser uses HTTP POST for API Proxy and generic Remote unary calls. API Gat Every Host RPC method and WebSocket stream requires one browser session; there is no method-specific loopback tier. Each process mints a random launch token. `dsh-web-app` prints and opens the ordinary root URL with `?token=...`; `frontend-static` delegates root and index requests to `ctx.connection.authorizeIndex`, which accepts that token only on `GET /`, writes an authority-bound signed cookie, and redirects to clean `/`. A missing, expired, malformed, or wrong-authority cookie returns 401 before RPC dispatch. Static assets remain public. The HTTP carrier accepts no query token outside the root exchange and no Authorization-header token. -The cookie signing secret is the owner-scoped `client-connection/browser-session` grant record in `ctx.credentials`. The local provider persists it in `$DSH_HOME/.credentials.yaml`; `BrowserAuth` reads the current record for every verification, so deletion or rotation revokes cookies without restarting the process. Cookies carry an absolute issue/expiry interval, defaulting to 30 days through `cookieMaxAgeDays`, and bind the normalized hostname plus port in both their deterministic name and signed payload. They are host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`; they deliberately omit `Secure` because the shipped server uses loopback HTTP. +The cookie signing secret is the owner-scoped `client-connection/browser-session` grant record in `ctx.credentials`. The local provider persists it in `$DSH_HOME/.credentials.yaml`; `BrowserAuth` loads or creates the record during Connection activation and retains the secret in memory, so request authentication is synchronous. Deleting or replacing the record takes effect on the next Connection activation. Cookies carry an absolute issue/expiry interval, defaulting to 30 days through `cookieMaxAgeDays`, and bind the normalized hostname plus port in both their deterministic name and signed payload. They are host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`; they deliberately omit `Secure` because the shipped server uses loopback HTTP. Before authentication, every request still passes `src/api-request-trust.ts`. Its `Host` must be loopback or match a `trustedHosts` entry: exact on `host:port`, any port on port-less entries, both sides WHATWG-normalized. An attached `Origin` must equal that Host and `sec-fetch-site: cross-site` is refused. Malformed configured authorities fail plugin load. These checks defend DNS rebinding and cross-site browser requests; they never establish identity. A failed Host/Origin check returns 403, while a trusted but unauthenticated request returns 401. `dsh web --host 0.0.0.0` remains unsupported. Decision records: [browser request trust](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md) and [browser token authentication](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md). @@ -32,4 +32,4 @@ None; this package neither assembles nor sends a provider request. - **The `/api` bridge buffers each request body in memory** — `maxRequestBodyBytes` (default 300 MiB, sized for the default 200 MiB aggregate image limit after base64 expansion plus envelope headroom) is therefore also the per-request resident bound; a streaming body path would be needed to lower it without shrinking the image limits. - **The browser cookie is not marked `Secure`** — loopback HTTP is the shipped transport, so deployments that make the same authority reachable over plaintext networking can expose the bearer cookie in transit. -- **There is no logout operation** — clearing the browser cookie ends one browser session; deleting the owner credential record revokes every session and the next launch-token exchange creates a new signing secret. +- **There is no logout operation** — clearing the browser cookie ends one browser session; deleting the owner credential record and restarting `dsh` revokes every session, and the next Connection activation creates a new signing secret. diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 1acc4e1748..da784c1ce2 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -10,7 +10,7 @@ 每个 Host RPC 方法和 WebSocket stream 都要求同一个浏览器会话,不存在按方法区分的 loopback 层。每个进程生成一个随机启动令牌。`dsh-web-app` 打印并打开带 `?token=...` 的普通根 URL;`frontend-static` 把根路径和 index 请求交给 `ctx.connection.authorizeIndex`,后者只在 `GET /` 接受该令牌,写入绑定 authority 的签名 cookie,再重定向到干净的 `/`。缺失、过期、畸形或 authority 不匹配的 cookie 会在 RPC 分发前得到 401。静态资源保持公开。HTTP 载体不在根路径交换之外接受 query token,也不接受 Authorization header token。 -cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-session` 拥有的 grant 记录。本地提供方把它持久化到 `$DSH_HOME/.credentials.yaml`;`BrowserAuth` 每次校验都读取当前记录,因此删除或轮换记录无需重启进程即可撤销 cookie。cookie 携带绝对签发与过期区间,`cookieMaxAgeDays` 默认设为 30 天,并在确定性名称与签名 payload 中同时绑定规范化 hostname 和 port。它是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`;随附服务器使用 loopback HTTP,因此刻意不设置 `Secure`。 +cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-session` 拥有的 grant 记录。本地提供方把它持久化到 `$DSH_HOME/.credentials.yaml`;`BrowserAuth` 在 Connection 激活期间加载或创建该记录,并把密钥留在内存中,因此请求认证同步执行。删除或替换该记录会在下一次 Connection 激活时生效。cookie 携带绝对签发与过期区间,`cookieMaxAgeDays` 默认设为 30 天,并在确定性名称与签名 payload 中同时绑定规范化 hostname 和 port。它是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`;随附服务器使用 loopback HTTP,因此刻意不设置 `Secure`。 认证之前,每个请求仍经过 `src/api-request-trust.ts`。其 `Host` 必须是 loopback,或与 `trustedHosts` 条目匹配:带端口的 `host:port` 精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化。若附带 `Origin`,它必须等于该 Host;`sec-fetch-site: cross-site` 一律拒绝。畸形配置 authority 会让插件加载失败。这些检查防御 DNS rebinding 与跨站浏览器请求,绝不建立身份。Host/Origin 校验失败返回 403;Host 可信但未认证的请求返回 401。`dsh web --host 0.0.0.0` 仍不受支持。决策记录:[浏览器请求信任](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)与[浏览器令牌认证](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md)。 @@ -32,4 +32,4 @@ API Gateway Client 把内部 `$events` logical stream 注册为唯一 generation - **`/api` 桥把每个请求体整体缓冲在内存里**:`maxRequestBodyBytes`(默认 300 MiB,按默认 200 MiB 图片总量上限经 base64 膨胀加信封余量得出)因此同时是单请求的驻留内存上界;要降低它而不缩小图片限额,需要流式请求体路径。 - **浏览器 cookie 不带 `Secure`**:随附载体是 loopback HTTP;若部署把同一 authority 经明文网络暴露,bearer cookie 可能在传输中泄露。 -- **没有 logout 操作**:清除浏览器 cookie 会结束单个浏览器会话;删除 owner 凭据记录会撤销全部会话,下一次启动令牌交换会创建新的签名密钥。 +- **没有 logout 操作**:清除浏览器 cookie 会结束单个浏览器会话;删除 owner 凭据记录并重启 `dsh` 会撤销全部会话,下一次 Connection 激活会创建新的签名密钥。 diff --git a/packages/client/connection/src/browser-auth.ts b/packages/client/connection/src/browser-auth.ts index 77194e16f0..10353d9a06 100644 --- a/packages/client/connection/src/browser-auth.ts +++ b/packages/client/connection/src/browser-auth.ts @@ -158,11 +158,29 @@ function decodeCookie(value: string, secret: Buffer): BrowserCookiePayload | und return decoded as unknown as BrowserCookiePayload } +async function initializeSecret(credentials: CredentialProvider): Promise { + const generated: StoredSecretPayload = { + version: STORED_SECRET_VERSION, + secret: encodeBase64Url(randomBytes(SECRET_BYTES)), + } + const record = await credentials.modifyRecord(AUTH_RECORD_KEY, (current) => { + if (current !== undefined) { + storedSecret(current) + return Promise.resolve(undefined) + } + return Promise.resolve({ kind: 'grant', payload: generated }) + }) + const secret = storedSecret(record) + if (secret === undefined) { + throw new Error('client-connection: browser-session credential record was not created') + } + return secret +} + /** * Process launch-token exchange and persistent signed-cookie verification. - * The credential provider owns the signing secret; this object reads it for - * each operation so deletion or rotation revokes existing cookies without a - * process restart. + * Connection loads the credential provider's signing secret during activation + * and retains it for synchronous request authentication. */ export class BrowserAuth { private readonly launchToken: string @@ -170,7 +188,7 @@ export class BrowserAuth { private constructor( processOwner: object, - private readonly credentials: CredentialProvider, + private readonly secret: Buffer, maxAgeDays: number, ) { this.launchToken = processLaunchToken(processOwner) @@ -194,9 +212,7 @@ export class BrowserAuth { credentials: CredentialProvider, maxAgeDays: number, ): Promise { - const auth = new BrowserAuth(processOwner, credentials, maxAgeDays) - await auth.ensureSecret() - return auth + return new BrowserAuth(processOwner, await initializeSecret(credentials), maxAgeDays) } /** @@ -221,7 +237,7 @@ export class BrowserAuth { * @param res - response owned when this method returns false. * @returns true only when the caller may serve index.html. */ - async authorizeIndex(req: ConnectionIndexRequest, res: ConnectionIndexResponse): Promise { + authorizeIndex(req: ConnectionIndexRequest, res: ConnectionIndexResponse): boolean { /* v8 ignore next -- node:http always supplies url on server requests. */ const url = new URL(req.url ?? '/', 'http://dsh.invalid') const tokens = url.searchParams.getAll(TOKEN_QUERY) @@ -236,7 +252,7 @@ export class BrowserAuth { authority, issuedAt, expiresAt, - }, await this.ensureSecret()) + }, this.secret) res.writeHead(303, { 'cache-control': 'no-store', 'location': '/', @@ -248,7 +264,7 @@ export class BrowserAuth { res.end() return false } - if (req.method === 'GET' && url.pathname === '/' && await this.isAuthenticated(req)) { + if (req.method === 'GET' && url.pathname === '/' && this.isAuthenticated(req)) { res.writeHead(303, { 'cache-control': 'no-store', 'location': '/', @@ -260,7 +276,7 @@ export class BrowserAuth { this.writeUnauthorized(req, res) return false } - if (await this.isAuthenticated(req)) return true + if (this.isAuthenticated(req)) return true this.writeUnauthorized(req, res) return false } @@ -268,17 +284,15 @@ export class BrowserAuth { /** * Verify the authority-bound browser cookie on a Host request. * @param request - request headers carrying Host and Cookie. - * @returns true only for an unexpired cookie signed by the current durable secret. + * @returns true only for an unexpired cookie signed by this activation's loaded secret. */ - async isAuthenticated(request: ConnectionTrustRequest): Promise { + isAuthenticated(request: ConnectionTrustRequest): boolean { const authority = requestAuthority(request.headers) const rawCookie = header(request.headers, 'cookie') if (authority === undefined || rawCookie === undefined) return false const value = cookieValue(rawCookie, cookieName(authority)) if (value === undefined) return false - const secret = storedSecret(await this.credentials.readRecord(AUTH_RECORD_KEY)) - if (secret === undefined) return false - const payload = decodeCookie(value, secret) + const payload = decodeCookie(value, this.secret) if (payload === undefined || payload.authority !== authority) return false const now = Date.now() return payload.issuedAt <= now @@ -287,25 +301,6 @@ export class BrowserAuth { && payload.expiresAt - payload.issuedAt <= this.maxAgeMilliseconds } - private async ensureSecret(): Promise { - const generated: StoredSecretPayload = { - version: STORED_SECRET_VERSION, - secret: encodeBase64Url(randomBytes(SECRET_BYTES)), - } - const record = await this.credentials.modifyRecord(AUTH_RECORD_KEY, (current) => { - if (current !== undefined) { - storedSecret(current) - return Promise.resolve(undefined) - } - return Promise.resolve({ kind: 'grant', payload: generated }) - }) - const secret = storedSecret(record) - if (secret === undefined) { - throw new Error('client-connection: browser-session credential record was not created') - } - return secret - } - private writeUnauthorized(req: ConnectionIndexRequest, res: ConnectionIndexResponse): void { res.writeHead(401, { 'cache-control': 'no-store', diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index ff4d3ba1c1..c7203f38d8 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -106,7 +106,7 @@ export async function apply(ctx: Context, config?: ConnectionConfig): Promise { - const rejection = await connection.requestRejection(req) + const rejection = connection.requestRejection(req) if (rejection !== undefined) { res.writeHead(rejection) res.end(rejection === 401 ? 'unauthorized' : 'forbidden') diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index 5c079e092a..4f1e78341b 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -77,13 +77,13 @@ export class HostConnectionService extends Service implements HostConnectionHand } /** Apply the configured Host/Origin fence, then browser authentication. */ - async requestRejection(request: ConnectionTrustRequest): Promise { + requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection { if (!isTrustedApiRequest(request, this.trustedHosts)) return 403 - return await this.browserAuth.isAuthenticated(request) ? undefined : 401 + return this.browserAuth.isAuthenticated(request) ? undefined : 401 } /** Authenticate an index request through the process-token exchange or cookie. */ - authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): Promise { + authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean { return this.browserAuth.authorizeIndex(request, response) } @@ -125,7 +125,7 @@ export class HostConnectionService extends Service implements HostConnectionHand kind: 'prefix', path: channel, handler: async (req, res) => { - const rejection = await this.requestRejection(req) + const rejection = this.requestRejection(req) if (rejection !== undefined) { res.writeHead(rejection) res.end(rejection === 401 ? 'unauthorized' : 'forbidden') diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index d05e2f0b81..1f879963af 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -81,7 +81,7 @@ export interface HostConnectionHandle { * @param request - request headers from the HTTP or upgrade request. * @returns rejection status, or undefined when the route may accept the request. */ - requestRejection(request: ConnectionTrustRequest): Promise + requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection /** * Authenticate one frontend index request, owning a token redirect or 401. @@ -89,7 +89,7 @@ export interface HostConnectionHandle { * @param response - response owned when the result is false. * @returns true only when the frontend may serve index.html. */ - authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): Promise + authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean /** * Add the fresh process token to an ordinary Web application URL. diff --git a/packages/client/connection/tests/browser-auth.host.spec.ts b/packages/client/connection/tests/browser-auth.host.spec.ts index 8b899ff7c9..885b726b69 100644 --- a/packages/client/connection/tests/browser-auth.host.spec.ts +++ b/packages/client/connection/tests/browser-auth.host.spec.ts @@ -9,8 +9,11 @@ import type { ConnectionIndexRequest, ConnectionIndexResponse } from '../src/rpc class RecordCredentials { record: CredentialRecord | undefined discardWrites = false + reads = 0 + modifies = 0 readRecord(): Promise { + this.reads += 1 return Promise.resolve(this.record) } @@ -18,6 +21,7 @@ class RecordCredentials { _key: unknown, mutate: (current: CredentialRecord | undefined) => Promise, ): Promise { + this.modifies += 1 const next = await mutate(this.record) if (this.discardWrites) return undefined if (next !== undefined) this.record = next @@ -96,14 +100,14 @@ function request(url: string, authority = '127.0.0.1:3080', init?: { } } -async function exchange( +function exchange( auth: BrowserAuth, authority = '127.0.0.1:3080', -): Promise<{ cookie: string; launchUrl: string; state: ResponseState }> { +): { cookie: string; launchUrl: string; state: ResponseState } { const launchUrl = auth.authenticatedUrl(`http://${authority}`) const target = new URL(launchUrl) const res = response() - expect(await auth.authorizeIndex(request(`${target.pathname}${target.search}`, authority), res.value)).toBe(false) + expect(auth.authorizeIndex(request(`${target.pathname}${target.search}`, authority), res.value)).toBe(false) const setCookie = res.state.headers?.['set-cookie'] if (setCookie === undefined) throw new Error('token exchange did not set a cookie') return { cookie: setCookie.split(';', 1)[0]!, launchUrl, state: res.state } @@ -118,7 +122,7 @@ describe('BrowserAuth', () => { const store = new RecordCredentials() const processOwner = {} const first = await createAuth(store, 30, processOwner) - const login = await exchange(first) + const login = exchange(first) expect(login.state).toMatchObject({ status: 303, @@ -130,25 +134,25 @@ describe('BrowserAuth', () => { }) expect(login.state.headers?.['set-cookie']).toMatch(/; Max-Age=2592000; Path=\/; Expires=.*; HttpOnly; SameSite=Strict$/u) expect(login.state.headers?.['set-cookie']).not.toContain('Secure') - expect(await first.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: login.cookie }))).toBe(true) - expect(await first.isAuthenticated({ + expect(first.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: login.cookie }))).toBe(true) + expect(first.isAuthenticated({ headers: new Headers({ host: '127.0.0.1:3080', cookie: login.cookie }), })).toBe(true) - expect(await first.isAuthenticated({ headers: new Headers() })).toBe(false) - expect(await first.isAuthenticated(request('/', 'localhost:3080', { cookie: login.cookie }))).toBe(false) - expect(await first.isAuthenticated(request('/', '127.0.0.1:3081', { cookie: login.cookie }))).toBe(false) + expect(first.isAuthenticated({ headers: new Headers() })).toBe(false) + expect(first.isAuthenticated(request('/', 'localhost:3080', { cookie: login.cookie }))).toBe(false) + expect(first.isAuthenticated(request('/', '127.0.0.1:3081', { cookie: login.cookie }))).toBe(false) const reloaded = await createAuth(store, 30, processOwner) expect(reloaded.authenticatedUrl('http://127.0.0.1:3080')).toBe(login.launchUrl) - expect(await reloaded.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: login.cookie }))).toBe(true) + expect(reloaded.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: login.cookie }))).toBe(true) const restarted = await createAuth(store) expect(new URL(restarted.authenticatedUrl('http://127.0.0.1:3080')).searchParams.get('token')) .not.toBe(new URL(login.launchUrl).searchParams.get('token')) - expect(await restarted.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: login.cookie }))).toBe(true) + expect(restarted.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: login.cookie }))).toBe(true) const staleUrl = new URL(login.launchUrl) const redirected = response() - expect(await restarted.authorizeIndex(request( + expect(restarted.authorizeIndex(request( `${staleUrl.pathname}${staleUrl.search}`, '127.0.0.1:3080', { cookie: login.cookie }, @@ -165,9 +169,9 @@ describe('BrowserAuth', () => { it('accepts the cookie for index serving and gives every unauthenticated request one response', async () => { const auth = await createAuth(new RecordCredentials()) - const { cookie } = await exchange(auth) + const { cookie } = exchange(auth) const allowed = response() - expect(await auth.authorizeIndex(request('/index.html', '127.0.0.1:3080', { cookie }), allowed.value)).toBe(true) + expect(auth.authorizeIndex(request('/index.html', '127.0.0.1:3080', { cookie }), allowed.value)).toBe(true) expect(allowed.state).toEqual({}) for (const candidate of [ @@ -178,7 +182,7 @@ describe('BrowserAuth', () => { request(auth.authenticatedUrl('http://127.0.0.1:3080'), '127.0.0.1:3080', { method: 'HEAD' }), ]) { const denied = response() - expect(await auth.authorizeIndex(candidate, denied.value)).toBe(false) + expect(auth.authorizeIndex(candidate, denied.value)).toBe(false) expect(denied.state.status).toBe(401) expect(denied.state.headers).toEqual({ 'cache-control': 'no-store', @@ -195,18 +199,18 @@ describe('BrowserAuth', () => { vi.setSystemTime(new Date('2026-08-24T00:00:00.000Z')) const store = new RecordCredentials() const auth = await createAuth(store) - const { cookie } = await exchange(auth) + const { cookie } = exchange(auth) const [name, value] = cookie.split('=') as [string, string] - expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=broken` }))).toBe(false) - expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=${value.slice(0, -1)}x` }))).toBe(false) - expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=%` }))).toBe(false) - expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { + expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=broken` }))).toBe(false) + expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=${value.slice(0, -1)}x` }))).toBe(false) + expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=%` }))).toBe(false) + expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: signedBodyCookie(store, name, 'a'), }))).toBe(false) - expect(await auth.isAuthenticated({ headers: {} })).toBe(false) - expect(await auth.isAuthenticated({ headers: { host: 'bad host', cookie } })).toBe(false) - expect(await auth.isAuthenticated({ headers: { host: '127.0.0.1:3080' } })).toBe(false) + expect(auth.isAuthenticated({ headers: {} })).toBe(false) + expect(auth.isAuthenticated({ headers: { host: 'bad host', cookie } })).toBe(false) + expect(auth.isAuthenticated({ headers: { host: '127.0.0.1:3080' } })).toBe(false) const invalidPayloads: unknown[] = [ 'not json', @@ -217,30 +221,37 @@ describe('BrowserAuth', () => { { version: 1, authority: '127.0.0.1:3080', issuedAt: Date.now(), expiresAt: 'later' }, ] for (const payload of invalidPayloads) { - expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { + expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: signedCookie(store, name, payload), }))).toBe(false) } const shorter = await createAuth(store, 1) - expect(await shorter.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false) + expect(shorter.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false) vi.setSystemTime(new Date('2026-09-24T00:00:00.000Z')) - expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false) + expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false) vi.setSystemTime(new Date('2026-08-23T00:00:00.000Z')) - expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false) + expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false) }) - it('revokes on record deletion and creates a new secret on the next token exchange', async () => { + it('loads one secret per activation and replaces it after deletion on the next activation', async () => { const store = new RecordCredentials() const auth = await createAuth(store) - const first = await exchange(auth) - await store.deleteRecord() - expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: first.cookie }))).toBe(false) + const first = exchange(auth) + expect(store).toMatchObject({ reads: 0, modifies: 1 }) - const second = await exchange(auth) + await store.deleteRecord() + expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: first.cookie }))).toBe(true) + const sameActivation = exchange(auth) + expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: sameActivation.cookie }))).toBe(true) + expect(store).toMatchObject({ reads: 0, modifies: 1 }) + + const reactivated = await createAuth(store) + const second = exchange(reactivated) expect(second.cookie).not.toBe(first.cookie) - expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: first.cookie }))).toBe(false) - expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: second.cookie }))).toBe(true) + expect(reactivated.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: first.cookie }))).toBe(false) + expect(reactivated.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: second.cookie }))).toBe(true) + expect(store).toMatchObject({ reads: 0, modifies: 2 }) }) it('fails loud on an invalid owner record instead of replacing it', async () => { diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index a175a7f005..6442b83145 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -106,10 +106,10 @@ async function mounted(config?: { trustedHosts?: string[] }): Promise<{ } /** Exchange a service's process token for one authority-bound Cookie header. */ -async function browserCookie(connection: HostConnectionHandle, authority: string): Promise { +function browserCookie(connection: HostConnectionHandle, authority: string): string { const url = new URL(connection.authenticatedUrl(`http://${authority}`)) const exchanged = fakeResponse() - await connection.authorizeIndex( + connection.authorizeIndex( fakeRequest({ host: authority }, `${url.pathname}${url.search}`), exchanged.response, ) @@ -184,7 +184,7 @@ describe('connection node half', () => { expect([method, denied.state.status, denied.state.body]).toEqual([method, 401, 'unauthorized']) } - const cookie = await browserCookie(connection, 'harness.example') + const cookie = browserCookie(connection, 'harness.example') for (const method of methods) { const allowed = fakeResponse() await routes[0]!.handler( @@ -207,7 +207,7 @@ describe('connection node half', () => { const loopback = fakeResponse() await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080', - cookie: await browserCookie(connection, '127.0.0.1:3080'), + cookie: browserCookie(connection, '127.0.0.1:3080'), }), loopback.response) expect(loopback.state.status).toBe(404) // An all-interfaces composition derives port-less LAN IP literals, which @@ -215,7 +215,7 @@ describe('connection node half', () => { const lan = fakeResponse() await routes[0]!.handler(fakeRequest({ host: '192.168.1.5:3080', - cookie: await browserCookie(connection, '192.168.1.5:3080'), + cookie: browserCookie(connection, '192.168.1.5:3080'), }), lan.response) expect(lan.state.status).toBe(404) // Declared public authority, same-origin browser shape. @@ -224,7 +224,7 @@ describe('connection node half', () => { host: 'harness.example:3080', origin: 'http://harness.example:3080', 'sec-fetch-site': 'same-origin', - cookie: await browserCookie(connection, 'harness.example:3080'), + cookie: browserCookie(connection, 'harness.example:3080'), }), declared.response) expect(declared.state.status).toBe(404) await dispose() @@ -235,11 +235,11 @@ describe('connection node half', () => { const loopback = fakeRequest({ host: '127.0.0.1:3080' }) const declared = fakeRequest({ host: 'harness.example' }) - expect(await connection.requestRejection(loopback)).toBe(401) - expect(await connection.requestRejection(declared)).toBe(401) - expect(await connection.requestRejection(fakeRequest({ + expect(connection.requestRejection(loopback)).toBe(401) + expect(connection.requestRejection(declared)).toBe(401) + expect(connection.requestRejection(fakeRequest({ host: 'harness.example', - cookie: await browserCookie(connection, 'harness.example'), + cookie: browserCookie(connection, 'harness.example'), }))).toBeUndefined() await dispose() }) @@ -272,7 +272,7 @@ describe('connection node half', () => { const result = fakeResponse() await route!.handler(fakePost({ host: '127.0.0.1:3080', - cookie: await browserCookie(connection, '127.0.0.1:3080'), + cookie: browserCookie(connection, '127.0.0.1:3080'), }, '/rpc/goals/create', request), result.response) expect(result.state.status).toBe(200) expect(JSON.parse(String(result.state.body))).toEqual({ @@ -330,7 +330,7 @@ describe('connection node half', () => { } const claimed = fakeResponse() - const loopbackCookie = await browserCookie(connection, '127.0.0.1:3080') + const loopbackCookie = browserCookie(connection, '127.0.0.1:3080') await route.handler(fakePost({ host: '127.0.0.1:3080', cookie: loopbackCookie, }, '/api/goals/create', request), claimed.response) @@ -371,7 +371,7 @@ describe('connection node half', () => { const declared = fakeResponse() await route.handler(fakePost({ host: 'harness.example', - cookie: await browserCookie(connection, 'harness.example'), + cookie: browserCookie(connection, 'harness.example'), }, '/api/goals/create', request), declared.response) expect(declared.state.status).toBe(200) await removeAuthenticated() @@ -393,7 +393,7 @@ describe('connection node half', () => { const route = routes.find(candidate => candidate.path === '/rpc')! const harnessHeaders = { host: 'harness.example', - cookie: await browserCookie(connection, 'harness.example'), + cookie: browserCookie(connection, 'harness.example'), } const denied = fakeResponse() @@ -514,7 +514,7 @@ describe('connection node half over a real HTTP server', () => { } expect(await call(port, 'settings.describe', 'other.example')).toBe(403) - const declaredCookie = await browserCookie(connection, 'harness.example') + const declaredCookie = browserCookie(connection, 'harness.example') for (const method of methods) { expect([method, await call(port, method, 'harness.example', declaredCookie)]).toEqual([method, 404]) } @@ -523,7 +523,7 @@ describe('connection node half over a real HTTP server', () => { port, 'settings.describe', loopbackAuthority, - await browserCookie(connection, loopbackAuthority), + browserCookie(connection, loopbackAuthority), )).toBe(404) } finally { await close() diff --git a/packages/host/frontend-static/src/index.ts b/packages/host/frontend-static/src/index.ts index 6855a21190..3d218f31e6 100644 --- a/packages/host/frontend-static/src/index.ts +++ b/packages/host/frontend-static/src/index.ts @@ -70,7 +70,7 @@ const STATIC_MISS_CODES: ReadonlySet = new Set([ */ export async function serveStatic( pathname: string, res: ServerResponse, distRoot: string, distIndex: string, - authorizeIndex: () => Promise, + authorizeIndex: () => boolean, renderIndex: () => Promise, ): Promise { const target = resolve(normalize(join(distRoot, pathname))) @@ -86,7 +86,7 @@ export async function serveStatic( let type: string try { if (target === distRoot || target === distIndex) { - if (!await authorizeIndex()) return + if (!authorizeIndex()) return body = await renderIndex() type = HTML_MIME } else { From b43d0934f7d4b76ca207b7470176f6f36505239c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:17:11 +0800 Subject: [PATCH 11/12] test(web): keep credential fixtures package-local --- .../api/gateway/tests/browser-credentials.ts | 17 +++++++++ .../gateway/tests/gateway-stream.host.spec.ts | 4 +-- .../api/gateway/tests/gateway.host.spec.ts | 4 +-- .../tests/browser-auth.host.spec.ts | 31 ++-------------- .../connection/tests/browser-credentials.ts | 36 +++++++++++++++++++ .../connection/tests/node-half.host.spec.ts | 12 +++---- 6 files changed, 65 insertions(+), 39 deletions(-) create mode 100644 packages/api/gateway/tests/browser-credentials.ts create mode 100644 packages/client/connection/tests/browser-credentials.ts diff --git a/packages/api/gateway/tests/browser-credentials.ts b/packages/api/gateway/tests/browser-credentials.ts new file mode 100644 index 0000000000..8661983ea8 --- /dev/null +++ b/packages/api/gateway/tests/browser-credentials.ts @@ -0,0 +1,17 @@ +import type { Context } from '@deepseek-ai/cordis' + +/** Provide an in-memory credential-record owner for a mounted Connection plugin. */ +export function provideBrowserCredentials(ctx: Context): void { + const records = new Map() + ctx.provide('credentials', { + async modifyRecord( + key: unknown, + mutate: (current: unknown) => Promise, + ): Promise { + const current = records.get(key) + const next = await mutate(current) + if (next !== undefined) records.set(key, next) + return next ?? current + }, + } as never) +} diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index 01fcc81b0f..d7289783d3 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -14,7 +14,7 @@ import { TypertRemoteFailure, } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' -import { MemoryCredentials } from '../../../credentials/credentials/tests/memory.ts' +import { provideBrowserCredentials } from './browser-credentials.ts' import TypertGatewayService, { TypertGatewayError, type TypertRemoteEventDispatch, @@ -969,7 +969,7 @@ async function setup(transport: boolean): Promise<{ readonly ctx: Context; reado roots.push(ctx) if (transport) { await ctx.plugin(WebServer, { host: '127.0.0.1', port: 0 }) - await ctx.plugin(MemoryCredentials) + provideBrowserCredentials(ctx) } await ctx.plugin(TypertRegistry) await ctx.plugin(TypertGatewayService) diff --git a/packages/api/gateway/tests/gateway.host.spec.ts b/packages/api/gateway/tests/gateway.host.spec.ts index 0798765e98..c0455bba10 100644 --- a/packages/api/gateway/tests/gateway.host.spec.ts +++ b/packages/api/gateway/tests/gateway.host.spec.ts @@ -18,7 +18,7 @@ import { } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry, { type TypertContribution } from '@deepseek-ai/dsh-typert-registry' import TypertGatewayService, { TypertGatewayError } from '@deepseek-ai/dsh-api-gateway' -import { MemoryCredentials } from '../../../credentials/credentials/tests/memory.ts' +import { provideBrowserCredentials } from './browser-credentials.ts' interface FixtureAgent { readonly id: string @@ -1168,7 +1168,7 @@ describe('TypertGatewayService', () => { it('dispatches claimed invocations through /api and leaves unclaimed endpoints to its fallback', async () => { const ctx = new Context().extend({ fixtureScope: 'http-caller' }) const routes: WebRoute[] = [] - await ctx.plugin(MemoryCredentials) + provideBrowserCredentials(ctx) ctx.provide('webServer', fakeHttpServer(routes) as WebServer) const connectionFiber = ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) await connectionFiber diff --git a/packages/client/connection/tests/browser-auth.host.spec.ts b/packages/client/connection/tests/browser-auth.host.spec.ts index 885b726b69..3f06672ff1 100644 --- a/packages/client/connection/tests/browser-auth.host.spec.ts +++ b/packages/client/connection/tests/browser-auth.host.spec.ts @@ -2,37 +2,10 @@ import { createHmac } from 'node:crypto' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { CredentialProvider, CredentialRecord } from '@deepseek-ai/dsh-credentials' +import type { CredentialProvider } from '@deepseek-ai/dsh-credentials' import { BrowserAuth } from '../src/browser-auth.ts' import type { ConnectionIndexRequest, ConnectionIndexResponse } from '../src/rpc.ts' - -class RecordCredentials { - record: CredentialRecord | undefined - discardWrites = false - reads = 0 - modifies = 0 - - readRecord(): Promise { - this.reads += 1 - return Promise.resolve(this.record) - } - - async modifyRecord( - _key: unknown, - mutate: (current: CredentialRecord | undefined) => Promise, - ): Promise { - this.modifies += 1 - const next = await mutate(this.record) - if (this.discardWrites) return undefined - if (next !== undefined) this.record = next - return this.record - } - - deleteRecord(): Promise { - this.record = undefined - return Promise.resolve() - } -} +import { RecordCredentials } from './browser-credentials.ts' function signedCookie(store: RecordCredentials, name: string, payload: unknown): string { const body = typeof payload === 'string' diff --git a/packages/client/connection/tests/browser-credentials.ts b/packages/client/connection/tests/browser-credentials.ts new file mode 100644 index 0000000000..3739101648 --- /dev/null +++ b/packages/client/connection/tests/browser-credentials.ts @@ -0,0 +1,36 @@ +import type { Context } from '@deepseek-ai/cordis' +import type { CredentialProvider, CredentialRecord } from '@deepseek-ai/dsh-credentials' + +/** Mutable credential-record double for Connection authentication tests. */ +export class RecordCredentials { + record: CredentialRecord | undefined + discardWrites = false + reads = 0 + modifies = 0 + + readRecord(): Promise { + this.reads += 1 + return Promise.resolve(this.record) + } + + async modifyRecord( + _key: unknown, + mutate: (current: CredentialRecord | undefined) => Promise, + ): Promise { + this.modifies += 1 + const next = await mutate(this.record) + if (this.discardWrites) return undefined + if (next !== undefined) this.record = next + return this.record + } + + deleteRecord(): Promise { + this.record = undefined + return Promise.resolve() + } +} + +/** Provide the record operations Connection needs during authentication setup. */ +export function provideBrowserCredentials(ctx: Context): void { + ctx.provide('credentials', new RecordCredentials() as unknown as CredentialProvider) +} diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index 6442b83145..75691394ed 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -12,7 +12,7 @@ import { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api' import type { WebServer, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' import { API_PATH, apply, inject, type HostConnectionHandle } from '../src/index.ts' import { DEFAULT_MAX_REQUEST_BODY_BYTES } from '../src/http-bridge.ts' -import { MemoryCredentials } from '../../../credentials/credentials/tests/memory.ts' +import { provideBrowserCredentials } from './browser-credentials.ts' /** Structural webServer fake recording both route registries. */ function fakeHttpServer( @@ -92,7 +92,7 @@ async function mounted(config?: { trustedHosts?: string[] }): Promise<{ const ctx = new Context() const routes: WebRoute[] = [] const upgrades: WebUpgradeRoute[] = [] - await ctx.plugin(MemoryCredentials) + provideBrowserCredentials(ctx) ctx.provide('webServer', fakeHttpServer(routes, upgrades) as WebServer) ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, config) @@ -141,7 +141,7 @@ describe('connection node half', () => { const routes: WebRoute[] = [] const upgrades: WebUpgradeRoute[] = [] const ctx = new Context() - await ctx.plugin(MemoryCredentials) + provideBrowserCredentials(ctx) ctx.provide('webServer', fakeHttpServer(routes, upgrades) as WebServer) ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] }) @@ -247,7 +247,7 @@ describe('connection node half', () => { it('provides a disposable dedicated RPC channel without requiring apiProxy', async () => { const ctx = new Context() const routes: WebRoute[] = [] - await ctx.plugin(MemoryCredentials) + provideBrowserCredentials(ctx) ctx.provide('webServer', fakeHttpServer(routes, []) as WebServer) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() @@ -296,7 +296,7 @@ describe('connection node half', () => { it('dispatches claimed /api endpoints before the API Proxy fallback and withdraws the claim', async () => { const ctx = new Context() const routes: WebRoute[] = [] - await ctx.plugin(MemoryCredentials) + provideBrowserCredentials(ctx) ctx.provide('webServer', fakeHttpServer(routes, []) as WebServer) ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] }) @@ -381,7 +381,7 @@ describe('connection node half', () => { it('applies the configured trust fence and JSON envelope checks to generic channels', async () => { const ctx = new Context() const routes: WebRoute[] = [] - await ctx.plugin(MemoryCredentials) + provideBrowserCredentials(ctx) ctx.provide('webServer', fakeHttpServer(routes, []) as WebServer) const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] }) await fiber.await() From 4de11c022964e47b9ef7abb52f8c6c8e2c3062e0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:11:38 +0800 Subject: [PATCH 12/12] test(web): authenticate streaming fence scaffold --- apps/web/tests/streaming-fence-highlight.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/streaming-fence-highlight.e2e.ts b/apps/web/tests/streaming-fence-highlight.e2e.ts index 594d51c949..411b37075c 100644 --- a/apps/web/tests/streaming-fence-highlight.e2e.ts +++ b/apps/web/tests/streaming-fence-highlight.e2e.ts @@ -98,7 +98,7 @@ describe.skipIf(MODE === 'record')('web e2e: streaming code-fence highlighting', browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000)