@fastagent-sh/fastagent 0.18.0 → 0.20.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 (222) hide show
  1. package/README.md +25 -7
  2. package/dist/agent.d.ts +1 -1
  3. package/dist/agent.js +1 -1
  4. package/dist/atomic-write.d.ts +23 -0
  5. package/dist/atomic-write.js +53 -0
  6. package/dist/{host/node.d.ts → channel.d.ts} +24 -28
  7. package/dist/channel.js +1 -0
  8. package/dist/channels/agentcore-limits.d.ts +9 -0
  9. package/dist/channels/agentcore-limits.js +9 -0
  10. package/dist/channels/agentcore-service.d.ts +55 -0
  11. package/dist/channels/agentcore-service.js +107 -0
  12. package/dist/channels/agentcore.d.ts +22 -20
  13. package/dist/channels/agentcore.js +20 -11
  14. package/dist/channels/busy.d.ts +1 -1
  15. package/dist/channels/busy.js +1 -1
  16. package/dist/channels/control.d.ts +55 -7
  17. package/dist/channels/control.js +312 -67
  18. package/dist/channels/discover.d.ts +59 -0
  19. package/dist/{engines/pi/channel.js → channels/discover.js} +19 -34
  20. package/dist/channels/feishu/card.js +1 -1
  21. package/dist/channels/feishu/context-buffer.d.ts +1 -1
  22. package/dist/channels/feishu/context-buffer.js +4 -4
  23. package/dist/channels/feishu/feishu-api.js +6 -9
  24. package/dist/channels/feishu/feishu.d.ts +1 -1
  25. package/dist/channels/feishu/feishu.js +41 -24
  26. package/dist/channels/feishu/invoke-turn.d.ts +1 -1
  27. package/dist/channels/feishu/invoke-turn.js +3 -4
  28. package/dist/channels/feishu/preview.d.ts +1 -1
  29. package/dist/channels/feishu/preview.js +7 -3
  30. package/dist/channels/feishu/ws-ingress.d.ts +1 -1
  31. package/dist/channels/github/github.d.ts +1 -1
  32. package/dist/channels/http.d.ts +11 -7
  33. package/dist/channels/http.js +1 -109
  34. package/dist/channels/kit/attachment-path.d.ts +12 -0
  35. package/dist/channels/kit/attachment-path.js +43 -0
  36. package/dist/channels/{context-buffer.d.ts → kit/context-buffer.d.ts} +9 -0
  37. package/dist/channels/{context-buffer.js → kit/context-buffer.js} +13 -2
  38. package/dist/channels/{invoke-turn-kit.d.ts → kit/invoke-turn-kit.d.ts} +14 -3
  39. package/dist/channels/{invoke-turn-kit.js → kit/invoke-turn-kit.js} +17 -6
  40. package/dist/channels/{preview-kit.d.ts → kit/preview-kit.d.ts} +3 -1
  41. package/dist/channels/{seen.js → kit/seen.js} +1 -1
  42. package/dist/channels/kit/signature.d.ts +12 -0
  43. package/dist/channels/kit/signature.js +17 -0
  44. package/dist/channels/{state.js → kit/state.js} +4 -7
  45. package/dist/channels/{stop-command.d.ts → kit/stop-command.d.ts} +1 -1
  46. package/dist/channels/{stop-command.js → kit/stop-command.js} +5 -5
  47. package/dist/channels/{tasks.d.ts → kit/tasks.d.ts} +1 -1
  48. package/dist/channels/{tasks.js → kit/tasks.js} +11 -5
  49. package/dist/channels/{thread-participants.js → kit/thread-participants.js} +1 -1
  50. package/dist/channels/{turn-queue.js → kit/turn-queue.js} +2 -2
  51. package/dist/channels/{turn-store.d.ts → kit/turn-store.d.ts} +19 -3
  52. package/dist/channels/{turn-store.js → kit/turn-store.js} +30 -3
  53. package/dist/channels/lark/lark.d.ts +1 -1
  54. package/dist/channels/serve.d.ts +73 -0
  55. package/dist/channels/serve.js +243 -0
  56. package/dist/channels/slack/bot-auth.js +3 -14
  57. package/dist/channels/slack/context-buffer.d.ts +2 -2
  58. package/dist/channels/slack/context-buffer.js +2 -2
  59. package/dist/channels/slack/invoke-turn.d.ts +2 -2
  60. package/dist/channels/slack/invoke-turn.js +13 -6
  61. package/dist/channels/slack/onboard.js +5 -5
  62. package/dist/channels/slack/onboarding-state.d.ts +7 -3
  63. package/dist/channels/slack/onboarding-state.js +13 -20
  64. package/dist/channels/slack/parse.js +2 -2
  65. package/dist/channels/slack/preview.d.ts +1 -1
  66. package/dist/channels/slack/preview.js +11 -3
  67. package/dist/channels/slack/register-webhook.js +1 -1
  68. package/dist/channels/slack/scaffold/slack-send.ts +2 -17
  69. package/dist/channels/slack/slack-api.js +23 -55
  70. package/dist/channels/slack/slack.d.ts +1 -1
  71. package/dist/channels/slack/slack.js +20 -20
  72. package/dist/channels/slack/welcomed.js +1 -1
  73. package/dist/channels/telegram/context-buffer.d.ts +2 -2
  74. package/dist/channels/telegram/context-buffer.js +2 -2
  75. package/dist/channels/telegram/invoke-turn.d.ts +1 -1
  76. package/dist/channels/telegram/invoke-turn.js +1 -1
  77. package/dist/channels/telegram/parse.d.ts +21 -1
  78. package/dist/channels/telegram/parse.js +67 -13
  79. package/dist/channels/telegram/preview.d.ts +1 -1
  80. package/dist/channels/telegram/preview.js +6 -2
  81. package/dist/channels/telegram/scaffold/channel.ts +7 -3
  82. package/dist/channels/telegram/telegram-api.js +5 -6
  83. package/dist/channels/telegram/telegram.d.ts +3 -3
  84. package/dist/channels/telegram/telegram.js +60 -72
  85. package/dist/channels/telegram/turn-store.d.ts +2 -2
  86. package/dist/channels/telegram/turn-store.js +2 -2
  87. package/dist/cli/add-slack.js +6 -5
  88. package/dist/cli/commands/add.js +1 -5
  89. package/dist/cli/commands/attach.d.ts +2 -4
  90. package/dist/cli/commands/attach.js +17 -15
  91. package/dist/cli/commands/deploy.d.ts +50 -5
  92. package/dist/cli/commands/deploy.js +183 -134
  93. package/dist/cli/commands/dev.js +25 -40
  94. package/dist/cli/commands/fire.js +1 -1
  95. package/dist/cli/commands/info.js +7 -5
  96. package/dist/cli/commands/init.js +1 -2
  97. package/dist/cli/commands/schedule.js +1 -1
  98. package/dist/cli/commands/start.js +80 -121
  99. package/dist/cli/commands/tool.js +5 -7
  100. package/dist/cli/program.js +1 -1
  101. package/dist/cli/serve.d.ts +22 -70
  102. package/dist/cli/serve.js +66 -257
  103. package/dist/cli/shared.d.ts +40 -10
  104. package/dist/cli/shared.js +53 -6
  105. package/dist/core.d.ts +3 -5
  106. package/dist/core.js +7 -5
  107. package/dist/deploy/agentcore/logs.d.ts +2 -2
  108. package/dist/deploy/agentcore/logs.js +2 -2
  109. package/dist/deploy/agentcore/plan.d.ts +12 -10
  110. package/dist/deploy/agentcore/plan.js +39 -31
  111. package/dist/deploy/agentcore/run.d.ts +5 -4
  112. package/dist/deploy/agentcore/run.js +13 -36
  113. package/dist/deploy/channel-ingress.d.ts +73 -0
  114. package/dist/deploy/channel-ingress.js +101 -0
  115. package/dist/deploy/docker/plan.d.ts +3 -7
  116. package/dist/deploy/docker/plan.js +4 -15
  117. package/dist/deploy/docker/run.d.ts +18 -1
  118. package/dist/deploy/docker/run.js +10 -0
  119. package/dist/deploy/fly/plan.d.ts +4 -5
  120. package/dist/deploy/fly/plan.js +7 -22
  121. package/dist/deploy/fly/run.d.ts +9 -23
  122. package/dist/deploy/fly/run.js +32 -68
  123. package/dist/deploy/preflight.d.ts +6 -7
  124. package/dist/deploy/preflight.js +27 -23
  125. package/dist/deploy/railway/plan.d.ts +4 -5
  126. package/dist/deploy/railway/plan.js +11 -26
  127. package/dist/deploy/railway/run.d.ts +9 -9
  128. package/dist/deploy/railway/run.js +40 -40
  129. package/dist/deploy/runner.js +22 -2
  130. package/dist/deploy/secrets.d.ts +18 -9
  131. package/dist/deploy/secrets.js +64 -12
  132. package/dist/dev-supervisor.js +11 -6
  133. package/dist/engines/pi/agent-session-factory.d.ts +104 -0
  134. package/dist/engines/pi/agent-session-factory.js +295 -0
  135. package/dist/engines/pi/auth.js +43 -43
  136. package/dist/engines/pi/config.d.ts +5 -5
  137. package/dist/engines/pi/config.js +1 -1
  138. package/dist/engines/pi/create.d.ts +67 -42
  139. package/dist/engines/pi/create.js +142 -72
  140. package/dist/engines/pi/definition.d.ts +42 -8
  141. package/dist/engines/pi/definition.js +114 -11
  142. package/dist/engines/pi/invoke-session.d.ts +22 -19
  143. package/dist/engines/pi/invoke-session.js +286 -100
  144. package/dist/engines/pi/login.js +39 -20
  145. package/dist/engines/pi/models.d.ts +14 -0
  146. package/dist/engines/pi/models.js +9 -1
  147. package/dist/engines/pi/open.d.ts +10 -8
  148. package/dist/engines/pi/open.js +21 -11
  149. package/dist/engines/pi/report.d.ts +0 -4
  150. package/dist/engines/pi/report.js +0 -7
  151. package/dist/engines/pi/search-tools.js +1 -1
  152. package/dist/engines/pi/service.d.ts +23 -0
  153. package/dist/engines/pi/service.js +27 -0
  154. package/dist/engines/pi/session-builder.js +87 -118
  155. package/dist/engines/pi/session-control.d.ts +28 -14
  156. package/dist/engines/pi/session-control.js +570 -438
  157. package/dist/engines/pi/session-inheritance.d.ts +30 -0
  158. package/dist/engines/pi/session-inheritance.js +261 -0
  159. package/dist/engines/pi/session-markers.d.ts +48 -0
  160. package/dist/engines/pi/session-markers.js +59 -0
  161. package/dist/engines/pi/session-settings.d.ts +15 -3
  162. package/dist/engines/pi/session-settings.js +22 -1
  163. package/dist/engines/pi/session-store.d.ts +140 -0
  164. package/dist/engines/pi/session-store.js +666 -0
  165. package/dist/engines/pi/tool-context.d.ts +36 -13
  166. package/dist/engines/pi/tool-context.js +51 -4
  167. package/dist/engines/pi/tool.d.ts +5 -5
  168. package/dist/engines/pi/tool.js +3 -3
  169. package/dist/engines/pi/turn-kit.d.ts +32 -6
  170. package/dist/engines/pi/turn-kit.js +28 -1
  171. package/dist/engines/pi/wake-tool.js +1 -1
  172. package/dist/env.js +1 -2
  173. package/dist/index.d.ts +1 -0
  174. package/dist/index.js +1 -0
  175. package/dist/loader.d.ts +49 -7
  176. package/dist/loader.js +79 -18
  177. package/dist/log.d.ts +3 -0
  178. package/dist/log.js +5 -1
  179. package/dist/node.d.ts +16 -0
  180. package/dist/node.js +20 -0
  181. package/dist/paths.d.ts +26 -0
  182. package/dist/paths.js +37 -2
  183. package/dist/pi.d.ts +14 -8
  184. package/dist/pi.js +6 -5
  185. package/dist/scaffold/add-channel.js +2 -2
  186. package/dist/scaffold/init.js +7 -2
  187. package/dist/scaffold/templates/persona.md +2 -2
  188. package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
  189. package/dist/schedule/discover.js +3 -15
  190. package/dist/schedule/state.js +4 -9
  191. package/dist/schedule/wake-alarm.d.ts +13 -7
  192. package/dist/schedule/wake-alarm.js +88 -46
  193. package/dist/schedule/wakeups.d.ts +2 -2
  194. package/dist/schedule/wakeups.js +11 -8
  195. package/dist/service.d.ts +157 -0
  196. package/dist/service.js +376 -0
  197. package/dist/session-remote.d.ts +8 -3
  198. package/dist/session-remote.js +162 -92
  199. package/dist/session.d.ts +229 -93
  200. package/dist/session.js +61 -23
  201. package/dist/telegram.d.ts +1 -1
  202. package/dist/telegram.js +1 -1
  203. package/dist/tunnel.d.ts +18 -3
  204. package/dist/tunnel.js +32 -40
  205. package/package.json +14 -5
  206. package/dist/engines/pi/channel.d.ts +0 -37
  207. package/dist/engines/pi/harness.d.ts +0 -115
  208. package/dist/engines/pi/harness.js +0 -154
  209. package/dist/engines/pi/invoke.d.ts +0 -60
  210. package/dist/engines/pi/invoke.js +0 -459
  211. package/dist/engines/pi/read-image.d.ts +0 -4
  212. package/dist/engines/pi/read-image.js +0 -62
  213. package/dist/engines/pi/sessions.d.ts +0 -60
  214. package/dist/engines/pi/sessions.js +0 -464
  215. package/dist/host/node.js +0 -52
  216. /package/dist/channels/{preview-kit.js → kit/preview-kit.js} +0 -0
  217. /package/dist/channels/{seen.d.ts → kit/seen.d.ts} +0 -0
  218. /package/dist/channels/{state.d.ts → kit/state.d.ts} +0 -0
  219. /package/dist/channels/{text.d.ts → kit/text.d.ts} +0 -0
  220. /package/dist/channels/{text.js → kit/text.js} +0 -0
  221. /package/dist/channels/{thread-participants.d.ts → kit/thread-participants.d.ts} +0 -0
  222. /package/dist/channels/{turn-queue.d.ts → kit/turn-queue.d.ts} +0 -0
