@fastagent-sh/fastagent 0.19.0 → 0.21.0

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 (212) hide show
  1. package/README.md +5 -1
  2. package/dist/atomic-write.d.ts +11 -7
  3. package/dist/atomic-write.js +16 -11
  4. package/dist/channels/agentcore-limits.d.ts +9 -0
  5. package/dist/channels/agentcore-limits.js +9 -0
  6. package/dist/channels/agentcore-protocol.d.ts +112 -0
  7. package/dist/channels/agentcore-protocol.js +22 -0
  8. package/dist/channels/agentcore-service.d.ts +12 -15
  9. package/dist/channels/agentcore-service.js +15 -24
  10. package/dist/channels/agentcore-state.d.ts +5 -11
  11. package/dist/channels/agentcore-state.js +4 -1
  12. package/dist/channels/agentcore.d.ts +9 -83
  13. package/dist/channels/agentcore.js +101 -93
  14. package/dist/channels/control.d.ts +50 -12
  15. package/dist/channels/control.js +251 -157
  16. package/dist/channels/discover.d.ts +26 -5
  17. package/dist/channels/discover.js +13 -26
  18. package/dist/channels/feishu/context-buffer.d.ts +6 -0
  19. package/dist/channels/feishu/context-buffer.js +0 -38
  20. package/dist/channels/feishu/crypto.d.ts +0 -2
  21. package/dist/channels/feishu/crypto.js +3 -8
  22. package/dist/channels/feishu/feishu-api.js +7 -12
  23. package/dist/channels/feishu/feishu.js +86 -128
  24. package/dist/channels/feishu/parse.d.ts +4 -3
  25. package/dist/channels/feishu/parse.js +5 -4
  26. package/dist/channels/feishu/register-webhook.d.ts +5 -5
  27. package/dist/channels/feishu/register-webhook.js +43 -57
  28. package/dist/channels/feishu/scaffold/feishu-send.ts +12 -67
  29. package/dist/channels/feishu/setup-mode.d.ts +30 -0
  30. package/dist/channels/feishu/setup-mode.js +26 -0
  31. package/dist/channels/feishu/shared-api.d.ts +10 -0
  32. package/dist/channels/feishu/shared-api.js +38 -0
  33. package/dist/channels/http.d.ts +0 -8
  34. package/dist/channels/http.js +4 -56
  35. package/dist/channels/kit/attachment-path.d.ts +12 -0
  36. package/dist/channels/kit/attachment-path.js +43 -0
  37. package/dist/channels/kit/context-buffer.d.ts +9 -0
  38. package/dist/channels/kit/context-buffer.js +11 -0
  39. package/dist/channels/kit/signature.d.ts +12 -0
  40. package/dist/channels/kit/signature.js +17 -0
  41. package/dist/channels/kit/stop-command.js +3 -3
  42. package/dist/channels/kit/tasks.d.ts +1 -1
  43. package/dist/channels/kit/tasks.js +10 -4
  44. package/dist/channels/kit/turn-runner.d.ts +59 -0
  45. package/dist/channels/kit/turn-runner.js +84 -0
  46. package/dist/channels/kit/turn-store.d.ts +19 -3
  47. package/dist/channels/kit/turn-store.js +29 -2
  48. package/dist/channels/lark/scaffold/lark-send.ts +12 -67
  49. package/dist/channels/registration.d.ts +36 -1
  50. package/dist/channels/registration.js +57 -1
  51. package/dist/channels/secret.d.ts +1 -0
  52. package/dist/channels/secret.js +16 -0
  53. package/dist/channels/slack/config-api.d.ts +35 -3
  54. package/dist/channels/slack/config-api.js +51 -9
  55. package/dist/channels/slack/invoke-turn.d.ts +1 -1
  56. package/dist/channels/slack/invoke-turn.js +5 -1
  57. package/dist/channels/slack/manifest.js +5 -1
  58. package/dist/channels/slack/onboard.d.ts +5 -5
  59. package/dist/channels/slack/onboard.js +44 -23
  60. package/dist/channels/slack/onboarding-state.d.ts +7 -3
  61. package/dist/channels/slack/onboarding-state.js +13 -20
  62. package/dist/channels/slack/register-webhook.d.ts +3 -2
  63. package/dist/channels/slack/register-webhook.js +35 -15
  64. package/dist/channels/slack/scaffold/channel.ts +3 -10
  65. package/dist/channels/slack/scaffold/slack-send.ts +18 -141
  66. package/dist/channels/slack/shared-api.d.ts +10 -0
  67. package/dist/channels/slack/shared-api.js +34 -0
  68. package/dist/channels/slack/slack-api.d.ts +20 -2
  69. package/dist/channels/slack/slack-api.js +100 -73
  70. package/dist/channels/slack/slack.d.ts +0 -10
  71. package/dist/channels/slack/slack.js +68 -107
  72. package/dist/channels/sse.d.ts +4 -0
  73. package/dist/channels/sse.js +66 -0
  74. package/dist/channels/telegram/parse.d.ts +21 -1
  75. package/dist/channels/telegram/parse.js +65 -11
  76. package/dist/channels/telegram/register-webhook.d.ts +6 -9
  77. package/dist/channels/telegram/register-webhook.js +44 -42
  78. package/dist/channels/telegram/scaffold/channel.ts +7 -3
  79. package/dist/channels/telegram/telegram-api.js +5 -6
  80. package/dist/channels/telegram/telegram.d.ts +2 -2
  81. package/dist/channels/telegram/telegram.js +97 -204
  82. package/dist/channels/wait-health.js +7 -4
  83. package/dist/cli/add-feishu.js +3 -10
  84. package/dist/cli/add-slack.js +13 -25
  85. package/dist/cli/commands/add.d.ts +0 -1
  86. package/dist/cli/commands/add.js +22 -35
  87. package/dist/cli/commands/attach.d.ts +2 -4
  88. package/dist/cli/commands/attach.js +17 -15
  89. package/dist/cli/commands/chat.js +6 -12
  90. package/dist/cli/commands/deploy/agentcore.d.ts +2 -0
  91. package/dist/cli/commands/deploy/agentcore.js +178 -0
  92. package/dist/cli/commands/deploy/docker.d.ts +2 -0
  93. package/dist/cli/commands/deploy/docker.js +119 -0
  94. package/dist/cli/commands/deploy/fly.d.ts +2 -0
  95. package/dist/cli/commands/deploy/fly.js +131 -0
  96. package/dist/cli/commands/deploy/railway.d.ts +2 -0
  97. package/dist/cli/commands/deploy/railway.js +71 -0
  98. package/dist/cli/commands/deploy/shared.d.ts +114 -0
  99. package/dist/cli/commands/deploy/shared.js +124 -0
  100. package/dist/cli/commands/deploy.d.ts +38 -25
  101. package/dist/cli/commands/deploy.js +101 -631
  102. package/dist/cli/commands/dev.js +20 -76
  103. package/dist/cli/commands/fire.js +6 -12
  104. package/dist/cli/commands/info.js +1 -1
  105. package/dist/cli/commands/init.js +1 -2
  106. package/dist/cli/commands/invoke.js +4 -11
  107. package/dist/cli/commands/schedule.js +1 -1
  108. package/dist/cli/commands/start.js +41 -80
  109. package/dist/cli/commands/tool.js +3 -8
  110. package/dist/cli/kernel.d.ts +0 -2
  111. package/dist/cli/kernel.js +0 -2
  112. package/dist/cli/program.js +7 -12
  113. package/dist/cli/serve.d.ts +42 -34
  114. package/dist/cli/serve.js +107 -28
  115. package/dist/cli/shared.d.ts +52 -27
  116. package/dist/cli/shared.js +69 -6
  117. package/dist/deploy/agentcore/forwarder.js +250 -0
  118. package/dist/deploy/agentcore/logs.d.ts +2 -2
  119. package/dist/deploy/agentcore/logs.js +2 -2
  120. package/dist/deploy/agentcore/plan.d.ts +34 -11
  121. package/dist/deploy/agentcore/plan.js +52 -259
  122. package/dist/deploy/agentcore/run.d.ts +9 -24
  123. package/dist/deploy/agentcore/run.js +35 -43
  124. package/dist/deploy/channel-ingress.d.ts +73 -0
  125. package/dist/deploy/channel-ingress.js +101 -0
  126. package/dist/deploy/docker/plan.d.ts +3 -7
  127. package/dist/deploy/docker/plan.js +4 -15
  128. package/dist/deploy/docker/run.d.ts +35 -4
  129. package/dist/deploy/docker/run.js +40 -8
  130. package/dist/deploy/fly/plan.d.ts +4 -5
  131. package/dist/deploy/fly/plan.js +14 -22
  132. package/dist/deploy/fly/run.d.ts +36 -23
  133. package/dist/deploy/fly/run.js +129 -83
  134. package/dist/deploy/hosts.d.ts +5 -0
  135. package/dist/deploy/hosts.js +4 -0
  136. package/dist/deploy/preflight.d.ts +6 -7
  137. package/dist/deploy/preflight.js +27 -23
  138. package/dist/deploy/railway/plan.d.ts +9 -5
  139. package/dist/deploy/railway/plan.js +18 -26
  140. package/dist/deploy/railway/run.d.ts +11 -10
  141. package/dist/deploy/railway/run.js +44 -43
  142. package/dist/deploy/runner.js +22 -2
  143. package/dist/deploy/secrets.d.ts +18 -9
  144. package/dist/deploy/secrets.js +63 -28
  145. package/dist/dev-supervisor.js +3 -4
  146. package/dist/engines/pi/agent-session-factory.d.ts +37 -17
  147. package/dist/engines/pi/agent-session-factory.js +111 -106
  148. package/dist/engines/pi/auth.js +43 -43
  149. package/dist/engines/pi/config.d.ts +2 -2
  150. package/dist/engines/pi/create.d.ts +22 -27
  151. package/dist/engines/pi/create.js +59 -74
  152. package/dist/engines/pi/definition.d.ts +1 -1
  153. package/dist/engines/pi/definition.js +11 -12
  154. package/dist/engines/pi/invoke-session.js +13 -30
  155. package/dist/engines/pi/login.js +32 -16
  156. package/dist/engines/pi/open.d.ts +3 -4
  157. package/dist/engines/pi/open.js +32 -42
  158. package/dist/engines/pi/retry-event.d.ts +6 -0
  159. package/dist/engines/pi/retry-event.js +15 -0
  160. package/dist/engines/pi/search-tools.js +1 -1
  161. package/dist/engines/pi/service.d.ts +1 -1
  162. package/dist/engines/pi/service.js +8 -0
  163. package/dist/engines/pi/session-builder.js +26 -140
  164. package/dist/engines/pi/session-control.d.ts +27 -22
  165. package/dist/engines/pi/session-control.js +551 -490
  166. package/dist/engines/pi/session-inheritance.d.ts +8 -22
  167. package/dist/engines/pi/session-inheritance.js +95 -76
  168. package/dist/engines/pi/session-markers.d.ts +48 -0
  169. package/dist/engines/pi/session-markers.js +59 -0
  170. package/dist/engines/pi/session-settings.d.ts +5 -5
  171. package/dist/engines/pi/session-settings.js +8 -5
  172. package/dist/engines/pi/session-store.d.ts +91 -26
  173. package/dist/engines/pi/session-store.js +413 -82
  174. package/dist/engines/pi/tool-context.d.ts +24 -11
  175. package/dist/engines/pi/tool-context.js +29 -4
  176. package/dist/engines/pi/tool.d.ts +6 -9
  177. package/dist/engines/pi/tool.js +3 -2
  178. package/dist/env.js +1 -2
  179. package/dist/feishu.d.ts +1 -0
  180. package/dist/feishu.js +1 -0
  181. package/dist/lark.d.ts +1 -0
  182. package/dist/lark.js +1 -0
  183. package/dist/loader.d.ts +51 -7
  184. package/dist/loader.js +84 -18
  185. package/dist/log.d.ts +9 -17
  186. package/dist/log.js +25 -30
  187. package/dist/paths.d.ts +26 -3
  188. package/dist/paths.js +43 -5
  189. package/dist/scaffold/add-channel.d.ts +6 -1
  190. package/dist/scaffold/add-channel.js +50 -67
  191. package/dist/scaffold/init.js +7 -2
  192. package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
  193. package/dist/schedule/discover.js +3 -15
  194. package/dist/schedule/wake-alarm.d.ts +14 -19
  195. package/dist/schedule/wake-alarm.js +89 -48
  196. package/dist/schedule/wakeups.d.ts +1 -1
  197. package/dist/schedule/wakeups.js +10 -7
  198. package/dist/service.d.ts +24 -24
  199. package/dist/service.js +33 -77
  200. package/dist/session-remote.d.ts +12 -7
  201. package/dist/session-remote.js +185 -118
  202. package/dist/session.d.ts +227 -93
  203. package/dist/session.js +61 -23
  204. package/dist/slack.d.ts +2 -0
  205. package/dist/slack.js +1 -0
  206. package/dist/telegram.d.ts +1 -1
  207. package/dist/telegram.js +1 -1
  208. package/dist/tunnel.d.ts +34 -9
  209. package/dist/tunnel.js +83 -50
  210. package/package.json +5 -4
  211. package/dist/channels/slack/bot-auth.d.ts +0 -15
  212. package/dist/channels/slack/bot-auth.js +0 -135
