@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
@@ -29,7 +29,7 @@ export type AgentcoreLogsOutcome = {
29
29
  };
30
30
  /**
31
31
  * Find and tail one AgentCore log source. Discovery is dynamic rather than spelling `-DEFAULT`:
32
- * endpoint naming belongs to AWS, and an edited stack may use a different endpoint. Runtime tailing
33
- * filters the log STREAM prefix so OTEL/spans in the same group never pollute the application log.
32
+ * endpoint naming belongs to AWS, and an edited stack may use a different endpoint. The whole group
33
+ * is tailed — see the file header for why a stream filter cannot narrow it and must not be added.
34
34
  */
35
35
  export declare function tailAgentcoreLogs(plan: AgentcoreLogsPlan, aws: CliRunner, announce?: (message: string) => void): Promise<AgentcoreLogsOutcome>;
@@ -17,8 +17,8 @@ function parseLogGroupNames(stdout) {
17
17
  }
18
18
  /**
19
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.
20
+ * endpoint naming belongs to AWS, and an edited stack may use a different endpoint. The whole group
21
+ * is tailed — see the file header for why a stream filter cannot narrow it and must not be added.
22
22
  */
23
23
  export async function tailAgentcoreLogs(plan, aws, announce = () => { }) {
24
24
  const stack = `fastagent-${plan.name}`;
@@ -1,4 +1,4 @@
1
- import type { ChannelKind } from "../../scaffold/add-channel.ts";
1
+ import type { DeclaredChannel } from "../../channels/discover.ts";
2
2
  import { type Artifact, type ContainerInput } from "../container.ts";
3
3
  /** The one schedule fact the plan needs (from loadSchedules) — name + cron + tz. */
4
4
  export interface ScheduleFact {
@@ -11,10 +11,9 @@ export interface AgentcorePlanInput extends ContainerInput {
11
11
  name: string;
12
12
  /** What satisfies model auth locally: an env-var name, an OAuth/stored label, or undefined. */
13
13
  modelAuth: string | undefined;
14
- /** Known first-party channelseach contributes its secret metadata + webhook step. */
15
- channels: ChannelKind[];
16
- /** ALL route-channel basenames (customs included) — any of them requires the forwarder. */
17
- routeChannels: string[];
14
+ /** Every declared channel and its ingress the source of the secret list, the webhook steps, and
15
+ * whether the forwarder is needed at all (ANY webhook channel requires it, customs included). */
16
+ channels: readonly DeclaredChannel[];
18
17
  /** Extra secret env-var names (fastagent.config deploy.secrets). */
19
18
  extraSecrets?: string[];
20
19
  /** Static schedules — each becomes an EventBridge Scheduler rule targeting the forwarder. */
@@ -32,6 +31,25 @@ export interface AgentcorePlan {
32
31
  name: string;
33
32
  reason: string;
34
33
  }[];
34
+ /** What the stack contains — the ONE reading the template, the runbook and the `--run` driver share. */
35
+ topology: AgentcoreTopology;
36
+ }
37
+ /**
38
+ * Which resources the definition puts in the stack. Read in one place because it was computed in
39
+ * three — the template, the plan and the CLI — and the third disagreed with the first about a
40
+ * schedule EventBridge cannot express (counted by the CLI, not by the template), which is a
41
+ * `cloudformation deploy` rejecting parameters the template never declared.
42
+ */
43
+ export interface AgentcoreTopology {
44
+ /** A webhook channel: the forwarder relays public Function URL traffic to it. */
45
+ webhooks: boolean;
46
+ /** The forwarder Lambda exists — for webhooks, for EventBridge cron rules, or for wake alarms —
47
+ * with its Function URL: every forwarder needs it as the authenticated refresh channel for the
48
+ * state snapshot's presigned URLs, since a turn can outlive the Lambda credentials that signed
49
+ * them. A schedule-only URL rejects every non-reserved path before invoking AgentCore. */
50
+ forwarder: boolean;
51
+ /** The forwarder mirrors the agent's wake-ups into one-shot EventBridge schedules. */
52
+ wakeAlarms: boolean;
35
53
  }
36
54
  /** SessionStorage mount = FASTAGENT_STATE_DIR (AgentCore requires exactly `/mnt/<one-level>`). It is
37
55
  * a fast LOCAL disk only: the platform wipes it on every runtime version update (= every deploy).
@@ -118,15 +136,20 @@ export declare function toEventBridgeCron(cron: string): {
118
136
  error: string;
119
137
  };
120
138
  /**
121
- * The forwarder Lambda source — the ONLY string both the template's inline ZipFile and the readable
122
- * `lambda/forwarder.js` artifact are generated from (one source, no drift). Zero-dependency: the
123
- * Lambda Node runtime bundles AWS SDK v3. CommonJS ON PURPOSE: CloudFormation inline code always
124
- * lands as `index.js`, where ESM `import` is a syntax error (found by the first real deploy). Two
139
+ * The forwarder Lambda source — `forwarder.js` beside this file, the ONE text both the deployment
140
+ * package and the readable `lambda/index.js` artifact are generated from. A real file, not a template
141
+ * literal: it is linted, and it needs no interpolation the one value it once took from here
142
+ * (`MAX_WEBHOOK_BODY_BYTES`) rides in as a Lambda environment variable. Zero-dependency: the
143
+ * Lambda Node runtime bundles AWS SDK v3. CommonJS ON PURPOSE: the package's entry lands as
144
+ * `index.js` ({@link zipSingleFile}), where ESM `import` is a syntax error. Two
125
145
  * event shapes: a Function URL webhook (reconstructed verbatim into a `webhook` envelope; the
126
146
  * channel's REAL response rides back inside the transport reply and is re-emitted byte-exact —
127
147
  * Feishu's URL-verification challenge depends on it), and an EventBridge Scheduler fire
128
- * (`{ scheduleFire }`, slot = the scheduled instant — the container's idempotency key). MUST stay
129
- * under CloudFormation's 4096-byte inline-code cap.
148
+ * (`{ scheduleFire }`, slot = the scheduled instant — the container's idempotency key).
149
+ *
150
+ * It ships as an S3 object rather than inline `ZipFile`, so there is no 4096-byte ceiling on it — a
151
+ * constraint this comment asserted for long after the template stopped inlining, which is the kind
152
+ * of stale rule that stops the next person making a correct change.
130
153
  */
131
154
  export declare function forwarderSource(): string;
132
155
  /**
@@ -28,10 +28,22 @@
28
28
  * host where the build runs on the operator's machine (docker buildx) instead of remotely.
29
29
  */
30
30
  import { createHash } from "node:crypto";
31
+ import { readFileSync } from "node:fs";
31
32
  import { MAX_WEBHOOK_BODY_BYTES } from "../../channels/agentcore-limits.js";
32
33
  import { SECRETS_DIRNAME } from "../../paths.js";
34
+ import { webhookKinds, webhookRunbook } from "../channel-ingress.js";
33
35
  import { containerArtifacts } from "../container.js";
34
36
  import { deploymentSecrets, isEnvKey } from "../secrets.js";
37
+ function agentcoreTopology(input,
38
+ /** Schedules EventBridge CAN express — the ones that become rules. */
39
+ translatedSchedules) {
40
+ const webhooks = input.channels.some((channel) => channel.ingress === "webhook");
41
+ return {
42
+ webhooks,
43
+ forwarder: webhooks || translatedSchedules > 0 || input.selfSchedule,
44
+ wakeAlarms: input.selfSchedule,
45
+ };
46
+ }
35
47
  /** SessionStorage mount = FASTAGENT_STATE_DIR (AgentCore requires exactly `/mnt/<one-level>`). It is
36
48
  * a fast LOCAL disk only: the platform wipes it on every runtime version update (= every deploy).
37
49
  * Durability across deploys comes from the S3 snapshot (channels/agentcore-state.ts). */
@@ -215,233 +227,23 @@ function yamlSingleQuote(value) {
215
227
  return `'${value.replace(/'/g, "''")}'`;
216
228
  }
217
229
  /**
218
- * The forwarder Lambda source — the ONLY string both the template's inline ZipFile and the readable
219
- * `lambda/forwarder.js` artifact are generated from (one source, no drift). Zero-dependency: the
220
- * Lambda Node runtime bundles AWS SDK v3. CommonJS ON PURPOSE: CloudFormation inline code always
221
- * lands as `index.js`, where ESM `import` is a syntax error (found by the first real deploy). Two
230
+ * The forwarder Lambda source — `forwarder.js` beside this file, the ONE text both the deployment
231
+ * package and the readable `lambda/index.js` artifact are generated from. A real file, not a template
232
+ * literal: it is linted, and it needs no interpolation the one value it once took from here
233
+ * (`MAX_WEBHOOK_BODY_BYTES`) rides in as a Lambda environment variable. Zero-dependency: the
234
+ * Lambda Node runtime bundles AWS SDK v3. CommonJS ON PURPOSE: the package's entry lands as
235
+ * `index.js` ({@link zipSingleFile}), where ESM `import` is a syntax error. Two
222
236
  * event shapes: a Function URL webhook (reconstructed verbatim into a `webhook` envelope; the
223
237
  * channel's REAL response rides back inside the transport reply and is re-emitted byte-exact —
224
238
  * Feishu's URL-verification challenge depends on it), and an EventBridge Scheduler fire
225
- * (`{ scheduleFire }`, slot = the scheduled instant — the container's idempotency key). MUST stay
226
- * under CloudFormation's 4096-byte inline-code cap.
239
+ * (`{ scheduleFire }`, slot = the scheduled instant — the container's idempotency key).
240
+ *
241
+ * It ships as an S3 object rather than inline `ZipFile`, so there is no 4096-byte ceiling on it — a
242
+ * constraint this comment asserted for long after the template stopped inlining, which is the kind
243
+ * of stale rule that stops the next person making a correct change.
227
244
  */
228
245
  export function forwarderSource() {
229
- return `// Generated by \`fastagent deploy agentcore\` — the deployment's only ingress.
230
- // REGENERATED ON EVERY DEPLOY; edits here are overwritten and never deployed. \`--run\` builds the
231
- // Lambda package from fastagent's own copy of this source, so this file is the readable MIRROR of
232
- // what runs (and what the manual runbook zips) — not an input you can change.
233
- // Webhooks (Function URL) and EventBridge Scheduler fires are forwarded as envelopes to the
234
- // AgentCore Runtime over SigV4 InvokeAgentRuntime, all on ONE fixed ingress session (fastagent
235
- // channel state is single-writer; one session = at most one microVM). With selfSchedule, this
236
- // Lambda also OWNS the wake alarms: the container POSTs its pending wake-ups to /__fastagent/
237
- // wake-alarm (shared secret) and each becomes a self-deleting one-shot EventBridge schedule that
238
- // pokes this Lambda — which wakes the container, whose wake pump fires the due entry.
239
- // CommonJS on purpose: the deployment package's entry lands as index.js, where ESM import is invalid.
240
- "use strict";
241
- const crypto = require("node:crypto");
242
- const { BedrockAgentCoreClient, InvokeAgentRuntimeCommand } = require("@aws-sdk/client-bedrock-agentcore");
243
- const client = new BedrockAgentCoreClient({});
244
- let ownUrl; // self-resolved once per cold start; rides on every envelope for the wake-alarm callback
245
-
246
- // Presigned S3 URLs for the container's state snapshot. AgentCore wipes the /mnt/state mount on
247
- // every runtime version update (= every deploy), so the durable copy lives in S3 — but the
248
- // container is given NO AWS credentials by the platform, so the only reachable form is a URL that
249
- // carries its own authorization. SigV4 query signing, node:crypto only (no SDK, nothing to install).
250
- const enc = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => \`%\${c.charCodeAt(0).toString(16).toUpperCase()}\`);
251
- const hmac = (key, data) => crypto.createHmac("sha256", key).update(data).digest();
252
-
253
- function presign(method, seconds) {
254
- const bucket = process.env.STATE_BUCKET, key = process.env.STATE_KEY, region = process.env.AWS_REGION;
255
- const host = \`\${bucket}.s3.\${region}.amazonaws.com\`;
256
- const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\\.\\d+/, "");
257
- const scope = \`\${stamp.slice(0, 8)}/\${region}/s3/aws4_request\`;
258
- const pairs = [
259
- ["X-Amz-Algorithm", "AWS4-HMAC-SHA256"],
260
- ["X-Amz-Credential", \`\${process.env.AWS_ACCESS_KEY_ID}/\${scope}\`],
261
- ["X-Amz-Date", stamp],
262
- ["X-Amz-Expires", String(seconds)],
263
- ["X-Amz-SignedHeaders", "host"],
264
- ];
265
- if (process.env.AWS_SESSION_TOKEN) pairs.push(["X-Amz-Security-Token", process.env.AWS_SESSION_TOKEN]);
266
- // The canonical query must be byte-identical to the one on the wire — build it ONCE, reuse below.
267
- const query = pairs
268
- .map(([k, v]) => [enc(k), enc(v)])
269
- .sort((a, b) => (a[0] < b[0] ? -1 : 1))
270
- .map((p) => p.join("="))
271
- .join("&");
272
- const path = \`/\${key.split("/").map(enc).join("/")}\`;
273
- const canonical = [method, path, query, \`host:\${host}\\n\`, "host", "UNSIGNED-PAYLOAD"].join("\\n");
274
- const sts = ["AWS4-HMAC-SHA256", stamp, scope, crypto.createHash("sha256").update(canonical).digest("hex")].join("\\n");
275
- let k = hmac(\`AWS4\${process.env.AWS_SECRET_ACCESS_KEY}\`, stamp.slice(0, 8));
276
- for (const part of [region, "s3", "aws4_request"]) k = hmac(k, part);
277
- return \`https://\${host}\${path}?\${query}&X-Amz-Signature=\${hmac(k, sts).toString("hex")}\`;
278
- }
279
-
280
- async function invoke(envelope) {
281
- if ((process.env.WAKE_SECRET || process.env.STATE_REFRESH_SECRET) && !ownUrl) {
282
- const { LambdaClient, GetFunctionUrlConfigCommand } = require("@aws-sdk/client-lambda");
283
- ownUrl = (await new LambdaClient({}).send(
284
- new GetFunctionUrlConfigCommand({ FunctionName: process.env.AWS_LAMBDA_FUNCTION_NAME }),
285
- )).FunctionUrl;
286
- }
287
- if (ownUrl) envelope.wake = { url: ownUrl };
288
- // Authenticates this envelope as coming from the forwarder (see the template's FastagentIngressSecret).
289
- envelope.auth = process.env.INGRESS_SECRET;
290
- // Keep each capability short-lived. Function-URL deployments also carry an authenticated refresh
291
- // endpoint, so a background turn settling hours after its webhook never depends on the temporary
292
- // Lambda credentials that signed the original pair still being alive.
293
- if (process.env.STATE_BUCKET) envelope.state = {
294
- getUrl: presign("GET", 3600),
295
- putUrl: presign("PUT", 3600),
296
- ...(ownUrl && process.env.STATE_REFRESH_SECRET ? {
297
- refresh: { url: ownUrl.replace(/\\/$/, "") + "/__fastagent/state-urls", auth: process.env.STATE_REFRESH_SECRET },
298
- } : {}),
299
- };
300
- const res = await client.send(new InvokeAgentRuntimeCommand({
301
- agentRuntimeArn: process.env.RUNTIME_ARN,
302
- runtimeSessionId: process.env.INGRESS_SESSION_ID,
303
- contentType: "application/json",
304
- accept: "application/json",
305
- payload: new TextEncoder().encode(JSON.stringify(envelope)),
306
- }));
307
- const body = Buffer.from(await res.response.transformToByteArray());
308
- return { status: res.statusCode ?? 200, body };
309
- }
310
-
311
- // Mirror the container's pending wake-ups into one-shot schedules: at(fireAt), poke me, delete
312
- // after firing. Upsert (create → conflict → update). The container pre-filters DUE alarms (it is
313
- // awake handling those), so every failure here is REAL — counted and propagated: a swallowed error
314
- // would leave a pending wake with no alarm, exactly the reliability hole this mechanism closes.
315
- // Cancelled wakes are NOT deleted here: their poke fires, finds nothing due, and the schedule
316
- // self-deletes (lazy cleanup by design).
317
- async function syncAlarms(alarms, ctx) {
318
- const { SchedulerClient, CreateScheduleCommand, UpdateScheduleCommand } = require("@aws-sdk/client-scheduler");
319
- const sch = new SchedulerClient({});
320
- let failed = 0;
321
- // Alarm name = a stable hash of the WHOLE wake id. A prefix of the id would collide (two wakes
322
- // sharing 8 hex chars), and a collision is INDISTINGUISHABLE from the legitimate re-arm below:
323
- // the second wake would "update" the first's alarm and silently steal its fire time.
324
- const names = new Map();
325
- for (const a of alarms) {
326
- const name = process.env.WAKE_PREFIX + crypto.createHash("sha256").update(a.id).digest("hex").slice(0, 16);
327
- if (names.has(name)) {
328
- failed += 1;
329
- console.log(\`alarm name collision \${name}: \${names.get(name)} vs \${a.id}\`);
330
- continue;
331
- }
332
- names.set(name, a.id);
333
- const p = {
334
- Name: name,
335
- ScheduleExpression: \`at(\${a.at.slice(0, 19)})\`,
336
- ScheduleExpressionTimezone: "UTC",
337
- FlexibleTimeWindow: { Mode: "OFF" },
338
- ActionAfterCompletion: "DELETE",
339
- Target: { Arn: ctx.invokedFunctionArn, RoleArn: process.env.WAKE_ROLE_ARN, Input: '{"wakePoke":true}' },
340
- };
341
- try {
342
- await sch.send(new CreateScheduleCommand(p));
343
- } catch (e) {
344
- try {
345
- if (e.name !== "ConflictException") throw e;
346
- await sch.send(new UpdateScheduleCommand(p));
347
- } catch (u) {
348
- failed += 1;
349
- console.log(\`alarm \${p.Name}: \${u}\`);
350
- }
351
- }
352
- }
353
- return failed;
354
- }
355
-
356
- exports.handler = async (event, ctx) => {
357
- // EventBridge wake-up poke: the invocation itself wakes the container; its pump does the rest.
358
- if (event && event.wakePoke) {
359
- const r = await invoke({ kind: "wake-poke" });
360
- console.log(\`wake-poke: \${r.status}\`);
361
- return { status: r.status };
362
- }
363
- // EventBridge Scheduler fire — throw on failure so the miss lands in CloudWatch, never silently.
364
- if (event && event.scheduleFire) {
365
- const { name, slot } = event.scheduleFire;
366
- const r = await invoke({ kind: "schedule-fire", name, slot });
367
- const out = r.body.toString();
368
- console.log(\`schedule-fire \${name} (\${slot}): \${r.status} \${out}\`);
369
- if (r.status >= 400) throw new Error(\`schedule-fire \${name} failed: \${r.status} \${out}\`);
370
- return { status: r.status };
371
- }
372
- const http = event && event.requestContext && event.requestContext.http;
373
- if (!http) throw new Error("unrecognized event shape");
374
- // Refresh the snapshot capabilities with THIS Lambda invocation's current temporary credentials.
375
- // The container may settle long after the webhook Lambda (and its credentials) expired.
376
- if (event.rawPath === "/__fastagent/state-urls") {
377
- const req = JSON.parse(event.isBase64Encoded ? Buffer.from(event.body, "base64").toString() : event.body || "{}");
378
- const actual = Buffer.from(typeof req.auth === "string" ? req.auth : "");
379
- const expected = Buffer.from(process.env.STATE_REFRESH_SECRET || "");
380
- if (!expected.length || actual.length !== expected.length || !crypto.timingSafeEqual(actual, expected)) {
381
- return { statusCode: 403, body: "forbidden\\n" };
382
- }
383
- return {
384
- statusCode: 200,
385
- headers: { "content-type": "application/json" },
386
- body: JSON.stringify({ getUrl: presign("GET", 3600), putUrl: presign("PUT", 3600) }),
387
- };
388
- }
389
- // The container's wake-alarm callback (reserved path, shared secret) — handled HERE, never forwarded.
390
- if (event.rawPath === "/__fastagent/wake-alarm") {
391
- const req = JSON.parse(event.isBase64Encoded ? Buffer.from(event.body, "base64").toString() : event.body || "{}");
392
- if (!process.env.WAKE_SECRET || req.secret !== process.env.WAKE_SECRET) return { statusCode: 403, body: "forbidden\\n" };
393
- const failed = await syncAlarms(req.alarms || [], ctx);
394
- // Partial failure IS failure: the container retries the whole (idempotent) set until every
395
- // pending wake really has its alarm.
396
- if (failed > 0) return { statusCode: 500, body: \`\${failed} alarm(s) failed\\n\` };
397
- return { statusCode: 200, body: "ok\\n" };
398
- }
399
- // The deploy driver's probe (reserved path, ingress secret): wake the runtime through the SAME
400
- // trusted envelope pipeline (state URLs included — a direct InvokeAgentRuntime call could not mint
401
- // them, and would make the runtime construct against a pre-restore mount) and pass its structured
402
- // transport-200 verdict back VERBATIM. The ordinary webhook path below folds a non-200 transport
403
- // into an opaque 502, which would strip exactly the diagnostics the probe exists to carry — and it
404
- // sits BEFORE the WEBHOOKS_ENABLED gate so schedule-only topologies (whose URLs refuse ordinary
405
- // public traffic) are probeable too.
406
- if (event.rawPath === "/__fastagent/probe") {
407
- const req = JSON.parse(event.isBase64Encoded ? Buffer.from(event.body, "base64").toString() : event.body || "{}");
408
- if (!process.env.INGRESS_SECRET || req.auth !== process.env.INGRESS_SECRET) return { statusCode: 403, body: "forbidden\\n" };
409
- const r = await invoke({ kind: "probe" });
410
- if (r.status !== 200) {
411
- console.log(\`probe transport error \${r.status}: \${r.body}\`);
412
- return { statusCode: 502, body: "upstream error\\n" };
413
- }
414
- return { statusCode: 200, headers: { "content-type": "application/json" }, body: r.body.toString() };
415
- }
416
- // Enforce the advertised ORIGINAL-body ceiling before base64 adds another 4/3 inside the runtime
417
- // envelope. This also leaves deterministic room for headers/query/JSON under Lambda's 6 MB cap.
418
- const webhookBytes = event.body === undefined ? 0
419
- : event.isBase64Encoded ? Buffer.byteLength(event.body, "base64") : Buffer.byteLength(event.body);
420
- if (webhookBytes > ${MAX_WEBHOOK_BODY_BYTES}) return { statusCode: 413, body: "payload too large\\n" };
421
- // A schedule-only deployment has a Function URL solely for the authenticated refresh callback.
422
- // Reject arbitrary public traffic BEFORE it can wake AgentCore (cost/DoS) or reach an inner route.
423
- if (process.env.WEBHOOKS_ENABLED !== "1") return { statusCode: 404, body: "not found\\n" };
424
- // Function URL webhook — forward the original request verbatim (signature material included).
425
- const r = await invoke({
426
- kind: "webhook",
427
- method: http.method,
428
- path: event.rawPath || "/",
429
- query: event.rawQueryString || undefined,
430
- headers: event.headers || {},
431
- bodyB64: event.body === undefined ? undefined
432
- : event.isBase64Encoded ? event.body : Buffer.from(event.body).toString("base64"),
433
- });
434
- if (r.status !== 200) {
435
- console.log(\`transport error \${r.status}: \${r.body}\`);
436
- return { statusCode: 502, body: "upstream error\\n" };
437
- }
438
- const reply = JSON.parse(r.body.toString()); // { status, headers, bodyB64 } from the adapter
439
- for (const k of Object.keys(reply.headers)) {
440
- if (/^(content-length|transfer-encoding|connection)$/i.test(k)) delete reply.headers[k];
441
- }
442
- return { statusCode: reply.status, headers: reply.headers, body: reply.bodyB64, isBase64Encoded: true };
443
- };
444
- `;
246
+ return readFileSync(new URL("./forwarder.js", import.meta.url), "utf8");
445
247
  }
446
248
  /**
447
249
  * The EventBridge physical name for a schedule. A schedule's local name is an arbitrary MODULE FILE
@@ -458,15 +260,9 @@ export function scheduleResourceName(agent, schedule) {
458
260
  return `${prefix}${safe.slice(0, room)}-${hash}`;
459
261
  }
460
262
  /** The CloudFormation template — the whole topology in one stack. */
461
- function template(input, translated) {
263
+ function template(input, translated, topology) {
462
264
  const runtimeName = toRuntimeName(input.name);
463
- // selfSchedule needs the forwarder too: it is both the wake-alarm registrar and the poke target.
464
- // Every forwarder also gets a Function URL as the authenticated state-capability refresh channel:
465
- // a schedule turn can outlive both its original presigned URL and the Lambda credentials that signed
466
- // it. Schedule-only URLs reject every non-reserved HTTP path before invoking AgentCore, so they do
467
- // not expose a webhook/data plane (and start never mounts the builtin /invoke under AgentCore).
468
- const needsForwarder = input.routeChannels.length > 0 || translated.length > 0 || input.selfSchedule;
469
- const needsFunctionUrl = needsForwarder;
265
+ const needsForwarder = topology.forwarder;
470
266
  const secrets = deploymentSecrets(input.modelAuth, input.channels, input.extraSecrets);
471
267
  const forwarderFnArn = `!Sub arn:aws:lambda:\${AWS::Region}:\${AWS::AccountId}:function:fastagent-${input.name}-forwarder`;
472
268
  // Secret env vars ride CFN NoEcho parameters. FASTAGENT_AUTH_SEED is always declared (Default "")
@@ -598,21 +394,21 @@ function template(input, translated) {
598
394
  ` Action: iam:PassRole`,
599
395
  ` Resource: !GetAtt WakeSchedulerRole.Arn`,
600
396
  ]
601
- : []), ...(needsFunctionUrl
397
+ : []), ...(needsForwarder
602
398
  ? [
603
399
  ` - Effect: Allow # self-resolve callback URL for state-URL refresh / wake alarms`,
604
400
  ` Action: lambda:GetFunctionUrlConfig`,
605
401
  ` Resource: ${forwarderFnArn}`,
606
402
  ]
607
- : []), ``, ` Forwarder:`, ` Type: AWS::Lambda::Function`, ` Properties:`, ` FunctionName: fastagent-${input.name}-forwarder`, ` Runtime: nodejs22.x`, ` Handler: index.handler`, ` # Webhook ACKs are fast, but schedule-fire holds the connection for the WHOLE agent turn`, ` # (claim-before-run means a timeout never double-fires; the turn also continues and is`, ` # audited container-side). EventBridge→Lambda is async, so the long timeout costs nothing.`, ` Timeout: 900`, ` MemorySize: 256`, ` Role: !GetAtt ForwarderRole.Arn`, ` Environment:`, ` Variables:`, ` RUNTIME_ARN: !GetAtt Runtime.AgentRuntimeArn`, ` INGRESS_SESSION_ID: ${ingressSessionId(input.name)}`, ...(needsFunctionUrl ? [` STATE_REFRESH_SECRET: !Ref FastagentIngressSecret`] : []), ...(input.routeChannels.length > 0 ? [` WEBHOOKS_ENABLED: "1"`] : []), ...(input.selfSchedule
403
+ : []), ``, ` Forwarder:`, ` Type: AWS::Lambda::Function`, ` Properties:`, ` FunctionName: fastagent-${input.name}-forwarder`, ` Runtime: nodejs22.x`, ` Handler: index.handler`, ` # Webhook ACKs are fast, but schedule-fire holds the connection for the WHOLE agent turn`, ` # (claim-before-run means a timeout never double-fires; the turn also continues and is`, ` # audited container-side). EventBridge→Lambda is async, so the long timeout costs nothing.`, ` Timeout: 900`, ` MemorySize: 256`, ` Role: !GetAtt ForwarderRole.Arn`, ` Environment:`, ` Variables:`, ` RUNTIME_ARN: !GetAtt Runtime.AgentRuntimeArn`, ` INGRESS_SESSION_ID: ${ingressSessionId(input.name)}`, ...(needsForwarder ? [` STATE_REFRESH_SECRET: !Ref FastagentIngressSecret`] : []), ...(topology.webhooks ? [` WEBHOOKS_ENABLED: "1"`] : []), ...(input.selfSchedule
608
404
  ? [
609
405
  ` WAKE_SECRET: !Ref FastagentWakeSecret`,
610
406
  ` WAKE_ROLE_ARN: !GetAtt WakeSchedulerRole.Arn`,
611
407
  ` WAKE_PREFIX: fa-${input.name}-wk-`,
612
408
  ]
613
- : []), ` INGRESS_SECRET: !Ref FastagentIngressSecret`, ` STATE_BUCKET: !Ref StateBucket`, ` STATE_KEY: ${STATE_KEY}`, ` # From S3, not inline: the forwarder mints SigV4-presigned URLs for the state snapshot and`, ` # no longer fits CloudFormation's 4096-byte inline cap. The key is content-hashed, so a`, ` # code change is a parameter change — CloudFormation cannot miss it.`, ` Code:`, ` S3Bucket: !Ref StateBucket`, ` S3Key: !Ref ForwarderS3Key`);
409
+ : []), ` INGRESS_SECRET: !Ref FastagentIngressSecret`, ` STATE_BUCKET: !Ref StateBucket`, ` STATE_KEY: ${STATE_KEY}`, ` MAX_WEBHOOK_BODY_BYTES: "${MAX_WEBHOOK_BODY_BYTES}"`, ` # From S3, not inline: the forwarder mints SigV4-presigned URLs for the state snapshot and`, ` # no longer fits CloudFormation's 4096-byte inline cap. The key is content-hashed, so a`, ` # code change is a parameter change — CloudFormation cannot miss it.`, ` Code:`, ` S3Bucket: !Ref StateBucket`, ` S3Key: !Ref ForwarderS3Key`);
614
410
  }
