nexus-agentd 0.2.3 → 0.2.6
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/LICENSE +22 -22
- package/README.md +223 -202
- package/dist/a2a/runtime.d.ts +3 -0
- package/dist/a2a/runtime.js +119 -12
- package/dist/acp/runtime.d.ts +5 -1
- package/dist/acp/runtime.js +180 -36
- package/dist/auth.js +29 -8
- package/dist/completion-contract.js +1 -1
- package/dist/control-plane.js +11 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +23 -5
- package/dist/run-store.d.ts +5 -0
- package/dist/run-store.js +10 -1
- package/dist/server.js +58 -8
- package/dist/session-contract.d.ts +1 -0
- package/dist/session.d.ts +14 -2
- package/dist/session.js +153 -74
- package/dist/types.d.ts +14 -0
- package/dist/webui/app/sources.d.ts +1 -0
- package/dist/webui/app/sources.js +17 -0
- package/dist/webui/icons.d.ts +17 -0
- package/dist/webui/icons.js +23 -6
- package/dist/webui/index.d.ts +1 -0
- package/dist/webui/index.js +32 -12
- package/dist/webui/markup.js +67 -65
- package/dist/webui/styles.js +746 -256
- package/dist/workspace.d.ts +30 -0
- package/dist/workspace.js +67 -2
- package/nexus-agentd.example.json +40 -40
- package/package.json +54 -52
- package/dist/webui/app.d.ts +0 -1
- package/dist/webui/app.js +0 -914
package/dist/a2a/runtime.js
CHANGED
|
@@ -44,6 +44,8 @@ export class A2AClientRuntime {
|
|
|
44
44
|
taskId = '';
|
|
45
45
|
contextId = '';
|
|
46
46
|
prompting = false;
|
|
47
|
+
promptFinished;
|
|
48
|
+
queuedResponse = false;
|
|
47
49
|
disposed = false;
|
|
48
50
|
activeController;
|
|
49
51
|
artifactCache = new Map();
|
|
@@ -57,6 +59,8 @@ export class A2AClientRuntime {
|
|
|
57
59
|
this.promptTimeoutMs = promptTimeoutMs;
|
|
58
60
|
}
|
|
59
61
|
async start() {
|
|
62
|
+
if (this.disposed)
|
|
63
|
+
throw new Error('A2A runtime is disposed');
|
|
60
64
|
const resolver = createResolver(this.config);
|
|
61
65
|
this.card = await resolveAgentCard(resolver, this.config);
|
|
62
66
|
const fetchImpl = authenticatedFetch(this.config);
|
|
@@ -70,6 +74,8 @@ export class A2AClientRuntime {
|
|
|
70
74
|
]
|
|
71
75
|
});
|
|
72
76
|
this.client = await factory.createFromAgentCard(this.card);
|
|
77
|
+
if (this.disposed)
|
|
78
|
+
throw new Error('A2A runtime is disposed');
|
|
73
79
|
this.sink.setState('created');
|
|
74
80
|
}
|
|
75
81
|
async prompt(message, attachments = []) {
|
|
@@ -80,6 +86,8 @@ export class A2AClientRuntime {
|
|
|
80
86
|
if (this.disposed)
|
|
81
87
|
throw new Error('A2A runtime is disposed');
|
|
82
88
|
this.prompting = true;
|
|
89
|
+
let finish;
|
|
90
|
+
this.promptFinished = new Promise((resolve) => { finish = resolve; });
|
|
83
91
|
this.sawTaskStatus = false;
|
|
84
92
|
this.sawAgentMessage = false;
|
|
85
93
|
this.completionProof = undefined;
|
|
@@ -94,9 +102,15 @@ export class A2AClientRuntime {
|
|
|
94
102
|
for await (const response of this.client.sendMessageStream(request, {
|
|
95
103
|
signal: controller.signal
|
|
96
104
|
})) {
|
|
105
|
+
if (controller.signal.aborted || this.disposed)
|
|
106
|
+
break;
|
|
107
|
+
// An accepted answer supersedes the old input-required stream.
|
|
108
|
+
// Its trailing status must not reopen the answered request.
|
|
109
|
+
if (this.queuedResponse)
|
|
110
|
+
continue;
|
|
97
111
|
this.processResponse(response);
|
|
98
112
|
}
|
|
99
|
-
if (this.sink.state === 'running') {
|
|
113
|
+
if (this.sink.state === 'running' && !this.queuedResponse) {
|
|
100
114
|
if (this.completionProof) {
|
|
101
115
|
this.sink.completeTurn(this.completionProof);
|
|
102
116
|
}
|
|
@@ -114,7 +128,7 @@ export class A2AClientRuntime {
|
|
|
114
128
|
}
|
|
115
129
|
}
|
|
116
130
|
catch (error) {
|
|
117
|
-
if (this.sink.state !== 'canceled') {
|
|
131
|
+
if (this.sink.state !== 'canceled' && !this.queuedResponse) {
|
|
118
132
|
this.sink.setState('failed', abortMessage(error, controller.signal));
|
|
119
133
|
}
|
|
120
134
|
}
|
|
@@ -123,9 +137,13 @@ export class A2AClientRuntime {
|
|
|
123
137
|
if (this.activeController === controller)
|
|
124
138
|
this.activeController = undefined;
|
|
125
139
|
this.prompting = false;
|
|
140
|
+
finish();
|
|
141
|
+
this.promptFinished = undefined;
|
|
126
142
|
}
|
|
127
143
|
}
|
|
128
144
|
async respondPending(response, attachments = []) {
|
|
145
|
+
if (!this.isAvailable())
|
|
146
|
+
throw new Error('A2A runtime is unavailable');
|
|
129
147
|
if (this.sink.state !== 'input_required') {
|
|
130
148
|
throw new Error('A2A session is not waiting for input');
|
|
131
149
|
}
|
|
@@ -138,10 +156,23 @@ export class A2AClientRuntime {
|
|
|
138
156
|
: response.message ?? response.optionId ?? '';
|
|
139
157
|
if (!message.trim())
|
|
140
158
|
throw new Error('A2A pending response is empty');
|
|
159
|
+
const previous = this.promptFinished || Promise.resolve();
|
|
160
|
+
this.queuedResponse = true;
|
|
141
161
|
this.sink.clearPending();
|
|
142
|
-
|
|
162
|
+
this.sink.setState('running');
|
|
163
|
+
void previous.then(async () => {
|
|
164
|
+
this.queuedResponse = false;
|
|
165
|
+
if (this.disposed || this.sink.state === 'canceled')
|
|
166
|
+
return;
|
|
167
|
+
await this.prompt(message, attachments);
|
|
168
|
+
}).catch((error) => {
|
|
169
|
+
if (!this.disposed && this.sink.state !== 'canceled')
|
|
170
|
+
this.sink.setState('failed', errorMessage(error));
|
|
171
|
+
});
|
|
143
172
|
}
|
|
144
173
|
async cancel() {
|
|
174
|
+
this.sink.clearPending();
|
|
175
|
+
this.sink.setState('canceled');
|
|
145
176
|
this.activeController?.abort(new Error('A2A session canceled'));
|
|
146
177
|
if (this.client && this.taskId) {
|
|
147
178
|
try {
|
|
@@ -162,6 +193,9 @@ export class A2AClientRuntime {
|
|
|
162
193
|
this.activeController?.abort(new Error('A2A runtime disposed'));
|
|
163
194
|
this.activeController = undefined;
|
|
164
195
|
}
|
|
196
|
+
isAvailable() {
|
|
197
|
+
return !this.disposed && Boolean(this.client && this.card);
|
|
198
|
+
}
|
|
165
199
|
messageRequest(text, attachments) {
|
|
166
200
|
return {
|
|
167
201
|
tenant: '',
|
|
@@ -280,13 +314,16 @@ export class A2AClientRuntime {
|
|
|
280
314
|
this.sink.clearPending();
|
|
281
315
|
this.sink.setState('canceled');
|
|
282
316
|
break;
|
|
283
|
-
case TaskState.TASK_STATE_INPUT_REQUIRED:
|
|
317
|
+
case TaskState.TASK_STATE_INPUT_REQUIRED: {
|
|
318
|
+
const classification = a2aInputClassification(statusText, status.message);
|
|
284
319
|
this.sink.setPending({
|
|
285
320
|
id: this.taskId || randomUUID(),
|
|
286
321
|
kind: 'input',
|
|
287
|
-
prompt: statusText || 'The A2A agent requires additional input.'
|
|
322
|
+
prompt: statusText || 'The A2A agent requires additional input.',
|
|
323
|
+
...classification
|
|
288
324
|
});
|
|
289
325
|
break;
|
|
326
|
+
}
|
|
290
327
|
case TaskState.TASK_STATE_FAILED:
|
|
291
328
|
case TaskState.TASK_STATE_REJECTED:
|
|
292
329
|
case TaskState.TASK_STATE_AUTH_REQUIRED:
|
|
@@ -335,12 +372,43 @@ function createResolver(config) {
|
|
|
335
372
|
legacyCompat: { enabled: true }
|
|
336
373
|
});
|
|
337
374
|
}
|
|
338
|
-
function resolveAgentCard(resolver, config) {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
375
|
+
async function resolveAgentCard(resolver, config) {
|
|
376
|
+
const origin = configuredOrigin(config);
|
|
377
|
+
const card = config.agentCardUrl
|
|
378
|
+
? await resolver.resolve(config.agentCardUrl, '')
|
|
379
|
+
: await resolver.resolve(config.agentUrl);
|
|
380
|
+
// The resolver normalizes v0.3 url/additionalInterfaces into this array.
|
|
381
|
+
// Validate every advertised endpoint before transport selection or probing.
|
|
382
|
+
if (!Array.isArray(card.supportedInterfaces) || !card.supportedInterfaces.length) {
|
|
383
|
+
throw new Error('A2A Agent Card must declare at least one interface URL');
|
|
384
|
+
}
|
|
385
|
+
for (const endpoint of card.supportedInterfaces)
|
|
386
|
+
assertSameOrigin(endpoint?.url, origin);
|
|
387
|
+
return card;
|
|
388
|
+
}
|
|
389
|
+
function a2aUrl(value) {
|
|
390
|
+
let url;
|
|
391
|
+
try {
|
|
392
|
+
url = new URL(value);
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
throw new Error('A2A URL is invalid');
|
|
396
|
+
}
|
|
397
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) {
|
|
398
|
+
throw new Error('A2A URL must use http(s) without credentials or a fragment');
|
|
399
|
+
}
|
|
400
|
+
return url;
|
|
401
|
+
}
|
|
402
|
+
function configuredOrigin(config) {
|
|
403
|
+
const value = config.agentCardUrl || config.agentUrl;
|
|
404
|
+
if (!value)
|
|
405
|
+
throw new Error('A2A Agent Card URL is required');
|
|
406
|
+
return a2aUrl(value).origin;
|
|
407
|
+
}
|
|
408
|
+
function assertSameOrigin(value, origin) {
|
|
409
|
+
if (a2aUrl(value).origin !== origin) {
|
|
410
|
+
throw new Error('A2A URL must have the same origin as the configured Agent Card URL');
|
|
411
|
+
}
|
|
344
412
|
}
|
|
345
413
|
function preferredTransports(config) {
|
|
346
414
|
if (config.preferredTransport === 'jsonrpc')
|
|
@@ -350,8 +418,12 @@ function preferredTransports(config) {
|
|
|
350
418
|
return undefined;
|
|
351
419
|
}
|
|
352
420
|
function authenticatedFetch(config) {
|
|
421
|
+
const origin = configuredOrigin(config);
|
|
353
422
|
return async (input, init = {}) => {
|
|
354
|
-
|
|
423
|
+
// The final guard also covers SDK-derived paths and future card flows.
|
|
424
|
+
// Check before attaching credentials; never let fetch follow redirects.
|
|
425
|
+
assertSameOrigin(input instanceof Request ? input.url : String(input), origin);
|
|
426
|
+
const headers = new Headers(init.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
355
427
|
if (config.auth?.type === 'bearer' && config.auth.value) {
|
|
356
428
|
headers.set('Authorization', `Bearer ${config.auth.value}`);
|
|
357
429
|
}
|
|
@@ -363,6 +435,7 @@ function authenticatedFetch(config) {
|
|
|
363
435
|
return fetch(input, {
|
|
364
436
|
...init,
|
|
365
437
|
headers,
|
|
438
|
+
redirect: 'error',
|
|
366
439
|
signal: init.signal || AbortSignal.timeout(config.timeoutMs || 60_000)
|
|
367
440
|
});
|
|
368
441
|
};
|
|
@@ -450,3 +523,37 @@ function disabledView(id, config) {
|
|
|
450
523
|
function errorMessage(error) {
|
|
451
524
|
return error instanceof Error ? error.message : String(error);
|
|
452
525
|
}
|
|
526
|
+
/**
|
|
527
|
+
* Detect payment / confirmation / choice hints in an A2A `input_required`
|
|
528
|
+
* message so the Gateway can surface them on `pendingRequest.metadata` and
|
|
529
|
+
* `inputType`. The A2A protocol has no first-class field for these so we
|
|
530
|
+
* scan text parts for URLs and simple keywords.
|
|
531
|
+
*/
|
|
532
|
+
function a2aInputClassification(statusText, message) {
|
|
533
|
+
const metadata = {};
|
|
534
|
+
const haystack = [
|
|
535
|
+
statusText,
|
|
536
|
+
...(message?.parts ? textParts(message.parts) : [])
|
|
537
|
+
]
|
|
538
|
+
.filter(Boolean)
|
|
539
|
+
.join('\n');
|
|
540
|
+
const urlMatch = haystack.match(/https?:\/\/\S+/);
|
|
541
|
+
if (urlMatch)
|
|
542
|
+
metadata.url = urlMatch[0];
|
|
543
|
+
let inputType = 'text';
|
|
544
|
+
if (/pay|支付|invoice|checkout|订单支付/i.test(haystack)) {
|
|
545
|
+
inputType = 'payment';
|
|
546
|
+
if (urlMatch)
|
|
547
|
+
metadata.paymentUrl = urlMatch[0];
|
|
548
|
+
}
|
|
549
|
+
else if (/[((]\s*[yY]\s*\/\s*[nN]\s*[))]|确认|是否/i.test(haystack)) {
|
|
550
|
+
inputType = 'confirmation';
|
|
551
|
+
}
|
|
552
|
+
else if (/(?:\n|^)\s*(?:\d+[.、]|[-*])\s+\S/.test(haystack)) {
|
|
553
|
+
inputType = 'choice';
|
|
554
|
+
}
|
|
555
|
+
return {
|
|
556
|
+
inputType,
|
|
557
|
+
...(Object.keys(metadata).length ? { metadata } : {})
|
|
558
|
+
};
|
|
559
|
+
}
|
package/dist/acp/runtime.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ import type { AgentdInputAttachment, AgentdPendingResponse } from '../types.js';
|
|
|
4
4
|
export declare class AcpProcessRuntime {
|
|
5
5
|
private readonly driver;
|
|
6
6
|
private readonly sink;
|
|
7
|
+
private readonly allowedRoots;
|
|
8
|
+
private readonly startupTimeoutMs;
|
|
7
9
|
private process?;
|
|
8
10
|
private connection?;
|
|
9
11
|
private pending?;
|
|
@@ -16,12 +18,14 @@ export declare class AcpProcessRuntime {
|
|
|
16
18
|
private inputDirectory?;
|
|
17
19
|
private workspace?;
|
|
18
20
|
private promptOutput;
|
|
19
|
-
constructor(driver: AgentDriver, sink: AcpSessionSink, maxStderrChunkChars?: number, promptTimeoutMs?: number);
|
|
21
|
+
constructor(driver: AgentDriver, sink: AcpSessionSink, maxStderrChunkChars?: number, promptTimeoutMs?: number, allowedRoots?: readonly string[], startupTimeoutMs?: number);
|
|
20
22
|
start(workspace: string): Promise<void>;
|
|
23
|
+
private connect;
|
|
21
24
|
prompt(message: string, attachments?: AgentdInputAttachment[]): Promise<void>;
|
|
22
25
|
respondPending(input: AgentdPendingResponse | string, _attachments?: AgentdInputAttachment[]): Promise<void>;
|
|
23
26
|
cancel(): Promise<void>;
|
|
24
27
|
dispose(): Promise<void>;
|
|
28
|
+
isAvailable(): boolean;
|
|
25
29
|
private requestPermission;
|
|
26
30
|
private finishPermission;
|
|
27
31
|
private requestInput;
|
package/dist/acp/runtime.js
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { mkdir,
|
|
2
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { Readable, Writable } from 'node:stream';
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
6
|
import * as acp from '@agentclientprotocol/sdk';
|
|
7
7
|
import { terminateProcessTree } from '../process-tree.js';
|
|
8
8
|
import { TURN_COMPLETION_CONTRACT } from '../completion-contract.js';
|
|
9
|
-
|
|
9
|
+
import { readContainedFile } from '../workspace.js';
|
|
10
|
+
// Matches MAX_PUBLISHED_FILE_BYTES in session.ts; larger files are encoded only to be dropped by the base64 budget.
|
|
11
|
+
const MAX_MEDIA_MARKER_FILE_BYTES = 12 * 1024 * 1024;
|
|
12
|
+
// The agent controls how many markers it emits, so bound the reads per turn.
|
|
13
|
+
const MAX_MEDIA_MARKER_ARTIFACTS = 8;
|
|
10
14
|
export class AcpProcessRuntime {
|
|
11
15
|
driver;
|
|
12
16
|
sink;
|
|
17
|
+
allowedRoots;
|
|
18
|
+
startupTimeoutMs;
|
|
13
19
|
process;
|
|
14
20
|
connection;
|
|
15
21
|
pending;
|
|
@@ -22,9 +28,11 @@ export class AcpProcessRuntime {
|
|
|
22
28
|
inputDirectory;
|
|
23
29
|
workspace;
|
|
24
30
|
promptOutput = '';
|
|
25
|
-
constructor(driver, sink, maxStderrChunkChars = 16 * 1024, promptTimeoutMs = 30 * 60 * 1000) {
|
|
31
|
+
constructor(driver, sink, maxStderrChunkChars = 16 * 1024, promptTimeoutMs = 30 * 60 * 1000, allowedRoots = [], startupTimeoutMs = 30_000) {
|
|
26
32
|
this.driver = driver;
|
|
27
33
|
this.sink = sink;
|
|
34
|
+
this.allowedRoots = allowedRoots;
|
|
35
|
+
this.startupTimeoutMs = startupTimeoutMs;
|
|
28
36
|
const limit = Number.isFinite(maxStderrChunkChars)
|
|
29
37
|
? Math.floor(maxStderrChunkChars)
|
|
30
38
|
: 16 * 1024;
|
|
@@ -32,6 +40,21 @@ export class AcpProcessRuntime {
|
|
|
32
40
|
this.promptTimeoutMs = Math.max(10_000, Math.trunc(promptTimeoutMs));
|
|
33
41
|
}
|
|
34
42
|
async start(workspace) {
|
|
43
|
+
if (this.disposed)
|
|
44
|
+
throw new Error('ACP runtime is disposed');
|
|
45
|
+
if (this.process)
|
|
46
|
+
throw new Error('ACP runtime is already started');
|
|
47
|
+
try {
|
|
48
|
+
await withTimeout(this.connect(workspace), this.startupTimeoutMs, 'ACP startup timed out');
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (this.sink.state !== 'canceled')
|
|
52
|
+
this.onProcessFailure(error);
|
|
53
|
+
await this.dispose();
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async connect(workspace) {
|
|
35
58
|
if (this.process)
|
|
36
59
|
throw new Error('ACP runtime is already started');
|
|
37
60
|
this.workspace = workspace;
|
|
@@ -69,6 +92,8 @@ export class AcpProcessRuntime {
|
|
|
69
92
|
version: '0.2.2'
|
|
70
93
|
}
|
|
71
94
|
});
|
|
95
|
+
if (this.disposed)
|
|
96
|
+
throw new Error('ACP runtime is disposed');
|
|
72
97
|
if (initialize.protocolVersion !== acp.PROTOCOL_VERSION) {
|
|
73
98
|
this.sink.emit('terminal_output', {
|
|
74
99
|
stream: 'system',
|
|
@@ -80,10 +105,14 @@ export class AcpProcessRuntime {
|
|
|
80
105
|
cwd: workspace,
|
|
81
106
|
mcpServers: []
|
|
82
107
|
});
|
|
108
|
+
if (this.disposed)
|
|
109
|
+
throw new Error('ACP runtime is disposed');
|
|
83
110
|
this.sink.setAcpSessionId(String(session.sessionId));
|
|
84
111
|
this.sink.setState('created');
|
|
85
112
|
}
|
|
86
113
|
async prompt(message, attachments = []) {
|
|
114
|
+
if (this.disposed)
|
|
115
|
+
throw new Error('ACP runtime is disposed');
|
|
87
116
|
if (!this.connection)
|
|
88
117
|
throw new Error('ACP runtime is not connected');
|
|
89
118
|
if (this.prompting)
|
|
@@ -199,11 +228,12 @@ export class AcpProcessRuntime {
|
|
|
199
228
|
if (this.pendingInput) {
|
|
200
229
|
this.finishInput('cancel');
|
|
201
230
|
}
|
|
202
|
-
|
|
231
|
+
this.sink.setState('canceled');
|
|
232
|
+
if (this.connection) {
|
|
203
233
|
try {
|
|
204
|
-
await this.connection.agent.notify(acp.methods.agent.session.cancel, {
|
|
234
|
+
await withTimeout(this.connection.agent.notify(acp.methods.agent.session.cancel, {
|
|
205
235
|
sessionId: this.requireSessionId()
|
|
206
|
-
});
|
|
236
|
+
}), 2_000, 'ACP cancellation notification timed out');
|
|
207
237
|
}
|
|
208
238
|
catch (error) {
|
|
209
239
|
this.sink.emit('terminal_output', {
|
|
@@ -229,8 +259,16 @@ export class AcpProcessRuntime {
|
|
|
229
259
|
this.pendingInput.resolve({ action: 'cancel' });
|
|
230
260
|
this.pendingInput = undefined;
|
|
231
261
|
}
|
|
232
|
-
this.connection
|
|
262
|
+
const connection = this.connection;
|
|
233
263
|
this.connection = undefined;
|
|
264
|
+
try {
|
|
265
|
+
connection?.close();
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
this.sink.emit('terminal_output', {
|
|
269
|
+
stream: 'system', text: `ACP connection close failed: ${error instanceof Error ? error.message : String(error)}`
|
|
270
|
+
});
|
|
271
|
+
}
|
|
234
272
|
const child = this.process;
|
|
235
273
|
this.process = undefined;
|
|
236
274
|
if (child) {
|
|
@@ -243,6 +281,10 @@ export class AcpProcessRuntime {
|
|
|
243
281
|
this.inputDirectory = undefined;
|
|
244
282
|
}
|
|
245
283
|
}
|
|
284
|
+
isAvailable() {
|
|
285
|
+
return !this.disposed && Boolean(this.connection) &&
|
|
286
|
+
(!this.process || (this.process.exitCode === null && this.process.signalCode === null));
|
|
287
|
+
}
|
|
246
288
|
requestPermission(params) {
|
|
247
289
|
if (this.driver.permissionPolicy === 'deny') {
|
|
248
290
|
const reject = params.options.find((option) => option.kind.startsWith('reject'));
|
|
@@ -275,11 +317,16 @@ export class AcpProcessRuntime {
|
|
|
275
317
|
kind: 'permission',
|
|
276
318
|
prompt: params.toolCall.title ||
|
|
277
319
|
`Permission requested for tool ${params.toolCall.toolCallId}`,
|
|
320
|
+
inputType: 'confirmation',
|
|
321
|
+
step: params.toolCall.toolCallId
|
|
322
|
+
? `tool:${params.toolCall.toolCallId}`
|
|
323
|
+
: undefined,
|
|
278
324
|
options: params.options.map((option) => ({
|
|
279
325
|
id: option.optionId,
|
|
280
326
|
name: option.name,
|
|
281
327
|
kind: option.kind
|
|
282
|
-
}))
|
|
328
|
+
})),
|
|
329
|
+
metadata: permissionMetadata(params)
|
|
283
330
|
};
|
|
284
331
|
this.sink.setPending(request);
|
|
285
332
|
return new Promise((resolve, reject) => {
|
|
@@ -314,7 +361,8 @@ export class AcpProcessRuntime {
|
|
|
314
361
|
prompt: params.mode === 'url'
|
|
315
362
|
? `${params.message}\n${params.url}`
|
|
316
363
|
: params.message,
|
|
317
|
-
options: elicitationOptions(params)
|
|
364
|
+
options: elicitationOptions(params),
|
|
365
|
+
...elicitationClassification(params)
|
|
318
366
|
};
|
|
319
367
|
this.sink.setPending(request);
|
|
320
368
|
return new Promise((resolve) => {
|
|
@@ -422,13 +470,23 @@ export class AcpProcessRuntime {
|
|
|
422
470
|
if (!markers.length)
|
|
423
471
|
return;
|
|
424
472
|
this.sink.replaceOutput(withoutMediaMarkers(this.promptOutput));
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
473
|
+
const roots = this.allowedRoots.length
|
|
474
|
+
? this.allowedRoots
|
|
475
|
+
: this.workspace
|
|
476
|
+
? [this.workspace]
|
|
477
|
+
: [];
|
|
478
|
+
const unique = Array.from(new Set(markers));
|
|
479
|
+
const accepted = unique.slice(0, MAX_MEDIA_MARKER_ARTIFACTS);
|
|
480
|
+
const ignored = unique.length - accepted.length;
|
|
481
|
+
if (ignored > 0) {
|
|
482
|
+
this.sink.emit('terminal_output', {
|
|
483
|
+
stream: 'system',
|
|
484
|
+
text: `Ignored ${ignored} MEDIA marker(s); at most ${MAX_MEDIA_MARKER_ARTIFACTS} artifacts can be attached per turn.`
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
for (const marker of accepted) {
|
|
430
488
|
try {
|
|
431
|
-
const artifact = await artifactFromMediaMarker(marker, `media:${randomUUID()}`,
|
|
489
|
+
const artifact = await artifactFromMediaMarker(marker, `media:${randomUUID()}`, roots);
|
|
432
490
|
this.sink.addArtifact(artifact);
|
|
433
491
|
this.sink.emit('artifact', artifact);
|
|
434
492
|
}
|
|
@@ -441,6 +499,11 @@ export class AcpProcessRuntime {
|
|
|
441
499
|
}
|
|
442
500
|
}
|
|
443
501
|
captureStderr(child) {
|
|
502
|
+
// Explicit UTF-8 streaming decoder: `String(chunk)` on a Buffer uses the
|
|
503
|
+
// default binary encoding, which mangles multi-byte characters if a
|
|
504
|
+
// chunk boundary splits a rune. `TextDecoder` with `stream: true`
|
|
505
|
+
// preserves the split byte between calls.
|
|
506
|
+
const decoder = new TextDecoder('utf-8');
|
|
444
507
|
let buffer = '';
|
|
445
508
|
let flushed = false;
|
|
446
509
|
const emit = (text) => {
|
|
@@ -471,10 +534,16 @@ export class AcpProcessRuntime {
|
|
|
471
534
|
if (flushed)
|
|
472
535
|
return;
|
|
473
536
|
flushed = true;
|
|
537
|
+
buffer += decoder.decode();
|
|
474
538
|
drain(true);
|
|
475
539
|
};
|
|
476
540
|
child.stderr.on('data', (chunk) => {
|
|
477
|
-
|
|
541
|
+
const bytes = chunk instanceof Uint8Array
|
|
542
|
+
? chunk
|
|
543
|
+
: Buffer.isBuffer(chunk)
|
|
544
|
+
? chunk
|
|
545
|
+
: Buffer.from(String(chunk));
|
|
546
|
+
buffer += decoder.decode(bytes, { stream: true });
|
|
478
547
|
drain();
|
|
479
548
|
});
|
|
480
549
|
child.stderr.once('end', flush);
|
|
@@ -662,7 +731,7 @@ function withoutMediaMarkers(output) {
|
|
|
662
731
|
.join('\n')
|
|
663
732
|
.trim();
|
|
664
733
|
}
|
|
665
|
-
async function artifactFromMediaMarker(marker, id,
|
|
734
|
+
async function artifactFromMediaMarker(marker, id, roots) {
|
|
666
735
|
if (/^https?:\/\//i.test(marker)) {
|
|
667
736
|
const filename = artifactFilename(marker, undefined, 'resource', undefined);
|
|
668
737
|
return {
|
|
@@ -673,29 +742,19 @@ async function artifactFromMediaMarker(marker, id, workspace) {
|
|
|
673
742
|
metadata: { source: 'acp_media_marker' }
|
|
674
743
|
};
|
|
675
744
|
}
|
|
676
|
-
const localPath = marker.startsWith('file:')
|
|
677
|
-
|
|
678
|
-
:
|
|
679
|
-
|
|
680
|
-
? localPath
|
|
681
|
-
: path.resolve(workspace || process.cwd(), localPath));
|
|
682
|
-
const details = await stat(resolved);
|
|
683
|
-
if (!details.isFile()) {
|
|
684
|
-
throw new Error('declared path is not a regular file');
|
|
685
|
-
}
|
|
686
|
-
if (details.size > MAX_MEDIA_MARKER_FILE_BYTES) {
|
|
687
|
-
throw new Error(`declared file exceeds the ${MAX_MEDIA_MARKER_FILE_BYTES} byte limit`);
|
|
688
|
-
}
|
|
689
|
-
const filename = path.basename(resolved);
|
|
745
|
+
const localPath = marker.startsWith('file:') ? fileURLToPath(marker) : marker;
|
|
746
|
+
const file = await readContainedFile(roots, localPath, {
|
|
747
|
+
maxBytes: MAX_MEDIA_MARKER_FILE_BYTES
|
|
748
|
+
});
|
|
690
749
|
return {
|
|
691
750
|
id,
|
|
692
|
-
name: filename,
|
|
693
|
-
filename,
|
|
694
|
-
mediaType: mediaTypeForPath(filename),
|
|
695
|
-
bytesBase64:
|
|
751
|
+
name: file.filename,
|
|
752
|
+
filename: file.filename,
|
|
753
|
+
mediaType: mediaTypeForPath(file.filename),
|
|
754
|
+
bytesBase64: file.bytes.toString('base64'),
|
|
696
755
|
metadata: {
|
|
697
756
|
source: 'acp_media_marker',
|
|
698
|
-
size:
|
|
757
|
+
size: file.size
|
|
699
758
|
}
|
|
700
759
|
};
|
|
701
760
|
}
|
|
@@ -824,3 +883,88 @@ function isFormElicitation(params) {
|
|
|
824
883
|
typeof params.requestedSchema ===
|
|
825
884
|
'object'));
|
|
826
885
|
}
|
|
886
|
+
/**
|
|
887
|
+
* Derive Agent Nexus semantic classification (inputType / step / metadata) from
|
|
888
|
+
* an ACP elicitation request. This is a best-effort projection: we look at the
|
|
889
|
+
* elicitation mode, the requested schema shape and well-known field names to
|
|
890
|
+
* decide whether the pending request is a plain text prompt, a choice, a
|
|
891
|
+
* yes/no confirmation, or a payment URL flow.
|
|
892
|
+
*/
|
|
893
|
+
function elicitationClassification(params) {
|
|
894
|
+
const metadata = {};
|
|
895
|
+
let inputType = 'unknown';
|
|
896
|
+
let step;
|
|
897
|
+
if (params.mode === 'url') {
|
|
898
|
+
const url = String(params.url || '').trim();
|
|
899
|
+
if (url) {
|
|
900
|
+
metadata.paymentUrl = url;
|
|
901
|
+
metadata.url = url;
|
|
902
|
+
}
|
|
903
|
+
inputType = /pay|支付|invoice|order/i.test(`${params.message || ''} ${url}`)
|
|
904
|
+
? 'payment'
|
|
905
|
+
: 'text';
|
|
906
|
+
step = 'url';
|
|
907
|
+
}
|
|
908
|
+
else if (isFormElicitation(params)) {
|
|
909
|
+
const properties = params.requestedSchema.properties || {};
|
|
910
|
+
const entries = Object.entries(properties);
|
|
911
|
+
step = entries.length === 1 ? entries[0][0] : 'form';
|
|
912
|
+
if (entries.length === 1) {
|
|
913
|
+
const [, schema] = entries[0];
|
|
914
|
+
if (schema.type === 'boolean')
|
|
915
|
+
inputType = 'confirmation';
|
|
916
|
+
else if (Array.isArray(schema.oneOf) ||
|
|
917
|
+
Array.isArray(schema.enum)) {
|
|
918
|
+
inputType = 'choice';
|
|
919
|
+
}
|
|
920
|
+
else {
|
|
921
|
+
inputType = 'text';
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
else {
|
|
925
|
+
inputType = 'text';
|
|
926
|
+
}
|
|
927
|
+
const paymentUrl = firstStringFromSchema(properties, [
|
|
928
|
+
'paymentUrl',
|
|
929
|
+
'payUrl',
|
|
930
|
+
'payment_url',
|
|
931
|
+
'checkoutUrl'
|
|
932
|
+
]);
|
|
933
|
+
if (paymentUrl) {
|
|
934
|
+
metadata.paymentUrl = paymentUrl;
|
|
935
|
+
inputType = 'payment';
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
else {
|
|
939
|
+
inputType = 'text';
|
|
940
|
+
}
|
|
941
|
+
return {
|
|
942
|
+
inputType,
|
|
943
|
+
...(step ? { step } : {}),
|
|
944
|
+
...(Object.keys(metadata).length ? { metadata } : {})
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
function permissionMetadata(params) {
|
|
948
|
+
const metadata = {};
|
|
949
|
+
const call = params.toolCall;
|
|
950
|
+
if (typeof call?.toolCallId === 'string' && call.toolCallId) {
|
|
951
|
+
metadata.toolCallId = call.toolCallId;
|
|
952
|
+
}
|
|
953
|
+
if (typeof call?.name === 'string' && call.name) {
|
|
954
|
+
metadata.tool = call.name;
|
|
955
|
+
}
|
|
956
|
+
return Object.keys(metadata).length ? metadata : undefined;
|
|
957
|
+
}
|
|
958
|
+
function firstStringFromSchema(properties, candidates) {
|
|
959
|
+
for (const key of candidates) {
|
|
960
|
+
const schema = properties[key];
|
|
961
|
+
if (!schema)
|
|
962
|
+
continue;
|
|
963
|
+
const value = (typeof schema.const === 'string' && schema.const) ||
|
|
964
|
+
(typeof schema.default === 'string' && schema.default) ||
|
|
965
|
+
'';
|
|
966
|
+
if (value)
|
|
967
|
+
return value;
|
|
968
|
+
}
|
|
969
|
+
return undefined;
|
|
970
|
+
}
|
package/dist/auth.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto';
|
|
1
|
+
import { createHash, randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
const scrypt = promisify(scryptCallback);
|
|
4
4
|
const KEY_LENGTH = 64;
|
|
@@ -41,28 +41,46 @@ export function validateAdminPassword(input) {
|
|
|
41
41
|
}
|
|
42
42
|
export class AdminSessionStore {
|
|
43
43
|
ttlMs;
|
|
44
|
+
// Stored keys are SHA-256 digests of the raw session id so a timing attack
|
|
45
|
+
// on Map lookup / string comparison cannot learn a valid prefix. The raw
|
|
46
|
+
// id never leaves the HttpOnly cookie.
|
|
44
47
|
sessions = new Map();
|
|
45
48
|
constructor(ttlMs) {
|
|
46
49
|
this.ttlMs = ttlMs;
|
|
47
50
|
}
|
|
48
51
|
create() {
|
|
49
52
|
this.cleanup();
|
|
50
|
-
const
|
|
51
|
-
this.sessions.set(
|
|
52
|
-
return
|
|
53
|
+
const raw = randomBytes(32).toString('base64url');
|
|
54
|
+
this.sessions.set(digest(raw), Date.now() + this.ttlMs);
|
|
55
|
+
return raw;
|
|
53
56
|
}
|
|
54
57
|
has(id) {
|
|
55
|
-
|
|
56
|
-
if (!expiresAt)
|
|
58
|
+
if (typeof id !== 'string' || !id)
|
|
57
59
|
return false;
|
|
60
|
+
const key = digest(id);
|
|
61
|
+
let match;
|
|
62
|
+
// Walk every stored digest in constant time. `timingSafeEqual` requires
|
|
63
|
+
// equal-length inputs, which is guaranteed because all digests have the
|
|
64
|
+
// same byte length.
|
|
65
|
+
for (const stored of this.sessions.keys()) {
|
|
66
|
+
const a = Buffer.from(stored, 'hex');
|
|
67
|
+
const b = Buffer.from(key, 'hex');
|
|
68
|
+
if (a.length === b.length && timingSafeEqual(a, b))
|
|
69
|
+
match = stored;
|
|
70
|
+
}
|
|
71
|
+
if (!match)
|
|
72
|
+
return false;
|
|
73
|
+
const expiresAt = this.sessions.get(match);
|
|
58
74
|
if (expiresAt <= Date.now()) {
|
|
59
|
-
this.sessions.delete(
|
|
75
|
+
this.sessions.delete(match);
|
|
60
76
|
return false;
|
|
61
77
|
}
|
|
62
78
|
return true;
|
|
63
79
|
}
|
|
64
80
|
delete(id) {
|
|
65
|
-
|
|
81
|
+
if (typeof id !== 'string' || !id)
|
|
82
|
+
return;
|
|
83
|
+
this.sessions.delete(digest(id));
|
|
66
84
|
}
|
|
67
85
|
clear() {
|
|
68
86
|
this.sessions.clear();
|
|
@@ -75,3 +93,6 @@ export class AdminSessionStore {
|
|
|
75
93
|
}
|
|
76
94
|
}
|
|
77
95
|
}
|
|
96
|
+
function digest(value) {
|
|
97
|
+
return createHash('sha256').update(value, 'utf8').digest('hex');
|
|
98
|
+
}
|
|
@@ -4,6 +4,6 @@ export const TURN_COMPLETION_CONTRACT = [
|
|
|
4
4
|
'If you need user input or permission, use the protocol input/permission request and wait for the response.',
|
|
5
5
|
'Before ending the turn, verify the requested work and provide a non-empty final response.',
|
|
6
6
|
'The final response must summarize the outcome, verification performed, and workspace-relative paths for generated files.',
|
|
7
|
-
'Attach generated resources or artifacts through the protocol when supported. If the runtime only reports a local deliverable path, put each requested file on its own final line as MEDIA:<path
|
|
7
|
+
'Attach generated resources or artifacts through the protocol when supported. If the runtime only reports a local deliverable path, put each requested file on its own final line as MEDIA:<path>, where <path> stays inside the configured workspace roots; Agent Nexus snapshots that declared file as an artifact before the turn completes, refuses paths outside those roots, and attaches at most 8 files per turn.',
|
|
8
8
|
'</agent_nexus_completion_contract>'
|
|
9
9
|
].join('\n');
|