package/dist/cli/serve.js CHANGED
@@ -1,27 +1,22 @@
1
1
  /**
2
2
  * What `dev` (its worker) and `start` need beyond the service itself: binding a port, the shutdown
3
- * order, the startup report, and the optional Cloudflare quick tunnel.
3
+ * order, the startup report, the optional Cloudflare quick tunnel, and the options the CLI hands the
4
+ * assembly (`cliMountOptions`, `resolveBindHost` — policy ABOUT the assembly, decided per command).
4
5
  *
5
- * The ASSEMBLY is not here — it lives in `src/service.ts`, which a public entry may import and this
6
- * directory may not be (it decides process-level things: `fail.ts` calls `process.exit`).
6
+ * The ASSEMBLY itself is not here — it lives in `src/service.ts`, which a public entry may import and
7
+ * this directory may not be (it decides process-level things: `fail.ts` calls `process.exit`).
7
8
  */
9
+ import { mkdirSync, rmSync } from "node:fs";
10
+ import { join } from "node:path";
8
11
  import { INVOKE_EXAMPLE_BODY } from "../channels/http.js";
9
- import { answersLocalhost, bindLabel, classifyBind, clientHost } from "../bind.js";
12
+ import { answersLocalhost, bindAddress, bindLabel, classifyBind, clientHost } from "../bind.js";
13
+ import { writeFileAtomic } from "../atomic-write.js";
10
14
  import { serveNode } from "../channels/serve.js";
