nexus-agentd 0.2.5 → 0.3.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 +359 -250
- package/dist/a2a/runtime.d.ts +7 -1
- package/dist/a2a/runtime.js +290 -17
- package/dist/acp/runtime.d.ts +5 -1
- package/dist/acp/runtime.js +88 -39
- package/dist/artifact-store.d.ts +162 -0
- package/dist/artifact-store.js +1081 -0
- package/dist/cli.js +13 -14
- package/dist/completion-contract.js +1 -1
- package/dist/config.js +45 -5
- package/dist/control-plane-validation.d.ts +54 -0
- package/dist/control-plane-validation.js +152 -0
- package/dist/control-plane.d.ts +9 -32
- package/dist/control-plane.js +52 -158
- package/dist/drivers/index.js +12 -0
- package/dist/drivers/pi.js +1 -1
- package/dist/drivers/stdio.d.ts +4 -1
- package/dist/drivers/stdio.js +135 -19
- package/dist/events.d.ts +9 -0
- package/dist/events.js +17 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +33 -6
- package/dist/process-tree.js +15 -6
- package/dist/run-store.d.ts +57 -7
- package/dist/run-store.js +188 -23
- package/dist/server/auth-policy.d.ts +27 -0
- package/dist/server/auth-policy.js +143 -0
- package/dist/server/connection.d.ts +8 -0
- package/dist/server/connection.js +67 -0
- package/dist/server/diagnostics.d.ts +40 -0
- package/dist/server/diagnostics.js +130 -0
- package/dist/server/metrics.d.ts +51 -0
- package/dist/server/metrics.js +56 -0
- package/dist/server/quota.d.ts +59 -0
- package/dist/server/quota.js +176 -0
- package/dist/server/validation.d.ts +29 -0
- package/dist/server/validation.js +221 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +682 -418
- package/dist/session/admin-runs.d.ts +73 -0
- package/dist/session/admin-runs.js +297 -0
- package/dist/session/admission.d.ts +32 -0
- package/dist/session/admission.js +62 -0
- package/dist/session/readiness.d.ts +33 -0
- package/dist/session/readiness.js +174 -0
- package/dist/session-contract.d.ts +1 -0
- package/dist/session.d.ts +64 -7
- package/dist/session.js +321 -159
- package/dist/sse-writer.d.ts +7 -0
- package/dist/sse-writer.js +60 -0
- package/dist/types.d.ts +44 -2
- package/dist/types.js +1 -0
- package/dist/webui/app/sources.js +18 -10
- package/dist/webui/index.js +11 -11
- package/dist/webui/markup.js +69 -66
- package/dist/webui/styles.js +791 -616
- package/dist/workspace.d.ts +30 -0
- package/dist/workspace.js +67 -2
- package/examples/client.mjs +102 -0
- package/nexus-agentd.example.json +21 -2
- package/package.json +6 -3
package/dist/workspace.d.ts
CHANGED
|
@@ -5,3 +5,33 @@ 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;
|
|
9
|
+
export type ContainedReadFailure = 'outside' | 'swapped' | 'not-a-file' | 'too-large';
|
|
10
|
+
/**
|
|
11
|
+
* Reported as a kind rather than a finished message because each caller owns a
|
|
12
|
+
* different error contract: workspace publish maps it to an HTTP status, while a
|
|
13
|
+
* MEDIA marker maps it to a terminal_output event.
|
|
14
|
+
*/
|
|
15
|
+
export declare class ContainedReadError extends Error {
|
|
16
|
+
readonly failure: ContainedReadFailure;
|
|
17
|
+
constructor(failure: ContainedReadFailure, message: string);
|
|
18
|
+
}
|
|
19
|
+
export interface ContainedFile {
|
|
20
|
+
root: string;
|
|
21
|
+
path: string;
|
|
22
|
+
relative: string;
|
|
23
|
+
filename: string;
|
|
24
|
+
bytes: Buffer;
|
|
25
|
+
size: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Reads a file that must resolve inside one of `roots`.
|
|
29
|
+
*
|
|
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).
|
|
34
|
+
*/
|
|
35
|
+
export declare function readContainedFile(roots: readonly string[], requested: string, options: {
|
|
36
|
+
maxBytes: number;
|
|
37
|
+
}): Promise<ContainedFile>;
|
package/dist/workspace.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { realpath } from 'node:fs/promises';
|
|
1
|
+
import { open, realpath } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
export class WorkspacePolicy {
|
|
4
4
|
roots;
|
|
@@ -22,13 +22,78 @@ export class WorkspacePolicy {
|
|
|
22
22
|
return [...this.roots];
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
|
-
function isWithin(root, candidate) {
|
|
25
|
+
export function isWithin(root, candidate) {
|
|
26
26
|
const relative = path.relative(root, candidate);
|
|
27
27
|
return (relative === '' ||
|
|
28
28
|
(!relative.startsWith(`..${path.sep}`) &&
|
|
29
29
|
relative !== '..' &&
|
|
30
30
|
!path.isAbsolute(relative)));
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Reported as a kind rather than a finished message because each caller owns a
|
|
34
|
+
* different error contract: workspace publish maps it to an HTTP status, while a
|
|
35
|
+
* MEDIA marker maps it to a terminal_output event.
|
|
36
|
+
*/
|
|
37
|
+
export class ContainedReadError extends Error {
|
|
38
|
+
failure;
|
|
39
|
+
constructor(failure, message) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.failure = failure;
|
|
42
|
+
this.name = 'ContainedReadError';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Reads a file that must resolve inside one of `roots`.
|
|
47
|
+
*
|
|
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).
|
|
52
|
+
*/
|
|
53
|
+
export async function readContainedFile(roots, requested, options) {
|
|
54
|
+
if (!roots.length) {
|
|
55
|
+
throw new ContainedReadError('outside', 'no workspace root is configured');
|
|
56
|
+
}
|
|
57
|
+
const unresolved = path.resolve(roots[0], requested);
|
|
58
|
+
const target = await realpath(unresolved);
|
|
59
|
+
const root = roots.find((candidate) => isWithin(candidate, target));
|
|
60
|
+
if (!root)
|
|
61
|
+
throw outsideError(requested);
|
|
62
|
+
let handle;
|
|
63
|
+
try {
|
|
64
|
+
handle = await open(target, 'r');
|
|
65
|
+
// Resolve again after opening so a swapped symlink cannot silently
|
|
66
|
+
// redirect the read outside the allowlist.
|
|
67
|
+
const verified = await realpath(unresolved);
|
|
68
|
+
if (!isWithin(root, verified))
|
|
69
|
+
throw outsideError(requested);
|
|
70
|
+
if (verified !== target) {
|
|
71
|
+
throw new ContainedReadError('swapped', 'file changed while it was being opened');
|
|
72
|
+
}
|
|
73
|
+
const stats = await handle.stat();
|
|
74
|
+
if (!stats.isFile()) {
|
|
75
|
+
throw new ContainedReadError('not-a-file', 'path is not a regular file');
|
|
76
|
+
}
|
|
77
|
+
if (stats.size > options.maxBytes) {
|
|
78
|
+
throw new ContainedReadError('too-large', `file exceeds the ${options.maxBytes} byte limit`);
|
|
79
|
+
}
|
|
80
|
+
const bytes = await handle.readFile();
|
|
81
|
+
return {
|
|
82
|
+
root,
|
|
83
|
+
path: verified,
|
|
84
|
+
relative: path.relative(root, verified).split(path.sep).join('/'),
|
|
85
|
+
filename: path.basename(verified),
|
|
86
|
+
bytes,
|
|
87
|
+
size: bytes.length
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
await handle?.close();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function outsideError(requested) {
|
|
95
|
+
return new ContainedReadError('outside', `path is outside the configured allowlist: ${requested}`);
|
|
96
|
+
}
|
|
32
97
|
function normalize(value) {
|
|
33
98
|
return path.resolve(value);
|
|
34
99
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
2
|
+
|
|
3
|
+
/** Minimal Node 20+ client. Keep API Keys out of URLs and browser storage. */
|
|
4
|
+
export function createGatewayClient(baseUrl, apiKey) {
|
|
5
|
+
const base = new URL(baseUrl)
|
|
6
|
+
const headers = { Authorization: `Bearer ${apiKey}` }
|
|
7
|
+
const route = (sessionId) => `/v1/sessions/${encodeURIComponent(sessionId)}`
|
|
8
|
+
async function request(path, method = 'GET', body, signal) {
|
|
9
|
+
const response = await fetch(new URL(path, base), {
|
|
10
|
+
method, headers: { ...headers, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
|
|
11
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
12
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
|
|
13
|
+
redirect: 'error'
|
|
14
|
+
})
|
|
15
|
+
if (!response.ok) {
|
|
16
|
+
const error = new Error(`Gateway request failed (${response.status})`)
|
|
17
|
+
error.status = response.status
|
|
18
|
+
await response.body?.cancel()
|
|
19
|
+
throw error
|
|
20
|
+
}
|
|
21
|
+
return response.json()
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
createSession: (agentId, workspace) => request('/v1/sessions', 'POST', { agentId, workspace }),
|
|
25
|
+
snapshot: (id) => request(route(id)),
|
|
26
|
+
message: (id, message) => request(`${route(id)}/message`, 'POST', { message }),
|
|
27
|
+
resolve: (id, requestId, answer) => request(`${route(id)}/requests/${encodeURIComponent(requestId)}/resolve`, 'POST', answer),
|
|
28
|
+
cancel: (id) => request(`${route(id)}/cancel`, 'POST', {}),
|
|
29
|
+
close: (id) => request(route(id), 'DELETE'),
|
|
30
|
+
/** A reset yields a replacement snapshot; old discarded events are not replayed. */
|
|
31
|
+
async *events(id, { after, signal = new AbortController().signal } = {}) {
|
|
32
|
+
let cursor = after
|
|
33
|
+
let retries = 0
|
|
34
|
+
while (!signal.aborted) {
|
|
35
|
+
const connection = new AbortController()
|
|
36
|
+
const combined = AbortSignal.any([signal, connection.signal])
|
|
37
|
+
let timer = setTimeout(() => connection.abort(new Error('SSE connection timeout')), 15_000)
|
|
38
|
+
let reader
|
|
39
|
+
let reset = false
|
|
40
|
+
try {
|
|
41
|
+
const response = await fetch(new URL(`${route(id)}/events`, base), {
|
|
42
|
+
headers: { ...headers, ...(cursor === undefined ? {} : { 'Last-Event-ID': cursor }) },
|
|
43
|
+
signal: combined, redirect: 'error'
|
|
44
|
+
})
|
|
45
|
+
clearTimeout(timer)
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
const error = new Error(`SSE request failed (${response.status})`)
|
|
48
|
+
error.status = response.status
|
|
49
|
+
await response.body?.cancel()
|
|
50
|
+
throw error
|
|
51
|
+
}
|
|
52
|
+
reader = response.body.getReader()
|
|
53
|
+
const decoder = new TextDecoder()
|
|
54
|
+
let buffer = ''
|
|
55
|
+
while (!signal.aborted && !reset) {
|
|
56
|
+
timer = setTimeout(() => connection.abort(new Error('SSE heartbeat timeout')), 45_000)
|
|
57
|
+
const { value, done } = await reader.read()
|
|
58
|
+
clearTimeout(timer)
|
|
59
|
+
if (done) break
|
|
60
|
+
buffer += decoder.decode(value, { stream: true }).replace(/\r/g, '')
|
|
61
|
+
if (buffer.length > 2 * 1024 * 1024) throw new Error('SSE frame limit exceeded')
|
|
62
|
+
let end
|
|
63
|
+
while ((end = buffer.indexOf('\n\n')) >= 0) {
|
|
64
|
+
const frame = buffer.slice(0, end)
|
|
65
|
+
buffer = buffer.slice(end + 2)
|
|
66
|
+
const lines = frame.split('\n')
|
|
67
|
+
const data = lines.filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trimStart()).join('\n')
|
|
68
|
+
if (!data) continue // Heartbeat comments carry no event.
|
|
69
|
+
const type = lines.find((line) => line.startsWith('event:'))?.slice(6).trim()
|
|
70
|
+
const eventId = lines.find((line) => line.startsWith('id:'))?.slice(3).trim()
|
|
71
|
+
if (type === 'reset') {
|
|
72
|
+
// Use our fixed same-origin Session route, never a remote recovery URL.
|
|
73
|
+
const snapshot = await request(route(id), 'GET', undefined, signal)
|
|
74
|
+
cursor = snapshot.lastEventId || '0'
|
|
75
|
+
yield { type: 'reset', data: snapshot, recovery: JSON.parse(data) }
|
|
76
|
+
reset = true
|
|
77
|
+
break
|
|
78
|
+
}
|
|
79
|
+
if (!eventId || !/^\d+$/.test(eventId)) continue
|
|
80
|
+
if (cursor !== undefined && BigInt(eventId) <= BigInt(cursor)) continue
|
|
81
|
+
cursor = eventId
|
|
82
|
+
retries = 0
|
|
83
|
+
yield JSON.parse(data)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (signal.aborted) return
|
|
88
|
+
// A revoked Key, narrowed scope or deleted/restarted Session needs user action.
|
|
89
|
+
if ([401, 403, 404].includes(error.status)) throw error
|
|
90
|
+
} finally {
|
|
91
|
+
clearTimeout(timer)
|
|
92
|
+
connection.abort()
|
|
93
|
+
await reader?.cancel().catch(() => {})
|
|
94
|
+
}
|
|
95
|
+
if (!reset && !signal.aborted) {
|
|
96
|
+
await delay(Math.min(30_000, 500 * 2 ** Math.min(retries++, 6)) + Math.random() * 250,
|
|
97
|
+
undefined, { signal }).catch((error) => { if (!signal.aborted) throw error })
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -16,8 +16,27 @@
|
|
|
16
16
|
"maxSessions": 64,
|
|
17
17
|
"maxSseConnections": 128,
|
|
18
18
|
"maxConnections": 256,
|
|
19
|
-
"sessionTtlMs": 86400000,
|
|
20
|
-
"
|
|
19
|
+
"sessionTtlMs": 86400000,
|
|
20
|
+
"quotas": {
|
|
21
|
+
"maxSessionsPerKey": 16,
|
|
22
|
+
"maxRunningRunsPerKey": 4,
|
|
23
|
+
"maxSsePerKey": 8,
|
|
24
|
+
"maxUploadBytesPerKey": 33554432
|
|
25
|
+
},
|
|
26
|
+
"history": { "maxRuns": 1000, "retentionDays": 30, "maxBytes": 67108864 },
|
|
27
|
+
"artifacts": { "retentionDays": 30, "maxBytes": 536870912, "maxArtifactBytes": 12582912, "maxQueuedBytes": 67108864 },
|
|
28
|
+
"agents": {
|
|
29
|
+
"custom": {
|
|
30
|
+
"protocol": "acp",
|
|
31
|
+
"driver": "stdio",
|
|
32
|
+
"name": "Custom ACP",
|
|
33
|
+
"enabled": false,
|
|
34
|
+
"command": "your-acp-agent",
|
|
35
|
+
"args": [],
|
|
36
|
+
"probeArgs": ["--version"],
|
|
37
|
+
"workspace": "/data/repos/project",
|
|
38
|
+
"permissionPolicy": "ask"
|
|
39
|
+
},
|
|
21
40
|
"codex": {
|
|
22
41
|
"protocol": "acp",
|
|
23
42
|
"driver": "codex",
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexus-agentd",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Local Nexus Gateway daemon
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"dist",
|
|
13
|
+
"examples",
|
|
13
14
|
"nexus-agentd.example.json",
|
|
14
15
|
"README.md"
|
|
15
16
|
],
|
|
@@ -34,8 +35,9 @@
|
|
|
34
35
|
],
|
|
35
36
|
"scripts": {
|
|
36
37
|
"build": "node scripts/generate-webui-sources.mjs && node scripts/clean-dist.mjs && tsc -p tsconfig.json",
|
|
37
|
-
"typecheck": "node scripts/generate-webui-sources.mjs && tsc -p tsconfig.json --noEmit",
|
|
38
|
+
"typecheck": "node scripts/generate-webui-sources.mjs && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.webui.json",
|
|
38
39
|
"test": "tsx --test test/*.test.ts",
|
|
40
|
+
"test:webui": "node scripts/generate-webui-sources.mjs && playwright test",
|
|
39
41
|
"prepack": "npm run build"
|
|
40
42
|
},
|
|
41
43
|
"dependencies": {
|
|
@@ -44,6 +46,7 @@
|
|
|
44
46
|
"zod": "^3.25.76"
|
|
45
47
|
},
|
|
46
48
|
"devDependencies": {
|
|
49
|
+
"@playwright/test": "^1.62.1",
|
|
47
50
|
"@types/node": "^20.14.0",
|
|
48
51
|
"tsx": "^4.21.0",
|
|
49
52
|
"typescript": "^5.6.0"
|