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.
Files changed (61) hide show
  1. package/README.md +359 -250
  2. package/dist/a2a/runtime.d.ts +7 -1
  3. package/dist/a2a/runtime.js +290 -17
  4. package/dist/acp/runtime.d.ts +5 -1
  5. package/dist/acp/runtime.js +88 -39
  6. package/dist/artifact-store.d.ts +162 -0
  7. package/dist/artifact-store.js +1081 -0
  8. package/dist/cli.js +13 -14
  9. package/dist/completion-contract.js +1 -1
  10. package/dist/config.js +45 -5
  11. package/dist/control-plane-validation.d.ts +54 -0
  12. package/dist/control-plane-validation.js +152 -0
  13. package/dist/control-plane.d.ts +9 -32
  14. package/dist/control-plane.js +52 -158
  15. package/dist/drivers/index.js +12 -0
  16. package/dist/drivers/pi.js +1 -1
  17. package/dist/drivers/stdio.d.ts +4 -1
  18. package/dist/drivers/stdio.js +135 -19
  19. package/dist/events.d.ts +9 -0
  20. package/dist/events.js +17 -4
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.js +33 -6
  23. package/dist/process-tree.js +15 -6
  24. package/dist/run-store.d.ts +57 -7
  25. package/dist/run-store.js +188 -23
  26. package/dist/server/auth-policy.d.ts +27 -0
  27. package/dist/server/auth-policy.js +143 -0
  28. package/dist/server/connection.d.ts +8 -0
  29. package/dist/server/connection.js +67 -0
  30. package/dist/server/diagnostics.d.ts +40 -0
  31. package/dist/server/diagnostics.js +130 -0
  32. package/dist/server/metrics.d.ts +51 -0
  33. package/dist/server/metrics.js +56 -0
  34. package/dist/server/quota.d.ts +59 -0
  35. package/dist/server/quota.js +176 -0
  36. package/dist/server/validation.d.ts +29 -0
  37. package/dist/server/validation.js +221 -0
  38. package/dist/server.d.ts +3 -0
  39. package/dist/server.js +682 -418
  40. package/dist/session/admin-runs.d.ts +73 -0
  41. package/dist/session/admin-runs.js +297 -0
  42. package/dist/session/admission.d.ts +32 -0
  43. package/dist/session/admission.js +62 -0
  44. package/dist/session/readiness.d.ts +33 -0
  45. package/dist/session/readiness.js +174 -0
  46. package/dist/session-contract.d.ts +1 -0
  47. package/dist/session.d.ts +64 -7
  48. package/dist/session.js +321 -159
  49. package/dist/sse-writer.d.ts +7 -0
  50. package/dist/sse-writer.js +60 -0
  51. package/dist/types.d.ts +44 -2
  52. package/dist/types.js +1 -0
  53. package/dist/webui/app/sources.js +18 -10
  54. package/dist/webui/index.js +11 -11
  55. package/dist/webui/markup.js +69 -66
  56. package/dist/webui/styles.js +791 -616
  57. package/dist/workspace.d.ts +30 -0
  58. package/dist/workspace.js +67 -2
  59. package/examples/client.mjs +102 -0
  60. package/nexus-agentd.example.json +21 -2
  61. package/package.json +6 -3
@@ -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
- "agents": {
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.2.5",
4
- "description": "Local Nexus Gateway daemon that exposes allowlisted Coding Agents through HTTP/SSE and ACP",
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"