Files
software-mansion__argent/packages/tool-server/test/http-host-validation.test.ts
filip131311 8efd7c6346 feat: add argent server start for long-lived tool-server (#182)
## 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>
2026-06-03 17:44:15 +02:00

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);
});
});