@@ -30,6 +30,7 @@
30
30
  import { createHash } from "node:crypto";
31
31
  import { MAX_WEBHOOK_BODY_BYTES } from "../../channels/agentcore-limits.js";
32
32
  import { SECRETS_DIRNAME } from "../../paths.js";
33
+ import { webhookKinds, webhookRunbook } from "../channel-ingress.js";
33
34
  import { containerArtifacts } from "../container.js";
34
35
  import { deploymentSecrets, isEnvKey } from "../secrets.js";
35
36
  /** SessionStorage mount = FASTAGENT_STATE_DIR (AgentCore requires exactly `/mnt/<one-level>`). It is
@@ -215,15 +216,18 @@ function yamlSingleQuote(value) {
215
216
  return `'${value.replace(/'/g, "''")}'`;
216
217
  }
217
218
  /**
218
- * The forwarder Lambda source — the ONLY string both the template's inline ZipFile and the readable
219
+ * The forwarder Lambda source — the ONLY string both the deployment package and the readable
219
220
  * `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
221
+ * Lambda Node runtime bundles AWS SDK v3. CommonJS ON PURPOSE: the package's entry lands as
222
+ * `index.js` ({@link zipSingleFile}), where ESM `import` is a syntax error. Two
222
223
  * event shapes: a Function URL webhook (reconstructed verbatim into a `webhook` envelope; the
223
224
  * channel's REAL response rides back inside the transport reply and is re-emitted byte-exact —
224
225
  * 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.
226
+ * (`{ scheduleFire }`, slot = the scheduled instant — the container's idempotency key).
227
+ *
228
+ * It ships as an S3 object rather than inline `ZipFile`, so there is no 4096-byte ceiling on it — a
229
+ * constraint this comment asserted for long after the template stopped inlining, which is the kind
230
+ * of stale rule that stops the next person making a correct change.
227
231
  */
