diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml
index a5cc96a557..9bef469d7d 100644
--- a/packages/sandbox/sandbox-policy/README.i18n.yaml
+++ b/packages/sandbox/sandbox-policy/README.i18n.yaml
@@ -21,8 +21,8 @@
en: 6704447e52ec295b
zh: 9ed812d9e29d5533
/deepseek-ai-dsh-sandbox-policy/use-this-package/minimal-configuration:
- en: 0616478ef5bb0566
- zh: e3a5802aefcf8424
+ en: 0b62eae224181946
+ zh: 2e77e7f49bd074d4
/deepseek-ai-dsh-sandbox-policy/use-this-package/registering-a-session-s-writable-directory:
en: 841ea47ef1ed7ee6
zh: cd573e67a67742e2
diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md
index 022ba841e4..0d26190122 100644
--- a/packages/sandbox/sandbox-policy/README.md
+++ b/packages/sandbox/sandbox-policy/README.md
@@ -45,7 +45,7 @@ Load the package with a default mode; the fail-safe default is `read-only`, and
| Field | Default | Meaning |
|---|---|---|
| `mode` | `read-only` | The deployment default mode a session starts from, validated at load |
-| `workspaceRoot` | `process.cwd()` | Absolute fallback root for agentless calls or sessions without a cwd; relative values fail at load. Normal agent calls use the session's immutable cwd |
+| `workspaceRoot` | `process.cwd()` | Absolute fallback root for agentless calls or sessions without a cwd; relative values fail at load. Normal agent calls use the session's immutable cwd; a cwd containing a NUL byte fails the call with `SANDBOX_INVALID_ROOT` |
| `additionalWritableRoots` | `[]` | Physical absolute directories also writable under `workspace-write`; Windows file tools honor them, Windows subprocesses ignore them, and `read-only` grants no access |
For a linked Git worktree, the caller can grant its shared Git directory with `additionalWritableRoots`. The caller selects that directory explicitly; the policy does not inspect Git files or grant a parent checkout. These grants are deployment configuration shared by the composition's sessions, rather than saved Session permissions. Granting the entire Git directory also permits changes to hooks and configuration that a later host Git invocation can execute outside the sandbox; use that grant only for trusted tasks. Use physical absolute directory paths. The enforcing host rejects missing paths, non-directories, and symlinked directories on each call, including a directory replaced by a symlink between calls. Remote grants are validated on the remote host.
diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md
index 49d976c7bf..a436f298d5 100644
--- a/packages/sandbox/sandbox-policy/README.zh.md
+++ b/packages/sandbox/sandbox-policy/README.zh.md
@@ -45,7 +45,7 @@ kind: "package-reference"
| 字段 | 默认值 | 含义 |
|---|---|---|
| `mode` | `read-only` | 会话起始的部署默认模式,加载时验证 |
-| `workspaceRoot` | `process.cwd()` | 无 agent 调用或没有 cwd 的会话所用的绝对回退根目录;相对值在加载时拒绝。普通 agent 调用使用会话的不可变 cwd |
+| `workspaceRoot` | `process.cwd()` | 无 agent 调用或没有 cwd 的会话所用的绝对回退根目录;相对值在加载时拒绝。普通 agent 调用使用会话的不可变 cwd;cwd 含 NUL 字节时调用以 `SANDBOX_INVALID_ROOT` 失败 |
| `additionalWritableRoots` | `[]` | 在 `workspace-write` 下额外允许写入的物理绝对目录;Windows 文件工具执行这些授权,Windows 子进程忽略它们,`read-only` 不授予访问权限 |
对于关联的 Git worktree,调用方可通过 `additionalWritableRoots` 授予共享 Git 目录的写权限。调用方必须显式选择该目录;策略不会读取 Git 文件或授权父检出目录。这些授权是组合中所有会话共享的部署配置,而非保存的 Session 权限。授权整个 Git 目录也允许修改钩子和配置,后续主机 Git 调用可能在沙箱之外执行它们;仅对可信任务使用此授权。使用物理绝对目录路径。执行限制的主机在每次调用时拒绝不存在的路径、非目录和符号链接目录,包括两次调用之间被替换为符号链接的目录。远端授权在远端主机上验证。
diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts
index 2136e33f45..8f8d5023ac 100644
--- a/packages/sandbox/sandbox-policy/src/index.ts
+++ b/packages/sandbox/sandbox-policy/src/index.ts
@@ -27,6 +27,7 @@ import { Context, Service } from '@deepseek-ai/cordis'
import { z as zod } from 'zod'
import z from '@deepseek-ai/schemastery'
import type {} from '@deepseek-ai/dsh-agent'
+import { SandboxInvalidRootError } from '@deepseek-ai/dsh-sandbox'
import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { Session } from '@deepseek-ai/dsh-session'
import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection'
@@ -34,10 +35,9 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
export { SANDBOX_MODES, setSandboxMode } from './session-mode.ts'
-/** Preserve execution-world spelling; enforcing providers resolve filesystem identity on their host. */
-function resolveWorkspaceRoot(path: string): string {
- if (!isAbsolute(path) || path.includes('\0')) throw new Error('sandbox-policy: workspace root must be an absolute execution-world path without NUL bytes')
- return path
+/** Whether a path is usable as an execution-world grant: absolute and free of NUL bytes. */
+function isExecutionWorldPath(path: string): boolean {
+ return isAbsolute(path) && !path.includes('\0')
}
/**
@@ -160,7 +160,12 @@ export class SandboxPolicyService extends Service {
// runtime fact. `workspaceRoot` has NO schema default, so its fallback to
// the process cwd is real branching, resolved absolute either way.
this.defaultMode = config.mode as SandboxMode
- this.workspaceRoot = resolveWorkspaceRoot(config.workspaceRoot ?? process.cwd())
+ // Execution-world spelling is preserved; enforcing providers resolve filesystem identity on their host.
+ const workspaceRoot = config.workspaceRoot ?? process.cwd()
+ if (!isExecutionWorldPath(workspaceRoot)) {
+ throw new Error('sandbox-policy: workspace root must be an absolute execution-world path without NUL bytes')
+ }
+ this.workspaceRoot = workspaceRoot
this.additionalWritableRoots = resolveAdditionalWritableRoots(config.additionalWritableRoots)
}
@@ -197,6 +202,8 @@ export class SandboxPolicyService extends Service {
* snapshotted together; existing operations and processes keep their policy.
* @param request - optional session and approved mode override.
* @returns the fully resolved per-call mode and absolute workspace root.
+ * @throws `SandboxInvalidRootError` (`SANDBOX_INVALID_ROOT`) when the session's recorded cwd is
+ * relative or contains a NUL byte.
*/
resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy {
const { session } = request
@@ -206,12 +213,22 @@ export class SandboxPolicyService extends Service {
: resolveAdditionalWritableRoots([...(this.additionalWritableRoots ?? []), ...registrations.values()])
return {
mode: request.mode ?? (session === undefined ? undefined : this.overrideOf(session)) ?? this.defaultMode,
- workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot),
+ workspaceRoot: this.sessionWorkspaceRoot(session),
...additionalWritableRoots === undefined ? {} : { additionalWritableRoots },
...session === undefined ? {} : { sessionId: session.id },
}
}
+ /** The session's recorded cwd, or the configured root, as the workspace-write boundary. */
+ private sessionWorkspaceRoot(session: Session | undefined): string {
+ const cwd = session?.header.cwd
+ if (cwd === undefined) return this.workspaceRoot
+ if (!isExecutionWorldPath(cwd)) {
+ throw new SandboxInvalidRootError(cwd, 'the Session working directory must be an absolute execution-world path without NUL bytes', 'sandbox workspace root')
+ }
+ return cwd
+ }
+
/**
* Permit writes under one directory in this Session's future workspace-write
* operations. The calling context owns the effect; disposal removes only this
diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts
index 1d4588f49e..954f789c02 100644
--- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts
+++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts
@@ -67,6 +67,20 @@ describe('SandboxPolicyService', () => {
}
})
+ it('rejects a session cwd containing a NUL byte with SANDBOX_INVALID_ROOT', async () => {
+ const cwd = '/invalid\0session'
+ const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/ws' })
+ try {
+ expect(() => ctx.sandboxPolicy.resolve({ session: session('sess-invalid-cwd', cwd) })).toThrow(expect.objectContaining({
+ name: 'SandboxInvalidRootError',
+ code: 'SANDBOX_INVALID_ROOT',
+ message: `Invalid sandbox workspace root ${JSON.stringify(cwd)}: the Session working directory must be an absolute execution-world path without NUL bytes`,
+ }))
+ } finally {
+ await ctx.fiber.dispose()
+ }
+ })
+
it('resolves the deployment policy for an agentless call', async () => {
const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/fallback' })
expect(ctx.sandboxPolicy.resolve()).toEqual({
diff --git a/packages/sandbox/sandbox/src/roots.ts b/packages/sandbox/sandbox/src/roots.ts
index 726f3bbfdc..363469ae42 100644
--- a/packages/sandbox/sandbox/src/roots.ts
+++ b/packages/sandbox/sandbox/src/roots.ts
@@ -19,10 +19,15 @@ import { relative } from 'node:path'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { SandboxExecutionPolicy } from './index.ts'
-/** An explicit directory grant cannot be enforced without changing the caller's selected path. */
+/** A workspace root or explicit directory grant cannot be enforced without changing the caller's selected path. */
export class SandboxInvalidRootError extends HarnessError {
- constructor(path: string, detail: string) {
- super(`Invalid additional writable directory ${JSON.stringify(path)}: ${detail}`, 'SANDBOX_INVALID_ROOT')
+ /**
+ * @param path - the rejected path, as the caller spelled it.
+ * @param detail - why the path cannot be enforced.
+ * @param subject - the role of the path in the policy, as the message names it.
+ */
+ constructor(path: string, detail: string, subject = 'additional writable directory') {
+ super(`Invalid ${subject} ${JSON.stringify(path)}: ${detail}`, 'SANDBOX_INVALID_ROOT')
this.name = 'SandboxInvalidRootError'
}
}
diff --git a/packages/session/tool-working-directory/README.i18n.yaml b/packages/session/tool-working-directory/README.i18n.yaml
index 742c9ffb0a..9e45d5365a 100644
--- a/packages/session/tool-working-directory/README.i18n.yaml
+++ b/packages/session/tool-working-directory/README.i18n.yaml
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 87d7d2dbd7777429
/deepseek-ai-dsh-tool-working-directory/use-this-package:
- en: bb750522dbdb3fef
- zh: 85586a5c2137afa9
+ en: 83ab57140ebea28e
+ zh: 213c36a664a21f65
/deepseek-ai-dsh-tool-working-directory/understand-the-implementation:
en: f361fe510916fce8
zh: a2127f768ef9ac95
diff --git a/packages/session/tool-working-directory/README.md b/packages/session/tool-working-directory/README.md
index 8eadd039a3..379743d651 100644
--- a/packages/session/tool-working-directory/README.md
+++ b/packages/session/tool-working-directory/README.md
@@ -25,7 +25,7 @@ Read the Session’s current directory or enter an existing directory with the s
## Use this package
-Mount alongside `tools` and `workingDirectory`. Call `working_directory({})` to read or `working_directory({ cd: "src" })` to change directory. The tool has no configuration fields.
+Mount alongside `tools` and `workingDirectory`. Call `working_directory({})` to read or `working_directory({ cd: "src" })` to change directory. The tool has no configuration fields. A call without a calling Agent fails with `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`), an empty `cd` with `ToolArgsError` (`INVALID_ARGS`), and a missing directory with the service's `WorkingDirectoryError` codes.
```yaml
- name: '@deepseek-ai/dsh-tool-working-directory'
diff --git a/packages/session/tool-working-directory/README.zh.md b/packages/session/tool-working-directory/README.zh.md
index 50c4b638fb..5c8cc05bd6 100644
--- a/packages/session/tool-working-directory/README.zh.md
+++ b/packages/session/tool-working-directory/README.zh.md
@@ -25,7 +25,7 @@ kind: "package-reference"
## 使用此包
-与 `tools` 和 `workingDirectory` 一起挂载。调用 `working_directory({})` 读取目录,或调用 `working_directory({ cd: "src" })` 改变目录。此工具没有配置字段。
+与 `tools` 和 `workingDirectory` 一起挂载。调用 `working_directory({})` 读取目录,或调用 `working_directory({ cd: "src" })` 改变目录。此工具没有配置字段。没有调用方 Agent 的调用以 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`)失败,`cd` 为空以 `ToolArgsError`(`INVALID_ARGS`)失败,目录不存在时使用服务的 `WorkingDirectoryError` code。
```yaml
- name: '@deepseek-ai/dsh-tool-working-directory'
diff --git a/packages/session/tool-working-directory/src/index.ts b/packages/session/tool-working-directory/src/index.ts
index f0aa8bc4f0..89022d3e57 100644
--- a/packages/session/tool-working-directory/src/index.ts
+++ b/packages/session/tool-working-directory/src/index.ts
@@ -4,7 +4,7 @@
*/
import type { Context } from '@deepseek-ai/cordis'
-import { defineTool } from '@deepseek-ai/dsh-tools'
+import { defineTool, ToolArgsError, ToolContextUnavailableError } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-working-directory'
/** Cordis plugin name. */
@@ -32,7 +32,8 @@ export function apply(ctx: Context): void {
render: (_args, value) => [{ type: 'text', text: value.cwd }],
},
async execute(args, exec) {
- if (exec.agent === undefined) throw new Error('working_directory requires an agent Session')
+ if (exec.agent === undefined) throw new ToolContextUnavailableError('working_directory requires an agent Session')
+ if (args.cd === '') throw new ToolArgsError(['cd must not be empty; omit it to read the current directory'])
const cwd = args.cd === undefined
? await ctx.workingDirectory.ensure(exec.agent, exec.signal)
: await ctx.workingDirectory.set(exec.agent, args.cd, exec.signal)
diff --git a/packages/session/working-directory/README.i18n.yaml b/packages/session/working-directory/README.i18n.yaml
index fd50066fe2..3fd77f1c5c 100644
--- a/packages/session/working-directory/README.i18n.yaml
+++ b/packages/session/working-directory/README.i18n.yaml
@@ -18,8 +18,8 @@
en: e65405026debde7c
zh: e12a648eb10ed16b
/deepseek-ai-dsh-working-directory/understand-the-implementation:
- en: f0458147d29631b1
- zh: 6a288670dd084ffb
+ en: 82d7372a58bb75c5
+ zh: e18ee833daa99581
/deepseek-ai-dsh-working-directory/further-exploration:
en: eabff683146786c8
zh: 7997dec17802ef64
diff --git a/packages/session/working-directory/README.md b/packages/session/working-directory/README.md
index 44ed9ba9dc..1a70cfbe8f 100644
--- a/packages/session/working-directory/README.md
+++ b/packages/session/working-directory/README.md
@@ -41,7 +41,7 @@ Directory validation and publication delegate through `working-directory/access`
Implementation internals — click to expand
-The Session projection owns the effective directory and exposes it in Session observations, so cold readers can use it without activating an Agent; the header continues to identify the original project. Changes serialize per Session, validate through the filesystem provider, check cancellation and Agent lifetime, commit an event, and queue a user-context notice. If notice queuing fails after commit, the operation returns the committed directory and logs a warning; the next request or resume still receives the required directory context. Prompt assembly validates the current directory before publishing its required context contribution. Existing processes and sandbox grants keep their independent ownership. The [service implementation](src/index.ts) defines the API.
+The Session projection owns the effective directory and exposes it in Session observations, so cold readers can use it without activating an Agent; the header continues to identify the original project. Changes serialize per Session, validate through the filesystem provider, check cancellation and Agent lifetime, commit an event, and queue a user-context notice. If notice queuing fails after commit, the operation returns the committed directory and logs a warning; the next request or resume still receives the required directory context. Prompt assembly validates the current directory before publishing its required context contribution. Existing processes and sandbox grants keep their independent ownership. The [service implementation](src/index.ts) defines the API. Failures are `WorkingDirectoryError` instances: `WORKING_DIRECTORY_INVALID` for an empty or malformed path, `WORKING_DIRECTORY_NOT_FOUND` when a requested change names no existing directory, and `WORKING_DIRECTORY_UNAVAILABLE` when neither the current directory nor the original project exists or the service has been disposed.
No runtime invariant companion is published because the service reads its only directory state from the Session projection and checks filesystem validity at use.
diff --git a/packages/session/working-directory/README.zh.md b/packages/session/working-directory/README.zh.md
index 9561d73972..b5f07d5577 100644
--- a/packages/session/working-directory/README.zh.md
+++ b/packages/session/working-directory/README.zh.md
@@ -41,7 +41,7 @@ kind: "package-reference"
实现细节 — 点击展开
-Session 投影拥有当前目录,并通过 Session observation 暴露它,因此冷态读取方无需激活 Agent 即可使用;header 继续标识原始项目。每个 Session 的变更串行执行,通过文件系统 provider 验证,并检查取消状态与 Agent 生命周期,提交事件后排入用户上下文通知。若提交后通知入队失败,操作仍返回已提交的目录并记录警告;下一次请求或恢复仍会收到必需的目录上下文。提示词组装在发布必需的上下文条目前验证当前目录。已有进程与沙箱授权继续由各自的服务拥有。[服务实现](src/index.ts) 定义 API。
+Session 投影拥有当前目录,并通过 Session observation 暴露它,因此冷态读取方无需激活 Agent 即可使用;header 继续标识原始项目。每个 Session 的变更串行执行,通过文件系统 provider 验证,并检查取消状态与 Agent 生命周期,提交事件后排入用户上下文通知。若提交后通知入队失败,操作仍返回已提交的目录并记录警告;下一次请求或恢复仍会收到必需的目录上下文。提示词组装在发布必需的上下文条目前验证当前目录。已有进程与沙箱授权继续由各自的服务拥有。[服务实现](src/index.ts) 定义 API。失败均为 `WorkingDirectoryError`:路径为空或格式错误时为 `WORKING_DIRECTORY_INVALID`,请求切换到不存在的目录时为 `WORKING_DIRECTORY_NOT_FOUND`,当前目录与原始项目都不存在或服务已释放时为 `WORKING_DIRECTORY_UNAVAILABLE`。
不发布 `./invariant` 伴生模块,因为服务从 Session 投影读取唯一的目录状态,并在使用时检查文件系统有效性。
diff --git a/packages/session/working-directory/src/error.ts b/packages/session/working-directory/src/error.ts
new file mode 100644
index 0000000000..ec2a2ef7a2
--- /dev/null
+++ b/packages/session/working-directory/src/error.ts
@@ -0,0 +1,34 @@
+/** Machine-routable Session working-directory failures. @module */
+
+import { HarnessError } from '@deepseek-ai/dsh-llm'
+
+/**
+ * Every {@link WorkingDirectoryError} code:
+ * - `WORKING_DIRECTORY_INVALID` — a requested directory is empty or otherwise malformed.
+ * - `WORKING_DIRECTORY_NOT_FOUND` — the requested directory does not exist or is not a directory; the caller picks another path.
+ * - `WORKING_DIRECTORY_UNAVAILABLE` — neither the current directory nor the original project exists, or the owning service was disposed.
+ */
+export const WORKING_DIRECTORY_ERROR_CODES = [
+ 'WORKING_DIRECTORY_INVALID',
+ 'WORKING_DIRECTORY_NOT_FOUND',
+ 'WORKING_DIRECTORY_UNAVAILABLE',
+] as const
+
+/** One code from {@link WORKING_DIRECTORY_ERROR_CODES}. */
+export type WorkingDirectoryErrorCode = typeof WORKING_DIRECTORY_ERROR_CODES[number]
+
+/** Working-directory failure carrying a stable {@link WorkingDirectoryErrorCode}. */
+export class WorkingDirectoryError extends HarnessError {
+ override readonly code: WorkingDirectoryErrorCode
+
+ /**
+ * @param message - names the directory and the safe next step.
+ * @param code - the failure class consumers route on.
+ * @param options - standard `cause` chaining.
+ */
+ constructor(message: string, code: WorkingDirectoryErrorCode, options?: ErrorOptions) {
+ super(message, code, options)
+ this.name = 'WorkingDirectoryError'
+ this.code = code
+ }
+}
diff --git a/packages/session/working-directory/src/index.ts b/packages/session/working-directory/src/index.ts
index 62c3750c2c..7a0f66ede6 100644
--- a/packages/session/working-directory/src/index.ts
+++ b/packages/session/working-directory/src/index.ts
@@ -13,6 +13,9 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { Session } from '@deepseek-ai/dsh-session'
import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection'
import type {} from '@deepseek-ai/dsh-system-prompt'
+import { WorkingDirectoryError } from './error.ts'
+
+export { WORKING_DIRECTORY_ERROR_CODES, WorkingDirectoryError, type WorkingDirectoryErrorCode } from './error.ts'
declare module '@deepseek-ai/cordis' {
interface Events {
@@ -125,7 +128,7 @@ export class WorkingDirectoryService extends Service {
return assembly
}, { prepend: true })
ctx.effect(() => async () => {
- this.lifecycle.abort(new Error('working-directory service disposed'))
+ this.lifecycle.abort(new WorkingDirectoryError('working-directory service disposed', 'WORKING_DIRECTORY_UNAVAILABLE'))
await Promise.allSettled(this.operations)
})
}
@@ -145,7 +148,8 @@ export class WorkingDirectoryService extends Service {
* @param signal - cancellation for filesystem inspection.
* @returns the existing directory; recovery is committed before fulfillment.
* A notice failure is warned without reverting the committed state.
- * @throws when the original project is also unavailable, or when a `working-directory/access` listener rejects.
+ * @throws {@link WorkingDirectoryError} `WORKING_DIRECTORY_UNAVAILABLE` when the original project is also
+ * unavailable or the service is disposed, or the rejection of a `working-directory/access` listener.
*/
ensure(agent: Agent, signal?: AbortSignal): Promise {
return this.serialize(agent, signal, control => this.ctx.waterfall('working-directory/access', { agent, path: null, signal: control }, async () => {
@@ -161,7 +165,10 @@ export class WorkingDirectoryService extends Service {
return current
}
const original = agent.session.header.cwd ?? this.defaultDirectory
- const restored = await this.directory(original, original, control)
+ const restored = await this.directory(original, original, control, () => new WorkingDirectoryError(
+ `working-directory: directory does not exist: ${JSON.stringify(original)}; the current working directory ${JSON.stringify(current)} is also unavailable`,
+ 'WORKING_DIRECTORY_UNAVAILABLE',
+ ))
this.commit(agent, restored, true, control)
return restored
}))
@@ -174,11 +181,13 @@ export class WorkingDirectoryService extends Service {
* @param signal - cancellation before the durable change.
* @returns the canonical absolute directory, committed before fulfillment.
* A notice failure is warned; the next request still receives the committed directory.
- * @throws when the requested path is not an existing directory.
+ * @throws {@link WorkingDirectoryError} `WORKING_DIRECTORY_INVALID` for an empty path and
+ * `WORKING_DIRECTORY_NOT_FOUND` when the path is not an existing directory, or the rejection of a
+ * `working-directory/access` listener.
*/
set(agent: Agent, path: string, signal?: AbortSignal): Promise {
return this.serialize(agent, signal, control => this.ctx.waterfall('working-directory/access', { agent, path, signal: control }, async () => {
- if (path.length === 0) throw new Error('working-directory: cd must not be empty')
+ if (path.length === 0) throw new WorkingDirectoryError('working-directory: cd must not be empty', 'WORKING_DIRECTORY_INVALID')
const directory = await this.directory(path, this.get(agent.session), control)
this.commit(agent, directory, false, control)
return directory
@@ -189,13 +198,19 @@ export class WorkingDirectoryService extends Service {
return `Current working directory: ${JSON.stringify(this.get(session))}.`
}
- private async directory(path: string, cwd: string, signal: AbortSignal): Promise {
+ private async directory(
+ path: string,
+ cwd: string,
+ signal: AbortSignal,
+ missing = (): WorkingDirectoryError => new WorkingDirectoryError(
+ `working-directory: directory does not exist: ${JSON.stringify(path)}`,
+ 'WORKING_DIRECTORY_NOT_FOUND',
+ ),
+ ): Promise {
const target = await this.ctx.fs.resolve(path, { cwd, signal })
const info = await this.ctx.fs.stat(target, signal)
signal.throwIfAborted()
- if (info?.type !== 'directory') {
- throw new Error(`working-directory: directory does not exist: ${JSON.stringify(path)}`)
- }
+ if (info?.type !== 'directory') throw missing()
return this.ctx.fs.processPath(target)
}
diff --git a/packages/session/working-directory/tests/working-directory.spec.ts b/packages/session/working-directory/tests/working-directory.spec.ts
index 22fb99af47..d4769979cf 100644
--- a/packages/session/working-directory/tests/working-directory.spec.ts
+++ b/packages/session/working-directory/tests/working-directory.spec.ts
@@ -115,6 +115,11 @@ describe('Session working directories', () => {
const agent = await f.harness.create(SessionId('removed-origin'), {}, { cwd: origin })
await rm(origin, { recursive: true })
await expect(f.ctx.workingDirectory.ensure(agent)).rejects.toThrow('directory does not exist')
+ await expect(f.ctx.workingDirectory.ensure(agent)).rejects.toMatchObject({
+ name: 'WorkingDirectoryError',
+ code: 'WORKING_DIRECTORY_UNAVAILABLE',
+ message: expect.stringContaining('is also unavailable') as string,
+ })
await expect(f.ctx.workingDirectory.set(agent, f.root)).resolves.toBe(f.root)
})
@@ -122,7 +127,9 @@ describe('Session working directories', () => {
await using f = await fixture()
await writeFile(join(f.root, 'file'), '')
await expect(f.ctx.workingDirectory.set(f.agent, 'file')).rejects.toThrow('directory does not exist')
+ await expect(f.ctx.workingDirectory.set(f.agent, 'file')).rejects.toMatchObject({ name: 'WorkingDirectoryError', code: 'WORKING_DIRECTORY_NOT_FOUND' })
await expect(f.ctx.workingDirectory.set(f.agent, '')).rejects.toThrow('cd must not be empty')
+ await expect(f.ctx.workingDirectory.set(f.agent, '')).rejects.toMatchObject({ name: 'WorkingDirectoryError', code: 'WORKING_DIRECTORY_INVALID' })
const controller = new AbortController()
controller.abort(new Error('cancelled directory change'))
await expect(f.ctx.workingDirectory.set(f.agent, f.root, controller.signal)).rejects.toThrow('cancelled directory change')
@@ -277,6 +284,7 @@ describe('Session working directories', () => {
expect(f.agent.session.snapshotEvents().filter(event => event.type === 'working-directory/change')).toEqual([])
expect(f.harness.claim(f.agent, 'next-step', 1)).toEqual([])
await expect(directories.ensure(f.agent)).rejects.toThrow('working-directory service disposed')
+ await expect(directories.ensure(f.agent)).rejects.toMatchObject({ name: 'WorkingDirectoryError', code: 'WORKING_DIRECTORY_UNAVAILABLE' })
expect(probe).toHaveBeenCalledOnce()
} finally {
release.resolve(undefined)
@@ -352,7 +360,18 @@ describe('Session working directories', () => {
})
expect(await execute({})).toMatchObject({ value: { cwd: f.root }, isError: false })
expect(await execute({ cd: 'child' })).toMatchObject({ value: { cwd: join(f.root, 'child') }, isError: false })
- expect(await execute({}, false)).toMatchObject({ isError: true })
+ expect(await execute({}, false)).toMatchObject({
+ isError: true,
+ error: { info: { name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' } },
+ })
+ expect(await execute({ cd: '' })).toMatchObject({
+ isError: true,
+ error: { info: { name: 'ToolArgsError', code: 'INVALID_ARGS' } },
+ })
+ expect(await execute({ cd: 'missing' })).toMatchObject({
+ isError: true,
+ error: { info: { name: 'WorkingDirectoryError', code: 'WORKING_DIRECTORY_NOT_FOUND' } },
+ })
await fiber.dispose()
expect(f.ctx.tools.schemas().some(tool => tool.name === 'working_directory')).toBe(false)
})