@agentrq/acp-gateway 0.2.2 → 0.2.4

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/dist/auth.js ADDED
@@ -0,0 +1,189 @@
1
+ /**
2
+ * auth.ts
3
+ *
4
+ * ACP authentication support: reading the login methods an agent advertises
5
+ * during `initialize`, recognising the protocol's `auth_required` failure, and
6
+ * running either kind of login on the user's behalf.
7
+ *
8
+ * See https://agentclientprotocol.com/protocol/v1/authentication
9
+ */
10
+ import { spawn } from "node:child_process";
11
+ import { createInterface } from "node:readline/promises";
12
+ /** JSON-RPC code ACP reserves for "the user must authenticate first". */
13
+ export const AUTH_REQUIRED_CODE = -32000;
14
+ /**
15
+ * `type` discriminates the two kinds of auth method on the wire, and the
16
+ * protocol treats a missing `type` as `agent`.
17
+ */
18
+ export function authMethodType(method) {
19
+ return method.type === "terminal" ? "terminal" : "agent";
20
+ }
21
+ /** Renders the agent's login options as a numbered list for the terminal. */
22
+ export function describeAuthMethods(methods) {
23
+ if (!methods?.length) {
24
+ return "The agent advertises no authentication methods — no login is needed.";
25
+ }
26
+ return methods
27
+ .map((m, i) => {
28
+ const kind = authMethodType(m) === "terminal" ? " [terminal login]" : "";
29
+ const description = m.description ? ` — ${m.description}` : "";
30
+ return ` ${i + 1}. ${m.name} (${m.id})${kind}${description}`;
31
+ })
32
+ .join("\n");
33
+ }
34
+ /** Pulls `code`/`message` out of a JSON-RPC failure in either shape it arrives in. */
35
+ function errorParts(err) {
36
+ if (!err || typeof err !== "object")
37
+ return { message: "" };
38
+ const candidate = err;
39
+ const source = candidate.error && typeof candidate.error === "object"
40
+ ? candidate.error
41
+ : candidate;
42
+ return {
43
+ code: typeof source.code === "number" ? source.code : undefined,
44
+ message: typeof source.message === "string" ? source.message : "",
45
+ };
46
+ }
47
+ /**
48
+ * Tells an "authenticate first" refusal apart from any other failure.
49
+ *
50
+ * ACP carries `auth_required` on the reserved code -32000, which agents also
51
+ * use for unrelated errors (a denied permission, for one), so the message has
52
+ * to agree before we send the user through a login.
53
+ */
54
+ export function isAuthRequiredError(err) {
55
+ const { code, message } = errorParts(err);
56
+ return code === AUTH_REQUIRED_CODE && /auth/i.test(message);
57
+ }
58
+ /**
59
+ * Chooses the login method to run without asking anyone.
60
+ *
61
+ * An explicitly named id always wins. Otherwise `agent` methods come first:
62
+ * the agent drives those itself, so they work in an unattended gateway, while
63
+ * a `terminal` method needs a human at a TTY.
64
+ */
65
+ export function pickAuthMethod(methods, { preferredId, allowTerminal = false } = {}) {
66
+ if (!methods?.length)
67
+ return undefined;
68
+ if (preferredId)
69
+ return methods.find((m) => m.id === preferredId);
70
+ return (methods.find((m) => authMethodType(m) === "agent") ??
71
+ (allowTerminal ? methods.find((m) => authMethodType(m) === "terminal") : undefined));
72
+ }
73
+ /** Asks on stderr so the gateway's stdout stays free for its own output. */
74
+ async function askOnTerminal(question) {
75
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
76
+ try {
77
+ return await rl.question(question);
78
+ }
79
+ finally {
80
+ rl.close();
81
+ }
82
+ }
83
+ /**
84
+ * Asks the user which login to use, the way an editor would on first run.
85
+ *
86
+ * A single method needs no question. An empty answer takes the first method,
87
+ * and an unrecognised one re-asks rather than logging in with something the
88
+ * user did not choose.
89
+ */
90
+ export async function promptForAuthMethod(methods, ask = askOnTerminal) {
91
+ if (!methods.length)
92
+ return undefined;
93
+ if (methods.length === 1)
94
+ return methods[0];
95
+ console.error(`\n[auth] The agent requires a login. Available methods:\n${describeAuthMethods(methods)}`);
96
+ for (let attempt = 0; attempt < 3; attempt++) {
97
+ const answer = (await ask(`[auth] Choose a method [1-${methods.length}, default 1]: `)).trim();
98
+ if (!answer)
99
+ return methods[0];
100
+ const byIndex = Number(answer);
101
+ if (Number.isInteger(byIndex) && byIndex >= 1 && byIndex <= methods.length) {
102
+ return methods[byIndex - 1];
103
+ }
104
+ const byId = methods.find((m) => m.id === answer);
105
+ if (byId)
106
+ return byId;
107
+ console.error(`[auth] "${answer}" is not one of the listed methods.`);
108
+ }
109
+ return undefined;
110
+ }
111
+ /**
112
+ * Runs a `terminal` login by re-launching the configured agent interactively.
113
+ *
114
+ * The protocol has the client reproduce its own agent invocation with the
115
+ * method's extra args and env, hand the process the user's terminal, and read
116
+ * success off the exit status.
117
+ */
118
+ export async function runTerminalAuth(method, launch) {
119
+ const extra = method.args ?? [];
120
+ const extraEnv = method.env ?? {};
121
+ const args = [...launch.args, ...extra];
122
+ console.error(`[auth] Running terminal login: ${launch.command} ${args.join(" ")}\n` +
123
+ `[auth] Complete the login in your terminal; the gateway resumes when it exits.`);
124
+ const child = spawn(launch.command, args, {
125
+ stdio: "inherit",
126
+ env: { ...process.env, ...launch.env, ...extraEnv },
127
+ });
128
+ await new Promise((resolve, reject) => {
129
+ child.on("error", (err) => reject(new Error(`Terminal login "${method.id}" failed to start: ${err.message}`)));
130
+ child.on("exit", (code, signal) => {
131
+ if (code === 0)
132
+ return resolve();
133
+ reject(new Error(`Terminal login "${method.id}" failed (code=${code}, signal=${signal}).`));
134
+ });
135
+ });
136
+ }
137
+ /** Runs whichever kind of login the chosen method calls for. */
138
+ export async function runAuthMethod(connection, method, launch) {
139
+ if (authMethodType(method) === "terminal") {
140
+ await runTerminalAuth(method, launch);
141
+ }
142
+ else {
143
+ // A `terminal` method must never reach `authenticate` — the agent does not
144
+ // implement one for it.
145
+ await connection.authenticate({ methodId: method.id });
146
+ }
147
+ console.error(`[auth] Logged in with "${method.name}" (${method.id}).`);
148
+ }
149
+ /**
150
+ * Logs in: picks a method — asking the user when one is there to ask — and
151
+ * runs it. Returns the method used, or undefined when the agent advertises no
152
+ * login at all.
153
+ */
154
+ export async function login({ connection, methods, launch, preferredId, interactive = false, ask, }) {
155
+ if (!methods?.length) {
156
+ console.error("[auth] The agent advertises no authentication methods; nothing to log in to.");
157
+ return undefined;
158
+ }
159
+ let method = pickAuthMethod(methods, { preferredId, allowTerminal: interactive });
160
+ if (preferredId && !method) {
161
+ throw new Error(`Unknown authentication method "${preferredId}". Available:\n${describeAuthMethods(methods)}`);
162
+ }
163
+ if (!preferredId && interactive) {
164
+ method = await promptForAuthMethod(methods, ask);
165
+ }
166
+ if (!method) {
167
+ throw new Error(`No usable authentication method. Available:\n${describeAuthMethods(methods)}\n` +
168
+ `Terminal logins need an interactive terminal; re-run acp-gateway from one, ` +
169
+ `or pass --auth-method <id>.`);
170
+ }
171
+ await runAuthMethod(connection, method, launch);
172
+ return method;
173
+ }
174
+ /** Whether the agent said it implements `logout` during `initialize`. */
175
+ export function supportsLogout(agentCapabilities) {
176
+ const auth = agentCapabilities?.auth;
177
+ return auth?.logout !== undefined && auth?.logout !== null;
178
+ }
179
+ /** Ends the agent's authenticated state, if it implements logout. */
180
+ export async function logout(connection, agentCapabilities) {
181
+ if (!supportsLogout(agentCapabilities)) {
182
+ console.error("[auth] The agent does not support logout.");
183
+ return false;
184
+ }
185
+ await connection.logout({});
186
+ console.error("[auth] Logged out.");
187
+ return true;
188
+ }
189
+ //# sourceMappingURL=auth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.js","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -7,7 +7,8 @@
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
14
  import { loadMcpConfig, pickAgentrqServer } from "./config.js";
