@fastagent-sh/fastagent 0.16.0 → 0.16.2

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 (54) 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 +21 -6
  5. package/dist/channels/control.js +3 -0
  6. package/dist/cli/commands/attach.d.ts +11 -1
  7. package/dist/cli/commands/attach.js +30 -2
  8. package/dist/cli/commands/deploy.d.ts +13 -0
  9. package/dist/cli/commands/deploy.js +18 -6
  10. package/dist/cli/commands/dev.d.ts +1 -0
  11. package/dist/cli/commands/dev.js +15 -4
  12. package/dist/cli/commands/info.js +1 -1
  13. package/dist/cli/commands/logs.d.ts +6 -0
  14. package/dist/cli/commands/logs.js +27 -0
  15. package/dist/cli/commands/start.d.ts +1 -0
  16. package/dist/cli/commands/start.js +11 -3
  17. package/dist/cli/commands/tool.js +10 -2
  18. package/dist/cli/program.js +35 -0
  19. package/dist/cli/serve.d.ts +26 -2
  20. package/dist/cli/serve.js +71 -17
  21. package/dist/cli/shared.d.ts +6 -0
  22. package/dist/cli/shared.js +14 -0
  23. package/dist/deploy/agentcore/logs.d.ts +30 -0
  24. package/dist/deploy/agentcore/logs.js +115 -0
  25. package/dist/deploy/agentcore/plan.d.ts +23 -0
  26. package/dist/deploy/agentcore/plan.js +41 -3
  27. package/dist/deploy/container.js +6 -3
  28. package/dist/deploy/preflight.js +18 -0
  29. package/dist/engines/pi/config.d.ts +7 -3
  30. package/dist/engines/pi/config.js +9 -3
  31. package/dist/engines/pi/create.d.ts +31 -18
  32. package/dist/engines/pi/create.js +46 -17
  33. package/dist/engines/pi/harness.d.ts +31 -33
  34. package/dist/engines/pi/harness.js +24 -76
  35. package/dist/engines/pi/open.d.ts +2 -2
  36. package/dist/engines/pi/open.js +2 -1
  37. package/dist/engines/pi/read-image.d.ts +4 -0
  38. package/dist/engines/pi/read-image.js +62 -0
  39. package/dist/engines/pi/search-tools.d.ts +6 -4
  40. package/dist/engines/pi/search-tools.js +3 -1
  41. package/dist/engines/pi/session-builder.js +7 -2
  42. package/dist/engines/pi/session-control.d.ts +12 -3
  43. package/dist/engines/pi/session-control.js +156 -27
  44. package/dist/engines/pi/session-settings.d.ts +51 -0
  45. package/dist/engines/pi/session-settings.js +73 -0
  46. package/dist/engines/pi/sessions.d.ts +21 -7
  47. package/dist/engines/pi/sessions.js +43 -0
  48. package/dist/engines/pi/tool.d.ts +13 -5
  49. package/dist/engines/pi/wake-tool.d.ts +3 -3
  50. package/dist/host/node.d.ts +2 -0
  51. package/dist/host/node.js +2 -1
  52. package/dist/pi.d.ts +1 -1
  53. package/dist/session.d.ts +34 -9
  54. package/package.json +4 -4
