@fastagent-sh/fastagent 0.16.0 → 0.16.1

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.
Files changed (38) hide show
  1. package/README.md +1 -1
  2. package/dist/bind.d.ts +34 -0
  3. package/dist/bind.js +74 -0
  4. package/dist/channels/agentcore-state.js +14 -6
  5. package/dist/cli/commands/deploy.d.ts +13 -0
  6. package/dist/cli/commands/deploy.js +7 -1
  7. package/dist/cli/commands/dev.d.ts +1 -0
  8. package/dist/cli/commands/dev.js +15 -4
  9. package/dist/cli/commands/info.js +1 -1
  10. package/dist/cli/commands/start.d.ts +1 -0
  11. package/dist/cli/commands/start.js +11 -3
  12. package/dist/cli/commands/tool.js +10 -2
  13. package/dist/cli/program.js +9 -0
  14. package/dist/cli/serve.d.ts +26 -2
  15. package/dist/cli/serve.js +71 -17
  16. package/dist/cli/shared.d.ts +6 -0
  17. package/dist/cli/shared.js +14 -0
  18. package/dist/deploy/agentcore/plan.js +1 -1
  19. package/dist/deploy/preflight.js +18 -0
  20. package/dist/engines/pi/config.d.ts +6 -2
  21. package/dist/engines/pi/config.js +8 -2
  22. package/dist/engines/pi/create.d.ts +25 -17
  23. package/dist/engines/pi/create.js +37 -14
  24. package/dist/engines/pi/harness.d.ts +19 -5
  25. package/dist/engines/pi/harness.js +3 -5
  26. package/dist/engines/pi/open.d.ts +2 -2
  27. package/dist/engines/pi/open.js +1 -1
  28. package/dist/engines/pi/read-image.d.ts +4 -0
  29. package/dist/engines/pi/read-image.js +62 -0
  30. package/dist/engines/pi/search-tools.d.ts +6 -4
  31. package/dist/engines/pi/search-tools.js +3 -1
  32. package/dist/engines/pi/session-builder.js +7 -2
  33. package/dist/engines/pi/tool.d.ts +13 -5
  34. package/dist/engines/pi/wake-tool.d.ts +3 -3
  35. package/dist/host/node.d.ts +2 -0
  36. package/dist/host/node.js +2 -1
  37. package/dist/pi.d.ts +1 -1
  38. package/package.json +4 -4
package/README.md CHANGED
@@ -196,7 +196,7 @@ FastAgent is pre-1.0. The stable design center is the Agent Handler contract in
196
196
  The neutral contract leaves room for capabilities that are not complete product features yet:
197
197
 
198
198
  - **Durable execution**: Telegram, Slack, and Feishu/Lark accepted turns replay at least once today; general durability and exactly-once execution remain future backend work.
199
- - **Sandboxed execution** — `ExecutionEnv` is an assembly seam, but the pi coding tools and project-context loader are still local; a complete sandbox adapter is future work.
199
+ - **Sandboxed execution** — `ExecutionEnv` governs the default coding tools, but project context and author-written `tools/` still reach the local process; a complete sandbox adapter is future work.
200
200
  - **Observability export** — leveled logs and per-turn traces exist today; an OpenTelemetry exporter does not.
201
201
  - **More harness bindings and channels** — pi is the built-in harness; another harness can implement the Agent contract, and community channels can use the channel kit.
202
202
  - **More deploy targets** — local Docker, Fly, Railway, and AWS Bedrock AgentCore ship today; the generated container is the portable path for other hosts.