@@ -33,36 +34,51 @@ export function mapMcpServers(configs) {
33
34
  });
34
35
  }
35
36
  import { AgentRQACPClient } from "./acpClient.js";
37
+ import { describeAuthMethods, isAuthRequiredError, login, logout, supportsLogout, } from "./auth.js";
38
+ import { resolveAgentLaunch } from "./agentInstall.js";
39
+ import { describeAgents, fetchRegistry, hostPlatformTarget, } from "./registry.js";
36
40
  import { extractTaskIdFromMeta, extractTaskIdFromText, } from "./taskIdentity.js";
37
41
  const lastTaskContent = new Map();
38
42
  export const activeSessions = new Map();
39
- export async function getOrCreateSession(taskId, acpCmdArgs, configs, agentrqConfig, mcpBridge) {
40
- const key = taskId || "default";
41
- const existing = activeSessions.get(key);
42
- if (existing) {
43
- return existing;
44
- }
43
+ /** Login preferences taken from the CLI, consulted whenever an agent demands auth. */
44
+ export const authConfig = {};
45
+ /**
46
+ * Whether a human is sitting in front of this process.
47
+ *
48
+ * Terminal logins hand the agent our own stdio, and the "which login method?"
49
+ * prompt needs someone to answer it — neither works when the gateway runs
50
+ * unattended under a supervisor.
51
+ */
52
+ export function isInteractiveTerminal() {
53
+ return Boolean(process.stdin.isTTY && process.stderr.isTTY);
54
+ }
55
+ /**
56
+ * Spawns an ACP agent, wires the JSON-RPC streams to it and completes the
57
+ * `initialize` handshake, returning the connection plus what the agent said
58
+ * about itself — including the login methods it advertises.
59
+ */
60
+ export async function openAgentConnection({ acpCmdArgs, mcpBridge, env, label, taskId, onExit, }) {
45
61
  const [cmd, ...cmdArgs] = acpCmdArgs;
46
- console.error(`[acp] Spawning agent for task ${key}: ${cmd} ${cmdArgs.join(" ")}`);
62
+ console.error(`[acp] Spawning agent for ${label}: ${cmd} ${cmdArgs.join(" ")}`);
47
63
  const agentProcess = spawn(cmd, cmdArgs, {
48
64
  stdio: ["pipe", "pipe", "inherit"],
49
- env: { ...process.env, ...agentrqConfig.env },
65
+ env: { ...process.env, ...env },
50
66
  });
51
67
  // Guard against unhandled child-process failures. Without these listeners a
52
68
  // crashed agent (e.g. on network loss) leaves a broken stdin pipe; the next
53
69
  // write raises EPIPE as an uncaught error and takes the gateway down with it.
54
70
  agentProcess.on("error", (err) => {
55
- console.error(`[acp] Agent process error for task ${key}:`, err.message);
56
- activeSessions.delete(key);
71
+ console.error(`[acp] Agent process error for ${label}:`, err.message);
72
+ onExit?.();
57
73
  });
58
74
  agentProcess.on("exit", (code, signal) => {
59
- console.error(`[acp] Agent process for task ${key} exited (code=${code}, signal=${signal})`);
60
- activeSessions.delete(key);
75
+ console.error(`[acp] Agent process for ${label} exited (code=${code}, signal=${signal})`);
76
+ onExit?.();
61
77
  });
62
78
  // stdin can emit EPIPE when the child dies mid-write; swallow it so it
63
79
  // doesn't surface as an uncaught exception.
64
80
  agentProcess.stdin?.on("error", (err) => {
65
- console.error(`[acp] Agent stdin error for task ${key}:`, err.message);
81
+ console.error(`[acp] Agent stdin error for ${label}:`, err.message);
66
82
  });
67
83
  const input = Writable.toWeb(agentProcess.stdin);
68
84
  const output = Readable.toWeb(agentProcess.stdout);
@@ -80,14 +96,62 @@ export async function getOrCreateSession(taskId, acpCmdArgs, configs, agentrqCon
80
96
  form: {},
81
97
  url: {},
82
98
  },
99
+ // Only claim terminal logins when we can actually hand the agent a
100
+ // terminal; otherwise the agent may offer a method we cannot run.
101
+ auth: {
102
+ terminal: isInteractiveTerminal(),
103
+ },
83
104
  },
84
105
  });