11
15
  import { log } from "../log.js";
12
16
  import { openExternalUrl } from "../open-url.js";
17
+ import { declaredChannels } from "../channels/discover.js";
13
18
  import { announceWebhooks, startCloudflareTunnel } from "../tunnel.js";
14
19
  import { failStartup, failUsage } from "./fail.js";
15
- /**
16
- * Mount the session control plane (`/control/*`) when the agent enabled it
17
- * (`config.sessionControl`): merge the bearer-authenticated routes and return an announcer that
18
- * writes `<stateRoot>/control.json` — `{ url, token }`, 0600 — once the port is known. The file is
19
- * the LOCAL discovery channel (`fastagent attach`, a local desktop app); filesystem permissions are
20
- * its trust boundary, and each boot overwrites it with a fresh per-boot token. A user channel
21
- * colliding on `/control/*` fails startup — the same disposition as a channel-channel collision
22
- * (routesFor): `sessionControl` is an explicit opt-in, so declaring both is a configuration error,
23
- * and silently shadowing either side would serve a surface the author didn't write.
24
- */
25
20
  /**
26
21
  * Refuse `--tunnel` with a bind that cloudflared cannot reach: it dials the NAME `localhost:<port>`
27
22
  * (the dev supervisor's tunnel too), so anything outside `127.0.0.1`/`::1`/wildcard — including a
@@ -45,14 +40,44 @@ export function assertTunnelBindable(host, tunnel, source) {
45
40
  failStartup(new Error(message));
46
41
  }
47
42
  /**
48
- * The startup lines that name WHERE the serve is: the bind report, and the curl the reader copies.
49
- * ONE function because they are one message they were two, and `--bind` updated the first while the
50
- * second went on dialing `localhost`, which is precisely what a non-wildcard bind stops answering. Now
51
- * neither can be changed without the other in view, and the address has a single derivation.
52
- *
53
- * A wildcard bind is every interface, and naming one address there would understate it — but the curl
54
- * still needs one to dial, which is what `clientHost` gives (loopback for a wildcard, itself otherwise).
43
+ * The bind address a serve uses: the flag, else `http.host` from the config — through `bindAddress`,
44
+ * so a configured `localhost` is an ADDRESS by the time anything binds, renders or dials it checked
45
+ * against `--tunnel` with the exit code the SOURCE of the value earns (serve.ts assertTunnelBindable).
55
46
  */
