nexus-agentd 0.4.2 → 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
 
@@ -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
  }
@@ -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;
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';
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.2",
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
  },