package/dist/bind.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ /** A bindable host: an IP literal (v4/v6, brackets optional) or "localhost". No other DNS name — a
2
+ * bind address must be an address of THIS machine, and resolving one is not this module's job. */
3
+ export declare function isBindAddress(host: string): boolean;
4
+ /**
5
+ * The ADDRESS form of an accepted bind value: `localhost` becomes `127.0.0.1`, everything else is
6
+ * already an address. Applied where a value ENTERS (the flag, `http.host`), so nothing downstream ever
7
+ * holds a name — not deferring the resolution but removing it, which is the module's whole point.
8
+ *
9
+ * Two things go wrong otherwise, and both are silent. `server.listen` hands the name to `dns.lookup`,
10
+ * which picks ONE of 127.0.0.1/::1 by rules we do not control — so what got bound is unknown here. And
11
+ * `clientHost` would then write that NAME into control.json and the copyable curl, where a consumer
12
+ * resolves it again, possibly to the other one.
13
+ */
14
+ export declare function bindAddress(host: string): string;
15
+ /**
16
+ * How far a bind address reaches: `wildcard` (unset or all-interfaces) reaches every interface and
17
+ * answers on loopback too; `loopback` is this machine only; `specific` is one interface, reachable
18
+ * only as itself. Loopback covers the whole reserved range (127/8, ::1) — a `127.0.0.2` bind is no
19
+ * more LAN-reachable than `127.0.0.1`.
20
+ */
21
+ export declare function classifyBind(host: string | undefined): "wildcard" | "loopback" | "specific";
22
+ /**
23
+ * Does a serve bound to `host` answer a dial of the NAME `localhost`? Only the addresses that name
24
+ * resolves to (127.0.0.1 / ::1) and a wildcard bind do — `127.0.0.2` is loopback yet unreachable that
25
+ * way. cloudflared dials by name, so `--tunnel` needs this question, not `classifyBind`'s reach.
26
+ */
27
+ export declare function answersLocalhost(host: string | undefined): boolean;
28
+ /** How to NAME a bind in a message: the wildcard is every interface, so calling it one address would
29
+ * understate it; anything else is dialable as itself. THE renderer — the ready lines, the
30
+ * already-in-use refusal and the generic bind failure all read a bind through this one, so a reader
31
+ * never sees the same bind described two ways. */
32
+ export declare function bindLabel(host: string | undefined, port: number): string;
33
+ /** The address a local client should dial for a serve bound to `host` (control.json, the ready log). */
34
+ export declare function clientHost(host: string | undefined): string;
package/dist/bind.js ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The ONE reading of a bind address, shared by everything that parses, binds, warns about, or ships
3
+ * one (the CLI flag, `http.host` validation, the Node host, the deploy pre-flight). Engine- and
4
+ * host-neutral on purpose: a bind address is a plain value, and two parsers of it would disagree.
5
+ */
6
+ import { isIP } from "node:net";
7
+ /** Lowercase, unbracketed, IPv4-mapped IPv6 reduced to its IPv4 form — the form the checks below read.
8
+ * Brackets come off only as a PAIR: a half-bracketed `[::1` must stay invalid, not become an address. */
9
+ function normalize(host) {
10
+ return host
11
+ .toLowerCase()
12
+ .replace(/^\[(.+)]$/, "$1")
13
+ .replace(/^::ffff:/, "");
14
+ }
15
+ /** A bindable host: an IP literal (v4/v6, brackets optional) or "localhost". No other DNS name — a
16
+ * bind address must be an address of THIS machine, and resolving one is not this module's job. */
17
+ export function isBindAddress(host) {
18
+ const h = normalize(host);
19
+ return h === "localhost" || isIP(h) !== 0;
20
+ }
21
+ /**
22
+ * The ADDRESS form of an accepted bind value: `localhost` becomes `127.0.0.1`, everything else is
23
+ * already an address. Applied where a value ENTERS (the flag, `http.host`), so nothing downstream ever
24
+ * holds a name — not deferring the resolution but removing it, which is the module's whole point.
25
+ *
26
+ * Two things go wrong otherwise, and both are silent. `server.listen` hands the name to `dns.lookup`,
27
+ * which picks ONE of 127.0.0.1/::1 by rules we do not control — so what got bound is unknown here. And
28
+ * `clientHost` would then write that NAME into control.json and the copyable curl, where a consumer
29
+ * resolves it again, possibly to the other one.
30
+ */
31
+ export function bindAddress(host) {
32
+ return normalize(host) === "localhost" ? "127.0.0.1" : host;
33
+ }
34
+ /**
35
+ * How far a bind address reaches: `wildcard` (unset or all-interfaces) reaches every interface and
36
+ * answers on loopback too; `loopback` is this machine only; `specific` is one interface, reachable
37
+ * only as itself. Loopback covers the whole reserved range (127/8, ::1) — a `127.0.0.2` bind is no
38
+ * more LAN-reachable than `127.0.0.1`.
39
+ */
40
+ export function classifyBind(host) {
41
+ if (host === undefined)
42
+ return "wildcard";
43
+ const h = normalize(host);
44
+ if (h === "0.0.0.0" || h === "::" || h === "::0")
45
+ return "wildcard";
46
+ if (h === "localhost" || h === "::1" || /^127\.\d+\.\d+\.\d+$/.test(h))
47
+ return "loopback";
48
+ return "specific";
49
+ }
50
+ /**
51
+ * Does a serve bound to `host` answer a dial of the NAME `localhost`? Only the addresses that name
52
+ * resolves to (127.0.0.1 / ::1) and a wildcard bind do — `127.0.0.2` is loopback yet unreachable that
53
+ * way. cloudflared dials by name, so `--tunnel` needs this question, not `classifyBind`'s reach.
54
+ */
55
+ export function answersLocalhost(host) {
56
+ if (classifyBind(host) === "wildcard")
57
+ return true;
58
+ const h = normalize(host);
59
+ return h === "localhost" || h === "127.0.0.1" || h === "::1";
60
+ }
61
+ /** How to NAME a bind in a message: the wildcard is every interface, so calling it one address would
62
+ * understate it; anything else is dialable as itself. THE renderer — the ready lines, the
63
+ * already-in-use refusal and the generic bind failure all read a bind through this one, so a reader
64
+ * never sees the same bind described two ways. */
65
+ export function bindLabel(host, port) {
66
+ return classifyBind(host) === "wildcard" ? `port ${port}` : `${clientHost(host)}:${port}`;
67
+ }
68
+ /** The address a local client should dial for a serve bound to `host` (control.json, the ready log). */
69
+ export function clientHost(host) {
70
+ if (classifyBind(host) === "wildcard")
71
+ return "127.0.0.1";
72
+ // biome-ignore lint/style/noNonNullAssertion: only a wildcard bind leaves host undefined
73
+ return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
74
+ }
@@ -126,17 +126,25 @@ export function createStateSync(options) {
126
126
  let looping = false;
127
127
  const runRestore = async (urls) => {
128
128
  const res = await doFetch(urls.getUrl, { method: "GET" });
129
- // ONLY a proven 404 is "first deploy". The signer holds s3:GetObject on exactly this key, so a
130
- // missing object answers NoSuchKey/404; a 403 means an expired or malformed signature, or a
131
- // revoked permission — i.e. the snapshot may well exist. Reading 403 as "absent" would serve an
132
- // empty agent and then overwrite the real snapshot with that emptiness.
129
+ // ONLY a proven 404 is "first deploy". A missing key answers 404 only because the generated
130
+ // template grants the signer s3:ListBucket on the snapshot prefix without it S3 folds "absent"
131
+ // into 403 (anti-enumeration). A 403 therefore means an expired or malformed signature, a
132
+ // revoked permission, or a template from before that grant — i.e. the snapshot may well exist.
133
+ // Reading 403 as "absent" would serve an empty agent and then overwrite the real snapshot with
134
+ // that emptiness.
133
135
  if (res.status === 404) {
134
136
  log.info("[agentcore] no state snapshot yet — starting from an empty state root (first deploy)");
135
137
  restored = true;
136
138
  return;
137
139
  }
138
- if (!res.ok)
139
- throw new Error(`state snapshot GET failed: ${res.status}`);
140
+ if (!res.ok) {
141
+ const hint = res.status === 403
142
+ ? " (an expired presigned URL, a revoked permission, or a template generated before the " +
143
+ "ForwarderRole granted s3:ListBucket — S3 answers 403 even for a MISSING first-deploy " +
144
+ "snapshot without it; regenerate with `fastagent deploy agentcore --force` and redeploy)"
145
+ : "";
146
+ throw new Error(`state snapshot GET failed: ${res.status}${hint}`);
147
+ }
140
148
  const written = await unpackIntoStateRoot(stateRoot, Buffer.from(await res.arrayBuffer()));
141
149
  log.info(`[agentcore] restored ${written} state file(s) from the snapshot`);
142
150
  restored = true;
@@ -13,3 +13,16 @@ export interface DeployOptions {
13
13
  input?: boolean;
14
14
  }
15
15
  export declare function runDeploy(host: DeployHost, dirArg: string, opts: DeployOptions): Promise<void>;
16
+ /**
17
+ * Write the plan's artifacts under `target`, honouring the ownership rule: a file we did not generate is
18
+ * never touched, ours is kept unless `--force`. Exported for its own test — this is a four-branch state
19
+ * machine over (exists, ours, force) that used to be proven by spawning the CLI eight times, which is
20
+ * command LOGIC re-run through a subprocess (see vitest.config.ts) and the suite's slowest test.
21
+ */
22
+ export declare function writeArtifacts(target: string, artifacts: {
23
+ path: string;
24
+ content: string;
25
+ }[], options: {
26
+ force: boolean;
27
+ alwaysWrite?: string[];
28
+ }): Promise<void>;
@@ -413,7 +413,13 @@ function isOurArtifact(path, content) {
413
413
  return isGeneratedAgentcoreTemplate(content);
414
414
  return false;
415
415
  }
416
- async function writeArtifacts(target, artifacts, options) {
416
+ /**
417
+ * Write the plan's artifacts under `target`, honouring the ownership rule: a file we did not generate is
418
+ * never touched, ours is kept unless `--force`. Exported for its own test — this is a four-branch state
419
+ * machine over (exists, ours, force) that used to be proven by spawning the CLI eight times, which is
420
+ * command LOGIC re-run through a subprocess (see vitest.config.ts) and the suite's slowest test.
421
+ */
422
+ export async function writeArtifacts(target, artifacts, options) {
417
423
  for (const a of artifacts) {
418
424
  const abs = join(target, a.path);
419
425
  // Pure build output, not operator-owned configuration. It must track the generated template/runbook.
@@ -1,5 +1,6 @@
1
1
  export interface DevOptions {
2
2
  port?: string;
3
+ bind?: string;
3
4
  model?: string;
4
5
  authPath?: string;
5
6
  /** false ⇔ `--no-watch`. */
@@ -12,9 +12,10 @@ import { setLogLevel } from "../../log.js";
12
12
  import { logAgentLoop } from "../../observe.js";
13
13
  import { installProxyFetch } from "../../proxy.js";
14
14
  import { workspaceHint } from "../../paths.js";
15
+ import { bindAddress } from "../../bind.js";
15
16
  import { failStartup, placementOrExit } from "../fail.js";
16
- import { maybeTunnel, mountSessionControl, routesFor, serve, startSchedules } from "../serve.js";
17
- import { parsePort, reportAuth, reportLine, resolveFirstRunModel, reportWorkspaceHint } from "../shared.js";
17
+ import { assertTunnelBindable, maybeTunnel, mountSessionControl, routesFor, serve, startSchedules } from "../serve.js";
18
+ import { parseBind, parsePort, reportAuth, reportLine, resolveFirstRunModel, reportWorkspaceHint } from "../shared.js";
18
19
  export async function runDev(dirArg, opts) {
19
20
  const dir = resolve(dirArg);
20
21
  const placement = placementOrExit(dir);
@@ -33,12 +34,16 @@ export async function runDev(dirArg, opts) {
33
34
  await serveOnce(dir, opts);
34
35
  return;
35
36
  }
36
- parsePort(opts.port, "--port", "flag"); // flag-shape check before spawning
37
+ parsePort(opts.port, "--port", "flag"); // flag-shape checks before spawning
38
+ // The --bind/--tunnel conflict is decidable from flags alone: refuse it HERE, before a worker and a
39
+ // tunnel exist. The worker repeats the check because `http.host` can supply the address instead.
40
+ assertTunnelBindable(parseBind(opts.bind), opts.tunnel ?? false, "flag");
37
41
  await runDevSupervisor(placement, { tunnel: opts.tunnel ?? false });
38
42
  }
39
43
  /** Assemble the agent and serve it once (the dev worker; also the --no-watch path). */
40
44
  async function serveOnce(dir, opts) {
41
45
  const portFlag = parsePort(opts.port, "--port", "flag");
46
+ const bindFlag = parseBind(opts.bind);
42
47
  loadDotEnv(placementOrExit(dir).agentDir);
43
48
  installProxyFetch();
44
49
  const a = await createPiAgentFromDir(dir, {
@@ -57,12 +62,18 @@ async function serveOnce(dir, opts) {
57
62
  // out in start (level info), keeping end-user content out of production logs. Wired in both postures.
58
63
  const traced = logAgentLoop(a.agent);
59
64
  const routed = await routesFor(a.agentDir, traced, a.stateRoot, a.sessionControl).catch(failStartup);
65
+ // `http.host` enters here the way the flag enters `parseBind` — through `bindAddress`, so a
66
+ // configured `localhost` is an ADDRESS by the time anything binds, renders or dials it.
67
+ const configured = a.config.http?.host;
68
+ const host = bindFlag ?? (configured === undefined ? undefined : bindAddress(configured));
69
+ assertTunnelBindable(host, opts.tunnel ?? false, bindFlag ? "flag" : "config");
60
70
  const withControl = mountSessionControl(routed.routes, a.sessionControl, a.stateRoot, {
61
71
  tunnel: opts.tunnel ?? false,
62
72
  agent: traced, // the remote data plane (POST /control/invoke) drives the SAME traced agent
73
+ host,
63
74
  });
64
75
  await startSchedules(a.agentDir, traced, a.stateRoot, a.config.selfSchedule ?? false);
65
- serve({ ...routed, routes: withControl.routes }, portFlag ?? a.config.http?.port ?? 8787, (p) => {
76
+ serve({ ...routed, routes: withControl.routes }, { port: portFlag ?? a.config.http?.port ?? 8787, host }, (p) => {
66
77
  withControl.announce(p);
67
78
  maybeTunnel(a.agentDir, routed.routeChannels, p, opts.tunnel ?? false, a.stateRoot);
68
79
  });
@@ -24,7 +24,7 @@ export async function runInfo(dirArg, opts) {
24
24
  // tool), is isolated the same way everywhere (G2): info, dev, AND start report it and keep going with
25
25
  // the tools that loaded. The `error`/`.catch` below only fires for a whole-load fault (an unreadable
26
26
  // tools/ dir), not a single bad file.
27
- const tools = await resolveAgentTools(config, agentDir, workspace)
27
+ const tools = await resolveAgentTools(config, agentDir)
28
28
  .then((r) => ({
29
29
  names: r.toolNames,
30
30
  deferred: r.deferredToolNames,
@@ -1,5 +1,6 @@
1
1
  export interface StartOptions {
2
2
  port?: string;
3
+ bind?: string;
3
4
  model?: string;
4
5
  sessionsDir?: string;
5
6
  authPath?: string;
@@ -17,14 +17,16 @@ import { setWakeupsSink } from "../../schedule/wakeups.js";
17
17
  import { logAgentLoop } from "../../observe.js";
18
18
  import { installProxyFetch } from "../../proxy.js";
19
19
  import { exists } from "../../paths.js";
20
+ import { bindAddress } from "../../bind.js";
20
21
  import { failStartup, placementOrExit } from "../fail.js";
21
- import { maybeTunnel, mountAgentcore, mountSessionControl, routesFor, serve, startSchedules } from "../serve.js";
22
- import { parsePort, reportAuth, reportLine, resolveFirstRunModel, reportWorkspaceHint } from "../shared.js";
22
+ import { assertTunnelBindable, maybeTunnel, mountAgentcore, mountSessionControl, routesFor, serve, startSchedules, } from "../serve.js";
23
+ import { parseBind, parsePort, reportAuth, reportLine, resolveFirstRunModel, reportWorkspaceHint } from "../shared.js";
23
24
  export async function runStart(dirArg, opts) {
24
25
  const dir = resolve(dirArg);
25
26
  // Flag validation first: a bad --port is a USAGE error (exit 2), and reporting it must not depend on
26
27
  // the directory being an agent (which is a runtime/environment failure, exit 1).
27
28
  const portFlag = parsePort(opts.port, "--port", "flag");
29
+ const bindFlag = parseBind(opts.bind);
28
30
  const placement = placementOrExit(dir);
29
31
  setLogLevel("info"); // production posture: info+, the debug turn trace (and its end-user content) gated out
30
32
  loadDotEnv(placement.agentDir);
@@ -88,9 +90,15 @@ export async function runStart(dirArg, opts) {
88
90
  // Same debug turn trace as dev; gated out here by the info level (see dev.ts serveOnce).
89
91
  const traced = logAgentLoop(agent);
90
92
  const routed = await routesFor(agentDir, traced, stateRoot, sessionControl, { builtinInvoke: !agentcore }).catch(failStartup);
93
+ // `http.host` enters here the way the flag enters `parseBind` — through `bindAddress`, so a
94
+ // configured `localhost` is an ADDRESS by the time anything binds, renders or dials it.
95
+ const configured = config.http?.host;
96
+ const host = bindFlag ?? (configured === undefined ? undefined : bindAddress(configured));
97
+ assertTunnelBindable(host, opts.tunnel ?? false, bindFlag ? "flag" : "config");
91
98
  const withControl = mountSessionControl(routed.routes, sessionControl, stateRoot, {
92
99
  tunnel: opts.tunnel ?? false,
93
100
  agent: traced,
101
+ host,
94
102
  });
95
103
  // AgentCore + selfSchedule: register the wake-ALARM sink BEFORE the scheduler starts — the boot
96
104
  // wake pump may advance a recurring entry (a store save) and that save must already re-arm its
@@ -126,7 +134,7 @@ export async function runStart(dirArg, opts) {
126
134
  }
127
135
  log.info(`[fastagent] agentcore: serving POST /invocations + GET /ping (FASTAGENT_AGENTCORE=1)`);
128
136
  }
129
- serve({ ...routed, routes }, portFlag ?? parsePort(process.env.PORT, "PORT env", "env") ?? config.http?.port ?? 8787, (p) => {
137
+ serve({ ...routed, routes }, { port: portFlag ?? parsePort(process.env.PORT, "PORT env", "env") ?? config.http?.port ?? 8787, host }, (p) => {
130
138
  withControl.announce(p);
131
139
  maybeTunnel(agentDir, routed.routeChannels, p, opts.tunnel ?? false, stateRoot);
132
140
  });
@@ -1,5 +1,6 @@
1
1
  /** `fastagent tool <name> '<json>' [dir]`: run one tool's body directly with JSON args — no model. */
2
2
  import { resolve } from "node:path";
3
+ import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
3
4
  import { loadDotEnv } from "../../env.js";
4
5
  import { loadConfig } from "../../engines/pi/config.js";
5
6
  import { resolveAgentTools } from "../../engines/pi/create.js";
@@ -16,7 +17,7 @@ export async function runTool(name, argsJson, dirArg) {
16
17
  // The same tool set dev/start mount (defaults + config.tools + discovered, deduped), so the runner
17
18
  // exercises exactly what gets served — a shadowed tool is surfaced, not silently run. Resolve the
18
19
  // placement like the openers, so `fastagent tool` finds the SAME tools/ as dev/start.
19
- const { tools, toolCollisions, toolFailures } = await resolveAgentTools(config, agentDir, workspace).catch(failStartup);
20
+ const { tools, toolCollisions, toolFailures } = await resolveAgentTools(config, agentDir).catch(failStartup);
20
21
  for (const c of toolCollisions) {
21
22
  console.error(`[fastagent] warn: tool "${c.name}" (${c.source}) is shadowed by a default/config tool — not mounted`);
22
23
  }
@@ -25,7 +26,14 @@ export async function runTool(name, argsJson, dirArg) {
25
26
  if (!tool) {
26
27
  failStartup(new Error(`unknown tool "${name}". available: ${tools.map((t) => t.name).join(", ") || "(none)"}`));
27
28
  }
28
- const result = await turnContext.run({ cwd: workspace }, () => tool.execute(`cli-${name}`, args)).catch(failStartup);
29
+ // Two contexts, because the two tool families read different ones and this command must serve both
30
+ // exactly as serving does: fastagent's own tools take cwd/session/activation from `turnContext`
31
+ // (AsyncLocalStorage), and pi's default coding tools take the ExecutionEnv from the harness's TOOL
32
+ // context — which no harness supplies here, so this stands in for it, rooted at the same workspace.
33
+ const env = new NodeExecutionEnv({ cwd: workspace });
34
+ const result = await turnContext
35
+ .run({ cwd: workspace }, () => tool.execute(`cli-${name}`, args, undefined, undefined, { env }))
36
+ .catch(failStartup);
29
37
  const out = result?.details !== undefined
30
38
  ? result.details
31
39
  : (result?.content ?? []).map((c) => ("text" in c ? c.text : "")).join("");
@@ -27,6 +27,10 @@ const NO_INPUT = {
27
27
  description: "never prompt (CI/scripts) — missing information becomes an error instead of a question",
28
28
  };
29
29
  const PORT = { flags: "--port <n>", description: "HTTP port" };
30
+ const BIND = {
31
+ flags: "--bind <addr>",
32
+ description: "bind address (default: all interfaces; 127.0.0.1 keeps it off the LAN)",
33
+ };
30
34
  const TUNNEL = {
31
35
  flags: "--tunnel",
32
36
  description: "expose a public HTTPS URL via a Cloudflare quick tunnel (needs cloudflared) and auto-register " +
@@ -85,6 +89,7 @@ const dev = {
85
89
  args: [DIR_ARG],
86
90
  flags: [
87
91
  PORT,
92
+ BIND,
88
93
  MODEL,
89
94
  AUTH_PATH,
90
95
  { flags: "--no-watch", description: "serve once, no file-watching" },
@@ -97,6 +102,7 @@ const dev = {
97
102
  ],
98
103
  run: async (args, f) => (await import("./commands/dev.js")).runDev(args[0], {
99
104
  port: f.port,
105
+ bind: f.bind,
100
106
  model: f.model,
101
107
  authPath: f.authPath,
102
108
  watch: f.watch !== false,
@@ -218,6 +224,7 @@ const start = {
218
224
  args: [DIR_ARG],
219
225
  flags: [
220
226
  PORT,
227
+ BIND,
221
228
  MODEL,
222
229
  { flags: "--sessions-dir <dir>", description: "sessions directory override" },
223
230
  AUTH_PATH,
@@ -230,6 +237,7 @@ const start = {
230
237
  ],
231
238
  notes: "Precedence chains:\n" +
232
239
  " port: --port > PORT env > fastagent.config.ts http.port > 8787\n" +
240
+ " bind: --bind > fastagent.config.ts http.host > all interfaces\n" +
233
241
  " state: FASTAGENT_STATE_DIR > <agent dir>/.state — mutable machine state\n" +
234
242
  " (sessions, channel state, schedule state); point it at a mounted\n" +
235
243
  " volume so a redeploy that replaces the directory never wipes it\n" +
@@ -240,6 +248,7 @@ const start = {
240
248
  " share one credential across projects)",
241
249
  run: async (args, f) => (await import("./commands/start.js")).runStart(args[0], {
242
250
  port: f.port,
251
+ bind: f.bind,
243
252
  model: f.model,
244
253
  sessionsDir: f.sessionsDir,
245
254
  authPath: f.authPath,
@@ -32,6 +32,7 @@ export declare function routesFor(agentDir: string, agent: Agent, stateRoot: str
32
32
  export declare function mountSessionControl(routes: Routes, control: SessionControl | undefined, stateRoot: string, options?: {
33
33
  tunnel?: boolean;
34
34
  agent?: Agent;
35
+ host?: string;
35
36
  }): {
36
37
  routes: Routes;
37
38
  announce: (boundPort: number) => void;
@@ -49,12 +50,35 @@ export declare function mountAgentcore(routes: Routes, options: {
49
50
  schedules: LoadedSchedule[];
50
51
  onStateReady?: () => void;
51
52
  }): Routes;
53
+ /**
54
+ * Refuse `--tunnel` with a bind that cloudflared cannot reach: it dials the NAME `localhost:<port>`
55
+ * (the dev supervisor's tunnel too), so anything outside `127.0.0.1`/`::1`/wildcard — including a
56
+ * `127.0.0.2` bind, loopback though it is — would leave the tunnel up and 502ing every request.
57
+ * Checked BEFORE the bind (the flag alone pre-spawn in `dev`, again once config is loaded, since
58
+ * `http.host` can carry the address), so the failure is clean rather than a live-but-broken public URL.
59
+ * `source` decides the exit code, per fail.ts: a flag COMBINATION is a usage error (2), a value that
60
+ * came from config is broken runtime configuration (1).
61
+ */
62
+ export declare function assertTunnelBindable(host: string | undefined, tunnel: boolean, source: "flag" | "config"): void;
63
+ /**
64
+ * The startup lines that name WHERE the serve is: the bind report, and the curl the reader copies.
65
+ * ONE function because they are one message — they were two, and `--bind` updated the first while the
66
+ * second went on dialing `localhost`, which is precisely what a non-wildcard bind stops answering. Now
67
+ * neither can be changed without the other in view, and the address has a single derivation.
68
+ *
69
+ * A wildcard bind is every interface, and naming one address there would understate it — but the curl
70
+ * still needs one to dial, which is what `clientHost` gives (loopback for a wildcard, itself otherwise).
71
+ */
72
+ export declare function readyAddressLines(host: string | undefined, boundPort: number, builtinInvoke: boolean): string[];
52
73
  /**
53
74
  * Bind HTTP, open long-connection channels, and report ready only when both forms are usable. Each
54
75
  * adapter owns reconnects; a terminal close rejects `closed` and fails the process visibly. Abort is
55
- * the sole clean-shutdown command.
76
+ * the sole clean-shutdown command. `host` unset binds all interfaces.
56
77
  */
57
- export declare function serve(surface: ServingSurface, port: number, onListening?: (boundPort: number) => void): void;
78
+ export declare function serve(surface: ServingSurface, bind: {
79
+ port: number;
80
+ host?: string;
81
+ }, onListening?: (boundPort: number) => void): void;
58
82
  /** Start a Cloudflare tunnel for route channels only. */
59
83
  export declare function maybeTunnel(agentDir: string, routeChannels: string[], boundPort: number, tunnel: boolean, stateRoot?: string): void;
60
84
  /**
package/dist/cli/serve.js CHANGED
@@ -12,13 +12,14 @@ import { INVOKE_EXAMPLE_BODY, createInvokeHandler } from "../channels/http.js";
12
12
  import { text } from "../channels/respond.js";
13
13
  import { loadChannels } from "../engines/pi/channel.js";
14
14
  import { reportModuleLoadFailures } from "../engines/pi/report.js";
15
+ import { answersLocalhost, bindLabel, classifyBind, clientHost } from "../bind.js";
15
16
  import { parseRouteKey, router, serveNode } from "../host/node.js";
16
17
  import { log } from "../log.js";
17
18
  import { openExternalUrl } from "../open-url.js";
18
19
  import { loadSchedules } from "../schedule/discover.js";
19
20
  import { createScheduler, fireScheduleOnce } from "../schedule/scheduler.js";
20
21
  import { announceWebhooks, startCloudflareTunnel } from "../tunnel.js";
21
- import { failStartup } from "./fail.js";
22
+ import { failStartup, failUsage } from "./fail.js";
22
23
  /**
23
24
  * The surface this deployment serves: default `GET /health` plus discovered channels, or the default
24
25
  * POST `/invoke` only when neither a route nor a long-connection channel was declared.
@@ -90,15 +91,22 @@ export function mountSessionControl(routes, control, stateRoot, options = {}) {
90
91
  // Atomic (tmp+rename, the state.ts pattern): attach re-reads this file exactly during the
91
92
  // restart window — a torn read would be misdiagnosed as "serve gone".
92
93
  const tmp = `${path}.tmp`;
93
- writeFileSync(tmp, `${JSON.stringify({ url: `http://127.0.0.1:${boundPort}`, token })}\n`, { mode: 0o600 });
94
+ writeFileSync(tmp, `${JSON.stringify({ url: `http://${clientHost(options.host)}:${boundPort}`, token })}\n`, {
95
+ mode: 0o600,
96
+ });
94
97
  chmodSync(tmp, 0o600); // an existing file keeps its old mode on rewrite — pin it
95
98
  renameSync(tmp, path);
96
99
  log.info(`[fastagent] session control on /control/* (token in ${path})`);
97
- // The serve binds ALL interfaces (containers require it), so /control/* is LAN-reachable
98
- // with the bearer token as the only protection — the tunnel and deploy paths warn loudly,
99
- // and the LAN path must not be the silent third way past the local trust story.
100
- log.warn("[fastagent] the port binds all interfaces: /control/* is reachable on your LAN, protected only by " +
101
- "the bearer token — firewall the port or wrap it for real exposure (docs: design §14)");
100
+ // The serve binds ALL interfaces by DEFAULT (containers require it), so /control/* is
101
+ // LAN-reachable with the bearer token as the only protection — the tunnel and deploy paths warn
102
+ // loudly, and the LAN path must not be the silent third way past the local trust story. A
103
+ // loopback bind closes exactly that reach, so it earns silence.
104
+ const bind = classifyBind(options.host);
105
+ if (bind !== "loopback") {
106
+ log.warn(`[fastagent] the port binds ${bind === "wildcard" ? "all interfaces" : `${options.host} (off this machine)`}: ` +
107
+ "/control/* is reachable on your LAN, protected only by the bearer token — bind loopback " +
108
+ "(--bind 127.0.0.1), firewall the port, or wrap it for real exposure (docs: design §14)");
109
+ }
102
110
  if (options.tunnel) {
103
111
  // Local trust = the token + its file permissions; --tunnel takes the whole port PUBLIC
104
112
  // (beyond even the LAN reach the mount already warned about). The operator asked for the tunnel (webhooks), but must not DISCOVER the control
@@ -175,13 +183,55 @@ export function mountAgentcore(routes, options) {
175
183
  }
176
184
  return { ...routes, ...mounted };
177
185
  }
186
+ /**
187
+ * Refuse `--tunnel` with a bind that cloudflared cannot reach: it dials the NAME `localhost:<port>`
188
+ * (the dev supervisor's tunnel too), so anything outside `127.0.0.1`/`::1`/wildcard — including a
189
+ * `127.0.0.2` bind, loopback though it is — would leave the tunnel up and 502ing every request.
190
+ * Checked BEFORE the bind (the flag alone pre-spawn in `dev`, again once config is loaded, since
191
+ * `http.host` can carry the address), so the failure is clean rather than a live-but-broken public URL.
192
+ * `source` decides the exit code, per fail.ts: a flag COMBINATION is a usage error (2), a value that
193
+ * came from config is broken runtime configuration (1).
194
+ */
195
+ export function assertTunnelBindable(host, tunnel, source) {
196
+ if (!tunnel || answersLocalhost(host))
197
+ return;
198
+ // Name the source, not just the exit code: under `config` there is no `--bind` to change and no flag
199
+ // to drop, so flag-only wording would send the reader looking for something they never typed.
200
+ const fix = source === "flag"
201
+ ? "bind 0.0.0.0 (or 127.0.0.1), or drop --tunnel"
202
+ : "set http.host to 0.0.0.0 (or 127.0.0.1) in fastagent.config.*, override it with --bind, or drop --tunnel";
203
+ const message = `--tunnel reaches the serve by dialing localhost, which the bind address ${host} does not answer — ${fix}`;
204
+ if (source === "flag")
205
+ failUsage(message);
206
+ failStartup(new Error(message));
207
+ }
208
+ /**
209
+ * The startup lines that name WHERE the serve is: the bind report, and the curl the reader copies.
210
+ * ONE function because they are one message — they were two, and `--bind` updated the first while the
211
+ * second went on dialing `localhost`, which is precisely what a non-wildcard bind stops answering. Now
212
+ * neither can be changed without the other in view, and the address has a single derivation.
213
+ *
214
+ * A wildcard bind is every interface, and naming one address there would understate it — but the curl
215
+ * still needs one to dial, which is what `clientHost` gives (loopback for a wildcard, itself otherwise).
216
+ */
217
+ export function readyAddressLines(host, boundPort, builtinInvoke) {
218
+ const dial = `${clientHost(host)}:${boundPort}`;
219
+ const lines = [
220
+ `[fastagent] http host on ${classifyBind(host) === "wildcard" ? `:${boundPort} (all interfaces)` : bindLabel(host, boundPort)}`,
221
+ ];
222
+ if (builtinInvoke) {
223
+ lines.push(`[fastagent] try it: curl -s ${dial}/invoke -X POST -H 'content-type: application/json' -d '${INVOKE_EXAMPLE_BODY}'`);
224
+ }
225
+ return lines;
226
+ }
178
227
  /**
179
228
  * Bind HTTP, open long-connection channels, and report ready only when both forms are usable. Each
180
229
  * adapter owns reconnects; a terminal close rejects `closed` and fails the process visibly. Abort is
181
- * the sole clean-shutdown command.
230
+ * the sole clean-shutdown command. `host` unset binds all interfaces.
182
231
  */
183
- export function serve(surface, port, onListening) {
184
- const hosted = serveNode(router(surface.routes), { port });
232
+ export function serve(surface, bind, onListening) {
233
+ const { port, host } = bind;
234
+ const hosted = serveNode(router(surface.routes), { port, host });
185
235
  const abort = new AbortController();
186
236
  let stopping = false;
187
237
  const stop = (exitCode) => {
@@ -236,14 +286,12 @@ export function serve(surface, port, onListening) {
236
286
  port: boundPort,
237
287
  routeChannels: surface.routeChannels,
238
288
  });
239
- log.info(`[fastagent] http host on :${boundPort}`);
289
+ for (const line of readyAddressLines(host, boundPort, surface.builtinInvoke))
290
+ log.info(line);
240
291
  log.info(`[fastagent] routes: ${Object.keys(surface.routes).join(", ") || "(none)"}`);
241
292
  if (surface.longConnections.length > 0) {
242
293
  log.info(`[fastagent] long connections: ${surface.longConnections.map((connection) => connection.name).join(", ")}`);
243
294
  }
244
- if (surface.builtinInvoke) {
245
- log.info(`[fastagent] try it: curl -s localhost:${boundPort}/invoke -X POST -H 'content-type: application/json' -d '${INVOKE_EXAMPLE_BODY}'`);
246
- }
247
295
  onListening?.(boundPort);
248
296
  }
249
297
  catch (error) {
@@ -254,9 +302,15 @@ export function serve(surface, port, onListening) {
254
302
  failStartup(error);
255
303
  }
256
304
  }, (error) => {
257
- if (error.code === "EADDRINUSE")
258
- failStartup(new Error(`port ${port} is already in use; choose another with --port`));
259
- failStartup(new Error(`cannot bind http channel on :${port}: ${error.message}`));
305
+ if (error.code === "EADDRINUSE") {
306
+ // With a bind address the port is only taken ON THAT interface, so moving the bind is as valid
307
+ // a fix as moving the port.
308
+ failStartup(new Error(`${bindLabel(host, port)} is already in use; choose another with ` +
309
+ `--port${classifyBind(host) === "wildcard" ? "" : " or --bind"}`));
310
+ }
311
+ // Through `bindLabel`, like every other message about a bind: hand-concatenating gives `:::8787`
312
+ // for an IPv6 bind and a bare `:8787` for the wildcard, which reads as an explicit bind of nothing.
313
+ failStartup(new Error(`cannot bind http channel on ${bindLabel(host, port)}: ${error.message}`));
260
314
  });
261
315
  }
262
316
  /** Start a Cloudflare tunnel for route channels only. */
@@ -20,6 +20,12 @@ export declare function isInteractive(): boolean;
20
20
  * bad `PORT` env is broken runtime configuration (1).
21
21
  */
22
22
  export declare function parsePort(value: string | undefined, source: string, from: "flag" | "env"): number | undefined;
23
+ /**
24
+ * Parse a `--bind` address: empty/whitespace is "not set" → undefined (the `??` chain falls through to
25
+ * config, then all interfaces). An unbindable string is a USAGE error (2) — caught here rather than as
26
+ * a node bind failure, or worse, as a "the interface you bound" diagnostic downstream.
27
+ */
28
+ export declare function parseBind(value: string | undefined): string | undefined;
23
29
  /** Report which source provides the model's credentials, surfacing a remediation hint at startup. Non-blocking. */
24
30
  export declare function reportAuth(modelSpec: string, authPath: string): Promise<void>;
25
31
  /**
@@ -14,6 +14,7 @@ import { createPiModels, probeApiKey, probeAuthSource, providerAuthStatuses } fr
14
14
  import { formatAuthReport } from "./auth-view.js";
15
15
  import { log } from "../log.js";
16
16
  import { openExternalUrl } from "../open-url.js";
17
+ import { bindAddress, isBindAddress } from "../bind.js";
17
18
  import { failStartup, failUsage } from "./fail.js";
18
19
  /**
19
20
  * The padded label writer for the STARTUP report (`dev`/`start`, stderr via the log level). Hand-spaced
@@ -56,6 +57,19 @@ export function parsePort(value, source, from) {
56
57
  }
57
58
  return Number(trimmed);
58
59
  }
60
+ /**
61
+ * Parse a `--bind` address: empty/whitespace is "not set" → undefined (the `??` chain falls through to
62
+ * config, then all interfaces). An unbindable string is a USAGE error (2) — caught here rather than as
63
+ * a node bind failure, or worse, as a "the interface you bound" diagnostic downstream.
64
+ */
65
+ export function parseBind(value) {
66
+ const trimmed = value?.trim();
67
+ if (!trimmed)
68
+ return undefined;
69
+ if (!isBindAddress(trimmed))
70
+ failUsage(`invalid --bind "${value}": must be an IP address or "localhost"`);
71
+ return bindAddress(trimmed); // a name never travels past this point — see bind.ts
72
+ }
59
73
  /** Report which source provides the model's credentials, surfacing a remediation hint at startup. Non-blocking. */
60
74
  export async function reportAuth(modelSpec, authPath) {
61
75
  const provider = providerOf(modelSpec);