@agentrq/acp-gateway 0.2.3 → 0.2.5
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 +96 -1
- package/dist/__tests__/acpClient.test.js +220 -106
- package/dist/__tests__/acpClient.test.js.map +1 -1
- package/dist/__tests__/agentInfo.test.js +79 -0
- package/dist/__tests__/agentInfo.test.js.map +1 -0
- package/dist/__tests__/agentInstall.test.js +342 -0
- package/dist/__tests__/agentInstall.test.js.map +1 -0
- package/dist/__tests__/auth.test.js +283 -0
- package/dist/__tests__/auth.test.js.map +1 -0
- package/dist/__tests__/config.test.js +45 -0
- package/dist/__tests__/config.test.js.map +1 -1
- package/dist/__tests__/index.test.js +544 -12
- package/dist/__tests__/index.test.js.map +1 -1
- package/dist/__tests__/mcpClient.test.js +12 -0
- package/dist/__tests__/mcpClient.test.js.map +1 -1
- package/dist/__tests__/registry.test.js +175 -0
- package/dist/__tests__/registry.test.js.map +1 -0
- package/dist/acpClient.js +215 -59
- package/dist/acpClient.js.map +1 -1
- package/dist/agentInfo.js +69 -0
- package/dist/agentInfo.js.map +1 -0
- package/dist/agentInstall.js +241 -0
- package/dist/agentInstall.js.map +1 -0
- package/dist/auth.js +189 -0
- package/dist/auth.js.map +1 -0
- package/dist/config.js +50 -16
- package/dist/config.js.map +1 -1
- package/dist/index.js +508 -46
- package/dist/index.js.map +1 -1
- package/dist/mcpClient.js +12 -1
- package/dist/mcpClient.js.map +1 -1
- package/dist/registry.js +118 -0
- package/dist/registry.js.map +1 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -7,22 +7,49 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
9
|
import { Writable, Readable } from "node:stream";
|
|
10
|
-
import { readFileSync } from "node:fs";
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import * as path from "node:path";
|
|
11
12
|
import * as acp from "@agentclientprotocol/sdk";
|
|
12
13
|
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
|
|
13
|
-
import { loadMcpConfig, pickAgentrqServer } from "./config.js";
|
|
14
|
+
import { loadMcpConfig, pickAgentrqServer, } from "./config.js";
|
|
14
15
|
import { MCPBridge } from "./mcpClient.js";
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
16
|
+
/**
|
|
17
|
+
* What the agent said about a transport, if anything.
|
|
18
|
+
*
|
|
19
|
+
* stdio is the one transport every agent must support. For the others the
|
|
20
|
+
* answer is only trustworthy when the agent actually stated it: an agent that
|
|
21
|
+
* advertises no MCP capabilities at all is far more likely to be terse than to
|
|
22
|
+
* be unable to reach an HTTP server, and dropping its servers on that reading
|
|
23
|
+
* would take the workspace's own MCP server away from it.
|
|
24
|
+
*/
|
|
25
|
+
function transportSupport(transport, agentCapabilities) {
|
|
26
|
+
if (transport === "stdio")
|
|
27
|
+
return "required";
|
|
28
|
+
const declared = agentCapabilities?.mcpCapabilities?.[transport];
|
|
29
|
+
if (declared === true)
|
|
30
|
+
return "declared";
|
|
31
|
+
if (declared === false)
|
|
32
|
+
return "refused";
|
|
33
|
+
return "unstated";
|
|
34
|
+
}
|
|
35
|
+
export function mapMcpServers(configs, agentCapabilities) {
|
|
36
|
+
return configs
|
|
37
|
+
.filter((cfg) => {
|
|
38
|
+
const support = transportSupport(cfg.type, agentCapabilities);
|
|
39
|
+
if (support === "refused") {
|
|
40
|
+
console.error(`[acp] ⚠️ Not passing MCP server "${cfg.name}" to the agent: it is ${cfg.type}, ` +
|
|
41
|
+
`and the agent says it does not support that transport. Passing it anyway ` +
|
|
42
|
+
`risks the agent refusing the whole session.`);
|
|
43
|
+
return false;
|
|
24
44
|
}
|
|
25
|
-
|
|
45
|
+
if (support === "unstated") {
|
|
46
|
+
console.error(`[acp] MCP server "${cfg.name}" is ${cfg.type}, which the agent does not ` +
|
|
47
|
+
`advertise either way — passing it and letting the agent decide.`);
|
|
48
|
+
}
|
|
49
|
+
return true;
|
|
50
|
+
})
|
|
51
|
+
.map((cfg) => {
|
|
52
|
+
if (cfg.type === "stdio") {
|
|
26
53
|
return {
|
|
27
54
|
name: cfg.name,
|
|
28
55
|
command: cfg.command,
|
|
@@ -30,45 +57,78 @@ export function mapMcpServers(configs) {
|
|
|
30
57
|
env: Object.entries(cfg.env || {}).map(([name, value]) => ({ name, value })),
|
|
31
58
|
};
|
|
32
59
|
}
|
|
60
|
+
return {
|
|
61
|
+
type: cfg.type,
|
|
62
|
+
name: cfg.name,
|
|
63
|
+
url: cfg.url,
|
|
64
|
+
headers: Object.entries(cfg.headers || {}).map(([name, value]) => ({ name, value })),
|
|
65
|
+
};
|
|
33
66
|
});
|
|
34
67
|
}
|
|
35
|
-
import { AgentRQACPClient } from "./acpClient.js";
|
|
68
|
+
import { AgentRQACPClient, DEFAULT_PERMISSION_TIMEOUT_MS } from "./acpClient.js";
|
|
69
|
+
import { describeAuthMethods, isAuthRequiredError, login, logout, supportsLogout, } from "./auth.js";
|
|
70
|
+
import { resolveAgentLaunch } from "./agentInstall.js";
|
|
71
|
+
import { describeAgentInfo } from "./agentInfo.js";
|
|
72
|
+
import { describeAgents, fetchRegistry, hostPlatformTarget, } from "./registry.js";
|
|
36
73
|
import { extractTaskIdFromMeta, extractTaskIdFromText, } from "./taskIdentity.js";
|
|
37
74
|
const lastTaskContent = new Map();
|
|
38
75
|
export const activeSessions = new Map();
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
76
|
+
/** Login preferences taken from the CLI, consulted whenever an agent demands auth. */
|
|
77
|
+
export const authConfig = {};
|
|
78
|
+
/** How long tool calls wait for a human, taken from the CLI at startup. */
|
|
79
|
+
export const permissionConfig = {};
|
|
80
|
+
/**
|
|
81
|
+
* Whether a human is sitting in front of this process.
|
|
82
|
+
*
|
|
83
|
+
* Terminal logins hand the agent our own stdio, and the "which login method?"
|
|
84
|
+
* prompt needs someone to answer it — neither works when the gateway runs
|
|
85
|
+
* unattended under a supervisor.
|
|
86
|
+
*/
|
|
87
|
+
export function isInteractiveTerminal() {
|
|
88
|
+
return Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Spawns an ACP agent, wires the JSON-RPC streams to it and completes the
|
|
92
|
+
* `initialize` handshake, returning the connection plus what the agent said
|
|
93
|
+
* about itself — including the login methods it advertises.
|
|
94
|
+
*/
|
|
95
|
+
export async function openAgentConnection({ acpCmdArgs, mcpBridge, env, label, taskId, onExit, }) {
|
|
45
96
|
const [cmd, ...cmdArgs] = acpCmdArgs;
|
|
46
|
-
console.error(`[acp] Spawning agent for
|
|
97
|
+
console.error(`[acp] Spawning agent for ${label}: ${cmd} ${cmdArgs.join(" ")}`);
|
|
47
98
|
const agentProcess = spawn(cmd, cmdArgs, {
|
|
48
99
|
stdio: ["pipe", "pipe", "inherit"],
|
|
49
|
-
env: { ...process.env, ...
|
|
100
|
+
env: { ...process.env, ...env },
|
|
101
|
+
});
|
|
102
|
+
const acpClient = new AgentRQACPClient(mcpBridge, () => taskId, {
|
|
103
|
+
permissionTimeoutMs: permissionConfig.timeoutMs,
|
|
50
104
|
});
|
|
51
105
|
// Guard against unhandled child-process failures. Without these listeners a
|
|
52
106
|
// crashed agent (e.g. on network loss) leaves a broken stdin pipe; the next
|
|
53
107
|
// write raises EPIPE as an uncaught error and takes the gateway down with it.
|
|
54
108
|
agentProcess.on("error", (err) => {
|
|
55
|
-
console.error(`[acp] Agent process error for
|
|
56
|
-
|
|
109
|
+
console.error(`[acp] Agent process error for ${label}:`, err.message);
|
|
110
|
+
acpClient.cancelPendingPermissions(`agent process for ${label} failed`);
|
|
111
|
+
onExit?.();
|
|
57
112
|
});
|
|
58
113
|
agentProcess.on("exit", (code, signal) => {
|
|
59
|
-
console.error(`[acp] Agent process for
|
|
60
|
-
|
|
114
|
+
console.error(`[acp] Agent process for ${label} exited (code=${code}, signal=${signal})`);
|
|
115
|
+
// Nothing will act on these answers now, but the tool calls waiting on them
|
|
116
|
+
// are holding task-queue slots that would never be given back.
|
|
117
|
+
acpClient.cancelPendingPermissions(`agent process for ${label} exited`);
|
|
118
|
+
onExit?.();
|
|
61
119
|
});
|
|
62
120
|
// stdin can emit EPIPE when the child dies mid-write; swallow it so it
|
|
63
121
|
// doesn't surface as an uncaught exception.
|
|
64
122
|
agentProcess.stdin?.on("error", (err) => {
|
|
65
|
-
console.error(`[acp] Agent stdin error for
|
|
123
|
+
console.error(`[acp] Agent stdin error for ${label}:`, err.message);
|
|
66
124
|
});
|
|
67
125
|
const input = Writable.toWeb(agentProcess.stdin);
|
|
68
126
|
const output = Readable.toWeb(agentProcess.stdout);
|
|
69
|
-
const acpClient = new AgentRQACPClient(mcpBridge, () => taskId);
|
|
70
127
|
const stream = acp.ndJsonStream(input, output);
|
|
71
128
|
const connection = new acp.ClientSideConnection((_agent) => acpClient, stream);
|
|
129
|
+
// Stopping a turn is only possible once the connection exists, and the
|
|
130
|
+
// connection is built around the client — so it is handed over afterwards.
|
|
131
|
+
acpClient.setSessionCanceller((sessionId) => connection.cancel({ sessionId }));
|
|
72
132
|
const initResult = await connection.initialize({
|
|
73
133
|
protocolVersion: acp.PROTOCOL_VERSION,
|
|
74
134
|
clientCapabilities: {
|
|
@@ -80,21 +140,73 @@ export async function getOrCreateSession(taskId, acpCmdArgs, configs, agentrqCon
|
|
|
80
140
|
form: {},
|
|
81
141
|
url: {},
|
|
82
142
|
},
|
|
143
|
+
// Only claim terminal logins when we can actually hand the agent a
|
|
144
|
+
// terminal; otherwise the agent may offer a method we cannot run.
|
|
145
|
+
auth: {
|
|
146
|
+
terminal: isInteractiveTerminal(),
|
|
147
|
+
},
|
|
83
148
|
},
|
|
84
149
|
});
|
|
85
|
-
console.error(`[acp] Connected to agent for
|
|
150
|
+
console.error(`[acp] Connected to agent for ${label} (protocol v${initResult.protocolVersion})`);
|
|
151
|
+
if (initResult.authMethods?.length) {
|
|
152
|
+
console.error(`[auth] Agent offers these login methods:\n${describeAuthMethods(initResult.authMethods)}`);
|
|
153
|
+
}
|
|
154
|
+
return { process: agentProcess, connection, acpClient, initResult };
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Starts a session, logging in first if the agent refuses without one.
|
|
158
|
+
*
|
|
159
|
+
* Agents only report `auth_required` when the session is requested, so this is
|
|
160
|
+
* where a first-run login belongs: authenticate once, then retry.
|
|
161
|
+
*/
|
|
162
|
+
export async function createSessionWithAuth(connection, params, auth) {
|
|
163
|
+
try {
|
|
164
|
+
return await connection.newSession(params);
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
if (!isAuthRequiredError(err))
|
|
168
|
+
throw err;
|
|
169
|
+
console.error("[auth] Agent requires authentication before a session can start.");
|
|
170
|
+
await login({ ...auth, connection: connection });
|
|
171
|
+
return await connection.newSession(params);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
export async function getOrCreateSession(taskId, acpCmdArgs, configs, agentrqConfig, mcpBridge) {
|
|
175
|
+
const key = taskId || "default";
|
|
176
|
+
const existing = activeSessions.get(key);
|
|
177
|
+
if (existing) {
|
|
178
|
+
return existing;
|
|
179
|
+
}
|
|
180
|
+
const [cmd, ...cmdArgs] = acpCmdArgs;
|
|
181
|
+
const { process: agentProcess, connection, acpClient, initResult } = await openAgentConnection({
|
|
182
|
+
acpCmdArgs,
|
|
183
|
+
mcpBridge,
|
|
184
|
+
env: agentrqConfig.env,
|
|
185
|
+
label: `task ${key}`,
|
|
186
|
+
taskId,
|
|
187
|
+
onExit: () => activeSessions.delete(key),
|
|
188
|
+
});
|
|
86
189
|
const newSessionParams = {
|
|
87
190
|
cwd: process.cwd(),
|
|
88
|
-
mcpServers: mapMcpServers(configs),
|
|
191
|
+
mcpServers: mapMcpServers(configs, initResult.agentCapabilities),
|
|
89
192
|
};
|
|
90
|
-
const sessionResult = await connection
|
|
193
|
+
const sessionResult = await createSessionWithAuth(connection, newSessionParams, {
|
|
194
|
+
methods: initResult.authMethods,
|
|
195
|
+
launch: { command: cmd, args: cmdArgs, env: agentrqConfig.env },
|
|
196
|
+
preferredId: authConfig.methodId,
|
|
197
|
+
interactive: isInteractiveTerminal(),
|
|
198
|
+
});
|
|
91
199
|
console.error(`[acp] Created session ${sessionResult.sessionId} for task ${key}`);
|
|
92
200
|
await enforceHumanApprovalMode(connection, sessionResult);
|
|
201
|
+
// The mode is pinned once here, but agents may move themselves back out of
|
|
202
|
+
// it, so keep watching for the rest of the session's life.
|
|
203
|
+
acpClient.setModeChangeHandler((changedSessionId, modeId) => handleAgentModeChange(connection, changedSessionId, modeId, sessionResult.modes));
|
|
93
204
|
const sessionInfo = {
|
|
94
205
|
process: agentProcess,
|
|
95
206
|
connection,
|
|
96
207
|
acpClient,
|
|
97
208
|
sessionId: sessionResult.sessionId,
|
|
209
|
+
initResult,
|
|
98
210
|
};
|
|
99
211
|
activeSessions.set(key, sessionInfo);
|
|
100
212
|
return sessionInfo;
|
|
@@ -155,6 +267,50 @@ export async function enforceHumanApprovalMode(connection, sessionResult) {
|
|
|
155
267
|
console.error(`[acp] ⚠️ Failed to set session mode to "${modeId}" — tool calls may execute without agentrq approval:`, err);
|
|
156
268
|
}
|
|
157
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* How many times the gateway will drag one session back into a mode that asks
|
|
272
|
+
* the human. An agent that keeps switching back is not going to stop, and an
|
|
273
|
+
* unbounded fight with it would be an endless stream of setSessionMode calls.
|
|
274
|
+
*/
|
|
275
|
+
const MAX_MODE_REENFORCEMENTS = 3;
|
|
276
|
+
/** sessionId → how many times its mode has already been put back. */
|
|
277
|
+
const modeReenforcements = new Map();
|
|
278
|
+
/**
|
|
279
|
+
* Puts a session back into a mode that asks the human, after the agent moved
|
|
280
|
+
* itself out of one.
|
|
281
|
+
*
|
|
282
|
+
* Agents may change modes on their own. If one moves into a mode that approves
|
|
283
|
+
* tool calls on the user's behalf, every later tool call — including
|
|
284
|
+
* destructive ones — executes without ever reaching agentrq, and nothing
|
|
285
|
+
* anywhere says so.
|
|
286
|
+
*/
|
|
287
|
+
export async function handleAgentModeChange(connection, sessionId, currentModeId, modes) {
|
|
288
|
+
const available = modes?.availableModes;
|
|
289
|
+
if (!available?.length)
|
|
290
|
+
return;
|
|
291
|
+
const mode = available.find((m) => m.id === currentModeId);
|
|
292
|
+
// A mode the agent never advertised cannot be vouched for either, so it is
|
|
293
|
+
// treated the same as one that approves on our behalf.
|
|
294
|
+
if (mode && !AUTO_APPROVING_MODE.test(describeMode(mode))) {
|
|
295
|
+
modeReenforcements.delete(sessionId);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const attempts = modeReenforcements.get(sessionId) ?? 0;
|
|
299
|
+
if (attempts >= MAX_MODE_REENFORCEMENTS) {
|
|
300
|
+
console.error(`[acp] ⚠️ Agent keeps returning session ${sessionId} to mode "${currentModeId}", ` +
|
|
301
|
+
`which approves tool calls without asking. Giving up after ` +
|
|
302
|
+
`${MAX_MODE_REENFORCEMENTS} attempts — tool calls may now execute without ` +
|
|
303
|
+
`agentrq approval.`);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
modeReenforcements.set(sessionId, attempts + 1);
|
|
307
|
+
console.error(`[acp] ⚠️ Agent moved session ${sessionId} into "${currentModeId}", which approves ` +
|
|
308
|
+
`tool calls without asking. Putting it back.`);
|
|
309
|
+
await enforceHumanApprovalMode(connection, {
|
|
310
|
+
sessionId,
|
|
311
|
+
modes: { availableModes: available, currentModeId },
|
|
312
|
+
});
|
|
313
|
+
}
|
|
158
314
|
export function createAcpSessionSwitcher(connection, params, initialSessionId) {
|
|
159
315
|
let currentSessionId = initialSessionId;
|
|
160
316
|
const taskSessionMap = new Map();
|
|
@@ -237,33 +393,337 @@ export class TaskQueue {
|
|
|
237
393
|
return this.queue.length;
|
|
238
394
|
}
|
|
239
395
|
}
|
|
396
|
+
/**
|
|
397
|
+
* Parses the gateway's own flags — everything before the `--` that introduces
|
|
398
|
+
* the agent command.
|
|
399
|
+
*/
|
|
400
|
+
export function parseGatewayArgs(args) {
|
|
401
|
+
const options = {
|
|
402
|
+
maxConcurrency: 2,
|
|
403
|
+
permissionTimeoutMs: DEFAULT_PERMISSION_TIMEOUT_MS,
|
|
404
|
+
command: "run",
|
|
405
|
+
allowUnverifiedAgent: false,
|
|
406
|
+
rest: [],
|
|
407
|
+
};
|
|
408
|
+
for (let i = 0; i < args.length; i++) {
|
|
409
|
+
// A following token is this flag's value only when it isn't a flag itself,
|
|
410
|
+
// so `--login` can stand alone or take a method id.
|
|
411
|
+
const next = args[i + 1];
|
|
412
|
+
const value = next !== undefined && !next.startsWith("-") ? next : undefined;
|
|
413
|
+
switch (args[i]) {
|
|
414
|
+
case "--max-concurrency":
|
|
415
|
+
case "--maxConcurrency": {
|
|
416
|
+
const parsed = parseInt(value ?? "", 10);
|
|
417
|
+
if (!isNaN(parsed)) {
|
|
418
|
+
options.maxConcurrency = parsed;
|
|
419
|
+
i++;
|
|
420
|
+
}
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
case "--permission-timeout": {
|
|
424
|
+
const minutes = parseInt(value ?? "", 10);
|
|
425
|
+
if (!isNaN(minutes) && minutes >= 0) {
|
|
426
|
+
options.permissionTimeoutMs = minutes * 60_000;
|
|
427
|
+
i++;
|
|
428
|
+
}
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
case "--auth-method":
|
|
432
|
+
if (value) {
|
|
433
|
+
options.authMethodId = value;
|
|
434
|
+
i++;
|
|
435
|
+
}
|
|
436
|
+
break;
|
|
437
|
+
case "--login":
|
|
438
|
+
options.command = "login";
|
|
439
|
+
if (value) {
|
|
440
|
+
options.authMethodId = value;
|
|
441
|
+
i++;
|
|
442
|
+
}
|
|
443
|
+
break;
|
|
444
|
+
case "--logout":
|
|
445
|
+
options.command = "logout";
|
|
446
|
+
break;
|
|
447
|
+
case "--list-auth-methods":
|
|
448
|
+
options.command = "list-auth-methods";
|
|
449
|
+
break;
|
|
450
|
+
case "--agent":
|
|
451
|
+
if (value) {
|
|
452
|
+
options.agentId = value;
|
|
453
|
+
i++;
|
|
454
|
+
}
|
|
455
|
+
break;
|
|
456
|
+
case "--list-agents":
|
|
457
|
+
options.command = "list-agents";
|
|
458
|
+
break;
|
|
459
|
+
case "--agent-info":
|
|
460
|
+
options.command = "agent-info";
|
|
461
|
+
break;
|
|
462
|
+
case "--allow-unverified-agent":
|
|
463
|
+
options.allowUnverifiedAgent = true;
|
|
464
|
+
break;
|
|
465
|
+
case "--registry-url":
|
|
466
|
+
if (value) {
|
|
467
|
+
options.registryUrl = value;
|
|
468
|
+
i++;
|
|
469
|
+
}
|
|
470
|
+
break;
|
|
471
|
+
case "--help":
|
|
472
|
+
case "-h":
|
|
473
|
+
options.command = "help";
|
|
474
|
+
break;
|
|
475
|
+
default:
|
|
476
|
+
// Anything unrecognised belongs to the agent command, which may be
|
|
477
|
+
// given without a `--` separator.
|
|
478
|
+
options.rest.push(args[i]);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return options;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Prints every agent the registry publishes, and how each one can be run here.
|
|
485
|
+
*/
|
|
486
|
+
export async function runListAgents(registryUrl, fetchImpl = fetch) {
|
|
487
|
+
const registry = await fetchRegistry(registryUrl, fetchImpl);
|
|
488
|
+
const target = hostPlatformTarget();
|
|
489
|
+
console.log(`ACP registry v${registry.version} — ${registry.agents.length} agents ` +
|
|
490
|
+
`(this machine: ${target ?? `${process.platform}/${process.arch}, unsupported`})\n`);
|
|
491
|
+
console.log(describeAgents(registry, target));
|
|
492
|
+
console.log(`\nRun one with: acp-gateway --agent <id>`);
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Works out which command actually starts the agent.
|
|
496
|
+
*
|
|
497
|
+
* `--agent <id>` resolves through the registry — installing the agent when the
|
|
498
|
+
* only distribution is a binary — and otherwise the command given after `--`
|
|
499
|
+
* is used as-is.
|
|
500
|
+
*/
|
|
501
|
+
export async function resolveAgentCommand(options, explicitCommand, fetchImpl = fetch) {
|
|
502
|
+
if (!options.agentId)
|
|
503
|
+
return { command: explicitCommand };
|
|
504
|
+
const registry = await fetchRegistry(options.registryUrl, fetchImpl);
|
|
505
|
+
const spec = await resolveAgentLaunch({
|
|
506
|
+
id: options.agentId,
|
|
507
|
+
registry,
|
|
508
|
+
platformTarget: hostPlatformTarget(),
|
|
509
|
+
allowUnverified: options.allowUnverifiedAgent,
|
|
510
|
+
fetchImpl,
|
|
511
|
+
});
|
|
512
|
+
console.error(`[registry] Running "${options.agentId}" via ${spec.kind}: ${spec.command} ${spec.args.join(" ")}`);
|
|
513
|
+
return { command: [spec.command, ...spec.args], env: spec.env };
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Whether a command can actually be run.
|
|
517
|
+
*
|
|
518
|
+
* A path is checked directly; a bare name is looked for along PATH, honouring
|
|
519
|
+
* PATHEXT on Windows where an executable is rarely named without a suffix.
|
|
520
|
+
*/
|
|
521
|
+
export function isRunnable(command, env = process.env, platform = process.platform) {
|
|
522
|
+
if (command.includes("/") || (platform === "win32" && command.includes("\\"))) {
|
|
523
|
+
return existsSync(command);
|
|
524
|
+
}
|
|
525
|
+
const extensions = platform === "win32" ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";") : [""];
|
|
526
|
+
const separator = platform === "win32" ? ";" : ":";
|
|
527
|
+
return (env.PATH ?? "")
|
|
528
|
+
.split(separator)
|
|
529
|
+
.filter(Boolean)
|
|
530
|
+
.some((dir) => extensions.some((ext) => existsSync(path.join(dir, command + ext))));
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Refuses to start with an agent that cannot be run.
|
|
534
|
+
*
|
|
535
|
+
* The agent is not spawned until the first task arrives, so without this a
|
|
536
|
+
* mistyped command — or a registry id passed as if it were one — starts a
|
|
537
|
+
* gateway that looks healthy and only fails much later, out of sight.
|
|
538
|
+
*/
|
|
539
|
+
export function assertAgentRunnable(command, usedRegistryId) {
|
|
540
|
+
if (isRunnable(command))
|
|
541
|
+
return;
|
|
542
|
+
const hint = usedRegistryId
|
|
543
|
+
? `The registry says to run it as "${command}", which is not installed.`
|
|
544
|
+
: `If "${command}" is an ACP registry agent id, run it with --agent ${command} ` +
|
|
545
|
+
`(--list-agents shows what is published).`;
|
|
546
|
+
throw new Error(`Agent command "${command}" was not found. ${hint}`);
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Runs a one-shot command against the agent and shuts it down again.
|
|
550
|
+
*
|
|
551
|
+
* These commands exist so a login — or a look at what the agent supports — can
|
|
552
|
+
* be done deliberately, before any task arrives, rather than only when a
|
|
553
|
+
* session is refused.
|
|
554
|
+
*/
|
|
555
|
+
export async function runAgentCommand(command, acpCmdArgs, agentrqConfig, mcpBridge, authMethodId) {
|
|
556
|
+
const [cmd, ...cmdArgs] = acpCmdArgs;
|
|
557
|
+
const agent = await openAgentConnection({
|
|
558
|
+
acpCmdArgs,
|
|
559
|
+
mcpBridge,
|
|
560
|
+
env: agentrqConfig.env,
|
|
561
|
+
label: command,
|
|
562
|
+
});
|
|
563
|
+
try {
|
|
564
|
+
const connection = agent.connection;
|
|
565
|
+
const { authMethods, agentCapabilities } = agent.initResult;
|
|
566
|
+
if (command === "agent-info") {
|
|
567
|
+
console.log(describeAgentInfo(agent.initResult, acpCmdArgs.join(" ")));
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (command === "list-auth-methods") {
|
|
571
|
+
console.log(`Authentication methods for "${acpCmdArgs.join(" ")}":\n${describeAuthMethods(authMethods)}`);
|
|
572
|
+
if (supportsLogout(agentCapabilities)) {
|
|
573
|
+
console.log("\nThe agent also supports --logout.");
|
|
574
|
+
}
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
if (command === "logout") {
|
|
578
|
+
await logout(connection, agentCapabilities);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
await login({
|
|
582
|
+
connection,
|
|
583
|
+
methods: authMethods,
|
|
584
|
+
launch: { command: cmd, args: cmdArgs, env: agentrqConfig.env },
|
|
585
|
+
preferredId: authMethodId,
|
|
586
|
+
interactive: isInteractiveTerminal(),
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
finally {
|
|
590
|
+
agent.process.kill();
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* The full help text.
|
|
595
|
+
*
|
|
596
|
+
* Shown for `--help`, and when the gateway is run with nothing to do — at
|
|
597
|
+
* which point the reason someone is looking at the terminal is that they do
|
|
598
|
+
* not yet know what to type.
|
|
599
|
+
*/
|
|
600
|
+
export function helpText(version = pkg.version) {
|
|
601
|
+
return `acp-gateway ${version} — bridges an ACP agent to an agentrq workspace.
|
|
602
|
+
|
|
603
|
+
USAGE
|
|
604
|
+
acp-gateway [options] -- <agent-command> [agent-args...]
|
|
605
|
+
acp-gateway [options] --agent <registry-id>
|
|
606
|
+
|
|
607
|
+
The agent is either a command you supply after \`--\`, or an id from the ACP
|
|
608
|
+
registry. Everything after \`--\` is passed to the agent untouched.
|
|
609
|
+
|
|
610
|
+
AGENT
|
|
611
|
+
--agent <registry-id> Run an agent from the ACP registry, installing it
|
|
612
|
+
if needed, instead of a command you supply.
|
|
613
|
+
--list-agents List every agent in the registry, and how each one
|
|
614
|
+
can run on this machine. Exits.
|
|
615
|
+
--agent-info What the agent says it supports — session
|
|
616
|
+
lifecycle, prompt content, MCP transports and
|
|
617
|
+
logins. Only a live handshake can tell you. Exits.
|
|
618
|
+
--allow-unverified-agent Install a registry binary that publishes no
|
|
619
|
+
checksum. Off by default: without a checksum there
|
|
620
|
+
is no way to tell what was downloaded.
|
|
621
|
+
--registry-url <url> Read a different registry index, for pinning it or
|
|
622
|
+
for testing.
|
|
623
|
+
|
|
624
|
+
AUTHENTICATION
|
|
625
|
+
--list-auth-methods List the login methods the agent offers. Exits.
|
|
626
|
+
--login [method-id] Log in to the agent. With no id, and a terminal to
|
|
627
|
+
ask in, you are asked which method to use. Exits.
|
|
628
|
+
--logout Log out of the agent, where it supports it. Exits.
|
|
629
|
+
--auth-method <id> The method to use when the agent demands a login
|
|
630
|
+
mid-run. Defaults to choosing one automatically.
|
|
631
|
+
|
|
632
|
+
BRIDGE
|
|
633
|
+
--max-concurrency <number> How many tasks may prompt the agent at once.
|
|
634
|
+
Defaults to 2.
|
|
635
|
+
--permission-timeout <min> How long a tool call waits for someone to approve
|
|
636
|
+
it before the turn is cancelled. Defaults to 30.
|
|
637
|
+
0 waits indefinitely, which is what a wedged
|
|
638
|
+
gateway looks like — use it knowingly.
|
|
639
|
+
|
|
640
|
+
OTHER
|
|
641
|
+
--help, -h Show this help. Exits.
|
|
642
|
+
|
|
643
|
+
EXAMPLES
|
|
644
|
+
acp-gateway --agent gemini Run Gemini from the registry
|
|
645
|
+
acp-gateway -- gemini --acp Run an agent you installed
|
|
646
|
+
acp-gateway --list-agents See what the registry offers
|
|
647
|
+
acp-gateway --agent-info --agent gemini See what that agent supports
|
|
648
|
+
acp-gateway --login -- gemini --acp Log in before running anything
|
|
649
|
+
acp-gateway --max-concurrency 4 -- gemini --acp
|
|
650
|
+
|
|
651
|
+
The workspace comes from .mcp.json, searched for in the current directory and up
|
|
652
|
+
to three directories above it.`;
|
|
653
|
+
}
|
|
654
|
+
export function printHelp() {
|
|
655
|
+
console.log(helpText());
|
|
656
|
+
}
|
|
240
657
|
async function main() {
|
|
241
|
-
console.log(`Starting [acp-gateway] ${pkg.name} v${pkg.version}`);
|
|
242
658
|
const args = process.argv.slice(2);
|
|
243
|
-
//
|
|
659
|
+
// Everything after `--` is the agent command. Without a separator the
|
|
660
|
+
// gateway's own options are still recognised and whatever is left over is
|
|
661
|
+
// the command, so `acp-gateway --agent gemini` needs no trailing `--`.
|
|
244
662
|
const cmdStartIndex = args.indexOf("--");
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
663
|
+
const gatewayArgs = cmdStartIndex !== -1 ? args.slice(0, cmdStartIndex) : args;
|
|
664
|
+
const options = parseGatewayArgs(gatewayArgs);
|
|
665
|
+
const explicitCommand = cmdStartIndex !== -1 ? args.slice(cmdStartIndex + 1) : options.rest;
|
|
666
|
+
const { maxConcurrency, command, authMethodId } = options;
|
|
667
|
+
if (command === "help") {
|
|
668
|
+
printHelp();
|
|
669
|
+
process.exit(0);
|
|
670
|
+
}
|
|
671
|
+
// Listing the registry needs neither a workspace nor an agent.
|
|
672
|
+
if (command === "list-agents") {
|
|
673
|
+
await runListAgents(options.registryUrl);
|
|
674
|
+
process.exit(0);
|
|
675
|
+
}
|
|
676
|
+
// Nothing to run: the reason someone is looking at the terminal now is that
|
|
677
|
+
// they do not yet know what to type, so show the help rather than an error
|
|
678
|
+
// about a workspace they have not got to yet.
|
|
679
|
+
if (!options.agentId && explicitCommand.length === 0) {
|
|
680
|
+
printHelp();
|
|
249
681
|
process.exit(1);
|
|
250
682
|
}
|
|
683
|
+
console.log(`Starting [acp-gateway] ${pkg.name} v${pkg.version}`);
|
|
251
684
|
// 1. Load MCP Config
|
|
252
685
|
const configs = loadMcpConfig();
|
|
253
686
|
const agentrqConfig = pickAgentrqServer(configs);
|
|
254
|
-
//
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
687
|
+
// 2. Work out what actually starts the agent — a registry id, or the command
|
|
688
|
+
// the user gave.
|
|
689
|
+
// These failures are all things the user can act on — an unknown registry
|
|
690
|
+
// id, no build for this platform, an unverifiable download, a mistyped
|
|
691
|
+
// command — so they get a sentence rather than a stack trace.
|
|
692
|
+
const fail = (err) => {
|
|
693
|
+
console.error(`[acp-gateway] ${err instanceof Error ? err.message : err}`);
|
|
694
|
+
return process.exit(1);
|
|
695
|
+
};
|
|
696
|
+
const resolved = await resolveAgentCommand(options, explicitCommand).catch(fail);
|
|
697
|
+
const acpCmdArgs = resolved.command;
|
|
698
|
+
try {
|
|
699
|
+
assertAgentRunnable(acpCmdArgs[0], Boolean(options.agentId));
|
|
700
|
+
}
|
|
701
|
+
catch (err) {
|
|
702
|
+
fail(err);
|
|
703
|
+
}
|
|
704
|
+
if (resolved.env) {
|
|
705
|
+
// The registry entry's env is part of how that agent must be launched, so
|
|
706
|
+
// it travels with the command into every session spawned from it.
|
|
707
|
+
agentrqConfig.env = { ...agentrqConfig.env, ...resolved.env };
|
|
263
708
|
}
|
|
709
|
+
authConfig.methodId = authMethodId;
|
|
710
|
+
permissionConfig.timeoutMs = options.permissionTimeoutMs;
|
|
264
711
|
const taskQueue = new TaskQueue(maxConcurrency);
|
|
265
|
-
//
|
|
712
|
+
// 3. Initialize MCP Bridge
|
|
266
713
|
const mcpBridge = new MCPBridge(agentrqConfig);
|
|
714
|
+
// Auth commands talk to the agent and exit; they never start bridging tasks.
|
|
715
|
+
// They run before the bridge connects, so a first-time login still works when
|
|
716
|
+
// the workspace is unreachable — `callTool` connects on demand if the login
|
|
717
|
+
// actually needs to reach agentrq.
|
|
718
|
+
if (command !== "run") {
|
|
719
|
+
try {
|
|
720
|
+
await runAgentCommand(command, acpCmdArgs, agentrqConfig, mcpBridge, authMethodId);
|
|
721
|
+
}
|
|
722
|
+
finally {
|
|
723
|
+
await mcpBridge.close();
|
|
724
|
+
}
|
|
725
|
+
process.exit(0);
|
|
726
|
+
}
|
|
267
727
|
await mcpBridge.connect();
|
|
268
728
|
try {
|
|
269
729
|
// Bridge: MCP -> ACP
|
|
@@ -287,6 +747,7 @@ async function main() {
|
|
|
287
747
|
prompt: [{ type: "text", text: content }],
|
|
288
748
|
});
|
|
289
749
|
await sessionInfo.acpClient.flushReply(sessionInfo.sessionId);
|
|
750
|
+
await sessionInfo.acpClient.reportStopReason(sessionInfo.sessionId, result.stopReason);
|
|
290
751
|
console.error(`\n[acp] Agent completed task. Reason: ${result.stopReason}`);
|
|
291
752
|
}
|
|
292
753
|
catch (err) {
|
|
@@ -374,6 +835,7 @@ export async function checkForNextTask(mcpBridge, acpCmdArgsOrConnection, config
|
|
|
374
835
|
prompt: [{ type: "text", text }],
|
|
375
836
|
});
|
|
376
837
|
await acpClientToUse.flushReply(sessionIdToUse);
|
|
838
|
+
await acpClientToUse.reportStopReason(sessionIdToUse, promptResult.stopReason);
|
|
377
839
|
console.error(`\n[acp] Agent completed with: ${promptResult.stopReason}`);
|
|
378
840
|
};
|
|
379
841
|
if (taskQueue) {
|