85
- console.error(`[acp] Connected to agent for task ${key} (protocol v${initResult.protocolVersion})`);
106
+ console.error(`[acp] Connected to agent for ${label} (protocol v${initResult.protocolVersion})`);
107
+ if (initResult.authMethods?.length) {
108
+ console.error(`[auth] Agent offers these login methods:\n${describeAuthMethods(initResult.authMethods)}`);
109
+ }
110
+ return { process: agentProcess, connection, acpClient, initResult };
111
+ }
112
+ /**
113
+ * Starts a session, logging in first if the agent refuses without one.
114
+ *
115
+ * Agents only report `auth_required` when the session is requested, so this is
116
+ * where a first-run login belongs: authenticate once, then retry.
117
+ */
118
+ export async function createSessionWithAuth(connection, params, auth) {
119
+ try {
120
+ return await connection.newSession(params);
121
+ }
122
+ catch (err) {
123
+ if (!isAuthRequiredError(err))
124
+ throw err;
125
+ console.error("[auth] Agent requires authentication before a session can start.");
126
+ await login({ ...auth, connection: connection });
127
+ return await connection.newSession(params);
128
+ }
129
+ }
130
+ export async function getOrCreateSession(taskId, acpCmdArgs, configs, agentrqConfig, mcpBridge) {
131
+ const key = taskId || "default";
132
+ const existing = activeSessions.get(key);
133
+ if (existing) {
134
+ return existing;
135
+ }
136
+ const [cmd, ...cmdArgs] = acpCmdArgs;
137
+ const { process: agentProcess, connection, acpClient, initResult } = await openAgentConnection({
138
+ acpCmdArgs,
139
+ mcpBridge,
140
+ env: agentrqConfig.env,
141
+ label: `task ${key}`,
142
+ taskId,
143
+ onExit: () => activeSessions.delete(key),
144
+ });
86
145
  const newSessionParams = {
87
146
  cwd: process.cwd(),
88
147
  mcpServers: mapMcpServers(configs),
89
148
  };
90
- const sessionResult = await connection.newSession(newSessionParams);
149
+ const sessionResult = await createSessionWithAuth(connection, newSessionParams, {
150
+ methods: initResult.authMethods,
151
+ launch: { command: cmd, args: cmdArgs, env: agentrqConfig.env },
152
+ preferredId: authConfig.methodId,
153
+ interactive: isInteractiveTerminal(),
154
+ });
91
155
  console.error(`[acp] Created session ${sessionResult.sessionId} for task ${key}`);
92
156
  await enforceHumanApprovalMode(connection, sessionResult);
93
157
  const sessionInfo = {
@@ -95,6 +159,7 @@ export async function getOrCreateSession(taskId, acpCmdArgs, configs, agentrqCon
95
159
  connection,
96
160
  acpClient,
97
161
  sessionId: sessionResult.sessionId,
162
+ initResult,
98
163
  };
99
164
  activeSessions.set(key, sessionInfo);
100
165
  return sessionInfo;
@@ -237,33 +302,311 @@ export class TaskQueue {
237
302
  return this.queue.length;
238
303
  }
239
304
  }
305
+ /**
306
+ * Parses the gateway's own flags — everything before the `--` that introduces
307
+ * the agent command.
308
+ */
309
+ export function parseGatewayArgs(args) {
310
+ const options = {
311
+ maxConcurrency: 2,
312
+ command: "run",
313
+ allowUnverifiedAgent: false,
314
+ rest: [],
315
+ };
316
+ for (let i = 0; i < args.length; i++) {
317
+ // A following token is this flag's value only when it isn't a flag itself,
318
+ // so `--login` can stand alone or take a method id.
319
+ const next = args[i + 1];
320
+ const value = next !== undefined && !next.startsWith("-") ? next : undefined;
321
+ switch (args[i]) {
322
+ case "--max-concurrency":
323
+ case "--maxConcurrency": {
324
+ const parsed = parseInt(value ?? "", 10);
325
+ if (!isNaN(parsed)) {
326
+ options.maxConcurrency = parsed;
327
+ i++;
328
+ }
329
+ break;
330
+ }
331
+ case "--auth-method":
332
+ if (value) {
333
+ options.authMethodId = value;
334
+ i++;
335
+ }
336
+ break;
337
+ case "--login":
338
+ options.command = "login";
339
+ if (value) {
340
+ options.authMethodId = value;
341
+ i++;
342
+ }
343
+ break;
344
+ case "--logout":
345
+ options.command = "logout";
346
+ break;
347
+ case "--list-auth-methods":
348
+ options.command = "list-auth-methods";
349
+ break;
350
+ case "--agent":
351
+ if (value) {
352
+ options.agentId = value;
353
+ i++;
354
+ }
355
+ break;
356
+ case "--list-agents":
357
+ options.command = "list-agents";
358
+ break;
359
+ case "--allow-unverified-agent":
360
+ options.allowUnverifiedAgent = true;
361
+ break;
362
+ case "--registry-url":
363
+ if (value) {
364
+ options.registryUrl = value;
365
+ i++;
366
+ }
367
+ break;
368
+ case "--help":
369
+ case "-h":
370
+ options.command = "help";
371
+ break;
372
+ default:
373
+ // Anything unrecognised belongs to the agent command, which may be
374
+ // given without a `--` separator.
375
+ options.rest.push(args[i]);
376
+ }
377
+ }
378
+ return options;
379
+ }
380
+ /**
381
+ * Prints every agent the registry publishes, and how each one can be run here.
382
+ */
383
+ export async function runListAgents(registryUrl, fetchImpl = fetch) {
384
+ const registry = await fetchRegistry(registryUrl, fetchImpl);
385
+ const target = hostPlatformTarget();
386
+ console.log(`ACP registry v${registry.version} — ${registry.agents.length} agents ` +
387
+ `(this machine: ${target ?? `${process.platform}/${process.arch}, unsupported`})\n`);
388
+ console.log(describeAgents(registry, target));
389
+ console.log(`\nRun one with: acp-gateway --agent <id>`);
390
+ }
391
+ /**
392
+ * Works out which command actually starts the agent.
393
+ *
394
+ * `--agent <id>` resolves through the registry — installing the agent when the
395
+ * only distribution is a binary — and otherwise the command given after `--`
396
+ * is used as-is.
397
+ */
398
+ export async function resolveAgentCommand(options, explicitCommand, fetchImpl = fetch) {
399
+ if (!options.agentId)
400
+ return { command: explicitCommand };
401
+ const registry = await fetchRegistry(options.registryUrl, fetchImpl);
402
+ const spec = await resolveAgentLaunch({
403
+ id: options.agentId,
404
+ registry,
405
+ platformTarget: hostPlatformTarget(),
406
+ allowUnverified: options.allowUnverifiedAgent,
407
+ fetchImpl,
408
+ });
409
+ console.error(`[registry] Running "${options.agentId}" via ${spec.kind}: ${spec.command} ${spec.args.join(" ")}`);
410
+ return { command: [spec.command, ...spec.args], env: spec.env };
411
+ }
412
+ /**
413
+ * Whether a command can actually be run.
414
+ *
415
+ * A path is checked directly; a bare name is looked for along PATH, honouring
416
+ * PATHEXT on Windows where an executable is rarely named without a suffix.
417
+ */
418
+ export function isRunnable(command, env = process.env, platform = process.platform) {
419
+ if (command.includes("/") || (platform === "win32" && command.includes("\\"))) {
420
+ return existsSync(command);
421
+ }
422
+ const extensions = platform === "win32" ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";") : [""];
423
+ const separator = platform === "win32" ? ";" : ":";
424
+ return (env.PATH ?? "")
425
+ .split(separator)
426
+ .filter(Boolean)
427
+ .some((dir) => extensions.some((ext) => existsSync(path.join(dir, command + ext))));
428
+ }
429
+ /**
430
+ * Refuses to start with an agent that cannot be run.
431
+ *
432
+ * The agent is not spawned until the first task arrives, so without this a
433
+ * mistyped command — or a registry id passed as if it were one — starts a
434
+ * gateway that looks healthy and only fails much later, out of sight.
435
+ */
436
+ export function assertAgentRunnable(command, usedRegistryId) {
437
+ if (isRunnable(command))
438
+ return;
439
+ const hint = usedRegistryId
440
+ ? `The registry says to run it as "${command}", which is not installed.`
441
+ : `If "${command}" is an ACP registry agent id, run it with --agent ${command} ` +
442
+ `(--list-agents shows what is published).`;
443
+ throw new Error(`Agent command "${command}" was not found. ${hint}`);
444
+ }
445
+ /**
446
+ * Runs a one-shot auth command against the agent and shuts it down again.
447
+ *
448
+ * These commands exist so a login can be done deliberately — before any task
449
+ * arrives — rather than only when a session is refused.
450
+ */
451
+ export async function runAuthCommand(command, acpCmdArgs, agentrqConfig, mcpBridge, authMethodId) {
452
+ const [cmd, ...cmdArgs] = acpCmdArgs;
453
+ const agent = await openAgentConnection({
454
+ acpCmdArgs,
455
+ mcpBridge,
456
+ env: agentrqConfig.env,
457
+ label: command,
458
+ });
459
+ try {
460
+ const connection = agent.connection;
461
+ const { authMethods, agentCapabilities } = agent.initResult;
462
+ if (command === "list-auth-methods") {
463
+ console.log(`Authentication methods for "${acpCmdArgs.join(" ")}":\n${describeAuthMethods(authMethods)}`);
464
+ if (supportsLogout(agentCapabilities)) {
465
+ console.log("\nThe agent also supports --logout.");
466
+ }
467
+ return;
468
+ }
469
+ if (command === "logout") {
470
+ await logout(connection, agentCapabilities);
471
+ return;
472
+ }
473
+ await login({
474
+ connection,
475
+ methods: authMethods,
476
+ launch: { command: cmd, args: cmdArgs, env: agentrqConfig.env },
477
+ preferredId: authMethodId,
478
+ interactive: isInteractiveTerminal(),
479
+ });
480
+ }
481
+ finally {
482
+ agent.process.kill();
483
+ }
484
+ }
485
+ /**
486
+ * The full help text.
487
+ *
488
+ * Shown for `--help`, and when the gateway is run with nothing to do — at
489
+ * which point the reason someone is looking at the terminal is that they do
490
+ * not yet know what to type.
491
+ */
492
+ export function helpText(version = pkg.version) {
493
+ return `acp-gateway ${version} — bridges an ACP agent to an agentrq workspace.
494
+
495
+ USAGE
496
+ acp-gateway [options] -- <agent-command> [agent-args...]
497
+ acp-gateway [options] --agent <registry-id>
498
+
499
+ The agent is either a command you supply after \`--\`, or an id from the ACP
500
+ registry. Everything after \`--\` is passed to the agent untouched.
501
+
502
+ AGENT
503
+ --agent <registry-id> Run an agent from the ACP registry, installing it
504
+ if needed, instead of a command you supply.
505
+ --list-agents List every agent in the registry, and how each one
506
+ can run on this machine. Exits.
507
+ --allow-unverified-agent Install a registry binary that publishes no
508
+ checksum. Off by default: without a checksum there
509
+ is no way to tell what was downloaded.
510
+ --registry-url <url> Read a different registry index, for pinning it or
511
+ for testing.
512
+
513
+ AUTHENTICATION
514
+ --list-auth-methods List the login methods the agent offers. Exits.
515
+ --login [method-id] Log in to the agent. With no id, and a terminal to
516
+ ask in, you are asked which method to use. Exits.
517
+ --logout Log out of the agent, where it supports it. Exits.
518
+ --auth-method <id> The method to use when the agent demands a login
519
+ mid-run. Defaults to choosing one automatically.
520
+
521
+ BRIDGE
522
+ --max-concurrency <number> How many tasks may prompt the agent at once.
523
+ Defaults to 2.
524
+
525
+ OTHER
526
+ --help, -h Show this help. Exits.
527
+
528
+ EXAMPLES
529
+ acp-gateway --agent gemini Run Gemini from the registry
530
+ acp-gateway -- gemini --acp Run an agent you installed
531
+ acp-gateway --list-agents See what the registry offers
532
+ acp-gateway --login -- gemini --acp Log in before running anything
533
+ acp-gateway --max-concurrency 4 -- gemini --acp
534
+
535
+ The workspace comes from .mcp.json, searched for in the current directory and up
536
+ to three directories above it.`;
537
+ }
538
+ export function printHelp() {
539
+ console.log(helpText());
540
+ }
240
541
  async function main() {
241
- console.log(`Starting [acp-gateway] ${pkg.name} v${pkg.version}`);
242
542
  const args = process.argv.slice(2);
243
- // Find where the command starts (after -- if provided, or just all args)
543
+ // Everything after `--` is the agent command. Without a separator the
544
+ // gateway's own options are still recognised and whatever is left over is
545
+ // the command, so `acp-gateway --agent gemini` needs no trailing `--`.
244
546
  const cmdStartIndex = args.indexOf("--");
245
- const acpCmdArgs = cmdStartIndex !== -1 ? args.slice(cmdStartIndex + 1) : args;
246
- if (acpCmdArgs.length === 0) {
247
- console.log("Usage: acp-gateway [--max-concurrency <number>] -- <acp-server-command> [args...]");
248
- console.log("Example: acp-gateway --max-concurrency 4 -- gemini --acp");
547
+ const gatewayArgs = cmdStartIndex !== -1 ? args.slice(0, cmdStartIndex) : args;
548
+ const options = parseGatewayArgs(gatewayArgs);
549
+ const explicitCommand = cmdStartIndex !== -1 ? args.slice(cmdStartIndex + 1) : options.rest;
550
+ const { maxConcurrency, command, authMethodId } = options;
551
+ if (command === "help") {
552
+ printHelp();
553
+ process.exit(0);
554
+ }
555
+ // Listing the registry needs neither a workspace nor an agent.
556
+ if (command === "list-agents") {
557
+ await runListAgents(options.registryUrl);
558
+ process.exit(0);
559
+ }
560
+ // Nothing to run: the reason someone is looking at the terminal now is that
561
+ // they do not yet know what to type, so show the help rather than an error
562
+ // about a workspace they have not got to yet.
563
+ if (!options.agentId && explicitCommand.length === 0) {
564
+ printHelp();
249
565
  process.exit(1);
250
566
  }
567
+ console.log(`Starting [acp-gateway] ${pkg.name} v${pkg.version}`);
251
568
  // 1. Load MCP Config
252
569
  const configs = loadMcpConfig();
253
570
  const agentrqConfig = pickAgentrqServer(configs);
254
- // Parse max concurrency from CLI args, falling back to default of 2
255
- let maxConcurrency = 2;
256
- const gatewayArgs = cmdStartIndex !== -1 ? args.slice(0, cmdStartIndex) : [];
257
- const maxConcurrencyIdx = gatewayArgs.findIndex((arg) => arg === "--max-concurrency" || arg === "--maxConcurrency");
258
- if (maxConcurrencyIdx !== -1 && maxConcurrencyIdx + 1 < gatewayArgs.length) {
259
- const val = parseInt(gatewayArgs[maxConcurrencyIdx + 1], 10);
260
- if (!isNaN(val)) {
261
- maxConcurrency = val;
262
- }
571
+ // 2. Work out what actually starts the agent a registry id, or the command
572
+ // the user gave.
573
+ // These failures are all things the user can act on — an unknown registry
574
+ // id, no build for this platform, an unverifiable download, a mistyped
575
+ // command so they get a sentence rather than a stack trace.
576
+ const fail = (err) => {
577
+ console.error(`[acp-gateway] ${err instanceof Error ? err.message : err}`);
578
+ return process.exit(1);
579
+ };
580
+ const resolved = await resolveAgentCommand(options, explicitCommand).catch(fail);
581
+ const acpCmdArgs = resolved.command;
582
+ try {
583
+ assertAgentRunnable(acpCmdArgs[0], Boolean(options.agentId));
584
+ }
585
+ catch (err) {
586
+ fail(err);
587
+ }
588
+ if (resolved.env) {
589
+ // The registry entry's env is part of how that agent must be launched, so
590
+ // it travels with the command into every session spawned from it.
591
+ agentrqConfig.env = { ...agentrqConfig.env, ...resolved.env };
263
592
  }
593
+ authConfig.methodId = authMethodId;
264
594
  const taskQueue = new TaskQueue(maxConcurrency);
265
- // 2. Initialize MCP Bridge
595
+ // 3. Initialize MCP Bridge
266
596
  const mcpBridge = new MCPBridge(agentrqConfig);
597
+ // Auth commands talk to the agent and exit; they never start bridging tasks.
598
+ // They run before the bridge connects, so a first-time login still works when
599
+ // the workspace is unreachable — `callTool` connects on demand if the login
600
+ // actually needs to reach agentrq.
601
+ if (command !== "run") {
602
+ try {
603
+ await runAuthCommand(command, acpCmdArgs, agentrqConfig, mcpBridge, authMethodId);
604
+ }
605
+ finally {
606
+ await mcpBridge.close();
607
+ }
608
+ process.exit(0);
609
+ }
267
610
  await mcpBridge.connect();
268
611
  try {
269
612
  // Bridge: MCP -> ACP