615
- if (needsFunctionUrl) {
411
+ if (needsForwarder) {
616
412
  lines.push(``, ` ForwarderUrl:`, ` Type: AWS::Lambda::Url`, ` Properties:`, ` TargetFunctionArn: !GetAtt Forwarder.Arn`, ` # NONE is deliberate: webhook callers (Telegram/Feishu) cannot SigV4-sign. Authenticity is`, ` # verified downstream by each channel (secret token / signature), exactly as on every host.`, ` AuthType: NONE`, ``, ` ForwarderUrlPermission:`, ` Type: AWS::Lambda::Permission`, ` Properties:`, ` FunctionName: !Ref Forwarder`, ` Action: lambda:InvokeFunctionUrl`, ` Principal: "*"`, ` FunctionUrlAuthType: NONE`, ``, ` # Function URLs created after Oct 2025 require lambda:InvokeFunction IN ADDITION to`, ` # lambda:InvokeFunctionUrl for public (NONE) access — with only the first, every request 403s`, ` # (found by the first real deploy). InvokedViaFunctionUrl scopes it to URL traffic: without it`, ` # the bare * principal would also let any AWS principal call the Lambda API directly, bypassing`, ` # the Function URL event shape to forge internal events.`, ` ForwarderInvokePermission:`, ` Type: AWS::Lambda::Permission`, ` Properties:`, ` FunctionName: !Ref Forwarder`, ` Action: lambda:InvokeFunction`, ` Principal: "*"`, ` InvokedViaFunctionUrl: true`);
617
413
  if (input.selfSchedule) {
618
414
  lines.push(``, ` # The role the wake-poke schedules assume to invoke the forwarder. Its policy names the`, ` # function by CONSTRUCTED arn (not !Ref) — the forwarder's env references this role, so a`, ` # !Ref back would be a circular dependency.`, ` WakeSchedulerRole:`, ` Type: AWS::IAM::Role`, ` Properties:`, ` AssumeRolePolicyDocument:`, ` Version: "2012-10-17"`, ` Statement:`, ` - Effect: Allow`, ` Principal: { Service: scheduler.amazonaws.com }`, ` Action: sts:AssumeRole`, ` Condition:`, ` StringEquals: { aws:SourceAccount: !Ref AWS::AccountId }`, ` Policies:`, ` - PolicyName: poke-forwarder`, ` PolicyDocument:`, ` Version: "2012-10-17"`, ` Statement:`, ` - Effect: Allow`, ` Action: lambda:InvokeFunction`, ` Resource: ${forwarderFnArn}`);
@@ -625,7 +421,7 @@ function template(input, translated) {
625
421
  }
626
422
  }
627
423
  lines.push(``, `Outputs:`, ` RuntimeArn:`, ` Value: !GetAtt Runtime.AgentRuntimeArn`);
628
- if (needsFunctionUrl) {
424
+ if (needsForwarder) {
629
425
  lines.push(` ForwarderUrl:`, ` Value: !GetAtt ForwarderUrl.FunctionUrl`);
630
426
  }
631
427
  return `${lines.join("\n")}\n`;
@@ -669,12 +465,10 @@ export function planAgentcoreDeploy(input) {
669
465
  }
670
466
  paramNames.set(p, s.name);
671
467
  }
672
- // Every forwarder needs its authenticated Function URL to refresh S3 snapshot capabilities during
673
- // a long turn. Without route channels, ordinary HTTP paths are rejected before AgentCore is invoked.
674
- const needsForwarder = input.routeChannels.length > 0 || translated.length > 0 || input.selfSchedule;
675
- const needsFunctionUrl = needsForwarder;
468
+ const topology = agentcoreTopology(input, translated.length);
469
+ const needsForwarder = topology.forwarder;
676
470
  const artifacts = [
677
- { path: `${prefix}${TEMPLATE_FILE}`, content: template(input, translated) },
471
+ { path: `${prefix}${TEMPLATE_FILE}`, content: template(input, translated, topology) },
678
472
  ...(needsForwarder ? [{ path: `${prefix}${FORWARDER_FILE}`, content: forwarderSource() }] : []),
679
473
  ...containerArtifacts(input),
680
474
  ];
@@ -726,9 +520,9 @@ export function planAgentcoreDeploy(input) {
726
520
  if (input.selfSchedule) {
727
521
  runbook.push(`# FastagentWakeSecret: the wake-alarm shared secret — any random string (\`--run\` mints one)`);
728
522
  }
729
- runbook.push(`aws cloudformation deploy --stack-name ${stack} --template-file ${prefix}${TEMPLATE_FILE} \\`, ` --capabilities CAPABILITY_IAM \\`, ` --parameter-overrides ImageUri=${image}${needsForwarder ? ` StateBucket=${bucketHint} ForwarderS3Key=forwarder/<hash>.zip` : ""}${requiredSecrets.length > 0 ? ` ${paramHint(requiredSecrets)}` : ""}${wakeSecretHint}`, ``, needsFunctionUrl
523
+ runbook.push(`aws cloudformation deploy --stack-name ${stack} --template-file ${prefix}${TEMPLATE_FILE} \\`, ` --capabilities CAPABILITY_IAM \\`, ` --parameter-overrides ImageUri=${image}${needsForwarder ? ` StateBucket=${bucketHint} ForwarderS3Key=forwarder/<hash>.zip` : ""}${requiredSecrets.length > 0 ? ` ${paramHint(requiredSecrets)}` : ""}${wakeSecretHint}`, ``, needsForwarder
730
524
  ? `# 4. Read the outputs (the runtime ARN + callback URL; it serves webhooks only when configured):`
731
- : `# 4. Read the outputs (the runtime ARN — this topology has NO public URL: nothing outside AWS`, ...(needsFunctionUrl
525
+ : `# 4. Read the outputs (the runtime ARN — this topology has NO public URL: nothing outside AWS`, ...(needsForwarder
732
526
  ? []
733
527
  : [`# 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 resolves the per-endpoint log group from the stack's RuntimeArn:`, `fastagent logs agentcore --follow`, ...(needsForwarder
734
528
  ? [
@@ -743,22 +537,21 @@ export function planAgentcoreDeploy(input) {
743
537
  ? `# Model auth: none found at the local auth path — pass --auth-path <file>, or \`--run\` carries it`
744
538
  : `# Model auth: your local auth is "${input.modelAuth}" — the plan can't read its value; \`--run\` carries it`, `# as the FastagentAuthSeed parameter (base64 of auth.json), materialized on first boot.`);
745
539
  }
746
- // Post-deploy webhook registration — same per-channel steps as every host, pointed at the
747
- // forwarder's Function URL (read from the stack outputs).
748
- const post = [];
749
- if (channels.includes("telegram")) {
750
- post.push(`# Register the Telegram webhook (default route POST /telegram; secret_token MUST equal TELEGRAM_SECRET_TOKEN):`, `curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/setWebhook" \\`, ` -d url=<ForwarderUrl>/telegram -d secret_token=<TELEGRAM_SECRET_TOKEN>`);
751
- }
752
- if (channels.includes("github")) {
753
- post.push(`# Set the GitHub webhook (repo Settings → Webhooks): Payload URL = <ForwarderUrl>/webhook,`, `# content type application/json, secret = GITHUB_WEBHOOK_SECRET.`, `# NOTE: github turns are fire-and-forget with no replay — a compute reclaimed mid-review drops it`, `# (the ping's HealthyBusy + time_of_last_update holds the session while turns run, but the 8 h compute ceiling is hard).`);
754
- }
755
- if (channels.includes("slack")) {
756
- post.push(`# Set Slack Event Subscriptions Request URL = <ForwarderUrl>/slack (scopes per channels/slack.ts).`);
757
- }
758
- for (const kind of ["feishu", "lark"]) {
759
- if (!channels.includes(kind))
760
- continue;
761
- post.push(`# Set the ${kind === "feishu" ? "Feishu" : "Lark"} event Request URL (developer console → Events & Callbacks):`, `# Request URL = <ForwarderUrl>/${kind} (the stack must be deployed when you save — the console`, `# sends a challenge, which rides through the forwarder to the channel and back verbatim).`);
540
+ // Post-deploy webhook registration — the shared channel-ingress steps, pointed at the forwarder's
541
+ // Function URL (read from the stack outputs). `--run` REFUSES a long-connection channel here, but
542
+ // generate-only only warns and still prints this runbook, so the steps are filtered on ingress like
543
+ // every other host's rather than on the CLI having gated.
544
+ const post = webhookRunbook(`<ForwarderUrl>`, channels);
545
+ // AgentCore asides the shared steps cannot carry: nothing else routes through a forwarder, and
546
+ // nothing else has a compute ceiling.
547
+ // Named rather than positional: the asides land after ALL the steps, so "the console" has to say
548
+ // WHICH console. One line for both kinds — an agent that declares feishu AND lark reads the same
549
+ // fact twice otherwise and looks for a second step that does not exist.
550
+ if (webhookKinds(channels).some((kind) => kind === "feishu" || kind === "lark")) {
551
+ post.push(`# NOTE: the Feishu/Lark console's challenge rides through the forwarder to the channel and back`, `# verbatim, so the stack must be deployed when you save the Request URL.`);
552
+ }
553
+ if (channels.some((channel) => channel.name === "github")) {
554
+ post.push(`# NOTE: github turns are fire-and-forget with no replay — a compute reclaimed mid-review drops it`, `# (the ping's HealthyBusy + time_of_last_update holds the session while turns run, but the 8 h compute ceiling is hard).`);
762
555
  }
763
556
  if (post.length > 0)
764
557
  runbook.push(``, ...post);
@@ -773,5 +566,5 @@ export function planAgentcoreDeploy(input) {
773
566
  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)}"`);
774
567
  }
775
568
  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.`);
776
- return { artifacts, runbook, untranslatableSchedules: untranslatable };
569
+ return { artifacts, runbook, untranslatableSchedules: untranslatable, topology };
777
570
  }
@@ -1,22 +1,7 @@
1
- /**
2
- * `fastagent deploy agentcore --run` — drive the AWS CLI + Docker to completion. The middle of the
3
- * deploy the plain runbook hands to the operator; `--run` executes it so a coding agent runs ONE
4
- * command. Idempotent (ECR check-then-act; `cloudformation deploy` converges the stack) and
5
- * resumable: it STOPS at a human gate with one actionable line and a non-zero exit.
6
- *
7
- * TWO runners, one seam ({@link CliRunner}): `aws` (identity, ECR, CloudFormation) and `docker`
8
- * (buildx). AgentCore is the ONE host whose image builds on the operator's machine — the platform
9
- * requires linux/arm64 in the account's ECR and has no remote builder — so a missing Docker/buildx
10
- * is a first-class gate, not an incidental failure.
11
- *
12
- * Secrets ride CloudFormation NoEcho parameters. `--parameter-overrides` on argv would put the
13
- * values in the process listing (the same reason Fly imports secrets over stdin), so they go through
14
- * a caller-provided temp parameters file (`file://…`, mode 0600, deleted by the caller) — the write
15
- * is injected to keep this module pure and the security-sensitive wiring testable.
16
- */
17
- import type { RegistrationOutcome } from "../../channels/registration.ts";
18
- import type { ChannelKind } from "../../scaffold/add-channel.ts";
1
+ import type { DeclaredChannel } from "../../channels/discover.ts";
2
+ import { type Registrars } from "../channel-ingress.ts";
19
3
  import type { CliRunner } from "../runner.ts";
4
+ import { type AgentcoreTopology } from "./plan.ts";
20
5
  export interface AgentcoreRunPlan {
21
6
  /** The base name — stack `fastagent-<name>`, ECR repo `fastagent/<name>`. */
22
7
  name: string;
@@ -35,11 +20,11 @@ export interface AgentcoreRunPlan {
35
20
  secrets: Record<string, string>;
36
21
  /** Required secret names with NO local value — gated before any side effect. */
37
22
  missingSecrets: string[];
38
- channels: ChannelKind[];
39
- /** Whether the topology includes the forwarder Lambda (route channels / schedules / selfSchedule).
40
- * It owns the state snapshot's presigned URLs, so its absence means an invoke-only deployment
41
- * with no cross-deploy state to keep. */
42
- needsForwarder: boolean;
23
+ /** Every declared channel and its ingress — the driver asks which of them have a webhook. */
24
+ channels: readonly DeclaredChannel[];
25
+ /** What the stack contains — the plan's own reading, so this driver cannot disagree with the
26
+ * template about whether a forwarder (and with it the state bucket and its parameters) exists. */
27
+ topology: AgentcoreTopology;
43
28
  }
44
29
  export type AgentcoreRunOutcome = {
45
30
  ok: true;
@@ -70,7 +55,7 @@ export declare function parseCheckpointReply(stdout: string): CheckpointReply |
70
55
  * post-deploy webhook steps from the builder machine against the forwarder's Function URL. Every
71
56
  * gate is fail-visible; `writeSecretFile` is the caller's 0600-temp-file seam (see the header).
72
57
  */
73
- export declare function deployAgentcoreRun(plan: AgentcoreRunPlan, aws: CliRunner, docker: CliRunner, log: (msg: string) => void, writeSecretFile: (content: string) => Promise<string>, writeForwarderZip: (bytes: Uint8Array) => Promise<string>, registerTelegram: (baseUrl: string) => Promise<RegistrationOutcome>, registerFeishu?: (baseUrl: string, kind: "feishu" | "lark") => Promise<RegistrationOutcome>, registerSlack?: (baseUrl: string) => Promise<RegistrationOutcome>,
58
+ export declare function deployAgentcoreRun(plan: AgentcoreRunPlan, aws: CliRunner, docker: CliRunner, log: (msg: string) => void, writeSecretFile: (content: string) => Promise<string>, writeForwarderZip: (bytes: Uint8Array) => Promise<string>, registrars: Registrars,
74
59
  /** Injected in tests; the probe itself stays inside the run so no deploy can skip it. */
75
60
  probe?: {
76
61
  fetchImpl?: typeof fetch;