mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
8efd7c6346
## Human summary:
This adds `argent server start` command to enable us to spawn tools
server in arbitrary location. we want this to enable using remote
tools-server via our mcp not only local ones.
## Summary
- Adds `argent server start` to spawn a tool-server that does **not**
auto-shutdown on idle, with configurable bind port and host — the
missing piece for running tool-server on a remote/dedicated host.
- Foreground by default so process supervisors (systemd, Docker,
supervisord) own the lifecycle; `--detach` keeps the existing
daemon-spawn behavior. Refuses to clobber a healthy running server
unless `--force`.
- Phase 2 (consumer-side `--tools-url` flag pointing the MCP / CLI at a
remote tool-server) is now a one-flag change away — `tools-client.ts`
and `mcp-server.ts` already honor `ARGENT_TOOLS_URL`. Out of scope for
this PR.
## What changed
- **`tool-server`** — read `HOST` env var (default `127.0.0.1` so the
existing auto-spawn path is unaffected), bind to it, attach an `error`
listener on `app.listen` so `EADDRINUSE` / `EACCES` exit cleanly instead
of routing through `uncaughtException`.
- **`argent-tools-client`** — broaden the readiness regex to match any
host; extend `buildToolsServerEnv` with optional `{ host,
idleTimeoutMinutes }`; record `host` in `~/.argent/tool-server.json`
(backward-compat: missing field defaults to `127.0.0.1`); export
`spawnToolsServer`, `findFreePort`, `isToolsServerHealthy`,
`isToolsServerProcessAlive`, the state read/write/clear helpers, and
`formatToolsServerUrl` so the CLI can reuse them.
- **`argent-cli`** — new `server start [flags]`: `--port`/`-p` (default
3001, `0` = pick free), `--host` (default `127.0.0.1`), `--idle-timeout`
(default `0` = never), `--detach`/`-d`, `--force`, `--help`. Warns when
host is non-loopback. Foreground mode forwards `SIGINT`/`SIGTERM` to the
child and writes the state file so local `argent run` / `argent mcp` can
attach.
- **`argent` dispatcher** — pass `BUNDLED_RUNTIME_PATHS` to `server()`
so the new subcommand can find the bundled tool-server.
## Test plan
- [x] `argent server --help` lists the new `start` subcommand
- [x] `argent server start --help` shows full flag reference
- [x] `argent server start --detach --port 0` → status alive/healthy,
`/tools` reachable, state file populated with `host` field
- [x] Second `argent server start --detach` refuses (`exit 1`) with
helpful message
- [x] `argent server start --detach --force` kills the old PID and
starts a new one
- [x] Foreground `argent server start --port 0` runs with inherited
stdio; child PID is the actual tool-server (not the wrapper); `argent
server stop` from a sibling shell tears down both
- [x] Existing auto-spawn path (`argent tools`) still works — verifies
the regex broadening + default-host change is backward-compatible
- [x] `tool-server` (444 tests) + `argent-tools-client` (1) + `argent`
dispatcher (1) suites pass
- [ ] Manual smoke on `--host 0.0.0.0` — could not exercise locally
(sandbox declined the public-interface bind); the warning + bind path is
identical to loopback besides the listen address
---------
Co-authored-by: pFornagiel <pawel.fornagiel2@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Paweł Fornagiel <85541429+pFornagiel@users.noreply.github.com>
123 lines
3.9 KiB
TypeScript
123 lines
3.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import supertest from "supertest";
|
|
import { createHttpApp, type HttpAppHandle } from "../src/http";
|
|
import type { Registry } from "@argent/registry";
|
|
|
|
vi.mock("../src/utils/update-checker", () => ({
|
|
getUpdateState: vi.fn(() => ({
|
|
updateAvailable: false,
|
|
latestVersion: null,
|
|
currentVersion: "1.0.0",
|
|
})),
|
|
isUpdateNoteSuppressed: vi.fn(() => true),
|
|
suppressUpdateNote: vi.fn(),
|
|
}));
|
|
|
|
function stubRegistry(): Registry {
|
|
return {
|
|
getSnapshot: vi.fn(() => ({ services: new Map(), namespaces: [], tools: [] })),
|
|
getTool: vi.fn(() => undefined),
|
|
invokeTool: vi.fn(async () => ({ ok: true })),
|
|
} as unknown as Registry;
|
|
}
|
|
|
|
describe("Host header validation", () => {
|
|
let handle: HttpAppHandle;
|
|
let request: typeof supertest;
|
|
|
|
beforeEach(async () => {
|
|
request = supertest;
|
|
handle = createHttpApp(stubRegistry());
|
|
});
|
|
|
|
afterEach(() => {
|
|
handle?.dispose();
|
|
});
|
|
|
|
it("accepts 127.0.0.1 with port", async () => {
|
|
const res = await request(handle.app).get("/tools").set("Host", "127.0.0.1:60770");
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("accepts localhost with port", async () => {
|
|
const res = await request(handle.app).get("/tools").set("Host", "localhost:60770");
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("accepts ::1 with port", async () => {
|
|
const res = await request(handle.app).get("/tools").set("Host", "[::1]:60770");
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("accepts 127.0.0.1 without port", async () => {
|
|
const res = await request(handle.app).get("/tools").set("Host", "127.0.0.1");
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("rejects a public hostname (DNS-rebinding attack)", async () => {
|
|
const res = await request(handle.app).get("/tools").set("Host", "evil.com:60770");
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toMatch(/loopback/i);
|
|
expect(res.body.error).toMatch(/DNS-rebinding/i);
|
|
});
|
|
|
|
it("rejects an IP that is not loopback", async () => {
|
|
const res = await request(handle.app).get("/tools").set("Host", "10.0.0.1:60770");
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("blocks DNS-rebinding even on POST /tools/:name", async () => {
|
|
const res = await request(handle.app)
|
|
.post("/tools/anything")
|
|
.set("Host", "attacker.example:60770")
|
|
.send({});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe("Host header validation — explicit non-loopback bind host", () => {
|
|
let handle: HttpAppHandle;
|
|
|
|
beforeEach(() => {
|
|
handle = createHttpApp(stubRegistry(), { bindHost: "192.168.92.208" });
|
|
});
|
|
|
|
afterEach(() => handle?.dispose());
|
|
|
|
it("accepts the configured bind host (the `argent link` path)", async () => {
|
|
const res = await supertest(handle.app).get("/tools").set("Host", "192.168.92.208:3001");
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("still accepts loopback", async () => {
|
|
const res = await supertest(handle.app).get("/tools").set("Host", "127.0.0.1:3001");
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("still rejects a different non-loopback host (DNS-rebinding)", async () => {
|
|
const res = await supertest(handle.app).get("/tools").set("Host", "evil.com:3001");
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toMatch(/192\.168\.92\.208/);
|
|
});
|
|
});
|
|
|
|
describe("Host header validation — wildcard bind disables the guard", () => {
|
|
let handle: HttpAppHandle;
|
|
|
|
beforeEach(() => {
|
|
handle = createHttpApp(stubRegistry(), { bindHost: "0.0.0.0" });
|
|
});
|
|
|
|
afterEach(() => handle?.dispose());
|
|
|
|
it("accepts an arbitrary Host when bound to all interfaces", async () => {
|
|
const res = await supertest(handle.app).get("/tools").set("Host", "10.0.0.42:3001");
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("accepts a request with no Host header", async () => {
|
|
const res = await supertest(handle.app).get("/tools");
|
|
expect(res.status).toBe(200);
|
|
});
|
|
});
|