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 +23 -1
- package/dist/artifact-store.js +9 -8
- package/dist/config.js +1 -0
- package/dist/control-plane-validation.js +1 -1
- package/dist/control-plane.js +2 -1
- package/dist/drivers/dsh.d.ts +2 -0
- package/dist/drivers/dsh.js +10 -0
- package/dist/drivers/grok.d.ts +2 -0
- package/dist/drivers/grok.js +12 -0
- package/dist/drivers/index.js +8 -0
- package/dist/drivers/stdio.d.ts +8 -0
- package/dist/drivers/stdio.js +13 -2
- package/dist/session.d.ts +5 -0
- package/dist/session.js +11 -3
- package/dist/types.d.ts +8 -1
- package/dist/types.js +3 -1
- package/dist/workspace.d.ts +30 -5
- package/dist/workspace.js +39 -13
- package/package.json +2 -2
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`。这不限制重复的单次自动授权,但只提供永久选项的
|
package/dist/artifact-store.js
CHANGED
|
@@ -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 (!
|
|
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 (!
|
|
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
|
-
|
|
574
|
-
|
|
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
|
}
|
package/dist/control-plane.js
CHANGED
|
@@ -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,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,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
|
+
}
|
package/dist/drivers/index.js
CHANGED
|
@@ -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/drivers/stdio.d.ts
CHANGED
|
@@ -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;
|
package/dist/drivers/stdio.js
CHANGED
|
@@ -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, {
|
|
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 ??
|
|
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
|
-
|
|
208
|
-
|
|
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
package/dist/workspace.d.ts
CHANGED
|
@@ -5,7 +5,27 @@ export declare class WorkspacePolicy {
|
|
|
5
5
|
resolve(input: string): Promise<string>;
|
|
6
6
|
listRoots(): string[];
|
|
7
7
|
}
|
|
8
|
-
|
|
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
|
|
31
|
-
*
|
|
32
|
-
* containment
|
|
33
|
-
* (
|
|
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) =>
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
49
|
-
*
|
|
50
|
-
* containment
|
|
51
|
-
* (
|
|
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) =>
|
|
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 (!
|
|
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.
|
|
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": "
|
|
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
|
},
|