From 4c8239a1f240eb460c0b411687edc6b673d3a9d4 Mon Sep 17 00:00:00 2001 From: Filip131311 Date: Mon, 4 May 2026 15:47:04 +0200 Subject: [PATCH] feat: add `argent server start` for long-lived tool-server Adds a CLI command to spawn a tool-server that does not auto-shutdown on idle, with configurable port and bind host. Lays the groundwork for remote tool-server connections (consumer side already honors ARGENT_TOOLS_URL). - tool-server: read HOST env var (default 127.0.0.1), bind to it, surface bind errors (EADDRINUSE / EACCES) cleanly. - tools-client: broaden readiness regex to any host; extend buildToolsServerEnv with host/idleTimeoutMinutes; add `host` to the state file; export spawn/state/health helpers for CLI reuse. - argent-cli: implement `server start [--port|-p] [--host] [--idle-timeout] [--detach|-d] [--force]`. Foreground by default for process supervisors; --detach reuses the daemon spawn path. Refuses to start when a healthy server is already running unless --force. Warns when binding to a non-loopback host. - dispatcher: thread BUNDLED_RUNTIME_PATHS through to `server`. --- packages/argent-cli/src/server.ts | 348 +++++++++++++++++-- packages/argent-tools-client/src/index.ts | 11 + packages/argent-tools-client/src/launcher.ts | 87 ++++- packages/argent/src/cli.ts | 5 +- packages/tool-server/src/index.ts | 20 +- 5 files changed, 421 insertions(+), 50 deletions(-) diff --git a/packages/argent-cli/src/server.ts b/packages/argent-cli/src/server.ts index 31fa7b9d..eed1cf6a 100644 --- a/packages/argent-cli/src/server.ts +++ b/packages/argent-cli/src/server.ts @@ -2,7 +2,19 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { homedir } from "node:os"; import { spawn } from "node:child_process"; -import { killToolServer } from "@argent/tools-client"; +import { + killToolServer, + findFreePort, + spawnToolsServer, + buildToolsServerEnv, + isToolsServerHealthy, + isToolsServerProcessAlive, + readToolsServerState, + writeToolsServerState, + clearToolsServerState, + formatToolsServerUrl, + type ToolsServerPaths, +} from "@argent/tools-client"; const STATE_DIR = path.join(homedir(), ".argent"); const STATE_FILE = path.join(STATE_DIR, "tool-server.json"); @@ -13,6 +25,7 @@ interface ToolsServerState { pid: number; startedAt: string; bundlePath: string; + host?: string; } function readState(): ToolsServerState | null { @@ -23,28 +36,6 @@ function readState(): ToolsServerState | null { } } -function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -async function isHealthy(port: number, timeoutMs = 2000): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const res = await fetch(`http://127.0.0.1:${port}/tools`, { signal: controller.signal }); - return res.ok; - } catch { - return false; - } finally { - clearTimeout(timer); - } -} - async function statusCmd(json: boolean): Promise { const state = readState(); if (!state) { @@ -55,14 +46,15 @@ async function statusCmd(json: boolean): Promise { } return; } - const alive = isProcessAlive(state.pid); - const healthy = alive ? await isHealthy(state.port) : false; + const host = state.host ?? "127.0.0.1"; + const alive = isToolsServerProcessAlive(state.pid); + const healthy = alive ? await isToolsServerHealthy(state.port, host) : false; if (json) { console.log(JSON.stringify({ running: alive && healthy, ...state, alive, healthy }, null, 2)); return; } console.log(`tool-server:`); - console.log(` url: http://127.0.0.1:${state.port}`); + console.log(` url: ${formatToolsServerUrl(host, state.port)}`); console.log(` pid: ${state.pid}`); console.log(` startedAt: ${state.startedAt}`); console.log(` process: ${alive ? "alive" : "dead"}`); @@ -97,13 +89,312 @@ function logsCmd(follow: boolean): void { child.on("exit", (code) => process.exit(code ?? 0)); } -export async function server(argv: string[]): Promise { +interface StartFlags { + port: number | null; + host: string; + idleTimeoutMinutes: number; + detach: boolean; + force: boolean; + help: boolean; +} + +class StartFlagError extends Error {} + +function parseStartFlags(argv: string[]): StartFlags { + const flags: StartFlags = { + port: null, + host: "127.0.0.1", + idleTimeoutMinutes: 0, + detach: false, + force: false, + help: false, + }; + + for (let i = 0; i < argv.length; i++) { + const tok = argv[i]!; + const takeValue = (name: string): string => { + const v = argv[i + 1]; + if (v === undefined) throw new StartFlagError(`${name} requires a value`); + i += 1; + return v; + }; + if (tok === "--help" || tok === "-h") { + flags.help = true; + continue; + } + if (tok === "--detach" || tok === "-d") { + flags.detach = true; + continue; + } + if (tok === "--force") { + flags.force = true; + continue; + } + if (tok === "--port" || tok === "-p") { + flags.port = parsePort(takeValue("--port")); + continue; + } + if (tok.startsWith("--port=")) { + flags.port = parsePort(tok.slice("--port=".length)); + continue; + } + if (tok === "--host") { + flags.host = takeValue("--host"); + continue; + } + if (tok.startsWith("--host=")) { + flags.host = tok.slice("--host=".length); + continue; + } + if (tok === "--idle-timeout") { + flags.idleTimeoutMinutes = parseIdle(takeValue("--idle-timeout")); + continue; + } + if (tok.startsWith("--idle-timeout=")) { + flags.idleTimeoutMinutes = parseIdle(tok.slice("--idle-timeout=".length)); + continue; + } + throw new StartFlagError(`Unknown flag: ${tok}`); + } + + return flags; +} + +function parsePort(raw: string): number { + const n = parseInt(raw, 10); + if (!Number.isInteger(n) || n < 0 || n > 65535) { + throw new StartFlagError(`--port must be an integer 0..65535, got "${raw}"`); + } + return n; +} + +function parseIdle(raw: string): number { + const n = parseInt(raw, 10); + if (!Number.isInteger(n) || n < 0) { + throw new StartFlagError(`--idle-timeout must be a non-negative integer, got "${raw}"`); + } + return n; +} + +function printStartHelp(): void { + console.log(`Usage: argent server start [flags] + +Spawn a long-lived tool-server. Foreground by default so process supervisors +(systemd, Docker, supervisord) can own the lifecycle. + +Flags: + --port, -p Bind to port (0 = pick a free port). Default: 3001 + --host Bind address. Default: 127.0.0.1 + Use 0.0.0.0 to expose on every interface. + --idle-timeout Auto-shutdown after idle minutes (0 disables). + Default: 0 (never auto-shutdown). + --detach, -d Run as a detached background process and return. + --force If a tool-server is already running, kill it first. + --help, -h Show this help. + +Examples: + argent server start + argent server start --port 4000 + argent server start --host 0.0.0.0 --port 4000 + argent server start --detach +`); +} + +function isLoopback(host: string): boolean { + return host === "127.0.0.1" || host === "localhost" || host === "::1"; +} + +async function ensureNoExistingServer(force: boolean): Promise { + const state = await readToolsServerState(); + if (!state) return; + const alive = isToolsServerProcessAlive(state.pid); + const healthy = alive ? await isToolsServerHealthy(state.port, state.host ?? "127.0.0.1") : false; + if (alive && healthy && !force) { + const url = formatToolsServerUrl(state.host ?? "127.0.0.1", state.port); + throw new StartFlagError( + `tool-server is already running at ${url} (pid ${state.pid}).\n` + + `Use \`argent server stop\` first, or pass \`--force\` to replace it.` + ); + } + if (alive && force) { + await killToolServer(); + } else { + // Stale state file — clear it so we don't leave it pointing at a dead pid. + await clearToolsServerState(); + } +} + +async function resolvePort(requested: number | null): Promise { + if (requested === null) return 3001; + if (requested === 0) return findFreePort(); + return requested; +} + +async function startCmd(argv: string[], paths: ToolsServerPaths | undefined): Promise { + if (!paths) { + console.error("argent server start: bundled runtime paths missing — this build is incomplete."); + process.exit(1); + } + + let flags: StartFlags; + try { + flags = parseStartFlags(argv); + } catch (err) { + if (err instanceof StartFlagError) { + console.error(`Error: ${err.message}\n`); + printStartHelp(); + process.exit(2); + } + throw err; + } + + if (flags.help) { + printStartHelp(); + return; + } + + try { + await ensureNoExistingServer(flags.force); + } catch (err) { + if (err instanceof StartFlagError) { + console.error(err.message); + process.exit(1); + } + throw err; + } + + const port = await resolvePort(flags.port); + + if (!isLoopback(flags.host)) { + process.stderr.write( + `WARNING: tool-server will be reachable on ${flags.host}:${port} — ` + + `do not expose to untrusted networks (no auth is enforced).\n` + ); + } + + if (flags.detach) { + await runDetached(paths, port, flags.host, flags.idleTimeoutMinutes); + return; + } + + await runForeground(paths, port, flags.host, flags.idleTimeoutMinutes); +} + +async function runDetached( + paths: ToolsServerPaths, + port: number, + host: string, + idleTimeoutMinutes: number +): Promise { + const { port: actualPort, pid } = await spawnToolsServer(paths, port, { + host, + idleTimeoutMinutes, + }); + await writeToolsServerState({ + port: actualPort, + pid, + startedAt: new Date().toISOString(), + bundlePath: paths.bundlePath, + host, + }); + const url = formatToolsServerUrl(host, actualPort); + console.log(`tool-server started: ${url} (pid ${pid})`); + console.log(` logs: ${LOG_FILE}`); + console.log(` status: argent server status`); + console.log(` stop: argent server stop`); +} + +async function runForeground( + paths: ToolsServerPaths, + port: number, + host: string, + idleTimeoutMinutes: number +): Promise { + fs.mkdirSync(STATE_DIR, { recursive: true }); + + const env = buildToolsServerEnv(paths, port, process.env, { + host, + idleTimeoutMinutes, + }); + + const child = spawn("node", [paths.bundlePath, "start"], { + stdio: "inherit", + env, + }); + + let stateWritten = false; + + // Forward signals so process supervisors can stop us cleanly. The child has + // its own SIGINT/SIGTERM handlers that drain HTTP + dispose the registry. + const forward = (signal: NodeJS.Signals) => () => { + try { + child.kill(signal); + } catch { + /* already gone */ + } + }; + const onInt = forward("SIGINT"); + const onTerm = forward("SIGTERM"); + process.on("SIGINT", onInt); + process.on("SIGTERM", onTerm); + + const childPid = child.pid; + if (childPid !== undefined) { + // Best-effort state-file registration so local `argent run` / `argent mcp` + // pick up this server. We can't reliably know when the child has actually + // bound the port without reading its stdout (which is inherited here, so + // the user sees it live), but the child writes its readiness banner before + // it serves any traffic, and the launcher's auto-spawn path is the only + // consumer that needs near-instant readiness. + writeToolsServerState({ + port, + pid: childPid, + startedAt: new Date().toISOString(), + bundlePath: paths.bundlePath, + host, + }) + .then(() => { + stateWritten = true; + }) + .catch(() => { + /* non-fatal: foreground run still works without the state file */ + }); + } + + await new Promise((resolve) => { + child.on("exit", (code, signal) => { + process.removeListener("SIGINT", onInt); + process.removeListener("SIGTERM", onTerm); + if (stateWritten) { + clearToolsServerState().catch(() => { + /* non-fatal */ + }); + } + // Mirror the child's exit. Signal-terminated children get conventional + // 128+signo exit codes so shells / supervisors see the right outcome. + const exitCode = code ?? (signal ? 128 + (signalNumber(signal) ?? 0) : 0); + process.exit(exitCode); + resolve(); + }); + }); +} + +function signalNumber(signal: NodeJS.Signals): number | null { + const map: Record = { SIGINT: 2, SIGTERM: 15, SIGHUP: 1, SIGKILL: 9 }; + return map[signal] ?? null; +} + +export async function server( + argv: string[], + options?: { paths?: ToolsServerPaths } +): Promise { const sub = argv[0]; const json = argv.includes("--json"); const follow = argv.includes("-f") || argv.includes("--follow"); if (!sub || sub === "--help" || sub === "-h") { console.log(`Usage: + argent server start [flags] Spawn a long-lived tool-server (see --help) argent server status [--json] Show tool-server pid, port, and health argent server stop Terminate the running tool-server argent server logs [-f] Print (or follow) the tool-server log @@ -112,6 +403,9 @@ export async function server(argv: string[]): Promise { } switch (sub) { + case "start": + await startCmd(argv.slice(1), options?.paths); + return; case "status": await statusCmd(json); return; diff --git a/packages/argent-tools-client/src/index.ts b/packages/argent-tools-client/src/index.ts index e4f4e517..ff7e911c 100644 --- a/packages/argent-tools-client/src/index.ts +++ b/packages/argent-tools-client/src/index.ts @@ -2,8 +2,19 @@ export { ensureToolsServer, killToolServer, buildToolsServerEnv, + spawnToolsServer, + findFreePort, + isToolsServerHealthy, + isToolsServerProcessAlive, + readToolsServerState, + writeToolsServerState, + clearToolsServerState, + formatToolsServerUrl, STATE_PATHS, type ToolsServerPaths, + type ToolsServerState, + type BuildToolsServerEnvOptions, + type SpawnToolsServerOptions, } from "./launcher.js"; export { diff --git a/packages/argent-tools-client/src/launcher.ts b/packages/argent-tools-client/src/launcher.ts index a294cf1d..6f543ee9 100644 --- a/packages/argent-tools-client/src/launcher.ts +++ b/packages/argent-tools-client/src/launcher.ts @@ -24,17 +24,30 @@ export interface ToolsServerPaths { nativeDevtoolsDir: string; } +export interface BuildToolsServerEnvOptions { + /** Bind host. Omit to inherit the tool-server default (127.0.0.1). */ + host?: string; + /** Idle-timeout minutes (0 disables). Omit to inherit the tool-server default. */ + idleTimeoutMinutes?: number; +} + export function buildToolsServerEnv( paths: ToolsServerPaths, port: number, - baseEnv: NodeJS.ProcessEnv = process.env + baseEnv: NodeJS.ProcessEnv = process.env, + options: BuildToolsServerEnvOptions = {} ): NodeJS.ProcessEnv { - return { + const env: NodeJS.ProcessEnv = { ...baseEnv, PORT: String(port), ARGENT_SIMULATOR_SERVER_DIR: paths.simulatorServerDir, ARGENT_NATIVE_DEVTOOLS_DIR: paths.nativeDevtoolsDir, }; + if (options.host !== undefined) env.HOST = options.host; + if (options.idleTimeoutMinutes !== undefined) { + env.ARGENT_IDLE_TIMEOUT_MINUTES = String(options.idleTimeoutMinutes); + } + return env; } interface ToolsServerState { @@ -42,9 +55,11 @@ interface ToolsServerState { pid: number; startedAt: string; bundlePath: string; + /** Bind host. Optional for backward-compat with state files written by older versions. */ + host?: string; } -function findFreePort(): Promise { +export function findFreePort(): Promise { return new Promise((resolve, reject) => { const srv = net.createServer(); srv.listen(0, "127.0.0.1", () => { @@ -72,11 +87,32 @@ function isProcessAlive(pid: number): boolean { } } -async function isHealthy(port: number): Promise { +/** + * The wildcard hosts (`0.0.0.0`, `::`) accept connections on every interface + * including loopback, but you cannot _connect_ to them — for the health check + * we have to use a routable address. + */ +function healthCheckHost(host: string): string { + if (host === "0.0.0.0" || host === "") return "127.0.0.1"; + if (host === "::" || host === "::0") return "::1"; + return host; +} + +function formatUrl(host: string, port: number): string { + // Bracket IPv6 literals in URLs. + const h = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; + return `http://${h}:${port}`; +} + +export async function isToolsServerHealthy( + port: number, + host: string = "127.0.0.1", + timeoutMs = 2000 +): Promise { const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 2000); + const timer = setTimeout(() => controller.abort(), timeoutMs); try { - const res = await fetch(`http://127.0.0.1:${port}/tools`, { + const res = await fetch(`${formatUrl(healthCheckHost(host), port)}/tools`, { signal: controller.signal, }); return res.ok; @@ -87,9 +123,16 @@ async function isHealthy(port: number): Promise { } } -function spawnToolsServer( +export function isToolsServerProcessAlive(pid: number): boolean { + return isProcessAlive(pid); +} + +export interface SpawnToolsServerOptions extends BuildToolsServerEnvOptions {} + +export function spawnToolsServer( paths: ToolsServerPaths, - port: number + port: number, + options: SpawnToolsServerOptions = {} ): Promise<{ port: number; pid: number }> { return new Promise((resolve, reject) => { let logFd: number; @@ -103,7 +146,7 @@ function spawnToolsServer( const child = spawn("node", [paths.bundlePath, "start"], { detached: true, stdio: ["ignore", "pipe", logFd], - env: buildToolsServerEnv(paths, port), + env: buildToolsServerEnv(paths, port, process.env, options), }); child.unref(); @@ -124,8 +167,10 @@ function spawnToolsServer( const rl = readline.createInterface({ input: child.stdout! }); rl.on("line", (line) => { - // Match: "Tools server listening on http://127.0.0.1:" - const match = line.match(/Tools server listening on http:\/\/127\.0\.0\.1:(\d+)/); + // Match: "Tools server listening on http://:" + // Greedy `.+` then `:digits` backtracks to the trailing port, so this + // works for hostnames, IPv4 (`127.0.0.1`), and bracketed IPv6 (`[::1]`). + const match = line.match(/Tools server listening on http:\/\/.+:(\d+)/); if (match) { const actualPort = parseInt(match[1]!, 10); rl.close(); @@ -155,7 +200,7 @@ function spawnToolsServer( }); } -async function readState(): Promise { +export async function readToolsServerState(): Promise { try { const raw = await readFile(STATE_FILE, "utf8"); return JSON.parse(raw) as ToolsServerState; @@ -164,12 +209,12 @@ async function readState(): Promise { } } -async function writeState(state: ToolsServerState): Promise { +export async function writeToolsServerState(state: ToolsServerState): Promise { await mkdir(STATE_DIR, { recursive: true }); await writeFile(STATE_FILE, JSON.stringify(state, null, 2) + "\n", "utf8"); } -async function clearState(): Promise { +export async function clearToolsServerState(): Promise { try { await unlink(STATE_FILE); } catch { @@ -177,6 +222,10 @@ async function clearState(): Promise { } } +const readState = readToolsServerState; +const writeState = writeToolsServerState; +const clearState = clearToolsServerState; + export async function killToolServer(): Promise { const state = await readState(); if (!state) return; @@ -194,9 +243,9 @@ export async function ensureToolsServer(paths: ToolsServerPaths): Promise Show one tool's flags * argent run [flags] Invoke a tool by name + * argent server start [flags] Spawn a long-lived tool-server (foreground by default) * argent server status|stop|logs Manage the shared tool-server */ @@ -71,7 +72,7 @@ Commands: remove Alias for uninstall tools List tools exposed by the tool-server run Invoke a tool by name (use \`argent run --help\` for flags) - server Manage the shared tool-server (status / stop / logs) + server Manage the shared tool-server (start / status / stop / logs) Options: --help, -h Show this help message @@ -116,7 +117,7 @@ async function main(): Promise { case "run": return (await loadCli()).run(rest, { paths: BUNDLED_RUNTIME_PATHS }); case "server": - return (await loadCli()).server(rest); + return (await loadCli()).server(rest, { paths: BUNDLED_RUNTIME_PATHS }); case "--version": case "-v": console.log(getInstalledVersion() ?? "unknown"); diff --git a/packages/tool-server/src/index.ts b/packages/tool-server/src/index.ts index ab8e9aaa..3ff31572 100644 --- a/packages/tool-server/src/index.ts +++ b/packages/tool-server/src/index.ts @@ -62,6 +62,9 @@ export function start(): void { // ── Config ──────────────────────────────────────────────────────── const PORT = parseInt(process.env.PORT ?? "3001", 10); + // HOST defaults to loopback so the local auto-spawn path stays safe. + // `argent server start --host 0.0.0.0` is the opt-in for remote exposure. + const HOST = process.env.HOST ?? "127.0.0.1"; const idleMinutes = parseInt( process.env.ARGENT_IDLE_TIMEOUT_MINUTES ?? String(DEFAULT_IDLE_TIMEOUT_MINUTES), 10 @@ -103,16 +106,25 @@ export function start(): void { // simulators before any agent tool call (e.g. launch-app) can arrive. watcherReady .then(() => { - server = httpHandle.app.listen(PORT, "127.0.0.1", () => { + server = httpHandle.app.listen(PORT, HOST, () => { const addr = server!.address(); const boundPort = typeof addr === "object" && addr ? addr.port : PORT; - process.stdout.write(`Tools server listening on http://127.0.0.1:${boundPort}\n`); - process.stderr.write(` GET http://127.0.0.1:${boundPort}/tools\n`); - process.stderr.write(` POST http://127.0.0.1:${boundPort}/tools/:name\n`); + process.stdout.write(`Tools server listening on http://${HOST}:${boundPort}\n`); + process.stderr.write(` GET http://${HOST}:${boundPort}/tools\n`); + process.stderr.write(` POST http://${HOST}:${boundPort}/tools/:name\n`); if (idleTimeoutMs > 0) { process.stderr.write(` Idle timeout: ${idleMinutes}min\n`); } }); + // Surface bind failures (EADDRINUSE / EACCES on privileged ports) as a + // clean exit instead of routing through uncaughtException → crashShutdown. + server.on("error", (err: NodeJS.ErrnoException) => { + const code = err.code ? `${err.code}: ` : ""; + process.stderr.write( + `[tool-server] Failed to bind ${HOST}:${PORT} — ${code}${err.message}\n` + ); + process.exit(1); + }); }) .catch((err) => { process.stderr.write(