228
232
  export function forwarderSource() {
229
233
  return `// Generated by \`fastagent deploy agentcore\` — the deployment's only ingress.
@@ -250,6 +254,15 @@ let ownUrl; // self-resolved once per cold start; rides on every envelope for th
250
254
  const enc = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => \`%\${c.charCodeAt(0).toString(16).toUpperCase()}\`);
251
255
  const hmac = (key, data) => crypto.createHmac("sha256", key).update(data).digest();
252
256
 
257
+ // Every shared-secret gate on this public URL goes through here. Constant-time, and an unset
258
+ // expected secret NEVER matches — otherwise a topology that did not configure one would accept a
259
+ // request that sent none. Non-string input is coerced to "" first: Buffer.from(8) allocates eight
260
+ // bytes rather than throwing, which would compare a number against a secret by length alone.
261
+ const secretEq = (given, expected) => {
262
+ const a = Buffer.from(typeof given === "string" ? given : ""), b = Buffer.from(expected || "");
263
+ return b.length > 0 && a.length === b.length && crypto.timingSafeEqual(a, b);
264
+ };
265
+
253
266
  function presign(method, seconds) {
254
267
  const bucket = process.env.STATE_BUCKET, key = process.env.STATE_KEY, region = process.env.AWS_REGION;
255
268
  const host = \`\${bucket}.s3.\${region}.amazonaws.com\`;
@@ -375,11 +388,7 @@ exports.handler = async (event, ctx) => {
375
388
  // The container may settle long after the webhook Lambda (and its credentials) expired.
376
389
  if (event.rawPath === "/__fastagent/state-urls") {
377
390
  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
- }
391
+ if (!secretEq(req.auth, process.env.STATE_REFRESH_SECRET)) return { statusCode: 403, body: "forbidden\\n" };
383
392
  return {
384
393
  statusCode: 200,
385
394
  headers: { "content-type": "application/json" },
@@ -389,7 +398,7 @@ exports.handler = async (event, ctx) => {
389
398
  // The container's wake-alarm callback (reserved path, shared secret) — handled HERE, never forwarded.
390
399
  if (event.rawPath === "/__fastagent/wake-alarm") {
391
400
  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" };
401
+ if (!secretEq(req.secret, process.env.WAKE_SECRET)) return { statusCode: 403, body: "forbidden\\n" };
393
402
  const failed = await syncAlarms(req.alarms || [], ctx);
394
403
  // Partial failure IS failure: the container retries the whole (idempotent) set until every
395
404
  // pending wake really has its alarm.
@@ -405,7 +414,7 @@ exports.handler = async (event, ctx) => {
405
414
  // public traffic) are probeable too.
406
415
  if (event.rawPath === "/__fastagent/probe") {
407
416
  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" };
417
+ if (!secretEq(req.auth, process.env.INGRESS_SECRET)) return { statusCode: 403, body: "forbidden\\n" };
409
418
  const r = await invoke({ kind: "probe" });
410
419
  if (r.status !== 200) {
411
420
  console.log(\`probe transport error \${r.status}: \${r.body}\`);
@@ -465,7 +474,7 @@ function template(input, translated) {
465
474
  // a schedule turn can outlive both its original presigned URL and the Lambda credentials that signed
466
475
  // it. Schedule-only URLs reject every non-reserved HTTP path before invoking AgentCore, so they do
467
476
  // 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;
477
+ const needsForwarder = input.channels.some((channel) => channel.ingress === "webhook") || translated.length > 0 || input.selfSchedule;
469
478
  const needsFunctionUrl = needsForwarder;
470
479
  const secrets = deploymentSecrets(input.modelAuth, input.channels, input.extraSecrets);
471
480
  const forwarderFnArn = `!Sub arn:aws:lambda:\${AWS::Region}:\${AWS::AccountId}:function:fastagent-${input.name}-forwarder`;
@@ -604,7 +613,7 @@ function template(input, translated) {
604
613
  ` Action: lambda:GetFunctionUrlConfig`,
605
614
  ` Resource: ${forwarderFnArn}`,
606
615
  ]
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
616
+ : []), ``, ` 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.channels.some((channel) => channel.ingress === "webhook") ? [` WEBHOOKS_ENABLED: "1"`] : []), ...(input.selfSchedule
608
617
  ? [
609
618
  ` WAKE_SECRET: !Ref FastagentWakeSecret`,
610
619
  ` WAKE_ROLE_ARN: !GetAtt WakeSchedulerRole.Arn`,
@@ -671,7 +680,7 @@ export function planAgentcoreDeploy(input) {
671
680
  }
672
681
  // Every forwarder needs its authenticated Function URL to refresh S3 snapshot capabilities during
673
682
  // 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;
683
+ const needsForwarder = input.channels.some((channel) => channel.ingress === "webhook") || translated.length > 0 || input.selfSchedule;
675
684
  const needsFunctionUrl = needsForwarder;
676
685
  const artifacts = [
677
686
  { path: `${prefix}${TEMPLATE_FILE}`, content: template(input, translated) },
@@ -743,22 +752,21 @@ export function planAgentcoreDeploy(input) {
743
752
  ? `# Model auth: none found at the local auth path — pass --auth-path <file>, or \`--run\` carries it`
744
753
  : `# 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
754
  }
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).`);
755
+ // Post-deploy webhook registration — the shared channel-ingress steps, pointed at the forwarder's
756
+ // Function URL (read from the stack outputs). `--run` REFUSES a long-connection channel here, but
757
+ // generate-only only warns and still prints this runbook, so the steps are filtered on ingress like
758
+ // every other host's rather than on the CLI having gated.
759
+ const post = webhookRunbook(`<ForwarderUrl>`, channels);
760
+ // AgentCore asides the shared steps cannot carry: nothing else routes through a forwarder, and
761
+ // nothing else has a compute ceiling.
762
+ // Named rather than positional: the asides land after ALL the steps, so "the console" has to say
763
+ // WHICH console. One line for both kinds — an agent that declares feishu AND lark reads the same
764
+ // fact twice otherwise and looks for a second step that does not exist.
765
+ if (webhookKinds(channels).some((kind) => kind === "feishu" || kind === "lark")) {
766
+ 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.`);
767
+ }
768
+ if (channels.some((channel) => channel.name === "github")) {
769
+ 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
770
  }
763
771
  if (post.length > 0)
764
772
  runbook.push(``, ...post);
@@ -14,8 +14,8 @@
14
14
  * a caller-provided temp parameters file (`file://…`, mode 0600, deleted by the caller) — the write
15
15
  * is injected to keep this module pure and the security-sensitive wiring testable.
16
16
  */
17
- import type { RegistrationOutcome } from "../../channels/registration.ts";
18
- import type { ChannelKind } from "../../scaffold/add-channel.ts";
17
+ import type { DeclaredChannel } from "../../channels/discover.ts";
18
+ import { type Registrars } from "../channel-ingress.ts";
19
19
  import type { CliRunner } from "../runner.ts";
20
20
  export interface AgentcoreRunPlan {
21
21
  /** The base name — stack `fastagent-<name>`, ECR repo `fastagent/<name>`. */
@@ -35,7 +35,8 @@ export interface AgentcoreRunPlan {
35
35
  secrets: Record<string, string>;
36
36
  /** Required secret names with NO local value — gated before any side effect. */
37
37
  missingSecrets: string[];
38
- channels: ChannelKind[];
38
+ /** Every declared channel and its ingress — the driver asks which of them have a webhook. */
39
+ channels: readonly DeclaredChannel[];
39
40
  /** Whether the topology includes the forwarder Lambda (route channels / schedules / selfSchedule).
40
41
  * It owns the state snapshot's presigned URLs, so its absence means an invoke-only deployment
41
42
  * with no cross-deploy state to keep. */
@@ -70,7 +71,7 @@ export declare function parseCheckpointReply(stdout: string): CheckpointReply |
70
71
  * post-deploy webhook steps from the builder machine against the forwarder's Function URL. Every
71
72
  * gate is fail-visible; `writeSecretFile` is the caller's 0600-temp-file seam (see the header).
72
73
  */
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>,
74
+ 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
75
  /** Injected in tests; the probe itself stays inside the run so no deploy can skip it. */
75
76
  probe?: {
76
77
  fetchImpl?: typeof fetch;
@@ -1,4 +1,4 @@
1
- import { registrationGate } from "../registration-gate.js";
1
+ import { registerWebhooks } from "../channel-ingress.js";
2
2
  import { createHash } from "node:crypto";
3
3
  import { Buffer } from "node:buffer";
4
4
  import { AUTH_SEED_CHUNK_SIZE, AUTH_SEED_MAX_CHUNKS, cfnParamName, forwarderSource, ingressSessionId, stateBucketName, } from "./plan.js";
@@ -123,7 +123,7 @@ export function parseCheckpointReply(stdout) {
123
123
  * post-deploy webhook steps from the builder machine against the forwarder's Function URL. Every
124
124
  * gate is fail-visible; `writeSecretFile` is the caller's 0600-temp-file seam (see the header).
125
125
  */
126
- export async function deployAgentcoreRun(plan, aws, docker, log, writeSecretFile, writeForwarderZip, registerTelegram, registerFeishu, registerSlack,
126
+ export async function deployAgentcoreRun(plan, aws, docker, log, writeSecretFile, writeForwarderZip, registrars,
127
127
  /** Injected in tests; the probe itself stays inside the run so no deploy can skip it. */
128
128
  probe = {}) {
129
129
  const gate = (g) => ({ ok: false, gate: g });
@@ -457,40 +457,17 @@ probe = {}) {
457
457
  }
458
458
  // 9. Post-deploy webhook registration — same registrar seam as every host, pointed at the
459
459
  // forwarder's Function URL. Gate policy is the shared registration-gate kernel.
460
- const reg = registrationGate(log, "re-run to retry registration (steps already done are skipped)");
461
- if (url) {
462
- if (plan.channels.includes("telegram")) {
463
- log("registering telegram webhook…");
464
- reg.track("telegram", await registerTelegram(url));
465
- }
466
- if (plan.channels.includes("github")) {
467
- log(`github: set the webhook in the repo (Settings → Webhooks) → ${url}/webhook`);
468
- reg.track("github", "manual"); // always a human step re-surfaced after the registrar output
469
- }
470
- if (plan.channels.includes("slack")) {
471
- if (registerSlack) {
472
- log("registering slack event URL…");
473
- reg.track("slack", await registerSlack(url));
474
- }
475
- else {
476
- log(`slack: set Event Subscriptions → Request URL → ${url}/slack`);
477
- reg.track("slack", "manual");
478
- }
479
- }
480
- for (const kind of ["feishu", "lark"]) {
481
- if (!plan.channels.includes(kind))
482
- continue;
483
- if (registerFeishu) {
484
- log(`registering ${kind} event URL…`);
485
- reg.track(kind, await registerFeishu(url, kind));
486
- }
487
- else {
488
- log(`${kind}: set the event Request URL (developer console → Events & Callbacks) → ${url}/${kind}`);
489
- reg.track(kind, "manual");
490
- }
491
- }
492
- }
493
- const registrationGateMsg = reg.gate();
460
+ // No long-connection channels reach here: `deploy.ts` gates them before the driver runs (AgentCore
461
+ // has no resident process to hold a connection), so the deploy's channels are all webhook ones.
462
+ const registrationGateMsg = url
463
+ ? await registerWebhooks({
464
+ baseUrl: url,
465
+ channels: plan.channels,
466
+ registrars,
467
+ log,
468
+ retryHint: "re-run to retry registration (steps already done are skipped)",
469
+ })
470
+ : undefined;
494
471
  if (registrationGateMsg)
495
472
  return gate(registrationGateMsg);
496
473
  return { ok: true, runtimeArn, url };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * HOW A RUNNING CHANNEL IS REACHED — the URL-neutral half of "point this channel at the agent": its
3
+ * default route, whether anything can set that URL end-to-end, and the words an operator needs when
4
+ * nothing can. Only the base URL varies, which is the argument every function here takes — a deploy
5
+ * host's (`<app>.fly.dev`, a minted Railway domain, a Function URL) or `dev --tunnel`'s Quick Tunnel.
6
+ * It lives under `deploy/` because that is where its answers are mostly consumed and where the gate
7
+ * policy it composes with lives; the serving path reads the same answers through `tunnel.ts`.
8
+ *
9
+ * It exists because that knowledge belongs to the CHANNEL and was written per HOST: three runbook
10
+ * plans, three `--run` drivers, a path table in the docker plan and the `--tunnel` announcer each
11
+ * hand-wrote the same five-branch if-chain. A rule spelled seven times drifts, and it did — the
12
+ * long-connection exception reached only the feishu/lark branches, so a long-connection Telegram
13
+ * deploy printed `setWebhook` in its runbook, which makes `getUpdates` return 409 and stops the
14
+ * channel the operator just deployed.
15
+ *
16
+ * Every function here takes the {@link DeclaredChannel} list and filters it ITSELF. Taking a
17
+ * pre-filtered list would put the rule back at the call sites, which is where it drifted from: the
18
+ * `--tunnel` announcer trusted its caller that way and had a default that passed every channel,
19
+ * long-connection ones included.
20
+ *
21
+ * A host adds its base URL and its own asides; it does not restate which channels have a webhook.
22
+ */
23
+ import type { DeclaredChannel } from "../channels/discover.ts";
24
+ import type { RegistrationOutcome } from "../channels/registration.ts";
25
+ import type { ChannelKind } from "../scaffold/add-channel.ts";
26
+ /** The registrars a host can drive. `telegram` is always available (fastagent holds the token and the
27
+ * URL); the others are optional so a caller without their credentials falls back to a manual step. */
28
+ export interface Registrars {
29
+ telegram: (baseUrl: string) => Promise<RegistrationOutcome>;
30
+ slack?: (baseUrl: string) => Promise<RegistrationOutcome>;
31
+ feishu?: (baseUrl: string, kind: "feishu" | "lark") => Promise<RegistrationOutcome>;
32
+ }
33
+ /**
34
+ * The first-party channels this deployment must point at a URL: webhook ingress, and a kind this tool
35
+ * knows how to instruct. ONE answer for every host and every path — the runbooks, the `--run`
36
+ * drivers, the docker ingress note, `--tunnel`. A custom channel is skipped here and reported by the
37
+ * pre-flight instead: its URL is its author's to set.
38
+ */
39
+ export declare function webhookKinds(channels: readonly DeclaredChannel[]): ChannelKind[];
40
+ /** Default route keys for {@link webhookKinds}, in the same order (the docker plan's ingress note). */
41
+ export declare function webhookPaths(channels: readonly DeclaredChannel[]): string[];
42
+ /** The runbook block for every channel that needs a URL set by hand, `baseUrl` spelled the host's way
43
+ * (a literal `https://app.fly.dev`, or a placeholder like `<your-domain>` the operator fills in). */
44
+ export declare function webhookRunbook(baseUrl: string, channels: readonly DeclaredChannel[]): string[];
45
+ /**
46
+ * Point every channel that has a webhook at `baseUrl`, reporting what each one ended as. All channels
47
+ * are attempted — one failure does not skip the rest — and a channel whose registrar the caller did
48
+ * not wire reports `manual` with the operator's instruction. The two callers differ in what they do
49
+ * with the outcomes, not in how they are produced: a deploy gates on them, a long-running serve
50
+ * cannot ({@link registerWebhooks} is the gating half).
51
+ */
52
+ export declare function pointChannelsAt(input: {
53
+ baseUrl: string;
54
+ channels: readonly DeclaredChannel[];
55
+ registrars: Registrars;
56
+ log: (msg: string) => void;
57
+ }): Promise<{
58
+ kind: ChannelKind;
59
+ outcome: RegistrationOutcome;
60
+ }[]>;
61
+ /**
62
+ * {@link pointChannelsAt} plus the shared gate policy, for a command that EXITS: an exit 0 claims the
63
+ * deployment is reachable, so a failed registration has to become a non-zero one. Returns the gate
64
+ * message, or undefined when nothing gates.
65
+ */
66
+ export declare function registerWebhooks(input: {
67
+ baseUrl: string;
68
+ channels: readonly DeclaredChannel[];
69
+ registrars: Registrars;
70
+ log: (msg: string) => void;
71
+ /** How THIS host retries — the only per-host words in the gate. */
72
+ retryHint: string;
73
+ }): Promise<string | undefined>;
@@ -0,0 +1,101 @@
1
+ import { registrationGate } from "./registration-gate.js";
2
+ const feishuCloud = (kind, label) => ({
3
+ path: `/${kind}`,
4
+ register: (r, baseUrl) => r.feishu?.(baseUrl, kind),
5
+ manual: (baseUrl) => `${kind}: set the event Request URL in the developer console (Events & Callbacks) → ${baseUrl}/${kind} (the app must be running when you save)`,
6
+ runbook: (baseUrl) => [
7
+ `# Set the ${label} event Request URL (developer console → Events & Callbacks). Default route`,
8
+ `# POST /${kind}; the app must be RUNNING when you save (the console verifies with a challenge):`,
9
+ `# Request URL = ${baseUrl}/${kind}`,
10
+ ],
11
+ });
12
+ /** Declaration order — the order every runbook and every driver reports in. */
13
+ const INGRESS = {
14
+ telegram: {
15
+ path: "/telegram",
16
+ register: (r, baseUrl) => r.telegram(baseUrl),
17
+ manual: (baseUrl) => `telegram: set the webhook → ${baseUrl}/telegram (secret_token = TELEGRAM_SECRET_TOKEN)`,
18
+ runbook: (baseUrl) => [
19
+ `# Register the Telegram webhook. The path assumes the default route (POST /telegram); if you`,
20
+ `# remapped it in channels/telegram.ts, use your path. secret_token MUST equal TELEGRAM_SECRET_TOKEN:`,
21
+ `curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/setWebhook" \\`,
22
+ ` -d url=${baseUrl}/telegram -d secret_token=<TELEGRAM_SECRET_TOKEN>`,
23
+ ],
24
+ },
25
+ github: {
26
+ path: "/webhook",
27
+ // The env-var name is the part a first-time operator cannot guess, and this line is all `--tunnel`
28
+ // prints — there is no runbook beside it to carry the detail.
29
+ manual: (baseUrl) => `github: set the webhook in the repo (Settings → Webhooks) → ${baseUrl}/webhook (content type application/json, secret = GITHUB_WEBHOOK_SECRET)`,
30
+ runbook: (baseUrl) => [
31
+ `# Set the GitHub webhook (repo Settings → Webhooks). Default route POST /webhook; if you remapped`,
32
+ `# it in channels/github.ts, use your path:`,
33
+ `# Payload URL = ${baseUrl}/webhook, content type application/json, secret = GITHUB_WEBHOOK_SECRET`,
34
+ ],
35
+ },
36
+ slack: {
37
+ path: "/slack",
38
+ register: (r, baseUrl) => r.slack?.(baseUrl),
39
+ manual: (baseUrl) => `slack: set Event Subscriptions → Request URL → ${baseUrl}/slack`,
40
+ runbook: (baseUrl) => [
41
+ `# Set Slack Event Subscriptions → Request URL (default route POST /slack; the running service`,
42
+ `# answers Slack's challenge), and match scopes/subscriptions to channels/slack.ts groupBehavior:`,
43
+ `# Request URL = ${baseUrl}/slack`,
44
+ ],
45
+ },
46
+ feishu: feishuCloud("feishu", "Feishu"),
47
+ lark: feishuCloud("lark", "Lark"),
48
+ };
49
+ /**
50
+ * The first-party channels this deployment must point at a URL: webhook ingress, and a kind this tool
51
+ * knows how to instruct. ONE answer for every host and every path — the runbooks, the `--run`
52
+ * drivers, the docker ingress note, `--tunnel`. A custom channel is skipped here and reported by the
53
+ * pre-flight instead: its URL is its author's to set.
54
+ */
55
+ export function webhookKinds(channels) {
56
+ const declared = new Set(channels.filter((c) => c.ingress === "webhook").map((c) => c.name));
57
+ return Object.keys(INGRESS).filter((kind) => declared.has(kind));
58
+ }
59
+ /** Default route keys for {@link webhookKinds}, in the same order (the docker plan's ingress note). */
60
+ export function webhookPaths(channels) {
61
+ return webhookKinds(channels).map((kind) => INGRESS[kind].path);
62
+ }
63
+ /** The runbook block for every channel that needs a URL set by hand, `baseUrl` spelled the host's way
64
+ * (a literal `https://app.fly.dev`, or a placeholder like `<your-domain>` the operator fills in). */
65
+ export function webhookRunbook(baseUrl, channels) {
66
+ return webhookKinds(channels).flatMap((kind) => INGRESS[kind].runbook(baseUrl));
67
+ }
68
+ /**
69
+ * Point every channel that has a webhook at `baseUrl`, reporting what each one ended as. All channels
70
+ * are attempted — one failure does not skip the rest — and a channel whose registrar the caller did
71
+ * not wire reports `manual` with the operator's instruction. The two callers differ in what they do
72
+ * with the outcomes, not in how they are produced: a deploy gates on them, a long-running serve
73
+ * cannot ({@link registerWebhooks} is the gating half).
74
+ */
75
+ export async function pointChannelsAt(input) {
76
+ const outcomes = [];
77
+ for (const kind of webhookKinds(input.channels)) {
78
+ const ingress = INGRESS[kind];
79
+ // Calling IS the question: a channel whose registrar the caller did not wire returns undefined.
80
+ const running = ingress.register?.(input.registrars, input.baseUrl);
81
+ if (!running) {
82
+ input.log(ingress.manual(input.baseUrl));
83
+ outcomes.push({ kind, outcome: "manual" }); // a human's step — re-surfaced after registrar output
84
+ continue;
85
+ }
86
+ input.log(`registering ${kind} webhook…`);
87
+ outcomes.push({ kind, outcome: await running });
88
+ }
89
+ return outcomes;
90
+ }
91
+ /**
92
+ * {@link pointChannelsAt} plus the shared gate policy, for a command that EXITS: an exit 0 claims the
93
+ * deployment is reachable, so a failed registration has to become a non-zero one. Returns the gate
94
+ * message, or undefined when nothing gates.
95
+ */
96
+ export async function registerWebhooks(input) {
97
+ const reg = registrationGate(input.log, input.retryHint);
98
+ for (const { kind, outcome } of await pointChannelsAt(input))
99
+ reg.track(kind, outcome);
100
+ return reg.gate();
101
+ }
@@ -4,7 +4,7 @@
4
4
  * process, its loopback port, exact environment-variable names, and one persistent state volume.
5
5
  * `--tunnel` can add an ephemeral Cloudflare Quick Tunnel service; durable ingress remains operator-owned.
6
6
  */
7
- import type { ChannelKind } from "../../scaffold/add-channel.ts";
7
+ import type { DeclaredChannel } from "../../channels/discover.ts";
8
8
  import { type Artifact, type ContainerInput } from "../container.ts";
9
9
  export interface DockerPlanInput extends ContainerInput {
10
10
  /** Stable Compose project name, sanitized by {@link toDockerProjectName}. */
@@ -13,10 +13,8 @@ export interface DockerPlanInput extends ContainerInput {
13
13
  port: number;
14
14
  /** What satisfies model auth locally: an env-var name, an OAuth/stored label, or undefined. */
15
15
  modelAuth: string | undefined;
16
- /** Known channels contribute their environment-variable names and webhook registration. */
17
- channels: ChannelKind[];
18
- /** All long-connection channel basenames, including custom channels. */
19
- longConnectionChannels?: string[];
16
+ /** Every declared channel and its ingress — the source of the secret list and the ingress note. */
17
+ channels: readonly DeclaredChannel[];
20
18
  /** Generate an optional Cloudflare Quick Tunnel service in Compose. Generation only; `--run` starts it. */
21
19
  tunnel: boolean;
22
20
  /** Extra environment-variable names declared in config.deploy.secrets. */
@@ -39,7 +37,5 @@ export declare function isGeneratedCompose(content: string): boolean;
39
37
  export declare function composeHasTunnelService(content: string): boolean;
40
38
  /** Compose project names are lowercase [a-z0-9_-] and must start with an alphanumeric character. */
41
39
  export declare function toDockerProjectName(directoryName: string): string;
42
- /** Default first-party webhook paths. Workspace glue may remap them, so guidance labels them defaults. */
43
- export declare function dockerWebhookPaths(channels: ChannelKind[]): string[];
44
40
  /** Compute local-Docker artifacts + the runbook; no Docker process is touched here. */
45
41
  export declare function planDockerDeploy(input: DockerPlanInput): DockerPlan;
@@ -1,3 +1,4 @@
1
+ import { webhookPaths } from "../channel-ingress.js";
1
2
  import { containerArtifacts } from "../container.js";
2
3
  import { deploymentSecrets, isEnvKey } from "../secrets.js";
3
4
  const MOUNT = "/data";
@@ -24,23 +25,12 @@ export function toDockerProjectName(directoryName) {
24
25
  .replace(/[-_]+$/g, "");
25
26
  return `fastagent-${slug || "agent"}`;
26
27
  }
27
- /** Default first-party webhook paths. Workspace glue may remap them, so guidance labels them defaults. */
28
- export function dockerWebhookPaths(channels) {
29
- const path = {
30
- github: "/webhook",
31
- telegram: "/telegram",
32
- slack: "/slack",
33
- feishu: "/feishu",
34
- lark: "/lark",
35
- };
36
- return channels.map((kind) => path[kind]);
37
- }
38
28
  /** `${NAME:-}` without making JavaScript treat it as interpolation. */
39
29
  function composeInterpolation(name) {
40
30
  return `\${${name}:-}`;
41
31
  }
42
32
  function composeYaml(input) {
43
- const secrets = deploymentSecrets(input.modelAuth, input.channels, input.extraSecrets, input.longConnectionChannels);
33
+ const secrets = deploymentSecrets(input.modelAuth, input.channels, input.extraSecrets);
44
34
  // Always leave the auth-seed seam in the committed topology. `--run` uses it for OAuth/stored auth;
45
35
  // it is empty otherwise. Values never land in this file — Compose interpolates them at invocation.
46
36
  const envNames = [...new Set([...secrets.map((secret) => secret.name), "FASTAGENT_AUTH_SEED"])];
@@ -100,11 +90,10 @@ export function planDockerDeploy(input) {
100
90
  const composePath = `${input.agentPrefix}${DOCKER_COMPOSE_FILE}`;
101
91
  const artifacts = [{ path: composePath, content: composeYaml(input) }, ...containerArtifacts(input)];
102
92
  const compose = `docker compose -f ${composePath}`;
103
- const secrets = deploymentSecrets(input.modelAuth, input.channels, input.extraSecrets, input.longConnectionChannels);
93
+ const secrets = deploymentSecrets(input.modelAuth, input.channels, input.extraSecrets);
104
94
  const required = secrets.filter((secret) => secret.required);
105
95
  const optional = secrets.filter((secret) => !secret.required);
106
- const routeChannels = input.channels.filter((kind) => !input.longConnectionChannels?.includes(kind));
107
- const paths = dockerWebhookPaths(routeChannels);
96
+ const paths = webhookPaths(input.channels);
108
97
  const runbook = [
109
98
  `# Run FastAgent in local Docker. ${composePath} / Dockerfile(.dockerignore) are generated above.`,
110
99
  `# Existing artifacts are authoritative and kept; edit Dockerfile or ${composePath} freely.`,
@@ -1,3 +1,4 @@
1
+ import type { RegistrationOutcome } from "../../channels/registration.ts";
1
2
  import type { CliRunner } from "../runner.ts";
2
3
  export interface DockerRunPlan {
3
4
  /** Compose file relative to the runner cwd (the workspace root). */
@@ -10,6 +11,10 @@ export interface DockerRunPlan {
10
11
  missingSecrets: string[];
11
12
  /** Neither an env-key credential nor a readable auth.json is available. */
12
13
  needsModelCredential: boolean;
14
+ /** Register the deployment's webhooks against the tunnel URL, reporting what each registrar
15
+ * answered. REQUIRED, not optional: an absent one would delete this whole step in silence, which
16
+ * is the failure this driver's gate exists to end. A topology with no tunnel never calls it. */
17
+ announce: DockerAnnounce;
13
18
  /** `--tunnel` was requested for this run; a kept Compose file must actually contain that service. */
14
19
  requireTunnel: boolean;
15
20
  }
@@ -17,10 +22,21 @@ export type DockerRunOutcome = {
17
22
  ok: true;
18
23
  url?: string;
19
24
  tunnelUrl?: string;
20
- } | {
25
+ }
26
+ /** `url`/`tunnelUrl` travel with a gate too: Compose is up, so the operator still needs to know
27
+ * where it is and what to re-run. */
28
+ | {
21
29
  ok: false;
22
30
  gate: string;
31
+ url?: string;
32
+ tunnelUrl?: string;
23
33
  };
34
+ /** Register the deployment's webhooks against its public URL, reporting what each registrar
35
+ * answered. Injected so the driver stays free of channel specifics — and so a test can fail one. */
36
+ type DockerAnnounce = (baseUrl: string) => Promise<{
37
+ kind: string;
38
+ outcome: RegistrationOutcome;
39
+ }[]>;
24
40
  export type DockerHealthProbe = (healthUrl: string) => Promise<boolean>;
25
41
  export type DockerTunnelUrlProbe = (docker: CliRunner, composeFile: string, env: NodeJS.ProcessEnv) => Promise<string | undefined>;
26
42
  /** Resolve Docker Compose's `host:port` output to a loopback URL (safe for 0.0.0.0/[::] bindings too). */
@@ -38,3 +54,4 @@ export declare function waitForComposeTunnelUrl(docker: CliRunner, composeFile:
38
54
  * service is still success (an operator-owned sidecar/reverse proxy may be its only ingress).
39
55
  */
40
56
  export declare function deployDockerRun(plan: DockerRunPlan, docker: CliRunner, log: (message: string) => void, healthProbe?: DockerHealthProbe, tunnelUrlProbe?: DockerTunnelUrlProbe): Promise<DockerRunOutcome>;
57
+ export {};
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { waitForHealth } from "../../channels/wait-health.js";
7
7
  import { parseTunnelUrl } from "../../tunnel.js";
8
+ import { registrationGate } from "../registration-gate.js";
8
9
  import { MIN_DOCKER_COMPOSE_VERSION } from "./plan.js";
9
10
  /** Resolve Docker Compose's `host:port` output to a loopback URL (safe for 0.0.0.0/[::] bindings too). */
10
11
  export function localUrlFromComposePort(stdout) {
@@ -122,5 +123,14 @@ export async function deployDockerRun(plan, docker, log, healthProbe = defaultHe
122
123
  if (!tunnelUrl) {
123
124
  return gate(`tunnel did not publish a Quick Tunnel URL — inspect \`docker compose -f ${plan.composeFile} logs tunnel\``);
124
125
  }
126
+ // Registration lives HERE, like every other host's driver, and not at the CLI: this is the layer
127
+ // that owns the outcome, so it is the layer that can gate on one. While it sat above, docker was
128
+ // the one target whose `--run` could exit 0 with a webhook that never registered.
129
+ const reg = registrationGate(log, `re-run this deploy to retry registration (Compose is already up)`);
130
+ for (const { kind, outcome } of await plan.announce(tunnelUrl))
131
+ reg.track(kind, outcome);
132
+ const blocked = reg.gate();
133
+ if (blocked)
134
+ return { ok: false, gate: blocked, url, tunnelUrl };
125
135
  return { ok: true, url, tunnelUrl };
126
136
  }
@@ -16,7 +16,7 @@
16
16
  * snapshot is discarded replays on the next start (the Telegram L1 turn store) — at-least-once, the
17
17
  * documented floor. State on the /data volume survives stop/suspend on the same machine.
18
18
  */
19
- import type { ChannelKind } from "../../scaffold/add-channel.ts";
19
+ import type { DeclaredChannel } from "../../channels/discover.ts";
20
20
  import { type Artifact, type ContainerInput } from "../container.ts";
21
21
  export interface FlyPlanInput extends ContainerInput {
22
22
  /** Fly app name — globally unique, lowercase; the CLI sanitizes it from the dir basename. */
@@ -28,10 +28,9 @@ export interface FlyPlanInput extends ContainerInput {
28
28
  * `"OAuth"`/`"stored credential"` (a local login the server can't use), or undefined (unconfigured).
29
29
  */
30
30
  modelAuth: string | undefined;
31
- /** Known first-party channelseach contributes its secret metadata + webhook step. */
32
- channels: ChannelKind[];
33
- /** All long-connection channel basenames, including custom channels require one running machine. */
34
- longConnectionChannels?: string[];
31
+ /** Every declared channel and its ingress the source of the secret list, the webhook steps, and
32
+ * whether a machine must stay up for an outbound connection. */
33
+ channels: readonly DeclaredChannel[];
35
34
  /** Extra secret env-var names (fastagent.config deploy.secrets) — added to the runbook's secret list. */
36
35
  extraSecrets?: string[];
37
36
  /** `auto_stop_machines` — `"suspend"` (default, fast resume) or `"stop"` (cold start). CLI `--stop`. */