@@ -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);
@@ -0,0 +1,30 @@
1
+ /**
2
+ * AgentCore log discovery + tailing. AWS puts the container's stdout/stderr and its OTEL telemetry
3
+ * in the same per-endpoint CloudWatch log group, while the forwarder Lambda has a separate group.
4
+ * This operator surface resolves the stack's RuntimeArn, discovers the endpoint group by prefix, and
5
+ * tails ONLY `[runtime-logs]` for the runtime source — the same application lines shown locally,
6
+ * without mixing in spans/otel-rt-logs.
7
+ */
8
+ import type { CliRunner } from "../runner.ts";
9
+ export type AgentcoreLogSource = "runtime" | "forwarder";
10
+ export interface AgentcoreLogsPlan {
11
+ /** Deployment base name — stack `fastagent-<name>`, forwarder `fastagent-<name>-forwarder`. */
12
+ name: string;
13
+ source: AgentcoreLogSource;
14
+ /** AWS CLI relative/ISO-8601 window (`10m`, `2h`, ...). Defaults to the CLI's own 10 minutes. */
15
+ since?: string;
16
+ follow: boolean;
17
+ }
18
+ export type AgentcoreLogsOutcome = {
19
+ ok: true;
20
+ logGroup: string;
21
+ } | {
22
+ ok: false;
23
+ gate: string;
24
+ };
25
+ /**
26
+ * Find and tail one AgentCore log source. Discovery is dynamic rather than spelling `-DEFAULT`:
27
+ * endpoint naming belongs to AWS, and an edited stack may use a different endpoint. Runtime tailing
28
+ * filters the log STREAM prefix so OTEL/spans in the same group never pollute the application log.
29
+ */
30
+ export declare function tailAgentcoreLogs(plan: AgentcoreLogsPlan, aws: CliRunner, announce?: (message: string) => void): Promise<AgentcoreLogsOutcome>;
@@ -0,0 +1,115 @@
1
+ import { parseStackOutputs } from "./run.js";
2
+ /** Runtime id from `arn:...:runtime/<id>` — the id prefixes AgentCore's per-endpoint log group. */
3
+ function runtimeIdFromArn(arn) {
4
+ const marker = ":runtime/";
5
+ const at = arn.lastIndexOf(marker);
6
+ const id = at === -1 ? "" : arn.slice(at + marker.length);
7
+ return id && !id.includes("/") ? id : undefined;
8
+ }
9
+ function parseLogGroupNames(stdout) {
10
+ try {
11
+ const parsed = JSON.parse(stdout);
12
+ return Array.isArray(parsed) && parsed.every((v) => typeof v === "string") ? parsed : undefined;
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ }
18
+ /**
19
+ * Find and tail one AgentCore log source. Discovery is dynamic rather than spelling `-DEFAULT`:
20
+ * endpoint naming belongs to AWS, and an edited stack may use a different endpoint. Runtime tailing
21
+ * filters the log STREAM prefix so OTEL/spans in the same group never pollute the application log.
22
+ */
23
+ export async function tailAgentcoreLogs(plan, aws, announce = () => { }) {
24
+ const stack = `fastagent-${plan.name}`;
25
+ const outputsResult = await aws(["cloudformation", "describe-stacks", "--stack-name", stack, "--query", "Stacks[0].Outputs", "--output", "json"], { capture: true });
26
+ if (outputsResult.code === 127) {
27
+ return { ok: false, gate: "aws CLI not found — install AWS CLI v2: https://docs.aws.amazon.com/cli/" };
28
+ }
29
+ if (outputsResult.code !== 0) {
30
+ return {
31
+ ok: false,
32
+ gate: `could not read AgentCore stack ${stack} — deploy it first, or fix the AWS account/region shown above`,
33
+ };
34
+ }
35
+ const outputs = parseStackOutputs(outputsResult.stdout);
36
+ let prefix;
37
+ let exact;
38
+ if (plan.source === "runtime") {
39
+ const runtimeArn = outputs.RuntimeArn;
40
+ const runtimeId = runtimeArn && runtimeIdFromArn(runtimeArn);
41
+ if (!runtimeId) {
42
+ return {
43
+ ok: false,
44
+ gate: `stack ${stack} has no valid RuntimeArn output — regenerate/deploy the AgentCore stack`,
45
+ };
46
+ }
47
+ prefix = `/aws/bedrock-agentcore/runtimes/${runtimeId}-`;
48
+ }
49
+ else {
50
+ // `ForwarderUrl` is the stack's INGRESS URL, NOT proof that a forwarder Lambda exists: plan.ts
51
+ // keeps needsForwarder and needsFunctionUrl as two variables on purpose (a schedules-only topology
52
+ // may keep the forwarder and drop the public URL). So DISCOVERY decides existence below, and the
53
+ // URL output only picks which not-found sentence is true.
54
+ exact = `/aws/lambda/fastagent-${plan.name}-forwarder`;
55
+ prefix = exact;
56
+ }
57
+ const groupsResult = await aws([
58
+ "logs",
59
+ "describe-log-groups",
60
+ "--log-group-name-prefix",
61
+ prefix,
62
+ "--query",
63
+ "logGroups[].logGroupName",
64
+ "--output",
65
+ "json",
66
+ ], { capture: true });
67
+ if (groupsResult.code !== 0) {
68
+ return { ok: false, gate: "could not discover the CloudWatch log group — see the AWS error above" };
69
+ }
70
+ const groups = parseLogGroupNames(groupsResult.stdout);
71
+ if (!groups) {
72
+ return { ok: false, gate: "AWS returned an invalid CloudWatch log-group response" };
73
+ }
74
+ const matches = groups.filter((group) => (exact ? group === exact : group.startsWith(prefix))).sort();
75
+ if (matches.length === 0) {
76
+ // Absent group = never used, EXCEPT when the stack has no forwarder at all — an invoke-only
77
+ // deployment would otherwise be told to deliver a webhook it can never receive. Both facts agree
78
+ // there (no ingress URL output either), so the message can name the topology instead of a trigger.
79
+ if (plan.source === "forwarder" && !outputs.ForwarderUrl) {
80
+ return {
81
+ ok: false,
82
+ gate: `stack ${stack} has neither a forwarder log group nor an ingress URL — this looks like an invoke-only deployment, which has Runtime logs only`,
83
+ };
84
+ }
85
+ const trigger = plan.source === "runtime" ? "invoke the Runtime once" : "deliver one webhook or schedule fire";
86
+ return {
87
+ ok: false,
88
+ gate: `no ${plan.source} log group exists yet — ${trigger}, then retry (AWS creates it on first use)`,
89
+ };
90
+ }
91
+ // A generated stack has one Runtime endpoint. If an operator's edited stack has several, choosing
92
+ // one silently would show a valid but potentially WRONG agent log — list them and make the choice explicit.
93
+ if (matches.length > 1) {
94
+ return {
95
+ ok: false,
96
+ gate: `several Runtime log groups match this stack: ${matches.join(", ")} — tail the intended one with the ` +
97
+ `same stream filter this command applies: aws logs tail <group> --log-stream-name-prefix '[runtime-logs]' ` +
98
+ `(quote the prefix — [...] is a shell glob)`,
99
+ };
100
+ }
101
+ const logGroup = matches[0];
102
+ announce(`${plan.source} → ${logGroup}`);
103
+ const tailArgs = ["logs", "tail", logGroup, "--format", "short"];
104
+ if (plan.since)
105
+ tailArgs.push("--since", plan.since);
106
+ if (plan.source === "runtime")
107
+ tailArgs.push("--log-stream-name-prefix", "[runtime-logs]");
108
+ if (plan.follow)
109
+ tailArgs.push("--follow");
110
+ const tailed = await aws(tailArgs);
111
+ if (tailed.code !== 0) {
112
+ return { ok: false, gate: `aws logs tail failed for ${logGroup} — see the AWS error above` };
113
+ }
114
+ return { ok: true, logGroup };
115
+ }
@@ -37,6 +37,27 @@ export interface AgentcorePlan {
37
37
  * a fast LOCAL disk only: the platform wipes it on every runtime version update (= every deploy).
38
38
  * Durability across deploys comes from the S3 snapshot (channels/agentcore-state.ts). */
39
39
  export declare const MOUNT = "/mnt/state";
40
+ /**
41
+ * FASTAGENT_SECRETS_DIR — the seeded-then-ROTATED auth.json, deliberately INSIDE the state root
42
+ * rather than beside it.
43
+ *
44
+ * Every other host mounts a real volume and puts the two machinery dirs side by side (`/data/.state`
45
+ * + `/data/.secrets`), because there the persistence boundary is the MOUNT POINT: anything under it
46
+ * survives. AgentCore has no volume. Its persistence boundary is `packStateRoot(stateRoot)` — the one
47
+ * directory tree the S3 snapshot copies out and back (channels/agentcore-state.ts) — while {@link MOUNT}
48
+ * itself is wiped on every runtime version update, i.e. on every deploy.
49
+ *
50
+ * So the sibling layout would put credentials INSIDE the mount but OUTSIDE the snapshot: nothing
51
+ * copies them out, the platform wipes them, and the next microVM re-seeds the deploy-time copy. With
52
+ * single-use OAuth refresh tokens that is a slow-motion outage — the box works until the seeded token
53
+ * is rotated away, then loses model access with only a redeploy to restore it.
54
+ *
55
+ * Nesting is what makes agentcore-state.ts's stated contract ("restores VERBATIM — including
56
+ * auth.json") reachable at all; `packStateRoot` walks the whole tree, so no snapshot code knows about
57
+ * this. Tests assert the containment, not just the two names — the sibling spelling looks tidier and
58
+ * reintroduces the outage silently.
59
+ */
60
+ export declare const SECRETS_DIR = "/mnt/state/.secrets";
40
61
  /**
41
62
  * How long an idle session keeps its microVM. Memory is billed per second across the WHOLE session
42
63
  * — idle included, at the peak level reached — so this tail is the standing cost of every burst of
@@ -70,6 +91,8 @@ export declare const TEMPLATE_FILE = "agentcore.template.yaml";
70
91
  export declare const GENERATED_TEMPLATE_MARKER = "# Generated by `fastagent deploy agentcore`";
71
92
  /** Whether an on-disk template is fastagent-generated (vs hand-written — kept, never gated). */
72
93
  export declare function isGeneratedAgentcoreTemplate(content: string): boolean;
94
+ /** Deployment base name from the workspace basename — the ONE mapping used to find its stack later. */
95
+ export declare function agentcoreName(workspaceBasename: string): string;
73
96
  /** Runtime name (`[a-zA-Z][a-zA-Z0-9_]{0,47}`) from a dir basename. */
74
97
  export declare function toRuntimeName(basename: string): string;
75
98
  /** The ONE fixed ingress session id (webhooks + schedule fires) — ≥ 33 chars (the API minimum),
@@ -29,12 +29,34 @@
29
29
  */
30
30
  import { createHash } from "node:crypto";
31
31
  import { MAX_WEBHOOK_BODY_BYTES } from "../../channels/agentcore-limits.js";
32
+ import { SECRETS_DIRNAME } from "../../paths.js";
32
33
  import { containerArtifacts } from "../container.js";
33
34
  import { deploymentSecrets, isEnvKey } from "../secrets.js";
34
35
  /** SessionStorage mount = FASTAGENT_STATE_DIR (AgentCore requires exactly `/mnt/<one-level>`). It is
35
36
  * a fast LOCAL disk only: the platform wipes it on every runtime version update (= every deploy).
36
37
  * Durability across deploys comes from the S3 snapshot (channels/agentcore-state.ts). */
37
38
  export const MOUNT = "/mnt/state";
39
+ /**
40
+ * FASTAGENT_SECRETS_DIR — the seeded-then-ROTATED auth.json, deliberately INSIDE the state root
41
+ * rather than beside it.
42
+ *
43
+ * Every other host mounts a real volume and puts the two machinery dirs side by side (`/data/.state`
44
+ * + `/data/.secrets`), because there the persistence boundary is the MOUNT POINT: anything under it
45
+ * survives. AgentCore has no volume. Its persistence boundary is `packStateRoot(stateRoot)` — the one
46
+ * directory tree the S3 snapshot copies out and back (channels/agentcore-state.ts) — while {@link MOUNT}
47
+ * itself is wiped on every runtime version update, i.e. on every deploy.
48
+ *
49
+ * So the sibling layout would put credentials INSIDE the mount but OUTSIDE the snapshot: nothing
50
+ * copies them out, the platform wipes them, and the next microVM re-seeds the deploy-time copy. With
51
+ * single-use OAuth refresh tokens that is a slow-motion outage — the box works until the seeded token
52
+ * is rotated away, then loses model access with only a redeploy to restore it.
53
+ *
54
+ * Nesting is what makes agentcore-state.ts's stated contract ("restores VERBATIM — including
55
+ * auth.json") reachable at all; `packStateRoot` walks the whole tree, so no snapshot code knows about
56
+ * this. Tests assert the containment, not just the two names — the sibling spelling looks tidier and
57
+ * reintroduces the outage silently.
58
+ */
59
+ export const SECRETS_DIR = `${MOUNT}/${SECRETS_DIRNAME}`;
38
60
  /**
39
61
  * How long an idle session keeps its microVM. Memory is billed per second across the WHOLE session
40
62
  * — idle included, at the peak level reached — so this tail is the standing cost of every burst of
@@ -72,6 +94,13 @@ export const GENERATED_TEMPLATE_MARKER = "# Generated by `fastagent deploy agent
72
94
  export function isGeneratedAgentcoreTemplate(content) {
73
95
  return content.startsWith(GENERATED_TEMPLATE_MARKER);
74
96
  }
97
+ /** Deployment base name from the workspace basename — the ONE mapping used to find its stack later. */
98
+ export function agentcoreName(workspaceBasename) {
99
+ return (workspaceBasename
100
+ .toLowerCase()
101
+ .replace(/[^a-z0-9-]+/g, "-")
102
+ .replace(/^-+|-+$/g, "") || "agent");
103
+ }
75
104
  /** Runtime name (`[a-zA-Z][a-zA-Z0-9_]{0,47}`) from a dir basename. */
76
105
  export function toRuntimeName(basename) {
77
106
  const slug = basename.replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "");
@@ -437,6 +466,10 @@ function template(input, translated) {
437
466
  ` PORT: "8080"`, // the Runtime service contract's fixed port (config.http.port does not apply here)
438
467
  ` FASTAGENT_AGENTCORE: "1"`, // serve mounts /invocations + /ping, arms no resident cron
439
468
  ` FASTAGENT_STATE_DIR: ${MOUNT}`,
469
+ // Inside the state root on purpose — the snapshot is this host's only durable store, and it copies
470
+ // exactly one tree. See {@link SECRETS_DIR}: the sibling layout every other host uses would leave a
471
+ // rotated OAuth credential outside it, i.e. discarded with the microVM.
472
+ ` FASTAGENT_SECRETS_DIR: ${SECRETS_DIR}`,
440
473
  ];
441
474
  // The auth seed is chunked (env values max 2048 chars — see AUTH_SEED_CHUNK_SIZE): N parameters,
442
475
  // each riding its own env var; `start` reassembles them (collectAuthSeed). Empty defaults = unused.
@@ -538,7 +571,7 @@ function template(input, translated) {
538
571
  ...envLines,
539
572
  ];
540
573
  if (needsForwarder) {
541
- lines.push(``, ` ForwarderRole:`, ` Type: AWS::IAM::Role`, ` Properties:`, ` AssumeRolePolicyDocument:`, ` Version: "2012-10-17"`, ` Statement:`, ` - Effect: Allow`, ` Principal: { Service: lambda.amazonaws.com }`, ` Action: sts:AssumeRole`, ` ManagedPolicyArns: [arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]`, ` Policies:`, ` - PolicyName: invoke-runtime`, ` PolicyDocument:`, ` Version: "2012-10-17"`, ` Statement:`, ` - Effect: Allow`, ` Action: bedrock-agentcore:InvokeAgentRuntime`, ` Resource:`, ` - !GetAtt Runtime.AgentRuntimeArn`, ` - !Sub "\${Runtime.AgentRuntimeArn}/*"`, ` - Effect: Allow # mint the presigned URLs the container uses for its state snapshot`, ` Action: [s3:GetObject, s3:PutObject]`, ` Resource: !Sub arn:aws:s3:::\${StateBucket}/${STATE_KEY}`, ...(input.selfSchedule
574
+ lines.push(``, ` ForwarderRole:`, ` Type: AWS::IAM::Role`, ` Properties:`, ` AssumeRolePolicyDocument:`, ` Version: "2012-10-17"`, ` Statement:`, ` - Effect: Allow`, ` Principal: { Service: lambda.amazonaws.com }`, ` Action: sts:AssumeRole`, ` ManagedPolicyArns: [arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]`, ` Policies:`, ` - PolicyName: invoke-runtime`, ` PolicyDocument:`, ` Version: "2012-10-17"`, ` Statement:`, ` - Effect: Allow`, ` Action: bedrock-agentcore:InvokeAgentRuntime`, ` Resource:`, ` - !GetAtt Runtime.AgentRuntimeArn`, ` - !Sub "\${Runtime.AgentRuntimeArn}/*"`, ` - Effect: Allow # mint the presigned URLs the container uses for its state snapshot`, ` Action: [s3:GetObject, s3:PutObject]`, ` Resource: !Sub arn:aws:s3:::\${StateBucket}/${STATE_KEY}`, ` # Without s3:ListBucket, S3 folds "key absent" into 403 (anti-enumeration), which is`, ` # indistinguishable from a broken signature — so the container's restore contract`, ` # (agentcore-state.ts: ONLY 404 means first deploy) would dead-end every first deploy.`, ` # Scoped to the snapshot prefix: this grants "may know whether the snapshot exists",`, ` # not a listing of the whole deployment bucket.`, ` - Effect: Allow`, ` Action: s3:ListBucket`, ` Resource: !Sub arn:aws:s3:::\${StateBucket}`, ` Condition:`, ` StringLike: { s3:prefix: state/* }`, ...(input.selfSchedule
542
575
  ? [
543
576
  ` - Effect: Allow # wake alarms: mirror pending wake-ups into one-shot schedules`,
544
577
  ` Action: [scheduler:CreateSchedule, scheduler:UpdateSchedule]`,
@@ -679,7 +712,12 @@ export function planAgentcoreDeploy(input) {
679
712
  ? `# 4. Read the outputs (the runtime ARN + callback URL; it serves webhooks only when configured):`
680
713
  : `# 4. Read the outputs (the runtime ARN — this topology has NO public URL: nothing outside AWS`, ...(needsFunctionUrl
681
714
  ? []
682
- : [`# sends to it, so no Function URL is created and the agent is reachable only via SigV4).`]), `aws cloudformation describe-stacks --stack-name ${stack} --query "Stacks[0].Outputs"`);
715
+ : [`# sends to it, so no Function URL is created and the agent is reachable only via SigV4).`]), `aws cloudformation describe-stacks --stack-name ${stack} --query "Stacks[0].Outputs"`, ``, `# 5. Tail the Runtime's application stdout/stderr (same fastagent messages + log level as locally).`, `# Discovery filters to [runtime-logs], excluding OTEL/spans AWS stores in the same log group:`, `fastagent logs agentcore --follow`, ...(needsForwarder
716
+ ? [
717
+ `# The ingress transport is a separate Lambda and therefore a separate log source:`,
718
+ `fastagent logs agentcore --source forwarder --follow`,
719
+ ]
720
+ : []));
683
721
  // Model-auth guidance mirrors the other hosts: an env key became a parameter above; OAuth/stored
684
722
  // can't be read at plan time — `--run` carries it as FastagentAuthSeed.
685
723
  if (!isEnvKey(input.modelAuth)) {
@@ -716,6 +754,6 @@ export function planAgentcoreDeploy(input) {
716
754
  if (needsForwarder) {
717
755
  runbook.push(``, `# After a REDEPLOY, stop the ingress session so the new image serves immediately — a live session`, `# keeps its old compute (and the OLD image) until ${IDLE_TIMEOUT_SECONDS}s idle / the 8 h compute ceiling`, `# (\`--run\` does this automatically):`, `aws bedrock-agentcore stop-runtime-session --agent-runtime-arn <RuntimeArn> \\`, ` --runtime-session-id "${ingressSessionId(name)}"`);
718
756
  }
719
- runbook.push(``, `# Redeploy = step 1b (new forwarder key, if its code changed) + step 2 with a NEW tag + step 3.`, `# STATE: ${MOUNT} is a LOCAL disk — AWS wipes it on every runtime version update (i.e. every`, `# deploy) and after 14 idle days. What survives is the S3 snapshot under s3://${bucketHint}/${STATE_KEY}:`, `# the container restores it on its first invocation and pushes it whenever work settles. Keep that`, `# bucket and the agent keeps its sessions, channel state and pending wake-ups across deploys;`, `# delete it and the agent starts blank. (A persistent MOUNT would need EFS + VPC mode + a NAT`, `# gateway for model/channel egress — see the template comment.)`);
757
+ runbook.push(``, `# Redeploy = step 1b (new forwarder key, if its code changed) + step 2 with a NEW tag + step 3.`, `# STATE: ${MOUNT} is a LOCAL disk — AWS wipes it on every runtime version update (i.e. every`, `# deploy) and after 14 idle days. What survives is the S3 snapshot under s3://${bucketHint}/${STATE_KEY}:`, `# the container restores it on its first invocation and pushes it whenever work settles. Keep that`, `# bucket and the agent keeps its sessions, channel state and pending wake-ups across deploys;`, `# delete it and the agent starts blank. (A persistent MOUNT would need EFS + VPC mode + a NAT`, `# gateway for model/channel egress — see the template comment.)`, `# CREDENTIALS RIDE THAT SNAPSHOT TOO: FASTAGENT_SECRETS_DIR is ${SECRETS_DIR}, inside the state`, `# root, so an OAuth auth.json ROTATED on the box persists (a refresh token is single-use — without`, `# this the next microVM would re-seed the deploy-time copy and eventually fail to authenticate).`, `# The bucket is therefore credential storage: it is created with public access blocked and`, `# versioning on, and deleting it costs model access until the next deploy re-seeds.`);
720
758
  return { artifacts, runbook, untranslatableSchedules: untranslatable };
721
759
  }
@@ -128,8 +128,10 @@ CMD ["./${into("node_modules/.bin/fastagent")}", "start", "/app"]
128
128
  /** Patterns are RECURSIVE (`**​/`) on purpose — dockerignore patterns are root-anchored (unlike
129
129
  * .gitignore), and a baked workspace can hold nested projects: a bare `node_modules` would upload
130
130
  * their build-machine deps (macOS binaries!) and a bare `.env` would bake their secrets into the
131
- * image. `.secrets`/`.state` are fastagent machinery — secrets travel through the host's secret
132
- * store, state lives on the volume; neither may ever enter an image. `.cache` is generic hygiene
131
+ * image. `.secrets`/`.state` are fastagent machinery — credential contents travel through the host's
132
+ * secret store and state lives on the volume, so neither may enter an image. The two tracked secrets
133
+ * scaffolds (`.env.example` + `.gitignore`) are the narrow exception: they carry no values and must stay
134
+ * beside the shipped `.git`, or the image starts with tracked deletions. `.cache` is generic hygiene
133
135
  * (a baked project's own build cache), not a fastagent directory.
134
136
  * `.git` is deliberately SHIPPED: the deployed agent's write-back (pull/commit/push) needs the
135
137
  * repo's history+remote — the WYSIWYG bake's freshness/durability loop runs through git, driven by
@@ -145,12 +147,13 @@ const dockerignore = (input) => DOCKERIGNORE_BASE +
145
147
  .join("");
146
148
  const DOCKERIGNORE_BASE = `${GENERATED_DOCKERIGNORE_MARKER}. Delete this line to take ownership (deploy then keeps your file).
147
149
  **/node_modules
148
- **/${SECRETS_DIRNAME}
150
+ **/${SECRETS_DIRNAME}/**
149
151
  **/${STATE_DIRNAME}
150
152
  **/.cache
151
153
  **/.env
152
154
  **/.env.*
153
155
  !**/.env.example
156
+ !**/${SECRETS_DIRNAME}/.gitignore
154
157
  **/*.log
155
158
  # .git is deliberately shipped: the agent can pull to freshen content and push its work back
156
159
  # (the generated image installs the git binary when this directory ships a .git; otherwise
@@ -12,6 +12,7 @@
12
12
  import { readdir, readFile } from "node:fs/promises";
13
13
  import { basename, isAbsolute, join, relative, sep } from "node:path";
14
14
  import ignore from "ignore";
15
+ import { classifyBind } from "../bind.js";
15
16
  import { resolveAuthPath } from "../engines/pi/config.js";
16
17
  import { resolveSecretsDir, resolveStateRoot } from "../paths.js";
17
18
  import { inspectChannels } from "../engines/pi/channel.js";
@@ -341,6 +342,23 @@ export async function preflightDeploy(input) {
341
342
  shipsGit,
342
343
  };
343
344
  const port = config.http?.port ?? 8787;
345
+ // `http.host` travels in the artifact (config is what deploy ships), and any non-wildcard value that
346
+ // is right on a laptop is wrong in a container: the wildcard bind is what makes the published port,
347
+ // the health check and webhook ingress reachable at all. `--bind` is the local-only knob; config is not.
348
+ const configBind = classifyBind(config.http?.host);
349
+ if (configBind !== "wildcard") {
350
+ const issue = `fastagent.config.ts sets http.host: "${config.http?.host}" — it travels into the image, where ` +
351
+ (configBind === "loopback"
352
+ ? `nothing outside the container can reach the serve (published port, health check, webhooks).`
353
+ : `that address does not exist, so the container fails to bind at start.`) +
354
+ ` Drop it and use \`--bind ${config.http?.host}\` locally instead.`;
355
+ // Same disposition as the model-travel issue: warn when producing artifacts (the operator may be
356
+ // deploying somewhere that fronts the port), gate `--run` — which would otherwise ship a box that
357
+ // answers nothing, or crash-loops on a bind that cannot resolve inside the container.
358
+ if (run)
359
+ return { ok: false, gate: issue };
360
+ messages.push({ level: "warn", text: issue });
361
+ }
344
362
  // What the agent declared it needs on the box (fastagent.config deploy.secrets) — carried like channel
345
363
  // secrets: listed in the runbook, set from the local env under --run, gated if a value is missing.
346
364
  const extraSecrets = config.deploy?.secrets ?? [];
@@ -1,7 +1,7 @@
1
1
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
2
  import type { FastagentTool } from "./tool.ts";
3
3
  import type { Models } from "@earendil-works/pi-ai";
4
- import { type AnyModel } from "./harness.ts";
4
+ import type { AnyModel } from "./harness.ts";
5
5
  export interface FastagentConfig {
6
6
  /** "provider/modelId". Precedence: CLI --model > FASTAGENT_MODEL > config. */
7
7
  model?: string;
@@ -12,8 +12,11 @@ export interface FastagentConfig {
12
12
  /** Extra custom tools, appended after pi defaults — never replaces them. `FastagentTool` = AgentTool
13
13
  * plus the optional `deferred` marker (see defineTool). */
14
14
  tools?: FastagentTool[];
15
+ /** `host` is the bind address: unset (or `0.0.0.0`) binds all interfaces — what containers need;
16
+ * `127.0.0.1` keeps the serve (including `/control/*`) off the LAN. Precedence: `--bind` > this. */
15
17
  http?: {
16
18
  port?: number;
19
+ host?: string;
17
20
  };
18
21
  /** Mount the built-in `wake` tool so the agent can schedule its OWN follow-up turns (self-scheduling).
19
22
  * Off by default — self-scheduling is an autonomy capability, opt in when you want it. Only takes
@@ -24,8 +27,9 @@ export interface FastagentConfig {
24
27
  * steer/abort/compact/set_model…) for remote consumers: a Web panel, a desktop app, `fastagent
25
28
  * attach`. Default off (it is a remote-control surface). When on, `dev`/`start` generate a
26
29
  * per-boot bearer token and write `<stateRoot>/control.json` for local discovery. The serve
27
- * binds all interfaces, so the routes are LAN-reachable with the token as the only protection —
28
- * firewall the port, or wrap it for real exposure (design §14).
30
+ * binds all interfaces by default, so the routes are LAN-reachable with the token as the only
31
+ * protection bind loopback (`--bind 127.0.0.1`; not `http.host`, which travels into a deployed
32
+ * image), firewall the port, or wrap it for real exposure (design §14).
29
33
  */
30
34
  sessionControl?: boolean;
31
35
  /** Deploy-time declarations for what the agent needs on the box, so real agents don't hand-write a
@@ -20,7 +20,8 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
20
20
  import { existsSync, statSync } from "node:fs";
21
21
  import { basename, join } from "node:path";
22
22
  import { pathToFileURL } from "node:url";
23
- import { THINKING_LEVELS } from "./harness.js";
23
+ import { THINKING_LEVELS } from "./session-settings.js";
24
+ import { isBindAddress } from "../../bind.js";
24
25
  import { moduleLoadHint } from "../../loader.js";
25
26
  import { AGENT_CONFIG_NAMES, resolveOverridePath, resolveSecretsDir } from "../../paths.js";
26
27
  /** Identity function for typing and IDE completion (vite/next-style). */
@@ -116,13 +117,18 @@ export async function loadConfig(dir) {
116
117
  throw new Error(`${path}: "http" must be an object`);
117
118
  }
118
119
  for (const key of Object.keys(c.http ?? {})) {
119
- if (key !== "port") {
120
- throw new Error(`${path}: unknown key "http.${key}" (valid keys: port)`);
120
+ if (key !== "port" && key !== "host") {
121
+ throw new Error(`${path}: unknown key "http.${key}" (valid keys: port, host)`);
121
122
  }
122
123
  }
123
124
  if (c.http?.port !== undefined && (typeof c.http.port !== "number" || !isValidPort(c.http.port))) {
124
125
  throw new Error(`${path}: "http.port" must be an integer 0-65535`);
125
126
  }
127
+ // Validated as strictly as http.port: an unbindable string ("banana") must fail HERE, not surface
128
+ // later as a topology diagnostic about "the interface you bound".
129
+ if (c.http?.host !== undefined && (typeof c.http.host !== "string" || !isBindAddress(c.http.host))) {
130
+ throw new Error(`${path}: "http.host" must be an IP address or "localhost" (e.g. "127.0.0.1", "0.0.0.0")`);
131
+ }
126
132
  if (c.deploy !== undefined && (typeof c.deploy !== "object" || c.deploy === null)) {
127
133
  throw new Error(`${path}: "deploy" must be an object`);
128
134
  }