nexus-agentd 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -108,6 +108,23 @@ Agent 的编辑页单独设置,默认 60 秒、最大 30 分钟。
108
108
  | `pi` | `pi-acp` |
109
109
  | `openclaw` | `openclaw acp` |
110
110
  | `hermes` | `hermes acp` |
111
+ | `dsh` | `dsh --profile acp` |
112
+ | `grok` | `grok --no-auto-update agent stdio` |
113
+
114
+ `dsh` 与 `grok` 使用各自 CLI 自带的 ACP 入口,不需要 Adapter。
115
+
116
+ `dsh`(DeepSeek Harness)默认执行 `dsh --profile acp`,要求 `dsh` 在 PATH 上。DeepSeek Harness
117
+ 桌面版把启动器放在 `<安装目录>\resources\runtime\cli\bin\dsh.cmd`,但不会自动出现在已运行进程的
118
+ PATH 里;两种接法二选一:把该目录加入用户 PATH(桌面版的命令管理功能会写 HKCU 用户环境变量,新开的
119
+ 进程才看得到),或在本机配置里用 `command` 覆盖成绝对路径,例如
120
+ `"command": "D:/DeepSeek/resources/runtime/cli/bin/dsh.cmd"`。
121
+
122
+ `grok`(Grok Build)默认执行 `grok --no-auto-update agent stdio`;`--no-auto-update` 用于跳过
123
+ 自动化环境中的后台更新检查,可用本机配置的 `args` 覆盖。前置条件是 CLI 已完成认证,三选一:先跑
124
+ `grok login` 缓存 token、用 `"inheritEnv": ["XAI_API_KEY"]` 把守护进程环境里的变量透传给 grok
125
+ 子进程、或用 `"env": { "XAI_API_KEY": "env:XAI_API_KEY" }` 显式赋值(`env:` 前缀从守护进程环境
126
+ 取值,变量缺失时配置加载直接报错)。Gateway 的 ACP 客户端不做 `authenticate` 请求,未认证时 grok
127
+ 会在握手后失败。DSH 是本地 agent,没有这个前置条件。
111
128
 
112
129
  Claude Code、Codex、Pi 通常需要对应 Adapter:
113
130
 
@@ -118,11 +135,16 @@ npm install -g \
118
135
  pi-acp
