@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.
- package/README.md +1 -1
- package/dist/bind.d.ts +34 -0
- package/dist/bind.js +74 -0
- package/dist/channels/agentcore-state.js +14 -6
- package/dist/cli/commands/deploy.d.ts +13 -0
- package/dist/cli/commands/deploy.js +7 -1
- package/dist/cli/commands/dev.d.ts +1 -0
- package/dist/cli/commands/dev.js +15 -4
- package/dist/cli/commands/info.js +1 -1
- package/dist/cli/commands/start.d.ts +1 -0
- package/dist/cli/commands/start.js +11 -3
- package/dist/cli/commands/tool.js +10 -2
- package/dist/cli/program.js +9 -0
- package/dist/cli/serve.d.ts +26 -2
- package/dist/cli/serve.js +71 -17
- package/dist/cli/shared.d.ts +6 -0
- package/dist/cli/shared.js +14 -0
- package/dist/deploy/agentcore/plan.js +1 -1
- package/dist/deploy/preflight.js +18 -0
- package/dist/engines/pi/config.d.ts +6 -2
- package/dist/engines/pi/config.js +8 -2
- package/dist/engines/pi/create.d.ts +25 -17
- package/dist/engines/pi/create.js +37 -14
- package/dist/engines/pi/harness.d.ts +19 -5
- package/dist/engines/pi/harness.js +3 -5
- package/dist/engines/pi/open.d.ts +2 -2
- package/dist/engines/pi/open.js +1 -1
- package/dist/engines/pi/read-image.d.ts +4 -0
- package/dist/engines/pi/read-image.js +62 -0
- package/dist/engines/pi/search-tools.d.ts +6 -4
- package/dist/engines/pi/search-tools.js +3 -1
- package/dist/engines/pi/session-builder.js +7 -2
- package/dist/engines/pi/tool.d.ts +13 -5
- package/dist/engines/pi/wake-tool.d.ts +3 -3
- package/dist/host/node.d.ts +2 -0
- package/dist/host/node.js +2 -1
- package/dist/pi.d.ts +1 -1
- 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`
|
|
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".
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/dist/cli/commands/dev.js
CHANGED
|
@@ -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
|
|
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
|
|
27
|
+
const tools = await resolveAgentTools(config, agentDir)
|
|
28
28
|
.then((r) => ({
|
|
29
29
|
names: r.toolNames,
|
|
30
30
|
deferred: r.deferredToolNames,
|
|
@@ -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
|
|
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
|
-
|
|
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("");
|
package/dist/cli/program.js
CHANGED
|
@@ -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,
|
package/dist/cli/serve.d.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
98
|
-
// with the bearer token as the only protection — the tunnel and deploy paths warn
|
|
99
|
-
// and the LAN path must not be the silent third way past the local trust story.
|
|
100
|
-
|
|
101
|
-
|
|
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,
|
|
184
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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. */
|
package/dist/cli/shared.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/cli/shared.js
CHANGED
|
@@ -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);
|