47
+ export function resolveBindHost(bindFlag, configured, tunnel) {
48
+ const host = bindFlag ?? (configured === undefined ? undefined : bindAddress(configured));
49
+ assertTunnelBindable(host, tunnel, bindFlag ? "flag" : "config");
50
+ return host;
51
+ }
52
+ /** What the CLI adds to the assembly: its shutdown grace, and exit on a connection that drops. */
53
+ export function cliMountOptions(wrapAgent) {
54
+ return {
55
+ wrapAgent,
56
+ closeTimeoutMs: SHUTDOWN_GRACE_MS,
57
+ onChannelClosed: (name, error) => failStartup(new Error(`${name} ${error === undefined ? "closed unexpectedly" : `failed: ${String(error)}`}`)),
58
+ };
59
+ }
60
+ /**
61
+ * Bind, report, announce the control plane, open the tunnel, and close in order on a signal — the
62
+ * tail dev's worker and start share once the service is assembled.
63
+ */
64
+ export function serveService(service, bind, posture) {
65
+ const { host } = bind;
66
+ const { tunnel, agentDir, stateRoot } = posture;
67
+ let unannounce = () => { };
68
+ serve(service.handler, bind, {
69
+ ready: service.ready,
70
+ onListening: (p) => {
71
+ reportServing(service, host, p);
72
+ unannounce = announceControl(service.control, stateRoot, { host, tunnel }, p);
73
+ maybeTunnel(agentDir, service.channels.routes, p, tunnel, stateRoot);
74
+ },
75
+ onShutdown: () => {
76
+ unannounce();
77
+ return service.close();
78
+ },
79
+ });
80
+ }
56
81
  /**
57
82
  * The "we are serving" report: the supervisor message `dev`'s watcher waits for, the addresses, and
58
83
  * what mounted. One function because both commands must say the same thing at the same moment —
@@ -67,6 +92,15 @@ export function reportServing(service, host, boundPort) {
67
92
  log.info(`[fastagent] long connections: ${service.channels.longConnections.join(", ")}`);
68
93
  }
69
94
  }
95
+ /**
96
+ * The startup lines that name WHERE the serve is: the bind report, and the curl the reader copies.
97
+ * ONE function because they are one message — they were two, and `--bind` updated the first while the
98
+ * second went on dialing `localhost`, which is precisely what a non-wildcard bind stops answering. Now
99
+ * neither can be changed without the other in view, and the address has a single derivation.
100
+ *
101
+ * A wildcard bind is every interface, and naming one address there would understate it — but the curl
102
+ * still needs one to dial, which is what `clientHost` gives (loopback for a wildcard, itself otherwise).
103
+ */
70
104
  export function readyAddressLines(host, boundPort, builtinInvoke) {
71
105
  const dial = `${clientHost(host)}:${boundPort}`;
72
106
  const lines = [
@@ -78,15 +112,57 @@ export function readyAddressLines(host, boundPort, builtinInvoke) {
78
112
  return lines;
79
113
  }
80
114
  /**
81
- * Bind HTTP, open long-connection channels, and report ready only when both forms are usable. Each
82
- * adapter owns reconnects; a terminal close rejects `closed` and fails the process visibly. Abort is
83
- * the sole clean-shutdown command. `host` unset binds all interfaces.
115
+ * Write `<stateRoot>/control.json` so a LOCAL client (`fastagent attach`) can find the control plane
116
+ * once the port is known, and say what reaches it. Returns the file's removal wired into the
117
+ * shutdown so a stale file cannot point a client at a dead port (`attach` then fails with "cannot
118
+ * read", accurate, instead of a stale token's misleading 401/ECONNREFUSED). A hard exit leaves the
119
+ * file behind: advisory, overwritten by the next boot.
120
+ *
121
+ * Here and not in the assembly: the file, its permissions and the warnings are how THIS process's
122
+ * operator finds and protects the plane. An embedder distributes `service.control` itself.
84
123
  */
124
+ export function announceControl(control, stateRoot, bind, boundPort) {
125
+ if (!control)
126
+ return () => { };
127
+ mkdirSync(stateRoot, { recursive: true, mode: 0o700 });
128
+ const path = join(stateRoot, "control.json");
129
+ const url = `http://${clientHost(bind.host)}:${boundPort}`;
130
+ writeFileAtomic(path, `${JSON.stringify({ url, token: control.token })}\n`, 0o600);
131
+ log.info(`[fastagent] session control on ${control.prefix}/* (token in ${path})`);
132
+ // LAN-reachable with the bearer token as the only protection — the tunnel and deploy paths warn
133
+ // loudly, and the LAN path must not be the silent third way past the local trust story. A
134
+ // loopback bind closes exactly that reach, so it earns silence.
135
+ const reach = classifyBind(bind.host);
136
+ if (reach !== "loopback") {
137
+ log.warn(`[fastagent] the port binds ${reach === "wildcard" ? "all interfaces" : `${bind.host} (off this machine)`}: ` +
138
+ "/control/* is reachable on your LAN, protected only by the bearer token — bind loopback " +
139
+ "(--bind 127.0.0.1), firewall the port, or wrap it for real exposure (docs/design/session-control.md §14)");
140
+ }
141
+ if (bind.tunnel) {
142
+ // Local trust = the token + its file permissions; --tunnel takes the whole port PUBLIC.
143
+ log.warn("[fastagent] --tunnel exposes /control/* (steer, stop, rewrite or delete a session) at the public tunnel URL, " +
144
+ "protected ONLY by the bearer token — wrap it with real auth before sharing that URL (docs/design/session-control.md §14)");
145
+ }
146
+ return () => {
147
+ try {
148
+ rmSync(path, { force: true });
149
+ }
150
+ catch {
151
+ /* the file is advisory — shutdown must not fail on it */
152
+ }
153
+ };
154
+ }
85
155
  /** What the CLI gives a service to stop in, and the hard exit that follows it. The order matters:
86
156
  * a forced exit before the service answers would report a clean shutdown over a stuck channel. */
87
- export const SHUTDOWN_GRACE_MS = 800;
157
+ const SHUTDOWN_GRACE_MS = 800;
88
158
  const FORCED_EXIT_MS = 1_500;
89
- export function serve(handler, bind, hooks = {}) {
159
+ /**
160
+ * Bind HTTP and report ready — but only once the SERVICE is, which is not the same moment: a bound
161
+ * socket is not a serving agent while a declared long-connection channel is still dialling, so this
162
+ * awaits `hooks.ready` (mountAgentService owns the connections themselves) before announcing
163
+ * anything. Signals are the sole clean-shutdown command; `host` unset binds all interfaces.
164
+ */
165
+ function serve(handler, bind, hooks = {}) {
90
166
  const { port, host } = bind;
91
167
  const hosted = serveNode(handler, { port, host });
92
168
  let stopping = false;
@@ -149,13 +225,16 @@ export function serve(handler, bind, hooks = {}) {
149
225
  });
150
226
  }
151
227
  /** Start a Cloudflare tunnel for route channels only. */
152
- export function maybeTunnel(agentDir, routeChannels, boundPort, tunnel, stateRoot) {
228
+ function maybeTunnel(agentDir, routeChannels, boundPort, tunnel, stateRoot) {
153
229
  if (!tunnel || process.env.FASTAGENT_DEV_WORKER === "1")
154
230
  return;
155
231
  void startCloudflareTunnel(boundPort).then((instance) => {
156
232
  if (!instance)
157
233
  return;
158
- void announceWebhooks(agentDir, instance.url, { openUrl: openExternalUrl, routeChannels, stateRoot });
234
+ void announceWebhooks(agentDir, instance.url, declaredChannels(routeChannels), {
235
+ openUrl: openExternalUrl,
236
+ stateRoot,
237
+ });
159
238
  const cleanup = () => instance.close();
160
239
  process.once("SIGINT", cleanup);
161
240
  process.once("SIGTERM", cleanup);
@@ -1,16 +1,58 @@
1
1
  import { type LoginIO, type LoginMethod, type LoginResult } from "../engines/pi/login.ts";
2
+ import type { LoadedDefinition } from "../engines/pi/definition.ts";
3
+ import { type ModuleLoadFailure } from "../loader.ts";
4
+ import type { ToolCollision } from "../engines/pi/tool.ts";
5
+ import { type ResolvedPlacement } from "../paths.ts";
2
6
  /**
3
- * The padded label writer for the STARTUP report (`dev`/`start`, stderr via the log level). Hand-spaced
4
- * labels drift out of alignment the moment a longer one appears which is exactly what happened when
5
- * `codingTools:` joined `workspace:`/`config:`/`model:`/`state:`. `info` keeps its own writer on purpose: its report is
6
- * stdout DATA (pipeable, its own label set, its own width), not a log line the shared thing is the
7
- * policy (pad, never hand-space), not a constant.
7
+ * How every command that runs the model enters its agent directory, in the one order that works:
8
+ * placement decides whose `.env` to read; `.env` may carry the proxy and the provider keys the
9
+ * picker's auth probe needs; the picker runs last. Six commands wrote these steps out by hand, and
10
+ * one carried the order as a comment. The result is the placement every later step reads.
8
11
  */
9
- export declare function reportLine(label: string, value: string): void;
10
- /** The workspace hint under the `agent:`/`workspace:` pair, when there is one ({@link workspaceHint}):
11
- * you pointed at the agent, and the project around it is probably what you meant. A hint, so it renders
12
- * as one and is silent otherwise — `dev` and `start` both print the pair, so both ask for it. */
13
- export declare function reportWorkspaceHint(hint: string | undefined): void;
12
+ export declare function enterAgentCommand(dirArg: string, opts: {
13
+ model?: string;
14
+ authPath?: string;
15
+ input?: boolean;
16
+ }): Promise<ResolvedPlacement>;
17
+ /** What the startup report reads off an opened directory — a structural subset of the opener's return,
18
+ * spelled out so this module does not depend on that function's whole shape. */
19
+ export interface ReportableAssembly {
20
+ agentDir: string;
21
+ workspace: string;
22
+ modelSpec: string;
23
+ authPath: string;
24
+ config: {
25
+ thinkingLevel?: string;
26
+ };
27
+ definition: LoadedDefinition;
28
+ toolNames: string[];
29
+ deferredToolNames: string[];
30
+ toolCollisions: ToolCollision[];
31
+ toolFailures: ModuleLoadFailure[];
32
+ }
33
+ /**
34
+ * What `dev` and `start` say about the directory they just opened, in the order they say it.
35
+ *
36
+ * ONE function because it is one report: the two commands wrote it out line by line, and the copies
37
+ * had already diverged over which lines exist at all — `dev` naming the config file, `start` naming
38
+ * state and sessions. A line added to one of two hand-written copies is invisible in the other.
39
+ *
40
+ * The divergence itself is PRESERVED, not resolved: `start`'s `state:`/`sessions:` pair introduces the
41
+ * persistence warnings that follow it in production posture, and `dev`'s `config:` has no counterpart
42
+ * there. Both are passed as explicit extras by the caller, so the asymmetry is visible at the call
43
+ * site rather than buried in two copies of a list. Whether it is RIGHT is a separate question from
44
+ * whether it has one owner.
45
+ *
46
+ * Findings (skill collisions, definition diagnostics) CLOSE the report — they are about the assembly
47
+ * just printed. What a command says next is its own posture talk, not report: `start`'s persistence
48
+ * notes now follow them rather than precede them.
49
+ */
50
+ export declare function reportAssembly(a: ReportableAssembly, extras?: {
51
+ /** Printed between `workspace:`/`hint:` and `model:` (`dev` names the config file here). */
52
+ beforeModel?: [label: string, value: string][];
53
+ /** Printed after the tool lines, before findings (`start` names state + sessions here). */
54
+ afterTools?: [label: string, value: string][];
55
+ }): Promise<void>;
14
56
  /** Both stdin and stdout are a terminal — the precondition for an interactive prompt. */
15
57
  export declare function isInteractive(): boolean;
16
58
  /**
@@ -30,23 +72,6 @@ export declare function parseBind(value: string | undefined): string | undefined
30
72
  * Probes through the AGENT's model surface (`agentDir` carries its models.json), so a custom endpoint is
31
73
  * reported like any built-in rather than as an unknown provider. */
32
74
  export declare function reportAuth(agentDir: string, modelSpec: string, authPath: string): Promise<void>;
33
- /**
34
- * First-run model resolution for every assembly command (dev/start/invoke/fire/chat/deploy): ONE
35
- * funnel, no dead ends. When no model is set (flag/env/config) and we're on a TTY, show the FULL
36
- * catalog annotated per provider — ready (with the credential source, so which account pays is
37
- * visible at the decision point) or login-required — and, when the choice needs auth, run the login
38
- * flow INLINE instead of exiting with "run `fastagent login` and come back". A no-op when a model is
39
- * already set; on a non-TTY (CI, a piped stdin), with `--no-input`, on cancel, or on a failed login
40
- * it stays quiet and lets the caller raise its own clear error (`missing model`, or deploy's
41
- * model-travel gate). The pick is exported to FASTAGENT_MODEL so a spawned `dev` worker inherits it,
42
- * and best-effort written back to the config so the next run is quiet. `agentDir` is the resolved
43
- * AGENT DIR (resolvePlacement().agentDir) — config and auth both live there.
44
- */
45
- export declare function resolveFirstRunModel(agentDir: string, options?: {
46
- model?: string;
47
- authPath?: string;
48
- input?: boolean;
49
- }): Promise<void>;
50
75
  /**
51
76
  * Interactive login with the api_key quick-fail probe closed into a LOOP: a definitively rejected key
52
77
  * (HTTP 401) deletes the bad credential and RE-PROMPTS immediately — the user's hands are on the
@@ -4,7 +4,7 @@
4
4
  * module-scoped flag access (`values.*`) became parameters.
5
5
  */
6
6
  import { readFile, writeFile } from "node:fs/promises";
7
- import { relative } from "node:path";
7
+ import { relative, resolve } from "node:path";
8
8
  import { autocomplete, isCancel, log as clackLog, password, select, text as clackText } from "@clack/prompts";
9
9
  import { buildModelPickerOptions } from "./models-view.js";
10
10
  import { fastagentCredentialStore } from "../engines/pi/auth.js";
@@ -12,27 +12,90 @@ import { isValidPort, listModels, loadConfig, providerOf, resolveAuthPath, resol
12
12
  import { LoginCancelled, loginFlow } from "../engines/pi/login.js";
13
13
  import { createPiModelRuntime, createPiModels, probeApiKey, probeAuthSource, providerAuthStatuses, } from "../engines/pi/models.js";
14
14
  import { formatAuthReport } from "./auth-view.js";
15
+ import { CODING_TOOL_NAMES } from "../engines/pi/create.js";
16
+ import { reportModuleLoadFailures } from "../loader.js";
17
+ import { reportFindingsIfChanged, reportToolCollisions } from "../engines/pi/report.js";
18
+ import { workspaceHint } from "../paths.js";
15
19
  import { log } from "../log.js";
20
+ import { loadDotEnv } from "../env.js";
16
21
  import { openExternalUrl } from "../open-url.js";
22
+ import { installProxyFetch } from "../proxy.js";
17
23
  import { bindAddress, isBindAddress } from "../bind.js";
18
- import { failStartup, failUsage } from "./fail.js";
24
+ import { failStartup, failUsage, placementOrExit } from "./fail.js";
25
+ /**
26
+ * How every command that runs the model enters its agent directory, in the one order that works:
27
+ * placement decides whose `.env` to read; `.env` may carry the proxy and the provider keys the
28
+ * picker's auth probe needs; the picker runs last. Six commands wrote these steps out by hand, and
29
+ * one carried the order as a comment. The result is the placement every later step reads.
30
+ */
31
+ export async function enterAgentCommand(dirArg, opts) {
32
+ const placement = placementOrExit(resolve(dirArg));
33
+ loadDotEnv(placement.agentDir);
34
+ installProxyFetch();
35
+ await resolveFirstRunModel(placement.agentDir, opts);
36
+ return placement;
37
+ }
19
38
  /**
20
39
  * The padded label writer for the STARTUP report (`dev`/`start`, stderr via the log level). Hand-spaced
21
40
  * labels drift out of alignment the moment a longer one appears — which is exactly what happened when
22
41
  * `codingTools:` joined `workspace:`/`config:`/`model:`/`state:`. `info` keeps its own writer on purpose: its report is
23
42
  * stdout DATA (pipeable, its own label set, its own width), not a log line — the shared thing is the
24
43
  * policy (pad, never hand-space), not a constant.
44
+ *
45
+ * Private to this module: {@link reportAssembly} is the report, and a command reaching past it for a
46
+ * line of its own is how the two copies of that report came to differ.
25
47
  */
26
- export function reportLine(label, value) {
48
+ function reportLine(label, value) {
27
49
  log.info(`[fastagent] ${`${label}:`.padEnd(13)}${value}`);
28
50
  }
29
51
  /** The workspace hint under the `agent:`/`workspace:` pair, when there is one ({@link workspaceHint}):
30
52
  * you pointed at the agent, and the project around it is probably what you meant. A hint, so it renders
31
- * as one and is silent otherwise — `dev` and `start` both print the pair, so both ask for it. */
32
- export function reportWorkspaceHint(hint) {
53
+ * as one and is silent otherwise. */
54
+ function reportWorkspaceHint(hint) {
33
55
  if (hint)
34
56
  reportLine("hint", hint);
35
57
  }
58
+ /**
59
+ * What `dev` and `start` say about the directory they just opened, in the order they say it.
60
+ *
61
+ * ONE function because it is one report: the two commands wrote it out line by line, and the copies
62
+ * had already diverged over which lines exist at all — `dev` naming the config file, `start` naming
63
+ * state and sessions. A line added to one of two hand-written copies is invisible in the other.
64
+ *
65
+ * The divergence itself is PRESERVED, not resolved: `start`'s `state:`/`sessions:` pair introduces the
66
+ * persistence warnings that follow it in production posture, and `dev`'s `config:` has no counterpart
67
+ * there. Both are passed as explicit extras by the caller, so the asymmetry is visible at the call
68
+ * site rather than buried in two copies of a list. Whether it is RIGHT is a separate question from
69
+ * whether it has one owner.
70
+ *
71
+ * Findings (skill collisions, definition diagnostics) CLOSE the report — they are about the assembly
72
+ * just printed. What a command says next is its own posture talk, not report: `start`'s persistence
73
+ * notes now follow them rather than precede them.
74
+ */
75
+ export async function reportAssembly(a, extras = {}) {
76
+ reportLine("agent", a.agentDir);
77
+ reportLine("workspace", a.workspace);
78
+ reportWorkspaceHint(workspaceHint(a));
79
+ for (const [label, value] of extras.beforeModel ?? [])
80
+ reportLine(label, value);
81
+ reportLine("model", `${a.modelSpec}${a.config.thinkingLevel ? ` (thinking: ${a.config.thinkingLevel})` : ""}`);
82
+ await reportAuth(a.agentDir, a.modelSpec, a.authPath);
83
+ reportLine("context", a.definition.contextFiles.map((f) => f.path).join(", ") || "(none)");
84
+ if (a.definition.persona)
85
+ reportLine("persona", "persona.md");
86
+ reportLine("skills", a.definition.skills.map((s) => s.name).join(", ") || "(none)");
87
+ reportLine("codingTools", CODING_TOOL_NAMES.join(", "));
88
+ if (a.toolNames.length > 0)
89
+ reportLine("tools", a.toolNames.join(", "));
90
+ if (a.deferredToolNames.length > 0) {
91
+ reportLine("deferred", `${a.deferredToolNames.join(", ")} (activated via search_tools)`);
92
+ }
93
+ reportToolCollisions(a.toolCollisions);
94
+ reportModuleLoadFailures(a.toolFailures);
95
+ for (const [label, value] of extras.afterTools ?? [])
96
+ reportLine(label, value);
97
+ reportFindingsIfChanged(a.definition.dir, a.definition);
98
+ }
36
99
  /** Both stdin and stdout are a terminal — the precondition for an interactive prompt. */
37
100
  export function isInteractive() {
38
101
  return Boolean(process.stdin.isTTY && process.stdout.isTTY);
@@ -101,7 +164,7 @@ export async function reportAuth(agentDir, modelSpec, authPath) {
101
164
  * and best-effort written back to the config so the next run is quiet. `agentDir` is the resolved
102
165
  * AGENT DIR (resolvePlacement().agentDir) — config and auth both live there.
103
166
  */
104
- export async function resolveFirstRunModel(agentDir, options = {}) {
167
+ async function resolveFirstRunModel(agentDir, options = {}) {
105
168
  const { config, path: configPath } = await loadConfig(agentDir).catch(failStartup);
106
169
  if (resolveModelSpec(options.model, config))
107
170
  return; // already set (flag > FASTAGENT_MODEL > config)
@@ -0,0 +1,250 @@
1
+ // Generated by `fastagent deploy agentcore` — the deployment's only ingress.
2
+ // (Inside fastagent this file IS the source; the sentence below applies to the copy it is emitted as,
3
+ // the workspace's `lambda/index.js`.)
4
+ // REGENERATED ON EVERY DEPLOY; edits here are overwritten and never deployed. `--run` builds the
5
+ // Lambda package from fastagent's own copy of this source, so this file is the readable MIRROR of
6
+ // what runs (and what the manual runbook zips) — not an input you can change.
7
+ // Webhooks (Function URL) and EventBridge Scheduler fires are forwarded as envelopes to the
8
+ // AgentCore Runtime over SigV4 InvokeAgentRuntime, all on ONE fixed ingress session (fastagent
9
+ // channel state is single-writer; one session = at most one microVM). With selfSchedule, this
10
+ // Lambda also OWNS the wake alarms: the container POSTs its pending wake-ups to /__fastagent/
11
+ // wake-alarm (shared secret) and each becomes a self-deleting one-shot EventBridge schedule that
12
+ // pokes this Lambda — which wakes the container, whose wake pump fires the due entry.
13
+ // CommonJS on purpose: the deployment package's entry lands as index.js, where ESM import is invalid.
14
+ "use strict";
15
+ const crypto = require("node:crypto");
16
+ const { BedrockAgentCoreClient, InvokeAgentRuntimeCommand } = require("@aws-sdk/client-bedrock-agentcore");
17
+ const client = new BedrockAgentCoreClient({});
18
+ let ownUrl; // self-resolved once per cold start; rides on every envelope for the wake-alarm callback
19
+
20
+ // Presigned S3 URLs for the container's state snapshot. AgentCore wipes the /mnt/state mount on
21
+ // every runtime version update (= every deploy), so the durable copy lives in S3 — but the
22
+ // container is given NO AWS credentials by the platform, so the only reachable form is a URL that
23
+ // carries its own authorization. SigV4 query signing, node:crypto only (no SDK, nothing to install).
24
+ const enc = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
25
+ const hmac = (key, data) => crypto.createHmac("sha256", key).update(data).digest();
26
+
27
+ // Every shared-secret gate on this public URL goes through here. Constant-time, and an unset
28
+ // expected secret NEVER matches — otherwise a topology that did not configure one would accept a
29
+ // request that sent none. Non-string input is coerced to "" first: Buffer.from(8) allocates eight
30
+ // bytes rather than throwing, which would compare a number against a secret by length alone.
31
+ const secretEq = (given, expected) => {
32
+ const a = Buffer.from(typeof given === "string" ? given : ""),
33
+ b = Buffer.from(expected || "");
34
+ return b.length > 0 && a.length === b.length && crypto.timingSafeEqual(a, b);
35
+ };
36
+
37
+ function presign(method, seconds) {
38
+ const bucket = process.env.STATE_BUCKET,
39
+ key = process.env.STATE_KEY,
40
+ region = process.env.AWS_REGION;
41
+ const host = `${bucket}.s3.${region}.amazonaws.com`;
42
+ const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+/, "");
43
+ const scope = `${stamp.slice(0, 8)}/${region}/s3/aws4_request`;
44
+ const pairs = [
45
+ ["X-Amz-Algorithm", "AWS4-HMAC-SHA256"],
46
+ ["X-Amz-Credential", `${process.env.AWS_ACCESS_KEY_ID}/${scope}`],
47
+ ["X-Amz-Date", stamp],
48
+ ["X-Amz-Expires", String(seconds)],
49
+ ["X-Amz-SignedHeaders", "host"],
50
+ ];
51
+ if (process.env.AWS_SESSION_TOKEN) pairs.push(["X-Amz-Security-Token", process.env.AWS_SESSION_TOKEN]);
52
+ // The canonical query must be byte-identical to the one on the wire — build it ONCE, reuse below.
53
+ const query = pairs
54
+ .map(([k, v]) => [enc(k), enc(v)])
55
+ .sort((a, b) => (a[0] < b[0] ? -1 : 1))
56
+ .map((p) => p.join("="))
57
+ .join("&");
58
+ const path = `/${key.split("/").map(enc).join("/")}`;
59
+ const canonical = [method, path, query, `host:${host}\n`, "host", "UNSIGNED-PAYLOAD"].join("\n");
60
+ const sts = ["AWS4-HMAC-SHA256", stamp, scope, crypto.createHash("sha256").update(canonical).digest("hex")].join(
61
+ "\n",
62
+ );
63
+ let k = hmac(`AWS4${process.env.AWS_SECRET_ACCESS_KEY}`, stamp.slice(0, 8));
64
+ for (const part of [region, "s3", "aws4_request"]) k = hmac(k, part);
65
+ return `https://${host}${path}?${query}&X-Amz-Signature=${hmac(k, sts).toString("hex")}`;
66
+ }
67
+
68
+ async function invoke(envelope) {
69
+ if ((process.env.WAKE_SECRET || process.env.STATE_REFRESH_SECRET) && !ownUrl) {
70
+ const { LambdaClient, GetFunctionUrlConfigCommand } = require("@aws-sdk/client-lambda");
71
+ ownUrl = (
72
+ await new LambdaClient({}).send(
73
+ new GetFunctionUrlConfigCommand({ FunctionName: process.env.AWS_LAMBDA_FUNCTION_NAME }),
74
+ )
75
+ ).FunctionUrl;
76
+ }
77
+ if (ownUrl) envelope.wake = { url: ownUrl };
78
+ // Authenticates this envelope as coming from the forwarder (see the template's FastagentIngressSecret).
79
+ envelope.auth = process.env.INGRESS_SECRET;
80
+ // Keep each capability short-lived. Function-URL deployments also carry an authenticated refresh
81
+ // endpoint, so a background turn settling hours after its webhook never depends on the temporary
82
+ // Lambda credentials that signed the original pair still being alive.
83
+ if (process.env.STATE_BUCKET)
84
+ envelope.state = {
85
+ getUrl: presign("GET", 3600),
86
+ putUrl: presign("PUT", 3600),
87
+ ...(ownUrl && process.env.STATE_REFRESH_SECRET
88
+ ? {
89
+ refresh: {
90
+ url: `${ownUrl.replace(/\/$/, "")}/__fastagent/state-urls`,
91
+ auth: process.env.STATE_REFRESH_SECRET,
92
+ },
93
+ }
94
+ : {}),
95
+ };
96
+ const res = await client.send(
97
+ new InvokeAgentRuntimeCommand({
98
+ agentRuntimeArn: process.env.RUNTIME_ARN,
99
+ runtimeSessionId: process.env.INGRESS_SESSION_ID,
100
+ contentType: "application/json",
101
+ accept: "application/json",
102
+ payload: new TextEncoder().encode(JSON.stringify(envelope)),
103
+ }),
104
+ );
105
+ const body = Buffer.from(await res.response.transformToByteArray());
106
+ return { status: res.statusCode ?? 200, body };
107
+ }
108
+
109
+ // Mirror the container's pending wake-ups into one-shot schedules: at(fireAt), poke me, delete
110
+ // after firing. Upsert (create → conflict → update). The container pre-filters DUE alarms (it is
111
+ // awake handling those), so every failure here is REAL — counted and propagated: a swallowed error
112
+ // would leave a pending wake with no alarm, exactly the reliability hole this mechanism closes.
113
+ // Cancelled wakes are NOT deleted here: their poke fires, finds nothing due, and the schedule
114
+ // self-deletes (lazy cleanup by design).
115
+ async function syncAlarms(alarms, ctx) {
116
+ const { SchedulerClient, CreateScheduleCommand, UpdateScheduleCommand } = require("@aws-sdk/client-scheduler");
117
+ const sch = new SchedulerClient({});
118
+ let failed = 0;
119
+ // Alarm name = a stable hash of the WHOLE wake id. A prefix of the id would collide (two wakes
120
+ // sharing 8 hex chars), and a collision is INDISTINGUISHABLE from the legitimate re-arm below:
121
+ // the second wake would "update" the first's alarm and silently steal its fire time.
122
+ const names = new Map();
123
+ for (const a of alarms) {
124
+ const name = process.env.WAKE_PREFIX + crypto.createHash("sha256").update(a.id).digest("hex").slice(0, 16);
125
+ if (names.has(name)) {
126
+ failed += 1;
127
+ console.log(`alarm name collision ${name}: ${names.get(name)} vs ${a.id}`);
128
+ continue;
129
+ }
130
+ names.set(name, a.id);
131
+ const p = {
132
+ Name: name,
133
+ ScheduleExpression: `at(${a.at.slice(0, 19)})`,
134
+ ScheduleExpressionTimezone: "UTC",
135
+ FlexibleTimeWindow: { Mode: "OFF" },
136
+ ActionAfterCompletion: "DELETE",
137
+ Target: { Arn: ctx.invokedFunctionArn, RoleArn: process.env.WAKE_ROLE_ARN, Input: '{"wakePoke":true}' },
138
+ };
139
+ try {
140
+ await sch.send(new CreateScheduleCommand(p));
141
+ } catch (e) {
142
+ try {
143
+ if (e.name !== "ConflictException") throw e;
144
+ await sch.send(new UpdateScheduleCommand(p));
145
+ } catch (u) {
146
+ failed += 1;
147
+ console.log(`alarm ${p.Name}: ${u}`);
148
+ }
149
+ }
150
+ }
151
+ return failed;
152
+ }
153
+
154
+ exports.handler = async (event, ctx) => {
155
+ // EventBridge wake-up poke: the invocation itself wakes the container; its pump does the rest.
156
+ if (event?.wakePoke) {
157
+ const r = await invoke({ kind: "wake-poke" });
158
+ console.log(`wake-poke: ${r.status}`);
159
+ return { status: r.status };
160
+ }
161
+ // EventBridge Scheduler fire — throw on failure so the miss lands in CloudWatch, never silently.
162
+ if (event?.scheduleFire) {
163
+ const { name, slot } = event.scheduleFire;
164
+ const r = await invoke({ kind: "schedule-fire", name, slot });
165
+ const out = r.body.toString();
166
+ console.log(`schedule-fire ${name} (${slot}): ${r.status} ${out}`);
167
+ if (r.status >= 400) throw new Error(`schedule-fire ${name} failed: ${r.status} ${out}`);
168
+ return { status: r.status };
169
+ }
170
+ const http = event?.requestContext?.http;
171
+ if (!http) throw new Error("unrecognized event shape");
172
+ // Refresh the snapshot capabilities with THIS Lambda invocation's current temporary credentials.
173
+ // The container may settle long after the webhook Lambda (and its credentials) expired.
174
+ if (event.rawPath === "/__fastagent/state-urls") {
175
+ const req = JSON.parse(event.isBase64Encoded ? Buffer.from(event.body, "base64").toString() : event.body || "{}");
176
+ if (!secretEq(req.auth, process.env.STATE_REFRESH_SECRET)) return { statusCode: 403, body: "forbidden\n" };
177
+ return {
178
+ statusCode: 200,
179
+ headers: { "content-type": "application/json" },
180
+ body: JSON.stringify({ getUrl: presign("GET", 3600), putUrl: presign("PUT", 3600) }),
181
+ };
182
+ }
183
+ // The container's wake-alarm callback (reserved path, shared secret) — handled HERE, never forwarded.
184
+ if (event.rawPath === "/__fastagent/wake-alarm") {
185
+ const req = JSON.parse(event.isBase64Encoded ? Buffer.from(event.body, "base64").toString() : event.body || "{}");
186
+ if (!secretEq(req.secret, process.env.WAKE_SECRET)) return { statusCode: 403, body: "forbidden\n" };
187
+ const failed = await syncAlarms(req.alarms || [], ctx);
188
+ // Partial failure IS failure: the container retries the whole (idempotent) set until every
189
+ // pending wake really has its alarm.
190
+ if (failed > 0) return { statusCode: 500, body: `${failed} alarm(s) failed\n` };
191
+ return { statusCode: 200, body: "ok\n" };
192
+ }
193
+ // The deploy driver's probe (reserved path, ingress secret): wake the runtime through the SAME
194
+ // trusted envelope pipeline (state URLs included — a direct InvokeAgentRuntime call could not mint
195
+ // them, and would make the runtime construct against a pre-restore mount) and pass its structured
196
+ // transport-200 verdict back VERBATIM. The ordinary webhook path below folds a non-200 transport
197
+ // into an opaque 502, which would strip exactly the diagnostics the probe exists to carry — and it
198
+ // sits BEFORE the WEBHOOKS_ENABLED gate so schedule-only topologies (whose URLs refuse ordinary
199
+ // public traffic) are probeable too.
200
+ if (event.rawPath === "/__fastagent/probe") {
201
+ const req = JSON.parse(event.isBase64Encoded ? Buffer.from(event.body, "base64").toString() : event.body || "{}");
202
+ if (!secretEq(req.auth, process.env.INGRESS_SECRET)) return { statusCode: 403, body: "forbidden\n" };
203
+ const r = await invoke({ kind: "probe" });
204
+ if (r.status !== 200) {
205
+ console.log(`probe transport error ${r.status}: ${r.body}`);
206
+ return { statusCode: 502, body: "upstream error\n" };
207
+ }
208
+ return { statusCode: 200, headers: { "content-type": "application/json" }, body: r.body.toString() };
209
+ }
210
+ // Enforce the advertised ORIGINAL-body ceiling before base64 adds another 4/3 inside the runtime
211
+ // envelope. This also leaves deterministic room for headers/query/JSON under Lambda's 6 MB cap.
212
+ const webhookBytes =
213
+ event.body === undefined
214
+ ? 0
215
+ : event.isBase64Encoded
216
+ ? Buffer.byteLength(event.body, "base64")
217
+ : Buffer.byteLength(event.body);
218
+ // The ceiling rides in as an env var (the template's MAX_WEBHOOK_BODY_BYTES), so a stack whose
219
+ // template predates it would compare against NaN — every comparison false, the limit silently gone.
220
+ const maxWebhookBytes = Number(process.env.MAX_WEBHOOK_BODY_BYTES);
221
+ if (!(maxWebhookBytes > 0))
222
+ throw new Error("MAX_WEBHOOK_BODY_BYTES is missing or invalid — redeploy to regenerate the stack template");
223
+ if (webhookBytes > maxWebhookBytes) return { statusCode: 413, body: "payload too large\n" };
224
+ // A schedule-only deployment has a Function URL solely for the authenticated refresh callback.
225
+ // Reject arbitrary public traffic BEFORE it can wake AgentCore (cost/DoS) or reach an inner route.
226
+ if (process.env.WEBHOOKS_ENABLED !== "1") return { statusCode: 404, body: "not found\n" };
227
+ // Function URL webhook — forward the original request verbatim (signature material included).
228
+ const r = await invoke({
229
+ kind: "webhook",
230
+ method: http.method,
231
+ path: event.rawPath || "/",
232
+ query: event.rawQueryString || undefined,
233
+ headers: event.headers || {},
234
+ bodyB64:
235
+ event.body === undefined
236
+ ? undefined
237
+ : event.isBase64Encoded
238
+ ? event.body
239
+ : Buffer.from(event.body).toString("base64"),
240
+ });
241
+ if (r.status !== 200) {
242
+ console.log(`transport error ${r.status}: ${r.body}`);
243
+ return { statusCode: 502, body: "upstream error\n" };
244
+ }
245
+ const reply = JSON.parse(r.body.toString()); // { status, headers, bodyB64 } from the adapter
246
+ for (const k of Object.keys(reply.headers)) {
247
+ if (/^(content-length|transfer-encoding|connection)$/i.test(k)) delete reply.headers[k];
248
+ }
249
+ return { statusCode: reply.status, headers: reply.headers, body: reply.bodyB64, isBase64Encoded: true };
250
+ };