119
136
  ```
120
137
 
121
- `command`、`args`、`probeArgs`、`inheritEnv` 和 `env` 是仅可在本机配置文件修改的高级字段,WebUI
138
+ `command`、`args`、`probeArgs`、`probeTimeoutMs`、`inheritEnv` 和 `env` 是仅可在本机配置文件修改的高级字段,WebUI
122
139
  不接受这些字段。Workspace 在启动进程前经过 `realpath` 边界校验。通用 `stdio` 驱动用于自定义入口,
123
140
  必须指定 `command`,用 `probeArgs` 指定可用性检查参数(例如 `["--version"]`);编辑其他字段时保留
124
141
  这些本地配置。命令探测不等于 ACP 握手成功。
125
142
 
143
+ `probeTimeoutMs` 是可用性探测的期限,默认 60000 毫秒。探测会真正拉起一次 Agent 进程,而 Agent 在
144
+ 应答探测前往往要加载大量插件(例如 Hermes 会注册 50 多个插件提供者),启动耗时远超普通 shell
145
+ 命令;期限过短会把正在正常启动的 Agent 报成「不可用 / probe timed out」。启动较慢的 Agent 按实测
146
+ 启动时间调高该值即可。
147
+
126
148
  权限策略支持 `ask`(询问)、`allow`(自动允许单次)和 `deny`(拒绝)。`allow` 只选择 Agent 提供的
127
149
  `allow_once`,缺少该选项则取消授权,不回退到永久授权。`ask` 模式下普通 `action: "accept"` 也只选择
128
150
  单次授权,永久授权需要客户端明确传入对应 `optionId`。这不限制重复的单次自动授权,但只提供永久选项的
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { chmod as fsChmod, lstat as fsLstat, mkdir as fsMkdir, open as fsOpen, readdir as fsReaddir, readFile as fsReadFile, rename as fsRename, rm as fsRm, unlink as fsUnlink, writeFile as fsWriteFile } from 'node:fs/promises';
3
3
  import { performance } from 'node:perf_hooks';
4
4
  import path from 'node:path';
5
+ import { isPathInside } from './workspace.js';
5
6
  const DAY_MS = 24 * 60 * 60 * 1000;
6
7
  const DEFAULT_MAX_ARTIFACT_BYTES = 12 * 1024 * 1024;
7
8
  const DEFAULT_MAX_ARTIFACTS_PER_RUN = 32;
@@ -354,7 +355,7 @@ export class ArtifactStore {
354
355
  await this.ensureDirectory(runDir, false);
355
356
  const runReal = await this.realDirectory(runDir);
356
357
  const fileReal = await this.realPath(entry.filePath);
357
- if (!isContained(runReal, fileReal))
358
+ if (!isPathInside(runReal, fileReal))
358
359
  return undefined;
359
360
  const stat = await this.io.lstat(entry.filePath);
360
361
  if (!stat.isFile() || stat.isSymbolicLink())
@@ -566,12 +567,16 @@ export class ArtifactStore {
566
567
  await this.io.mkdir(runDir, { recursive: true, mode: 0o700 });
567
568
  await this.ensureDirectory(runDir, false);
568
569
  const runReal = await this.realDirectory(runDir);
569
- if (!isContained(await this.realDirectory(this.rootDir), runReal)) {
570
+ if (!isPathInside(await this.realDirectory(this.rootDir), runReal)) {
570
571
  throw new Error('Artifact run directory escaped the artifact root');
571
572
  }
572
573
  const filePath = this.payloadPath(item.runId, item.id);
573
- const fileRealParent = path.dirname(filePath);
574
- if (!isContained(runReal, fileRealParent))
574
+ // payloadPath() derives from the configured artifact root, which is not
575
+ // necessarily canonical (a Windows 8.3 short path or a symlinked parent),
576
+ // while runReal was resolved through realDirectory(). Compare the payload
577
+ // with the same canonical directory instead of the raw string.
578
+ const fileRealParent = await this.realDirectory(path.dirname(filePath));
579
+ if (!isPathInside(runReal, fileRealParent))
575
580
  throw new Error('Invalid artifact payload path');
576
581
  const delta = item.bytes.length - (entry.bytes || 0);
577
582
  const previousView = entry.durableView;
@@ -1161,10 +1166,6 @@ function nonNegativeInteger(value, fallback) {
1161
1166
  function nonNegativeNumber(value, fallback) {
1162
1167
  return Number.isFinite(value) && Number(value) >= 0 ? Number(value) : fallback;
1163
1168
  }
1164
- function isContained(root, target) {
1165
- const relative = path.relative(root, target);
1166
- return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
1167
- }
1168
1169
  function isMissing(error) {
1169
1170
  return error?.code === 'ENOENT';
1170
1171
  }
package/dist/config.js CHANGED
@@ -181,6 +181,7 @@ function parseAgent(id, value) {
181
181
  probeArgs: input.probeArgs === undefined
182
182
  ? undefined
183
183
  : stringArray(input.probeArgs, `agents.${id}.probeArgs`),
184
+ probeTimeoutMs: integerValue(input.probeTimeoutMs, `agents.${id}.probeTimeoutMs`, 60 * 1000, 1000, 10 * 60 * 1000),
184
185
  inheritEnv: input.inheritEnv === undefined
185
186
  ? undefined
186
187
  : stringArray(input.inheritEnv, `agents.${id}.inheritEnv`),
@@ -117,7 +117,7 @@ export function clampTimeout(value, fallback, min, max) {
117
117
  }
118
118
  export function preservedAcpAdvanced(previous) {
119
119
  const result = {};
120
- for (const key of ['command', 'args', 'probeArgs', 'inheritEnv', 'env']) {
120
+ for (const key of ['command', 'args', 'probeArgs', 'probeTimeoutMs', 'inheritEnv', 'env']) {
121
121
  if (previous[key] !== undefined)
122
122
  result[key] = structuredClone(previous[key]);
123
123
  }
@@ -513,7 +513,8 @@ export class AgentdControlPlane {
513
513
  enabled: config.enabled !== false,
514
514
  workspace: config.workspace || this.config.workspaceRoots[0] || '',
515
515
  permissionPolicy: config.permissionPolicy || 'ask',
516
- permissionTimeoutMs: config.permissionTimeoutMs || 15 * 60 * 1000
516
+ permissionTimeoutMs: config.permissionTimeoutMs || 15 * 60 * 1000,
517
+ probeTimeoutMs: config.probeTimeoutMs || 60 * 1000
517
518
  };
518
519
  }
519
520
  exclusive(task) {
@@ -0,0 +1,2 @@
1
+ import type { AgentdDriverConfig } from '../types.js';
2
+ export declare function createDshDriver(id: string, config: AgentdDriverConfig): import("./types.js").AgentDriver;
@@ -0,0 +1,10 @@
1
+ import { createStdioAcpDriver } from './stdio.js';
2
+ export function createDshDriver(id, config) {
3
+ return createStdioAcpDriver(id, config, {
4
+ name: 'DeepSeek Harness',
5
+ description: 'DeepSeek Harness through its native ACP stdio profile',
6
+ command: 'dsh',
7
+ args: ['--profile', 'acp'],
8
+ probeArgs: ['--version']
9
+ });
10
+ }
@@ -0,0 +1,2 @@
1
+ import type { AgentdDriverConfig } from '../types.js';
2
+ export declare function createGrokDriver(id: string, config: AgentdDriverConfig): import("./types.js").AgentDriver;
@@ -0,0 +1,12 @@
1
+ import { createStdioAcpDriver } from './stdio.js';
2
+ export function createGrokDriver(id, config) {
3
+ return createStdioAcpDriver(id, config, {
4
+ name: 'Grok Build',
5
+ description: 'Grok Build through its native ACP stdio agent',
6
+ command: 'grok',
7
+ // The xAI CLI documents `--no-auto-update` for `grok agent stdio` in
8
+ // scripts and CI, which is exactly how this gateway launches it.
9
+ args: ['--no-auto-update', 'agent', 'stdio'],
10
+ probeArgs: ['version']
11
+ });
12
+ }
@@ -1,5 +1,7 @@
1
1
  import { createClaudeDriver } from './claude.js';
2
2
  import { createCodexDriver } from './codex.js';
3
+ import { createDshDriver } from './dsh.js';
4
+ import { createGrokDriver } from './grok.js';
3
5
  import { createHermesDriver } from './hermes.js';
4
6
  import { createOpenCodeDriver } from './opencode.js';
5
7
  import { createOpenClawDriver } from './openclaw.js';
@@ -42,6 +44,12 @@ export function createDriverRegistry(config) {
42
44
  case 'hermes':
43
45
  drivers.set(id, createHermesDriver(id, driver));
44
46
  break;
47
+ case 'dsh':
48
+ drivers.set(id, createDshDriver(id, driver));
49
+ break;
50
+ case 'grok':
51
+ drivers.set(id, createGrokDriver(id, driver));
52
+ break;
45
53
  }
46
54
  }
47
55
  return drivers;
@@ -1,5 +1,13 @@
1
1
  import type { AgentdDriverConfig } from '../types.js';
2
2
  import type { AgentDriver } from './types.js';
3
+ /**
4
+ * How long an availability probe may take before it is reported as a timeout.
5
+ * Probes spawn a real agent process, and agents that load plugins before
6
+ * answering (Hermes registers 50+ plugin providers) need far longer than the
7
+ * five seconds that used to be hard-coded here — they were reported as down
8
+ * while starting up normally. Override per agent with `probeTimeoutMs`.
9
+ */
10
+ export declare const DEFAULT_PROBE_TIMEOUT_MS = 60000;
3
11
  export interface StdioAcpDriverDefaults {
4
12
  name: string;
5
13
  description: string;
@@ -27,6 +27,14 @@ const BASE_ENV_KEYS = [
27
27
  'ProgramFiles(x86)',
28
28
  'PATHEXT'
29
29
  ];
30
+ /**
31
+ * How long an availability probe may take before it is reported as a timeout.
32
+ * Probes spawn a real agent process, and agents that load plugins before
33
+ * answering (Hermes registers 50+ plugin providers) need far longer than the
34
+ * five seconds that used to be hard-coded here — they were reported as down
35
+ * while starting up normally. Override per agent with `probeTimeoutMs`.
36
+ */
37
+ export const DEFAULT_PROBE_TIMEOUT_MS = 60_000;
30
38
  export function createStdioAcpDriver(id, config, defaults) {
31
39
  const command = config.command || defaults.command;
32
40
  const args = config.args === undefined ? [...defaults.args] : [...config.args];
@@ -45,7 +53,10 @@ export function createStdioAcpDriver(id, config, defaults) {
45
53
  ownsProcessGroup: process.platform !== 'win32',
46
54
  async probe() {
47
55
  try {
48
- const version = await probeCommand(command, config.probeArgs || defaults.probeArgs || ['--version'], env, name, { ownsProcessGroup: process.platform !== 'win32' });
56
+ const version = await probeCommand(command, config.probeArgs || defaults.probeArgs || ['--version'], env, name, {
57
+ ownsProcessGroup: process.platform !== 'win32',
58
+ timeoutMs: config.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS
59
+ });
49
60
  return {
50
61
  id,
51
62
  name,
@@ -155,7 +166,7 @@ export function probeCommand(command, args, env, label, options = {}) {
155
166
  // process. Wait for tree termination before rejecting so a timed
156
167
  // out health check cannot leave an agent running in the background.
157
168
  void cleanup().then(() => finish(new Error(`${label} probe timed out`)), (error) => finish(new Error(`${label} probe timed out; cleanup failed: ${cleanupFailure(error)}`)));
158
- }, options.timeoutMs ?? 5000);
169
+ }, options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS);
159
170
  timer.unref?.();
160
171
  child.stdout.on('data', (chunk) => {
161
172
  output = `${output}${String(chunk)}`.slice(-8192);
package/dist/session.d.ts CHANGED
@@ -52,6 +52,11 @@ export declare class ManagedSession implements AgentSessionSink, AcpSessionSink
52
52
  private runtime?;
53
53
  private readonly artifactStore?;
54
54
  private readonly onRunUpdate?;
55
+ /**
56
+ * `workspace` must be the canonical path the workspace policy resolved at
57
+ * session creation. Callers that hand over a raw path would make publish
58
+ * compare a non-canonical root against realpath'd targets.
59
+ */
55
60
  constructor(agentId: string, protocol: AgentdProtocol, workspace: string | undefined, ownerKeyId: string, maxEvents: number, maxOutputChars: number, agentName: string, runStore?: RunStore | undefined, instanceId?: string, artifactStore?: SessionArtifactStore, onRunUpdate?: (runId: string, state: AgentdSessionState) => void);
56
61
  attach(runtime: AgentSessionRuntime): void;
57
62
  runtimeAvailable(): boolean;
package/dist/session.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { realpath } from 'node:fs/promises';
3
2
  import { extname } from 'node:path';
4
3
  import { AcpProcessRuntime } from './acp/runtime.js';
5
4
  import { A2AClientRuntime } from './a2a/runtime.js';
@@ -40,6 +39,11 @@ export class ManagedSession {
40
39
  runtime;
41
40
  artifactStore;
42
41
  onRunUpdate;
42
+ /**
43
+ * `workspace` must be the canonical path the workspace policy resolved at
44
+ * session creation. Callers that hand over a raw path would make publish
45
+ * compare a non-canonical root against realpath'd targets.
46
+ */
43
47
  constructor(agentId, protocol, workspace, ownerKeyId, maxEvents, maxOutputChars, agentName, runStore, instanceId = randomUUID(), artifactStore, onRunUpdate) {
44
48
  this.agentId = agentId;
45
49
  this.protocol = protocol;
@@ -204,8 +208,12 @@ export class ManagedSession {
204
208
  }
205
209
  async publishSingleFile(requestedPath) {
206
210
  try {
207
- const workspaceRoot = await realpath(this.workspace);
208
- const file = await readContainedFile([workspaceRoot], requestedPath, {
211
+ // `this.workspace` is the canonical path the session was created
212
+ // with, already resolved through the workspace policy. Resolving it
213
+ // again here would hand the trust decision back to the file system,
214
+ // so a workspace replaced by a link after creation would silently
215
+ // become the new root and publish would read outside the allowlist.
216
+ const file = await readContainedFile([this.workspace], requestedPath, {
209
217
  maxBytes: MAX_PUBLISHED_FILE_BYTES
210
218
  });
211
219
  const artifact = {
package/dist/types.d.ts CHANGED
@@ -14,7 +14,7 @@ export interface AgentdTurnCompletion extends AgentdTurnCompletionProof {
14
14
  artifactCount: number;
15
15
  completedAt: number;
16
16
  }
17
- export declare const agentdDriverKinds: readonly ["stdio", "opencode", "claude", "codex", "pi", "openclaw", "hermes"];
17
+ export declare const agentdDriverKinds: readonly ["stdio", "opencode", "claude", "codex", "pi", "openclaw", "hermes", "dsh", "grok"];
18
18
  export type AgentdDriverKind = (typeof agentdDriverKinds)[number];
19
19
  export interface AgentdDriverConfig {
20
20
  protocol?: 'acp';
@@ -27,6 +27,12 @@ export interface AgentdDriverConfig {
27
27
  args?: string[];
28
28
  /** Arguments used by a command availability probe. */
29
29
  probeArgs?: string[];
30
+ /**
31
+ * Milliseconds the availability probe may take before it is reported as a
32
+ * timeout. Agent CLIs that load plugins before answering a probe need far
33
+ * more than a shell command; defaults to 60000.
34
+ */
35
+ probeTimeoutMs?: number;
30
36
  inheritEnv?: string[];
31
37
  env?: Record<string, string>;
32
38
  permissionPolicy?: PermissionPolicy;
@@ -141,6 +147,7 @@ export interface AgentdAgentConfigView {
141
147
  workspace?: string;
142
148
  permissionPolicy?: PermissionPolicy;
143
149
  permissionTimeoutMs?: number;
150
+ probeTimeoutMs?: number;
144
151
  agentCardUrl?: string;
145
152
  agentUrl?: string;
146
153
  preferredTransport?: A2ATransportPreference;
package/dist/types.js CHANGED
@@ -5,5 +5,7 @@ export const agentdDriverKinds = [
5
5
  'codex',
6
6
  'pi',
7
7
  'openclaw',
8
- 'hermes'
8
+ 'hermes',
9
+ 'dsh',
10
+ 'grok'
9
11
  ];
@@ -5,7 +5,27 @@ export declare class WorkspacePolicy {
5
5
  resolve(input: string): Promise<string>;
6
6
  listRoots(): string[];
7
7
  }
8
- export declare function isWithin(root: string, candidate: string): boolean;
8
+ /**
9
+ * Reports whether `target` is `root` itself or lives below it.
10
+ *
11
+ * Both arguments must already be canonical (realpath'd) absolute paths.
12
+ * `realpath` returns the spelling stored on disk, so on a normal
13
+ * case-insensitive volume two differently-cased spellings of one directory still
14
+ * compare equal here. A prefix comparison is used rather than `path.relative`,
15
+ * whose win32 implementation lower-cases both sides and would therefore read a
16
+ * case-variant sibling inside a case-sensitive directory as contained. The
17
+ * separator is required so that `\\server\share2` is not read as living inside
18
+ * `\\server\share`.
19
+ *
20
+ * `path.resolve` only folds lexical noise (`.`, `..`, duplicate separators) for
21
+ * callers that hand over a hand-written path, which the previous
22
+ * `path.relative` implementation also tolerated. It must never be replaced with
23
+ * `realpath`: resolving against the file system here would hand the trust
24
+ * decision back to whatever the directory currently points at.
25
+ */
26
+ export declare function isPathInside(root: string, target: string): boolean;
27
+ /** @deprecated Use {@link isPathInside}. Kept because this module is re-exported. */
28
+ export declare const isWithin: typeof isPathInside;
9
29
  export type ContainedReadFailure = 'outside' | 'swapped' | 'not-a-file' | 'too-large';
10
30
  /**
11
31
  * Reported as a kind rather than a finished message because each caller owns a
@@ -27,10 +47,15 @@ export interface ContainedFile {
27
47
  /**
28
48
  * Reads a file that must resolve inside one of `roots`.
29
49
  *
30
- * `roots` must already be realpath'd absolute paths; `WorkspacePolicy.listRoots()`
31
- * satisfies this. A caller that passes an unresolved root fails closed, because
32
- * containment is checked against a realpath'd target. `requested` may be absolute
33
- * (an agent-declared path) or relative to `roots[0]` (a client-supplied path).
50
+ * `roots` must be the canonical paths the caller decided to trust — not the
51
+ * result of a fresh `realpath()` of that same path. Re-resolving a root at call
52
+ * time hands the containment decision back to the file system, so a link
53
+ * planted after the decision (for example a workspace swapped for a junction)
54
+ * would move the trust boundary with it. `WorkspacePolicy.listRoots()` and a
55
+ * session's own canonical workspace both satisfy this. An unresolved root fails
56
+ * closed, because containment is checked against a realpath'd target.
57
+ * `requested` may be absolute (an agent-declared path) or relative to `roots[0]`
58
+ * (a client-supplied path).
34
59
  */
35
60
  export declare function readContainedFile(roots: readonly string[], requested: string, options: {
36
61
  maxBytes: number;
package/dist/workspace.js CHANGED
@@ -13,7 +13,7 @@ export class WorkspacePolicy {
13
13
  if (!input?.trim())
14
14
  throw new Error('workspace is required');
15
15
  const candidate = normalize(await realpath(input));
16
- const allowed = this.roots.some((root) => isWithin(root, candidate));
16
+ const allowed = this.roots.some((root) => isPathInside(root, candidate));
17
17
  if (!allowed)
18
18
  throw new Error(`workspace is outside the configured allowlist: ${input}`);
19
19
  return candidate;
@@ -22,13 +22,34 @@ export class WorkspacePolicy {
22
22
  return [...this.roots];
23
23
  }
24
24
  }
25
- export function isWithin(root, candidate) {
26
- const relative = path.relative(root, candidate);
27
- return (relative === '' ||
28
- (!relative.startsWith(`..${path.sep}`) &&
29
- relative !== '..' &&
30
- !path.isAbsolute(relative)));
25
+ /**
26
+ * Reports whether `target` is `root` itself or lives below it.
27
+ *
28
+ * Both arguments must already be canonical (realpath'd) absolute paths.
29
+ * `realpath` returns the spelling stored on disk, so on a normal
30
+ * case-insensitive volume two differently-cased spellings of one directory still
31
+ * compare equal here. A prefix comparison is used rather than `path.relative`,
32
+ * whose win32 implementation lower-cases both sides and would therefore read a
33
+ * case-variant sibling inside a case-sensitive directory as contained. The
34
+ * separator is required so that `\\server\share2` is not read as living inside
35
+ * `\\server\share`.
36
+ *
37
+ * `path.resolve` only folds lexical noise (`.`, `..`, duplicate separators) for
38
+ * callers that hand over a hand-written path, which the previous
39
+ * `path.relative` implementation also tolerated. It must never be replaced with
40
+ * `realpath`: resolving against the file system here would hand the trust
41
+ * decision back to whatever the directory currently points at.
42
+ */
43
+ export function isPathInside(root, target) {
44
+ const base = path.resolve(root);
45
+ const candidate = path.resolve(target);
46
+ if (candidate === base)
47
+ return true;
48
+ const prefix = base.endsWith(path.sep) ? base : base + path.sep;
49
+ return candidate.startsWith(prefix);
31
50
  }
51
+ /** @deprecated Use {@link isPathInside}. Kept because this module is re-exported. */
52
+ export const isWithin = isPathInside;
32
53
  /**
33
54
  * Reported as a kind rather than a finished message because each caller owns a
34
55
  * different error contract: workspace publish maps it to an HTTP status, while a
@@ -45,10 +66,15 @@ export class ContainedReadError extends Error {
45
66
  /**
46
67
  * Reads a file that must resolve inside one of `roots`.
47
68
  *
48
- * `roots` must already be realpath'd absolute paths; `WorkspacePolicy.listRoots()`
49
- * satisfies this. A caller that passes an unresolved root fails closed, because
50
- * containment is checked against a realpath'd target. `requested` may be absolute
51
- * (an agent-declared path) or relative to `roots[0]` (a client-supplied path).
69
+ * `roots` must be the canonical paths the caller decided to trust — not the
70
+ * result of a fresh `realpath()` of that same path. Re-resolving a root at call
71
+ * time hands the containment decision back to the file system, so a link
72
+ * planted after the decision (for example a workspace swapped for a junction)
73
+ * would move the trust boundary with it. `WorkspacePolicy.listRoots()` and a
74
+ * session's own canonical workspace both satisfy this. An unresolved root fails
75
+ * closed, because containment is checked against a realpath'd target.
76
+ * `requested` may be absolute (an agent-declared path) or relative to `roots[0]`
77
+ * (a client-supplied path).
52
78
  */
53
79
  export async function readContainedFile(roots, requested, options) {
54
80
  if (!roots.length) {
@@ -56,7 +82,7 @@ export async function readContainedFile(roots, requested, options) {
56
82
  }
57
83
  const unresolved = path.resolve(roots[0], requested);
58
84
  const target = await realpath(unresolved);
59
- const root = roots.find((candidate) => isWithin(candidate, target));
85
+ const root = roots.find((candidate) => isPathInside(candidate, target));
60
86
  if (!root)
61
87
  throw outsideError(requested);
62
88
  let handle;
@@ -65,7 +91,7 @@ export async function readContainedFile(roots, requested, options) {
65
91
  // Resolve again after opening so a swapped symlink cannot silently
66
92
  // redirect the read outside the allowlist.
67
93
  const verified = await realpath(unresolved);
68
- if (!isWithin(root, verified))
94
+ if (!isPathInside(root, verified))
69
95
  throw outsideError(requested);
70
96
  if (verified !== target) {
71
97
  throw new ContainedReadError('swapped', 'file changed while it was being opened');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexus-agentd",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Local Nexus Gateway daemon with an HTTP/SSE API and WebUI for allowlisted ACP and remote A2A agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -36,7 +36,7 @@
36
36
  "scripts": {
37
37
  "build": "node scripts/generate-webui-sources.mjs && node scripts/clean-dist.mjs && tsc -p tsconfig.json",
38
38
  "typecheck": "node scripts/generate-webui-sources.mjs && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.webui.json",
39
- "test": "tsx --test --test-force-exit --test-concurrency=1 test/*.test.ts",
39
+ "test": "node scripts/run-tests.mjs",
40
40
  "test:webui": "node scripts/generate-webui-sources.mjs && playwright test",
41
41
  "prepack": "npm run build"
42
42
  },