Resolve filesystem paths against the caller's session cwd
相对文件系统路径按调用方的会话 cwd 解析
The ACP bridge gives every session its own workspace: `session/new` records the automation client's project directory as `SessionHeader.cwd`, and `dsh-tool-bash` defaults each bash call's `workdir` to the calling agent's `session.header.cwd` (see [the ACP package](../../../../packages/acp/acp) and `resolveWorkdir` in `dsh-tool-bash`). So a bash command in session A runs in A's project, and in session B runs in B's —
English
Problem
The ACP bridge gives every session its own workspace: session/new records the automation client's project directory as SessionHeader.cwd, and dsh-tool-bash defaults each bash call's workdir to the calling agent's session.header.cwd (see the ACP package and resolveWorkdir in dsh-tool-bash). So a bash command in session A runs in A's project, and in session B runs in B's — one server process, N workspaces.
Filesystem resolution used one plugin-load cwd while bash used the session project directory. Relative paths therefore disagreed whenever the automation client's project differed from the server launch directory; snapshots hid the bug by making those paths identical.
A valid absolute cwd can itself have two apparent parents: when it contains symlink/.., filesystem lookup follows the symlink before applying .., while path.resolve() erases both components lexically. Resolving sandbox policy lexically while launching bash from the raw cwd granted the unrelated lexical parent, denied writes in the real workspace, and let filesystem tools resolve relative paths into the wrong directory.
An ordinary symlink cwd exposes the same distinction when the requested relative path contains ..: a process traverses from the symlink's physical target, while path.resolve(cwd, path) traverses from its lexical spelling. Reads would therefore select a different file than bash or a sandboxed mutation for the same model-supplied path.
Decision
Thread the caller's session cwd into path resolution, exactly as dsh-tool-bash already does for workdir. When either the cwd or the requested path contains a parent segment, resolve the cwd to its native filesystem identity before any lexical join; ordinary cwd spellings stay stable for display when no traversal makes their identity observable. Reuse the resolved sandbox-policy root for mutations and sandboxed bash calls so one call has one workspace identity. The caller (the tool) supplies the cwd; the provider does not read a session or agent.
FileSystem.resolveacceptsresolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget>.opts.cwdis the base a RELATIVEpathresolves against; an absolutepathignores it; omittingopts.cwduses the backend's own default.opts.signalcancels resolution when the backend performs I/O. The options object keeps both caller-owned resolution controls together without positional growth.dsh-fs-local.resolveusesresolveLocalTarget(opts?.cwd ?? this.config.cwd, path).config.cwdstays the default for a caller that supplies no session cwd.dsh-tool-fs'sread/write/editderive the session cwd through a sharedsessionCwd(exec, requestedPath)helper (exec.agent?.session.header.cwd, mirroring bash'sresolveWorkdir) and pass it toresolve. The helper uses native realpath semantics when a parent segment in either value could cross a symlink while retaining ordinary spellings otherwise; a sandboxed mutation reuses the complete policy'sworkspaceRoot; a non-agent / headerless caller yieldsundefined, so the backend applies its default.
Alternatives considered
Why the caller supplies the cwd (not the provider)
The provider contract must not depend on dsh-agent / dsh-session — it is a text-storage backend that a sandboxed or remote implementation also satisfies, and those have no notion of an "agent session". The tool already receives the ToolExecution (exec), which carries the agent, so the tool is the right place to project exec → cwd and hand the provider a plain string. This is the "explicit > implicit at package boundaries" convention: the base directory arrives as an explicit argument the provider acts on, not smuggled in by having the provider reach into a session it should not know about. It also matches dsh-tool-bash one-to-one, so the two model-facing file surfaces resolve paths identically.
The default lives in ONE place — the provider's config.cwd. sessionCwd returns undefined rather than process.cwd() when there is no session, so the tool never manufactures a base the provider would otherwise choose.
Consequences
- In the ACP demo the fs tools and bash agree on each session's workspace; an automation client can select any absolute project directory and both tool families act on it.
- A session cwd containing
symlink/.., or an ordinary symlink cwd paired with a parent-traversing relative path, resolves from the same physical workspace for bash, filesystem tools, and the sandbox grant; the lexical parent receives no grant. - No change to
FsTargetidentity:targetKeyis still the realpath of the resolved absolute path, so observed-state keying and symlink identity are unaffected — a correct per-session cwd produces the same key bash targets. - Backward compatible: every existing
resolve(path)call (all in tests) keeps working; the new argument is optional. - The single-session stdio demo is unaffected: it supplies no session cwd (its agent's session has no
cwd), so resolution falls back toconfig.cwd = process.cwd(), which is the workspace.
中文
问题
ACP(Agent Client Protocol)桥接层为每个会话提供独立的工作区:session/new 将自动化客户端的项目目录记录为 SessionHeader.cwd,dsh-tool-bash 将每次 bash 调用的 workdir 默认设为调用方 agent(智能体)的 session.header.cwd(见 ACP 包 与 dsh-tool-bash 中的 resolveWorkdir)。因此会话 A 中的 bash 命令在 A 的项目目录执行,会话 B 中的在 B 的项目目录执行——一个服务器进程,N 个工作区。
文件系统解析使用的是插件加载时的 cwd,而 bash 使用的是会话的项目目录。因此,当自动化客户端的项目目录与服务器启动目录不同时,相对路径的解析结果就会不一致;快照测试因为让这两个路径相同而掩盖了这个 bug。
一个有效的绝对 cwd 本身可能看起来有两个父目录:当它包含 symlink/.. 时,文件系统查找会先跟随符号链接再应用 ..,而 path.resolve() 会从词法上抹掉这两个组件。如果用词法解析沙箱策略却从原始 cwd 启动 bash,就会把权限授予无关的词法父目录、拒绝真实工作区内的写入,并让文件系统工具把相对路径解析进错误目录。
普通的符号链接 cwd 在请求的相对路径包含 .. 时也暴露同一区别:进程从符号链接的物理目标开始遍历,path.resolve(cwd, path) 却从其词法拼写开始遍历。因此,对于同一个模型提供的路径,read 所选文件会不同于 bash 或沙箱化 mutation 对同一路径所选的文件。
决策
将调用方的会话 cwd 传入路径解析,与 dsh-tool-bash 对 workdir 的处理方式完全一致。当 cwd 或请求路径任一包含父目录段时,在任何词法 join 之前把 cwd 解析为原生文件系统标识;没有遍历会使标识可观察时,则保留普通 cwd 拼写以供展示。mutation 和沙箱化 bash 调用复用解析后的沙箱策略根目录,使一次调用只有一个工作区标识。调用方(即工具)提供 cwd;提供方不读取会话或 agent。
FileSystem.resolve接受resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget>。opts.cwd是相对path解析时的基准目录;绝对path忽略它;省略opts.cwd则使用后端自身的默认值。后端执行 I/O 时,opts.signal可以取消解析。options 对象把调用方拥有的两个解析控制项放在一起,避免位置参数继续增长。dsh-fs-local.resolve使用resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)。config.cwd仍作为调用方未提供会话 cwd 时的默认值。dsh-tool-fs的read/write/edit通过共享的sessionCwd(exec, requestedPath)辅助函数(exec.agent?.session.header.cwd,与 bash 的resolveWorkdir对应)获取会话 cwd,并传给resolve。只要任一值中的父目录段可能跨越符号链接,该辅助函数就使用原生 realpath 语义,否则保留普通拼写;沙箱化 mutation 复用完整策略的workspaceRoot;非 agent/无 header 的调用方得到undefined,后端因此应用其默认值。
曾考虑的替代方案
为何由调用方(而非提供方)提供 cwd
提供方约定不得依赖 dsh-agent/dsh-session——这是一项文本存储后端约定,沙箱化实现或远程实现同样满足该约定,而这些实现没有「agent 会话」的概念。工具已经接收了 ToolExecution(exec),其中携带 agent,因此工具是将 exec → cwd 投影并向提供方传递一个纯字符串的正确位置。这遵循「包边界处显式优于隐式」的约定:基准目录作为显式参数传入,提供方据此行动,而非让提供方越界去读取它不应知晓的会话。这也与 dsh-tool-bash 一一对应,使两个面向模型的文件操作接口以相同方式解析路径。
默认值只存在于一个地方——提供方的 config.cwd。sessionCwd 在没有会话时返回 undefined 而非 process.cwd(),因此工具永远不会自行制造一个提供方本应自行选择的基准目录。
后果
- 在 ACP 演示中,fs 工具与 bash 对每个会话的工作区达成一致;自动化客户端可以选择任意绝对项目目录,两类工具都在该目录下操作。
- 对于包含
symlink/..的会话 cwd,或普通符号链接 cwd 搭配含父目录遍历的相对路径,bash、文件系统工具和沙箱授权都会从同一个物理工作区解析;词法父目录不会获得授权。 FsTarget的标识不变:targetKey仍为解析后绝对路径的 realpath,因此 observed-state 键控与符号链接标识不受影响——正确的每会话 cwd 产生与 bash 目标相同的 key。- 向后兼容:所有现有的
resolve(path)调用(均在测试中)继续正常工作;新参数是可选的。 - 单会话 stdio 演示不受影响:它不提供会话 cwd(其 agent 的会话没有
cwd),因此解析回退到config.cwd = process.cwd(),即工作区本身。