@fastagent-sh/fastagent 0.21.1 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (388) hide show
  1. package/README.md +9 -12
  2. package/dist/agent.d.ts +24 -37
  3. package/dist/agent.js +7 -15
  4. package/dist/atomic-write.d.ts +13 -17
  5. package/dist/atomic-write.js +18 -29
  6. package/dist/bind.d.ts +12 -22
  7. package/dist/bind.js +18 -27
  8. package/dist/channel.d.ts +11 -33
  9. package/dist/channels/agentcore-limits.d.ts +2 -13
  10. package/dist/channels/agentcore-limits.js +2 -13
  11. package/dist/channels/agentcore-protocol.d.ts +67 -58
  12. package/dist/channels/agentcore-protocol.js +23 -15
  13. package/dist/channels/agentcore-service.d.ts +19 -33
  14. package/dist/channels/agentcore-service.js +176 -59
  15. package/dist/channels/agentcore.d.ts +39 -33
  16. package/dist/channels/agentcore.js +90 -224
  17. package/dist/channels/body.d.ts +15 -2
  18. package/dist/channels/body.js +24 -2
  19. package/dist/channels/busy.d.ts +1 -21
  20. package/dist/channels/busy.js +2 -39
  21. package/dist/channels/control.d.ts +17 -62
  22. package/dist/channels/control.js +74 -182
  23. package/dist/channels/define-channel.d.ts +39 -0
  24. package/dist/channels/define-channel.js +9 -0
  25. package/dist/channels/discover.d.ts +10 -31
  26. package/dist/channels/discover.js +37 -31
  27. package/dist/channels/feishu/bootstrap-token.d.ts +1 -8
  28. package/dist/channels/feishu/bootstrap-token.js +7 -23
  29. package/dist/channels/feishu/card.d.ts +14 -28
  30. package/dist/channels/feishu/card.js +16 -30
  31. package/dist/channels/feishu/cloud.d.ts +1 -5
  32. package/dist/channels/feishu/context-buffer.d.ts +4 -23
  33. package/dist/channels/feishu/context-buffer.js +8 -31
  34. package/dist/channels/feishu/crypto.d.ts +10 -5
  35. package/dist/channels/feishu/crypto.js +12 -19
  36. package/dist/channels/feishu/feishu-api.d.ts +2 -1
  37. package/dist/channels/feishu/feishu-api.js +5 -4
  38. package/dist/channels/feishu/feishu.d.ts +4 -10
  39. package/dist/channels/feishu/feishu.js +113 -227
  40. package/dist/channels/feishu/invoke-turn.d.ts +5 -4
  41. package/dist/channels/feishu/invoke-turn.js +36 -54
  42. package/dist/channels/feishu/model.d.ts +4 -21
  43. package/dist/channels/feishu/model.js +1 -8
  44. package/dist/channels/feishu/normalize.d.ts +2 -10
  45. package/dist/channels/feishu/normalize.js +7 -42
  46. package/dist/channels/feishu/parse.d.ts +9 -29
  47. package/dist/channels/feishu/parse.js +8 -26
  48. package/dist/channels/feishu/preview.d.ts +12 -14
  49. package/dist/channels/feishu/preview.js +108 -207
  50. package/dist/channels/feishu/register-app.d.ts +8 -23
  51. package/dist/channels/feishu/register-app.js +7 -20
  52. package/dist/channels/feishu/register-webhook.d.ts +2 -9
  53. package/dist/channels/feishu/register-webhook.js +9 -33
  54. package/dist/channels/feishu/scaffold/channel.ts +27 -19
  55. package/dist/channels/feishu/scaffold/channel.websocket.ts +31 -0
  56. package/dist/channels/feishu/setup-mode.d.ts +11 -36
  57. package/dist/channels/feishu/setup-mode.js +7 -26
  58. package/dist/channels/feishu/ws-ingress.d.ts +1 -2
  59. package/dist/channels/feishu/ws-ingress.js +6 -8
  60. package/dist/channels/http.d.ts +15 -15
  61. package/dist/channels/http.js +19 -9
  62. package/dist/channels/kit/attachment-path.d.ts +4 -5
  63. package/dist/channels/kit/attachment-path.js +10 -34
  64. package/dist/channels/kit/context-buffer.d.ts +14 -21
  65. package/dist/channels/kit/context-buffer.js +25 -38
  66. package/dist/channels/kit/delivery.d.ts +35 -0
  67. package/dist/channels/kit/delivery.js +129 -0
  68. package/dist/channels/kit/event-stream.d.ts +5 -0
  69. package/dist/channels/kit/event-stream.js +29 -0
  70. package/dist/channels/kit/invoke-turn-kit.d.ts +53 -26
  71. package/dist/channels/kit/invoke-turn-kit.js +89 -32
  72. package/dist/channels/kit/preview-kit.d.ts +18 -72
  73. package/dist/channels/kit/preview-kit.js +22 -88
  74. package/dist/channels/kit/signature.d.ts +3 -9
  75. package/dist/channels/kit/signature.js +3 -9
  76. package/dist/channels/kit/state.d.ts +1 -2
  77. package/dist/channels/kit/state.js +5 -14
  78. package/dist/channels/kit/stop-command.d.ts +1 -2
  79. package/dist/channels/kit/stop-command.js +6 -9
  80. package/dist/channels/kit/tasks.d.ts +1 -1
  81. package/dist/channels/kit/tasks.js +8 -21
  82. package/dist/channels/kit/text.d.ts +2 -16
  83. package/dist/channels/kit/text.js +23 -31
  84. package/dist/channels/kit/thread-participants.d.ts +4 -17
  85. package/dist/channels/kit/thread-participants.js +8 -55
  86. package/dist/channels/kit/transport.d.ts +26 -0
  87. package/dist/channels/kit/transport.js +20 -0
  88. package/dist/channels/kit/turn-queue.d.ts +7 -11
  89. package/dist/channels/kit/turn-queue.js +21 -39
  90. package/dist/channels/kit/turn-runner.d.ts +32 -18
  91. package/dist/channels/kit/turn-runner.js +98 -53
  92. package/dist/channels/kit/turn-store.d.ts +32 -32
  93. package/dist/channels/kit/turn-store.js +45 -88
  94. package/dist/channels/lark/lark.d.ts +1 -5
  95. package/dist/channels/lark/onboard.d.ts +3 -12
  96. package/dist/channels/lark/onboard.js +3 -13
  97. package/dist/channels/lark/scaffold/channel.ts +27 -19
  98. package/dist/channels/lark/scaffold/channel.websocket.ts +31 -0
  99. package/dist/channels/registration.d.ts +9 -35
  100. package/dist/channels/registration.js +6 -37
  101. package/dist/channels/secret.js +2 -5
  102. package/dist/channels/serve.d.ts +47 -44
  103. package/dist/channels/serve.js +165 -101
  104. package/dist/channels/slack/config-api.d.ts +4 -27
  105. package/dist/channels/slack/config-api.js +5 -34
  106. package/dist/channels/slack/context-buffer.d.ts +2 -3
  107. package/dist/channels/slack/context-buffer.js +5 -5
  108. package/dist/channels/slack/invoke-turn.d.ts +3 -1
  109. package/dist/channels/slack/invoke-turn.js +25 -34
  110. package/dist/channels/slack/manifest.js +1 -3
  111. package/dist/channels/slack/model.d.ts +1 -2
  112. package/dist/channels/slack/model.js +1 -1
  113. package/dist/channels/slack/onboard.d.ts +4 -3
  114. package/dist/channels/slack/onboard.js +8 -11
  115. package/dist/channels/slack/onboarding-state.d.ts +3 -5
  116. package/dist/channels/slack/onboarding-state.js +9 -6
  117. package/dist/channels/slack/parse.d.ts +4 -13
  118. package/dist/channels/slack/parse.js +7 -28
  119. package/dist/channels/slack/preview.d.ts +12 -2
  120. package/dist/channels/slack/preview.js +292 -269
  121. package/dist/channels/slack/reaction.d.ts +11 -3
  122. package/dist/channels/slack/reaction.js +19 -8
  123. package/dist/channels/slack/register-webhook.js +4 -7
  124. package/dist/channels/slack/scaffold/channel.ts +20 -13
  125. package/dist/channels/slack/scaffold/slack-send.ts +2 -2
  126. package/dist/channels/slack/setup-server.d.ts +1 -5
  127. package/dist/channels/slack/setup-server.js +1 -5
  128. package/dist/channels/slack/shared-api.d.ts +1 -4
  129. package/dist/channels/slack/shared-api.js +2 -13
  130. package/dist/channels/slack/slack-api.d.ts +2 -1
  131. package/dist/channels/slack/slack-api.js +6 -4
  132. package/dist/channels/slack/slack.d.ts +11 -14
  133. package/dist/channels/slack/slack.js +121 -122
  134. package/dist/channels/sse.d.ts +1 -1
  135. package/dist/channels/sse.js +1 -1
  136. package/dist/channels/telegram/context-buffer.d.ts +7 -22
  137. package/dist/channels/telegram/context-buffer.js +6 -23
  138. package/dist/channels/telegram/invoke-turn.d.ts +7 -7
  139. package/dist/channels/telegram/invoke-turn.js +24 -43
  140. package/dist/channels/telegram/parse.d.ts +24 -51
  141. package/dist/channels/telegram/parse.js +32 -84
  142. package/dist/channels/telegram/preview.d.ts +10 -1
  143. package/dist/channels/telegram/preview.js +79 -100
  144. package/dist/channels/telegram/register-webhook.js +5 -23
  145. package/dist/channels/telegram/scaffold/channel.ts +25 -18
  146. package/dist/channels/telegram/scaffold/telegram-send.ts +6 -4
  147. package/dist/channels/telegram/telegram-api.d.ts +4 -3
  148. package/dist/channels/telegram/telegram-api.js +9 -7
  149. package/dist/channels/telegram/telegram.d.ts +4 -18
  150. package/dist/channels/telegram/telegram.js +38 -94
  151. package/dist/channels/telegram/turn-store.d.ts +11 -14
  152. package/dist/channels/telegram/turn-store.js +8 -6
  153. package/dist/channels/wait-health.d.ts +4 -6
  154. package/dist/channels/wait-health.js +10 -14
  155. package/dist/cli/add-feishu.d.ts +2 -12
  156. package/dist/cli/add-feishu.js +24 -71
  157. package/dist/cli/add-slack.d.ts +5 -3
  158. package/dist/cli/add-slack.js +53 -76
  159. package/dist/cli/auth-view.d.ts +2 -16
  160. package/dist/cli/auth-view.js +2 -16
  161. package/dist/cli/commands/add.js +25 -40
  162. package/dist/cli/commands/chat.d.ts +0 -1
  163. package/dist/cli/commands/chat.js +5 -6
  164. package/dist/cli/commands/deploy/agentcore.js +60 -65
  165. package/dist/cli/commands/deploy/docker.js +50 -23
  166. package/dist/cli/commands/deploy/fly.js +23 -61
  167. package/dist/cli/commands/deploy/railway.js +23 -18
  168. package/dist/cli/commands/deploy/shared.d.ts +38 -57
  169. package/dist/cli/commands/deploy/shared.js +40 -59
  170. package/dist/cli/commands/deploy.d.ts +10 -27
  171. package/dist/cli/commands/deploy.js +38 -60
  172. package/dist/cli/commands/destroy.d.ts +4 -0
  173. package/dist/cli/commands/destroy.js +56 -0
  174. package/dist/cli/commands/dev.d.ts +2 -1
  175. package/dist/cli/commands/dev.js +20 -23
  176. package/dist/cli/commands/info.d.ts +0 -2
  177. package/dist/cli/commands/info.js +83 -49
  178. package/dist/cli/commands/init.d.ts +1 -2
  179. package/dist/cli/commands/init.js +16 -55
  180. package/dist/cli/commands/invoke.d.ts +0 -1
  181. package/dist/cli/commands/invoke.js +5 -7
  182. package/dist/cli/commands/login.d.ts +2 -1
  183. package/dist/cli/commands/login.js +33 -38
  184. package/dist/cli/commands/logs.js +3 -4
  185. package/dist/cli/commands/models.js +1 -5
  186. package/dist/cli/commands/routine-run.d.ts +6 -0
  187. package/dist/cli/commands/routine-run.js +47 -0
  188. package/dist/cli/commands/routine.d.ts +25 -0
  189. package/dist/cli/commands/routine.js +122 -0
  190. package/dist/cli/commands/start.d.ts +31 -3
  191. package/dist/cli/commands/start.js +131 -81
  192. package/dist/cli/commands/tool.js +26 -12
  193. package/dist/cli/fail.d.ts +13 -23
  194. package/dist/cli/fail.js +20 -23
  195. package/dist/cli/invoke-stream.d.ts +1 -8
  196. package/dist/cli/kernel.d.ts +6 -38
  197. package/dist/cli/kernel.js +14 -77
  198. package/dist/cli/models-view.d.ts +1 -9
  199. package/dist/cli/models-view.js +11 -22
  200. package/dist/cli/program.d.ts +5 -6
  201. package/dist/cli/program.js +120 -157
  202. package/dist/cli/serve.d.ts +57 -41
  203. package/dist/cli/serve.js +94 -121
  204. package/dist/cli/shared.d.ts +15 -61
  205. package/dist/cli/shared.js +55 -181
  206. package/dist/cli.js +1 -8
  207. package/dist/collect.d.ts +6 -0
  208. package/dist/collect.js +6 -0
  209. package/dist/core.d.ts +4 -1
  210. package/dist/core.js +9 -7
  211. package/dist/declared-secrets.d.ts +55 -0
  212. package/dist/declared-secrets.js +88 -0
  213. package/dist/deploy/agentcore/aws-cli.d.ts +91 -0
  214. package/dist/deploy/agentcore/aws-cli.js +64 -0
  215. package/dist/deploy/agentcore/destroy.d.ts +51 -0
  216. package/dist/deploy/agentcore/destroy.js +273 -0
  217. package/dist/deploy/agentcore/forwarder.js +28 -79
  218. package/dist/deploy/agentcore/logs.d.ts +2 -18
  219. package/dist/deploy/agentcore/logs.js +27 -51
  220. package/dist/deploy/agentcore/plan.d.ts +85 -105
  221. package/dist/deploy/agentcore/plan.js +152 -314
  222. package/dist/deploy/agentcore/run.d.ts +28 -28
  223. package/dist/deploy/agentcore/run.js +227 -306
  224. package/dist/deploy/agentcore/zip.d.ts +2 -15
  225. package/dist/deploy/agentcore/zip.js +3 -17
  226. package/dist/deploy/build-context.d.ts +21 -0
  227. package/dist/deploy/build-context.js +137 -0
  228. package/dist/deploy/channel-ingress.d.ts +26 -39
  229. package/dist/deploy/channel-ingress.js +57 -28
  230. package/dist/deploy/container.d.ts +20 -34
  231. package/dist/deploy/container.js +49 -100
  232. package/dist/deploy/docker/plan.d.ts +12 -11
  233. package/dist/deploy/docker/plan.js +43 -24
  234. package/dist/deploy/docker/run.d.ts +22 -25
  235. package/dist/deploy/docker/run.js +39 -37
  236. package/dist/deploy/fly/plan.d.ts +15 -44
  237. package/dist/deploy/fly/plan.js +41 -73
  238. package/dist/deploy/fly/run.d.ts +16 -51
  239. package/dist/deploy/fly/run.js +42 -95
  240. package/dist/deploy/hosts.d.ts +24 -3
  241. package/dist/deploy/hosts.js +24 -3
  242. package/dist/deploy/preflight.d.ts +47 -41
  243. package/dist/deploy/preflight.js +282 -383
  244. package/dist/deploy/railway/plan.d.ts +16 -42
  245. package/dist/deploy/railway/plan.js +42 -60
  246. package/dist/deploy/railway/run.d.ts +30 -68
  247. package/dist/deploy/railway/run.js +86 -100
  248. package/dist/deploy/registration-gate.d.ts +6 -13
  249. package/dist/deploy/residency.d.ts +37 -0
  250. package/dist/deploy/residency.js +24 -0
  251. package/dist/deploy/runner.d.ts +10 -9
  252. package/dist/deploy/runner.js +7 -18
  253. package/dist/deploy/secrets.d.ts +40 -41
  254. package/dist/deploy/secrets.js +117 -103
  255. package/dist/deploy/workspace.d.ts +50 -0
  256. package/dist/deploy/workspace.js +197 -0
  257. package/dist/dev-supervisor.d.ts +1 -9
  258. package/dist/dev-supervisor.js +21 -64
  259. package/dist/effect-port.d.ts +50 -0
  260. package/dist/effect-port.js +77 -0
  261. package/dist/engines/pi/agent-session-factory.d.ts +50 -69
  262. package/dist/engines/pi/agent-session-factory.js +249 -146
  263. package/dist/engines/pi/auth.d.ts +15 -11
  264. package/dist/engines/pi/auth.js +96 -99
  265. package/dist/engines/pi/chat.d.ts +1 -5
  266. package/dist/engines/pi/chat.js +2 -14
  267. package/dist/engines/pi/config.d.ts +53 -51
  268. package/dist/engines/pi/config.js +83 -92
  269. package/dist/engines/pi/create.d.ts +60 -75
  270. package/dist/engines/pi/create.js +108 -141
  271. package/dist/engines/pi/definition.d.ts +14 -58
  272. package/dist/engines/pi/definition.js +21 -85
  273. package/dist/engines/pi/invoke-session.d.ts +5 -25
  274. package/dist/engines/pi/invoke-session.js +233 -244
  275. package/dist/engines/pi/login.d.ts +95 -35
  276. package/dist/engines/pi/login.js +259 -107
  277. package/dist/engines/pi/machine.d.ts +31 -0
  278. package/dist/engines/pi/machine.js +106 -0
  279. package/dist/engines/pi/models.d.ts +70 -89
  280. package/dist/engines/pi/models.js +188 -81
  281. package/dist/engines/pi/open.d.ts +65 -53
  282. package/dist/engines/pi/open.js +129 -69
  283. package/dist/engines/pi/report.d.ts +3 -12
  284. package/dist/engines/pi/report.js +2 -16
  285. package/dist/engines/pi/search-tools.d.ts +1 -9
  286. package/dist/engines/pi/search-tools.js +26 -57
  287. package/dist/engines/pi/service.d.ts +2 -16
  288. package/dist/engines/pi/service.js +2 -16
  289. package/dist/engines/pi/session-builder.d.ts +5 -9
  290. package/dist/engines/pi/session-builder.js +44 -82
  291. package/dist/engines/pi/session-control.js +286 -269
  292. package/dist/engines/pi/session-effects.d.ts +14 -0
  293. package/dist/engines/pi/session-effects.js +30 -0
  294. package/dist/engines/pi/session-inheritance.d.ts +9 -18
  295. package/dist/engines/pi/session-inheritance.js +103 -160
  296. package/dist/engines/pi/session-markers.d.ts +31 -30
  297. package/dist/engines/pi/session-markers.js +26 -25
  298. package/dist/engines/pi/session-settings.d.ts +6 -22
  299. package/dist/engines/pi/session-settings.js +10 -21
  300. package/dist/engines/pi/session-store.d.ts +26 -92
  301. package/dist/engines/pi/session-store.js +96 -310
  302. package/dist/engines/pi/tool-context.d.ts +17 -45
  303. package/dist/engines/pi/tool-context.js +7 -26
  304. package/dist/engines/pi/tool.d.ts +45 -36
  305. package/dist/engines/pi/tool.js +43 -57
  306. package/dist/engines/pi/turn-kit.d.ts +14 -52
  307. package/dist/engines/pi/turn-kit.js +18 -67
  308. package/dist/engines/pi/wake-tool.d.ts +2 -10
  309. package/dist/engines/pi/wake-tool.js +7 -27
  310. package/dist/env.d.ts +27 -28
  311. package/dist/env.js +45 -50
  312. package/dist/index.js +0 -2
  313. package/dist/lark.d.ts +1 -2
  314. package/dist/lark.js +1 -2
  315. package/dist/loader.d.ts +14 -55
  316. package/dist/loader.js +24 -43
  317. package/dist/log.d.ts +2 -12
  318. package/dist/log.js +3 -24
  319. package/dist/node.d.ts +2 -15
  320. package/dist/node.js +2 -18
  321. package/dist/observe.d.ts +2 -8
  322. package/dist/open-url.d.ts +1 -1
  323. package/dist/open-url.js +1 -1
  324. package/dist/paths.d.ts +70 -134
  325. package/dist/paths.js +165 -232
  326. package/dist/pi.d.ts +4 -6
  327. package/dist/pi.js +3 -3
  328. package/dist/proxy.d.ts +16 -6
  329. package/dist/proxy.js +52 -16
  330. package/dist/runtime.d.ts +18 -12
  331. package/dist/runtime.js +18 -11
  332. package/dist/scaffold/add-channel.d.ts +14 -31
  333. package/dist/scaffold/add-channel.js +48 -112
  334. package/dist/scaffold/init.d.ts +9 -31
  335. package/dist/scaffold/init.js +45 -138
  336. package/dist/scaffold/templates/env.example +6 -4
  337. package/dist/scaffold/templates/fastagent.config.ts +36 -0
  338. package/dist/scaffold/templates.d.ts +3 -3
  339. package/dist/scaffold/templates.js +5 -8
  340. package/dist/scaffold/vendor-skill.d.ts +2 -5
  341. package/dist/scaffold/vendor-skill.js +19 -29
  342. package/dist/schedule/cron.d.ts +9 -5
  343. package/dist/schedule/cron.js +9 -11
  344. package/dist/schedule/discover.d.ts +12 -12
  345. package/dist/schedule/discover.js +43 -34
  346. package/dist/schedule/routine.d.ts +43 -0
  347. package/dist/schedule/routine.js +32 -0
  348. package/dist/schedule/run.d.ts +55 -0
  349. package/dist/schedule/run.js +146 -0
  350. package/dist/schedule/scheduler.d.ts +44 -55
  351. package/dist/schedule/scheduler.js +291 -265
  352. package/dist/schedule/state.d.ts +105 -10
  353. package/dist/schedule/state.js +293 -19
  354. package/dist/schedule/wake-alarm.d.ts +10 -23
  355. package/dist/schedule/wake-alarm.js +91 -121
  356. package/dist/schedule/wakeups.d.ts +23 -39
  357. package/dist/schedule/wakeups.js +33 -75
  358. package/dist/secrets-gate.d.ts +32 -0
  359. package/dist/secrets-gate.js +33 -0
  360. package/dist/service.d.ts +137 -98
  361. package/dist/service.js +250 -252
  362. package/dist/session-remote.d.ts +33 -44
  363. package/dist/session-remote.js +203 -192
  364. package/dist/session.d.ts +166 -229
  365. package/dist/session.js +33 -55
  366. package/dist/tunnel.d.ts +8 -36
  367. package/dist/tunnel.js +24 -65
  368. package/dist/version.d.ts +2 -2
  369. package/dist/version.js +2 -2
  370. package/package.json +10 -16
  371. package/dist/channels/agentcore-state.d.ts +0 -77
  372. package/dist/channels/agentcore-state.js +0 -276
  373. package/dist/channels/github/github.d.ts +0 -31
  374. package/dist/channels/github/github.js +0 -89
  375. package/dist/channels/github/scaffold/channel.ts +0 -25
  376. package/dist/cli/commands/attach.d.ts +0 -108
  377. package/dist/cli/commands/attach.js +0 -637
  378. package/dist/cli/commands/fire.d.ts +0 -7
  379. package/dist/cli/commands/fire.js +0 -38
  380. package/dist/cli/commands/schedule.d.ts +0 -12
  381. package/dist/cli/commands/schedule.js +0 -87
  382. package/dist/github.d.ts +0 -3
  383. package/dist/github.js +0 -2
  384. package/dist/scaffold/templates/fastagent.config.mjs +0 -23
  385. package/dist/schedule/audit.d.ts +0 -18
  386. package/dist/schedule/audit.js +0 -57
  387. package/dist/schedule/schedule.d.ts +0 -35
  388. package/dist/schedule/schedule.js +0 -23
@@ -1,96 +1,114 @@
1
1
  /**
2
- * The host-NEUTRAL deploy pre-flight: everything `fastagent deploy <host>` computes and checks BEFORE
3
- * the target branch (Docker / Fly / Railway). Model-travel gate, channel discovery, model-auth probe, the
4
- * container facts + their warnings, and the hand-written-Dockerfile apt warning are identical on every
5
- * host — so they live here, out of the CLI dispatcher, testable in isolation (call it against a temp dir
6
- * and assert the gate / messages / facts). The CLI stays thin: run this, print the messages, branch by host.
7
- *
8
- * It returns messages rather than printing them (the CLI owns stderr) and a `{ ok }` outcome mirroring
9
- * the run modules' {@link import("./fly/run.ts").FlyRunOutcome}: a model that won't travel is a GATE the
10
- * CLI stops on, distinct from the advisory warnings/notes it prints and proceeds past.
2
+ * The host-NEUTRAL deploy pre-flight: everything `fastagent deploy <host>` computes and checks BEFORE the target
3
+ * branch (Docker / Fly / Railway).
11
4
  */
12
- import { readdir, readFile } from "node:fs/promises";
13
- import { basename, isAbsolute, join, relative, sep } from "node:path";
14
- import ignore from "ignore";
15
- import { classifyBind } from "../bind.js";
16
- import { resolveAuthPath } from "../engines/pi/config.js";
17
- import { resolveSecretsDir, resolveStateRoot, exists, readTextIfExists } from "../paths.js";
5
+ import { readFile } from "node:fs/promises";
6
+ import { randomUUID } from "node:crypto";
7
+ import { basename, join, relative } from "node:path";
8
+ import { isModelSpec, isReleaseAgentName } from "./workspace.js";
9
+ import { providerOf, resolveAuthPath } from "../engines/pi/config.js";
10
+ import { AGENT_MODELS_FILE, exists } from "../paths.js";
18
11
  import { inspectChannels } from "../channels/discover.js";
19
- import { discoverScheduleFiles } from "../schedule/discover.js";
20
- import { createPiModelRuntime, modelCredentialCarry, probeAuthSource } from "../engines/pi/models.js";
12
+ import { loadRoutines } from "../schedule/discover.js";
13
+ import { resolveAgentTools } from "../engines/pi/create.js";
14
+ import { allSecrets } from "../declared-secrets.js";
15
+ import { createPiModelRuntime, literalKeyProviders, isBuiltinProvider, machineModels, modelCredentialCarry, probeAuthSource, } from "../engines/pi/models.js";
21
16
  import { CHANNEL_KINDS } from "../scaffold/add-channel.js";
22
17
  import { detectRuntime, readPackageJson } from "../runtime.js";
23
18
  import { fastagentVersion } from "../version.js";
24
- import { isGeneratedDockerfile, isGeneratedDockerignore } from "./container.js";
25
- import { CONTROL_TOKEN_ENV } from "../channels/control.js";
26
- import { isEnvKey } from "./secrets.js";
27
- /**
28
- * "Would docker's packer drop this path?" — built from a `.dockerignore`'s text via the `ignore`
29
- * matcher (the same library the workspace ignore files use), so `!` negation and last-match-wins are
30
- * the library's problem, not ours. Anchoring is normalized: dockerignore patterns are root-anchored
31
- * while .gitignore's match at any depth, so a bare `foo` becomes `/foo` — without that, a root-only
32
- * `.secrets` line would read as covering `fastagent/.secrets` and hand back a false all-clear on the
33
- * exact check that guards credentials.
34
- *
35
- * Known dialect gap: `ignore` keeps git's rule that a path under an EXCLUDED directory cannot be
36
- * re-included, which docker does not have — so an allowlist file (`*` + `!fastagent` + `!fastagent/**`)
37
- * can read as excluding a path docker would ship. The callers below absorb that: the drop-the-agent
38
- * gate requires the agent DIRECTORY itself to read as excluded too, which an allowlist re-includes.
39
- */
40
- function dockerignoreMatcher(text) {
41
- const anchored = text
42
- .split("\n")
43
- .map((raw) => {
44
- const line = raw.trim();
45
- if (line === "" || line.startsWith("#"))
46
- return line;
47
- const negated = line.startsWith("!");
48
- const pattern = negated ? line.slice(1) : line;
49
- if (pattern.startsWith("/") || pattern.startsWith("**/"))
50
- return line;
51
- return `${negated ? "!" : ""}/${pattern}`;
52
- })
53
- .join("\n");
54
- const matcher = ignore({ ignorecase: false }).add(anchored);
55
- return (path) => matcher.ignores(path);
19
+ import { isGeneratedDockerfile } from "./container.js";
20
+ import { buildContextPaths, checkKeptIgnoreFiles } from "./build-context.js";
21
+ import { CRON_CAN_BE_EXTERNAL, residencyFor } from "./residency.js";
22
+ import { dotEnvPath, loadEnvValues } from "../env.js";
23
+ import { deploymentSecrets, isEnvKey } from "./secrets.js";
24
+ import { DEFAULT_HTTP_PORT, describeAnonymousSurface, shouldServeRun } from "../service.js";
25
+ import { CONTROL_PREFIX } from "../channels/control.js";
26
+ /** The pre-flight's one early exit: thrown by a check, turned into `{ ok: false, gate }` by {@link preflightDeploy}. */
27
+ class DeployGate {
28
+ text;
29
+ constructor(text) {
30
+ this.text = text;
31
+ }
56
32
  }
57
- /**
58
- * Run the host-neutral pre-flight. Throws on a real fault (an unreadable channels/ dir, a throwing
59
- * provider) — the CLI wraps the call in its `failStartup` so the fault surfaces and exits, never silently.
60
- */
33
+ /** Run the host-neutral pre-flight. */
61
34
  export async function preflightDeploy(input) {
62
- const { placement: { agentDir, workspace }, config, modelSpec, run, force, externalClock, authPathFlag, } = input;
63
- // The ONE derived placement fact every host plan needs: where the agent's files sit relative to the
64
- // build context (the workspace). Nested → "fastagent/"; flat → "" (the agent IS the workspace root).
65
- const nested = agentDir !== workspace;
66
- const agentPrefix = nested ? `${basename(agentDir)}/` : "";
67
35
  const messages = [];
68
- // The deployed box resolves the model from fastagent.config.ts ONLY (in the image); a model set via
69
- // env/flag/.env doesn't travel. `--run` would ship a known crash-loop — hard gate; generate-only warns.
70
- const modelIssue = modelTravelIssue(config.model, modelSpec);
71
- if (modelIssue) {
72
- if (run)
73
- return { ok: false, gate: modelIssue };
74
- messages.push({ level: "warn", text: modelIssue });
36
+ const report = {
37
+ note: (text) => void messages.push({ level: "note", text }),
38
+ warn: (text) => void messages.push({ level: "warn", text }),
39
+ issue: (text) => {
40
+ if (input.run)
41
+ throw new DeployGate(text);
42
+ messages.push({ level: "warn", text });
43
+ },
44
+ };
45
+ try {
46
+ return { ok: true, messages, ...(await gatherFacts(input, report)) };
47
+ }
48
+ catch (error) {
49
+ if (error instanceof DeployGate)
50
+ return { ok: false, gate: error.text };
51
+ throw error;
52
+ }
53
+ }
54
+ async function gatherFacts(input, report) {
55
+ const { placement: { agentDir, workspace }, config, force, externalClock, publicUrl = true, } = input;
56
+ // The release manifest carries this name into the container, where it is joined onto the storage root — so `init`'s
57
+ // "one path segment" is not enough here.
58
+ if (!isReleaseAgentName(basename(agentDir))) {
59
+ throw new DeployGate(`the agent directory "${basename(agentDir)}" cannot be deployed — a deployed agent directory ` +
60
+ `may use only letters, digits, "-" and "_"; rename it (the fastagent.config.ts inside is what ` +
61
+ `makes it an agent, never its name)`);
75
62
  }
76
- // The control plane on a deployed box: `start` honors `sessionControl: true`, so `/control/*`
77
- // (steer, stop, rewrite or delete a session) rides the PUBLIC host URL, protected only by the bearer token. The token
78
- // travels as a deploy secret (see extraSecrets below) so the caller has it; the reach still warrants
79
- // a warning — the tunnel path warns loudly and deploy must not be the silent second way to break the
80
- // loopback trust story.
81
- if (config.sessionControl === true) {
82
- messages.push({
83
- level: "warn",
84
- text: `sessionControl: true — the deployed box serves /control/* (steer, stop, rewrite or delete a session) at its public URL, ` +
85
- `protected only by a bearer token. Set ${CONTROL_TOKEN_ENV} (listed with the other secrets) and give the ` +
86
- `same value to callers: attach --url <public-url> --token …. Unset, the box mints its own per boot — ` +
87
- `readable only by shelling in (\`docker compose exec\`/\`fly ssh console\`: <stateRoot>/control.json, whose ` +
88
- `url field is container-loopback) and replaced on every restart. Front the endpoint with real auth for ` +
89
- `anything wider (docs/design/session-control.md §14)`,
90
- });
63
+ const agentPrefix = `${basename(agentDir)}/`;
64
+ // The model this deployment will run on, and where it came from. Resolved HERE so the plan side and the run side
65
+ // cannot disagree about it.
66
+ const valueFile = relative(workspace, dotEnvPath(agentDir));
67
+ const values = loadEnvValues(dotEnvPath(agentDir));
68
+ const model = resolveDeployModel(config, values, valueFile);
69
+ if (model.invalid !== undefined) {
70
+ // A gate rather than a warning even without `--run`: the release manifest validates the spec on the way out, so
71
+ // there is no artifact to produce either. Same class as the agent-directory-name gate above.
72
+ throw new DeployGate(`the model in ${model.source} is not a "provider/modelId" spec: ${JSON.stringify(model.invalid)}`);
91
73
  }
92
- // Known channel kinds only — a custom channel's secrets/webhook are unknown to us; note and let the
93
- // author wire them.
74
+ if (!model.spec) {
75
+ const issue = `no model resolves for this deployment — set \`model: "provider/id"\` in fastagent.config.ts (it travels ` +
76
+ `in the image), or FASTAGENT_MODEL in ${valueFile} (deploy records that one in the release manifest)` +
77
+ // Whoever has the variable set right here sees `fastagent info` report a model, so "no model resolves" reads
78
+ // like a bug until the message says which environment was read. It states that fact WITHOUT attributing the
79
+ // value: it may be the operator's shell, or the first-run picker's own pick a second earlier (which prints
80
+ // its own "set `model:` in your config" hint), and the two remedies are the two sources named above.
81
+ (process.env.FASTAGENT_MODEL
82
+ ? `. Note that a FASTAGENT_MODEL in the environment running deploy is not one of those sources — it ` +
83
+ `belongs to this machine, not to the deployment`
84
+ : ``);
85
+ report.issue(issue);
86
+ }
87
+ else {
88
+ report.note(`model ${model.spec} (source: ${model.source})`);
89
+ }
90
+ const modelSpec = model.spec;
91
+ // Time triggers (static routines or self-scheduling) need a machine kept running, and a declared schedule also
92
+ // decides whether `POST /run` mounts — so the load happens HERE, before the warning that has to name it.
93
+ // Loaded, not just listed: the same load answers "are there time triggers" AND "what did they declare they need".
94
+ // A file that FAILED to load still counts as a trigger — the author will fix it, and a plan that scaled to zero
95
+ // because a cron was broken on deploy day would sleep through it afterwards.
96
+ const loadedRoutines = await loadRoutines(agentDir);
97
+ // What the PUBLIC host URL answers with no authentication of ours, NAMED FROM WHAT WILL ACTUALLY MOUNT (through the
98
+ // same `shouldServeRun` the assembly uses), never from the host alone: listing an endpoint this deployment does not
99
+ // serve is how an operator learns to skim past every deploy warning — the same reason `publicUrl` exists.
100
+ const unauthenticated = describeAnonymousSurface({
101
+ invoke: config.http?.invoke !== false,
102
+ run: loadedRoutines.routines.length > 0 && shouldServeRun(config.http),
103
+ ...(config.sessionControl === true ? { controlPrefix: CONTROL_PREFIX } : {}),
104
+ });
105
+ if (publicUrl && unauthenticated.length > 0) {
106
+ report.warn(`the deployed box answers ${unauthenticated.join(" and ")} at its public URL, UNAUTHENTICATED — ` +
107
+ `fastagent authenticates nothing. Put a gateway, an IdP-backed proxy or a private network in front of that ` +
108
+ `URL (docs/design/session-control.md §14)`);
109
+ }
110
+ // Known channel kinds only — a custom channel's webhook (and, unless it declared them, its secrets) are unknown to
111
+ // us; note and let the author wire them.
94
112
  const inspected = await inspectChannels(agentDir);
95
113
  if (inspected.failures.length > 0) {
96
114
  throw new Error(`cannot inspect channel modules: ${inspected.failures.map((failure) => `${failure.label}: ${failure.message}`).join("; ")}`);
@@ -99,46 +117,45 @@ export async function preflightDeploy(input) {
99
117
  for (const { name, ingress } of channels) {
100
118
  if (CHANNEL_KINDS.includes(name))
101
119
  continue;
102
- messages.push({
103
- level: "note",
104
- text: ingress === "long-connection"
105
- ? `long-connection channel "${name}" is custom — configure its secrets yourself; generated deploy plans keep the process running and skip webhook registration`
106
- : `route channel "${name}" is custom — configure its secrets and webhook yourself`,
107
- });
120
+ const secretsPart = `its variables travel from ${valueFile} like every other`;
121
+ report.note(ingress === "long-connection"
122
+ ? `long-connection channel "${name}" is custom — ${secretsPart}; generated deploy plans keep the process running and skip webhook registration`
123
+ : `route channel "${name}" is custom — ${secretsPart}; configure its webhook yourself`);
108
124
  }
109
125
  const longConnectionChannels = channels.filter((c) => c.ingress === "long-connection").map((c) => c.name);
110
- // Time triggers (static schedules or self-scheduling) need a machine kept running — unlike a webhook,
111
- // nothing external wakes a scale-to-zero box for a cron instant or a wake-up. The note is CONDITIONAL
112
- // ("the generated plan…"): in KEEP mode an existing fly.toml is not rewritten — the CLI warns separately
113
- // when a kept fly.toml still scales to zero.
114
- const hasTimeTriggers = (await discoverScheduleFiles(agentDir)).length > 0 || !!config.selfSchedule;
126
+ // A ROUTINE IS NOT A CRON. `cron` is a field, so counting routine FILES answered a different question: a
127
+ // definition whose only routine is reached by name (`POST /run`) would pin one machine up forever and print a
128
+ // note about a cron instant it does not have. `deploy agentcore` already filtered the same way when it turned
129
+ // routines into EventBridge rules; this is the other reader of that fact, and they must agree.
130
+ //
131
+ // A FAILED file still counts, on the conservative side: it may well declare a cron, and a plan that scaled to
132
+ // zero because the file did not parse would hide that behind silence.
133
+ const hasCron = loadedRoutines.routines.some((r) => r.cron !== undefined) || loadedRoutines.failures.length > 0;
115
134
  if (longConnectionChannels.length > 0 && !externalClock) {
116
- messages.push({
117
- level: "note",
118
- text: `long-connection channel present (${longConnectionChannels.join(", ")}) — a GENERATED plan keeps one machine running ` +
119
- `(an outbound connection cannot wake a scaled-to-zero service).`,
120
- });
135
+ report.note(`long-connection channel present (${longConnectionChannels.join(", ")}) — a GENERATED plan keeps one machine running ` +
136
+ `(an outbound connection cannot wake a scaled-to-zero service).`);
121
137
  }
122
- if (hasTimeTriggers && !externalClock) {
123
- messages.push({
124
- level: "note",
125
- text: `schedules/self-scheduling present — a GENERATED plan keeps one machine running (cron/wake has ` +
126
- `no external wake-up; scale-to-zero would sleep through them).`,
127
- });
138
+ // A cron has an external substitute, and an operator who is paying for an idle box should be told so — unless a
139
+ // long connection pins the box anyway, which residency.ts decides.
140
+ if (hasCron && !externalClock) {
141
+ const wayOut = residencyFor({ channels, hasCron })?.reason === CRON_CAN_BE_EXTERNAL;
142
+ report.note(`routines/ present — a GENERATED plan keeps one machine running (nothing wakes this box at a cron ` +
143
+ `instant).` +
144
+ (wayOut
145
+ ? ` To scale to zero instead, keep the time in a scheduler you own and let it call ` +
146
+ `\`POST /run\` (an API that runs one declared unit of work by name — docs/api-reference.md#post-run).`
147
+ : ""));
128
148
  }
129
- // Probe auth from the SAME project-level file the opener/login use — not the global default, which would
130
- // miss a `fastagent login` credential and falsely report "none configured". Through the AGENT's model
131
- // surface too (its models.json travels into the image), so a custom endpoint is not read as an unknown
132
- // provider — this probe feeds the gate that decides whether `--run` may proceed.
133
- const authPath = resolveAuthPath(agentDir, authPathFlag);
134
- const models = await createPiModelRuntime({ agentDir, authPath });
149
+ await checkMachineModels(agentDir, modelSpec, report);
150
+ // Probe auth from the SAME project-level file the opener/login use, on the registry the DEPLOYED agent has: the
151
+ // machine's models.json does not ship, so an entry there (a gateway over a built-in provider, a key) must not decide
152
+ // how the credential reaches the host.
153
+ const authPath = resolveAuthPath(agentDir);
154
+ const models = await createPiModelRuntime({ agentDir, auth: { path: authPath }, machineLayer: false });
135
155
  let modelAuth = modelSpec ? await probeAuthSource(models, modelSpec) : undefined;
136
156
  let modelKeyInDefinition = false;
137
- // probeAuthSource answers "is it authenticated here", which is not the deploy question ("how does the
138
- // credential REACH the host"). It reports every models.json endpoint as "configured API key" — not an
139
- // env-var name — so without this the gate below sees no credential and stops the deploy with two
140
- // remedies that are both wrong for such an agent: `fastagent login` cannot serve a custom provider,
141
- // and the key is already in the environment.
157
+ // probeAuthSource answers "is it authenticated here", which is not the deploy question ("how does the credential
158
+ // REACH the host").
142
159
  if (modelSpec && !isEnvKey(modelAuth)) {
143
160
  const carry = modelCredentialCarry(models, modelSpec);
144
161
  if (carry.envVar)
@@ -146,9 +163,19 @@ export async function preflightDeploy(input) {
146
163
  else
147
164
  modelKeyInDefinition = carry.inDefinition;
148
165
  }
149
- // Container facts (shared by every host) + the warnings that follow. The facts describe the AGENT —
150
- // its package.json/runtime/lockfile drive the image's install step — never the workspace's (the bake
151
- // ships the whole tree, but the workspace's own manifest belongs to its own deploy).
166
+ // Reported, not refused. Whether a string is a credential is the AUTHOR's knowledge: pi's docs prescribe
167
+ // `"apiKey": "ollama"` for a keyless local server, and no static rule separates that from a leaked key. The
168
+ // `.dockerignore` check next to this one IS a gate because it catches a packing rule putting FastAgent's OWN
169
+ // `.secrets/auth.json` into the image — the framework's doing. This is the author's own committed file.
170
+ // Asked of EVERY provider, not just the selected model's: the file ships whole.
171
+ const literalKeys = await literalKeyProviders(agentDir);
172
+ if (literalKeys.length > 0) {
173
+ report.warn(`${AGENT_MODELS_FILE} carries a literal apiKey for ${literalKeys.map((id) => `"${id}"`).join(", ")} — that ` +
174
+ `file ships inside the image, where anyone who can pull it reads the layer. If it is a credential, use ` +
175
+ `"$YOUR_ENV_VAR" (deploy carries it like any provider key) or "!command" (it runs on the box and never ` +
176
+ `travels); a placeholder for a keyless local server is fine as it is.`);
177
+ }
178
+ // Container facts (shared by every host) + the warnings that follow.
152
179
  const hasPackageJson = await exists(join(agentDir, "package.json"));
153
180
  const pkg = await readPackageJson(agentDir);
154
181
  const { runtime, bunVersion, hasLockfile } = detectRuntime(agentDir, pkg);
@@ -156,300 +183,172 @@ export async function preflightDeploy(input) {
156
183
  const runner = runtime === "bun" ? "bun run fastagent" : "./node_modules/.bin/fastagent";
157
184
  const hasOtherLock = runtime === "node" &&
158
185
  ((await exists(join(agentDir, "pnpm-lock.yaml"))) || (await exists(join(agentDir, "yarn.lock"))));
159
- // Does the baked workspace ship a `.git`? ONE fact driving both the image's git install (below)
160
- // and the plans' runbook wording — the write-back loop needs the history AND the binary together.
186
+ // Does the baked workspace ship a `.git`?
161
187
  const shipsGit = await exists(join(workspace, ".git"));
162
- // After the facts: the deps sentence must match the agent's actual shape (a markdown-only agent has
163
- // no package.json and installs nothing — the note must not point at a file that doesn't exist).
188
+ // After the facts: the deps sentence must match the agent's actual shape (a markdown-only agent has no package.json
189
+ // and installs nothing — the note must not point at a file that doesn't exist).
164
190
  const deps = hasPackageJson
165
- ? `only the agent's deps (${agentPrefix}package.json) are installed${nested ? " — the workspace's own deps are the agent's runtime concern" : ""}`
191
+ ? `only the agent's deps (${agentPrefix}package.json) are installed — the workspace's own deps are the agent's runtime concern`
166
192
  : `the agent has no package.json, so no deps are installed (the pinned global CLI serves the directory)`;
167
- const durability = shipsGit
168
- ? `Un-pushed changes on the box do not survive a redeploy; freshness and write-back run through git, ` +
169
- `driven by the agent itself (persona owns the policy; GH_TOKEN etc. go in config.deploy.secrets)`
170
- : `no .git here, so no history ships and the image does not install git — changes on the box are ` +
171
- `ephemeral and do not survive a redeploy`;
172
- messages.push({
173
- level: "note",
174
- text: `the whole directory is baked as the agent's workspace (WYSIWYG — what you see is what ships, ` +
175
- `git or not, clean or not); ${deps}. ${durability}.`,
176
- });
177
- // A code agent with no lockfile builds via a non-frozen install (ranges resolve at build time) — not
178
- // reproducible. A pnpm/yarn user gets an accurate message (their lockfile is ignored by the npm Dockerfile).
193
+ // What a RELEASE does — host-neutral, because how long the storage under it lives is the host's own answer and its
194
+ // runbook gives it (a Fly volume outlives every deploy; AgentCore's mount does not).
195
+ report.note(`the whole directory is baked as the agent's workspace (WYSIWYG — what you see is what ships, ` +
196
+ `git or not, clean or not); ${deps}. The image seeds the storage once; a later release replaces ` +
197
+ `only ${agentPrefix} and leaves the rest of the workspace, state and credentials in place — for ` +
198
+ `how long, see this host's storage note below`);
199
+ // A code agent with no lockfile builds via a non-frozen install (ranges resolve at build time) — not reproducible.
179
200
  if (hasPackageJson && !hasLockfile) {
180
201
  const lock = runtime === "bun" ? "bun.lock" : "package-lock.json";
181
- messages.push({
182
- level: "warn",
183
- text: hasOtherLock
184
- ? `the generated Dockerfile is npm-based — your pnpm/yarn lockfile is NOT used (build runs ` +
185
- `\`npm install\`, not reproducible). Edit the Dockerfile for your package manager, or vendor a package-lock.json.`
186
- : `no ${lock} — the image build resolves deps at build time (not reproducible). ` +
187
- `Run \`${install}\` and commit the lockfile for pinned redeploys.`,
188
- });
202
+ report.warn(hasOtherLock
203
+ ? `the generated Dockerfile is npm-based — your pnpm/yarn lockfile is NOT used (build runs ` +
204
+ `\`npm install\`, not reproducible). Edit the Dockerfile for your package manager, or vendor a package-lock.json.`
205
+ : `no ${lock} — the image build resolves deps at build time (not reproducible). ` +
206
+ `Run \`${install}\` and commit the lockfile for pinned redeploys.`);
189
207
  }
190
- // The code-path Dockerfile runs `${runner}` — the agent's OWN local dependency, never the
191
- // registry — so a package.json missing it means the container fails at start (no bin to run).
208
+ // The code-path Dockerfile runs `${runner}`.
192
209
  if (hasPackageJson && !("@fastagent-sh/fastagent" in { ...pkg.dependencies, ...pkg.devDependencies })) {
193
- messages.push({
194
- level: "warn",
195
- text: `package.json does not list @fastagent-sh/fastagent — the image's \`${runner}\` has no local bin to run, ` +
196
- `so the container fails at start. Add it to dependencies and re-run \`${install}\`.`,
197
- });
210
+ report.warn(`package.json does not list @fastagent-sh/fastagent — the image's \`${runner}\` has no local bin to run, ` +
211
+ `so the container fails at start. Add it to dependencies and re-run \`${install}\`.`);
198
212
  }
199
- // A KEPT workspace-root .dockerignore silently replaces the generated one's protections — so ASK IT
200
- // about the exact paths that matter (the generic "kept" line suggests --force, which never clobbers
201
- // the workspace's own file). Two are GATES under --run, same discipline as the model-travel gate:
202
- // dropping the agent dir ships a context with no persona/config (the box crash-loops), and an
203
- // unexcluded secrets path BAKES CREDENTIALS INTO THE IMAGE. The other two are advisory: the build
204
- // machine's node_modules (native binaries for YOUR OS) clobbering the image's, and an excluded .git
205
- // killing the agent's pull/push loop (a legitimate slimming choice). Not force-gated — the file is
206
- // kept even under --force.
207
- // Which paths INSIDE the build context hold secrets — resolved, then made workspace-relative (the
208
- // context root). An external secrets dir (a mounted volume) is outside the context: nothing to check
209
- // and nothing to exclude. Also fed to the generated .dockerignore, so a custom in-tree dir is
210
- // excluded by PATH even though its name is not `.secrets`.
211
- const inContext = (p) => {
212
- const rel = relative(workspace, p);
213
- return rel === "" || rel.startsWith("..") || isAbsolute(rel) ? undefined : rel.split(sep).join("/");
214
- };
215
- // The secrets DIR is the unit of RESPONSIBILITY, but never the unit of the leak QUESTION below: the
216
- // generated ignore excludes the dir's CONTENTS (`**/.secrets/**`) so its two value-free tracked
217
- // scaffolds can be re-included, and a directory-level question reads that correct file as "not
218
- // excluded" — the generator's own default output gated its own deploy (field-hit: a fresh
219
- // kit-layout workspace without --force; --force skips checking our own file, which is why the
220
- // combination stayed invisible). What leaks is a FILE, so files are what the gate asks about — see
221
- // secretDirFiles below, which enumerates what is actually inside (an atomic-write temp beside
222
- // auth.json, a second key file, an editor backup of `.env`: the dir-as-unit worry, covered per
223
- // file). The auth path adds an entry only when an override puts it OUTSIDE that dir. An external
224
- // secrets dir (the deployed posture: a mounted volume) is outside the context — nothing to check,
225
- // nothing to exclude.
226
- const secretsRel = inContext(resolveSecretsDir(agentDir));
227
- const authRel = inContext(authPath);
228
- const authElsewhere = authRel !== undefined && (secretsRel === undefined || !authRel.startsWith(`${secretsRel}/`));
229
- const secretPaths = [...(secretsRel ? [secretsRel] : []), ...(authElsewhere ? [authRel] : [])];
230
- // ONE rule for every checked path: a file that is not there cannot be baked, so gating on it would
231
- // be a refusal about a spelling rather than about what would ship (an agent that has never run
232
- // `login` has no auth.json). The generated .dockerignore still excludes them unconditionally —
233
- // cheap, and correct the moment they appear.
234
- const present = async (rels) => {
235
- const found = [];
236
- for (const rel of rels)
237
- if (await exists(join(workspace, rel)))
238
- found.push(rel);
239
- return found;
240
- };
241
- // State gets the same treatment (a custom in-tree FASTAGENT_STATE_DIR is invisible to the
242
- // name-based `**/.state`), at warn level: shipping stale sessions is waste, not a credential leak.
243
- const stateRel = inContext(resolveStateRoot(agentDir));
244
- // Existence gates the WARNING, never the generated exclude (same split as secretPaths vs
245
- // leakCandidates): an agent that has never run has no `.state/`, so telling its author a kept ignore
246
- // file fails to exclude one is a remark about a spelling — while the file we generate must still carry
247
- // the line, since it is written once and correct the moment the directory appears.
248
- const stateShips = stateRel !== undefined && (await exists(join(workspace, stateRel))) ? stateRel : undefined;
249
- // The `.env` family at the two levels fastagent is RESPONSIBLE for: the agent dir and the workspace
250
- // root. Asking only about a root-level `.env` missed both halves that matter — an `<agent>/.env`, the
251
- // file habit puts there (env.ts warns about it by name), and the `.env.local` / `.env.production`
252
- // spellings. DISCOVERED rather than spelled, so the existing rule still holds: only a file that is
253
- // there can be baked, so only it is gated.
254
- //
255
- // Deliberately NOT the recursive `**/.env` the generated file carries: walking a whole monorepo for
256
- // credential files is a secret scanner, not a placement pre-flight, and a bounded walk would be a
257
- // heuristic pretending to be a guarantee. The division of responsibility is the honest one — the file
258
- // WE generate covers every level; an author who keeps their own owns its coverage of their own tree,
259
- // and this gate speaks only for the paths fastagent itself puts credentials in.
260
- const dotEnvFiles = async (relDir) => {
261
- const names = await readdir(join(workspace, relDir || ".")).catch(() => []);
262
- // POSIX separators, like every other context-relative path here (`inContext`): these strings are
263
- // matched against dockerignore patterns, and a Windows `fastagent\.env` would match none of them —
264
- // silently turning the one check whose failure mode is "credentials in a published image" into a
265
- // no-op.
266
- return names
267
- .filter((n) => (n === ".env" || n.startsWith(".env.")) && n !== ".env.example")
268
- .map((n) => join(relDir, n).split(sep).join("/"));
269
- };
270
- const envFiles = (await Promise.all([...new Set(["", agentPrefix])].map(dotEnvFiles))).flat();
271
- // Everything ACTUALLY inside the secrets dir, minus the two tracked scaffolds the image ships on
272
- // purpose (they carry no values; the generated ignore re-includes them by name). Existence is the
273
- // enumeration itself — readdir lists exactly what could be baked — and a hand-written ignore that
274
- // misses the dir now gates NAMING the leaking file, a better diagnostic than pointing at a
275
- // directory. Recurses: a subdirectory inside .secrets is unusual but its files leak all the same.
276
- const secretDirFiles = async (dirRel) => {
277
- const entries = await readdir(join(workspace, dirRel), { withFileTypes: true }).catch(() => []);
278
- const files = [];
279
- for (const entry of entries) {
280
- if (entry.name === ".gitignore" || entry.name === ".env.example")
281
- continue;
282
- if (entry.isDirectory())
283
- files.push(...(await secretDirFiles(`${dirRel}/${entry.name}`)));
284
- else
285
- files.push(`${dirRel}/${entry.name}`);
286
- }
287
- return files;
288
- };
289
- const leakCandidates = [
290
- ...(secretsRel ? await secretDirFiles(secretsRel) : []),
291
- ...(await present(authElsewhere && authRel !== undefined ? [authRel] : [])),
292
- ...envFiles,
293
- ];
294
- // Same existence rule: a node_modules that is not there cannot be uploaded.
295
- const depDirs = await present([...new Set([`${agentPrefix}node_modules`, "node_modules"])]);
296
- const machineryPaths = [...secretPaths, ...(stateRel ? [stateRel] : [])];
297
- // BOTH ignore files deploy emits get the same interrogation. The workspace-root one is what
298
- // flyctl/railway's packers read; the per-Dockerfile one is what BuildKit PREFERS for a plain
299
- // `docker build` (so it is the file that actually decides `deploy docker`). Checking only the root one
300
- // left the credential gate not covering the path it was written for.
301
- for (const rel of [".dockerignore", `${agentPrefix}Dockerfile.dockerignore`]) {
302
- const kept = await readTextIfExists(join(workspace, rel));
303
- if (kept === undefined)
304
- continue;
305
- // One WE generated is regenerated by this very run under --force, so checking the stale content on
306
- // disk would gate a deploy on a file about to be replaced. Ours + --force: skip. Ours WITHOUT --force
307
- // is still checked (it is what would ship), but the remedy differs: hand-adding lines to fastagent's
308
- // own output is not the fix — regenerating it is.
309
- const keptIsOurs = isGeneratedDockerignore(kept);
310
- if (force && keptIsOurs)
311
- continue;
312
- const remedy = (lines) => keptIsOurs
313
- ? `Re-run with --force to regenerate it.`
314
- : `Add ${lines.map((p) => `\`${p}\``).join(" and ")} before deploying (the same lines the generated ${rel} writes).`;
315
- const excluded = dockerignoreMatcher(kept);
316
- // Asked as a DIRECTORY (trailing slash), which is what it is. A bare-name test answers `false` for
317
- // the `fastagent/` spelling — a directory-only pattern, and the one a hand-written ignore file is
318
- // most likely to carry — so the agent would be dropped from the context with no warning at all. The
319
- // pairing this replaced (the directory AND a file inside it) was dead weight rather than a
320
- // safeguard: git's rule that a file under an excluded directory cannot be re-included is
321
- // implemented by the matcher, so `excluded(dir)` already implies `excluded(dir/persona.md)` and the
322
- // second test could never change the answer. What it was aimed at — an allowlist (`*` +
323
- // `!fastagent` + `!fastagent/**`) that re-includes the agent — is handled by the first test alone,
324
- // which reads `false` there, as it should.
325
- if (nested && excluded(`${basename(agentDir)}/`)) {
326
- const text = `your ${rel} (kept) excludes \`${basename(agentDir)}\` — the build context would ship WITHOUT the ` +
327
- `agent entirely (the deployed box has no persona/config and crash-loops). Remove that rule ` +
328
- `before deploying.`;
329
- if (run)
330
- return { ok: false, gate: text };
331
- messages.push({ level: "warn", text });
332
- }
333
- // Resolved paths, not spellings: dockerignore patterns are root-anchored (unlike .gitignore), so a
334
- // bare `.secrets` line does not cover `fastagent/.secrets` — and FASTAGENT_SECRETS_DIR /
335
- // FASTAGENT_AUTH_PATH can put credentials anywhere in the baked tree, where the name-based excludes
336
- // never reach. This is the one check whose failure mode is "credentials in a published image".
337
- const leaks = leakCandidates.filter((p) => !excluded(p));
338
- if (leaks.length > 0) {
339
- const text = `your ${rel} (kept) does not exclude ${leaks.map((p) => `\`${p}\``).join(", ")} — the build ` +
340
- `context would BAKE SECRETS INTO THE IMAGE. ${remedy(leaks.map((p) => `/${p}`))}`;
341
- if (run)
342
- return { ok: false, gate: text };
343
- messages.push({ level: "warn", text });
344
- }
345
- if (stateShips && !excluded(`${stateShips}/sessions`)) {
346
- messages.push({
347
- level: "warn",
348
- text: `your ${rel} (kept) does not exclude \`${stateRel}\` — the build machine's sessions/channel state would ship in the image. ${remedy([`/${stateRel}`])}`,
349
- });
350
- }
351
- // Both the agent's own node_modules and the workspace's: either would upload the build machine's
352
- // deps (native binaries for YOUR OS) and clobber the image's freshly-installed ones. Named by the
353
- // PATHS actually found unexcluded, like every other check here — not by a rule's spelling.
354
- const unexcludedDeps = depDirs.filter((p) => !excluded(`${p}/.package-lock.json`));
355
- if (unexcludedDeps.length > 0) {
356
- messages.push({
357
- level: "warn",
358
- text: `your ${rel} does not exclude ${unexcludedDeps.map((p) => `\`${p}\``).join(" or ")} — the ` +
359
- `build machine's deps (native binaries for YOUR OS) would be uploaded and clobber the image's ` +
360
- `freshly-installed ones. ${remedy(unexcludedDeps.map((p) => `/${p}`))}`,
361
- });
362
- }
363
- if (excluded(".git/HEAD")) {
364
- messages.push({
365
- level: "note",
366
- text: `your ${rel} excludes .git — the baked copy ships WITHOUT history/remote, so the agent ` +
367
- `cannot pull/commit/push it; it must \`git clone\` its repo in the workspace instead (or remove the .git line).`,
368
- });
369
- }
370
- }
371
- // Write-back mechanics are fastagent's (the policy is the persona's): the image carries the git
372
- // BINARY iff the baked workspace ships a `.git` (history without the binary is a
373
- // dead loop; the binary without history is dead weight). A non-git workspace that still needs git
374
- // (the agent clones repos as its job) declares config.deploy.apt: ["git"] explicitly. Merged with
375
- // (never duplicating) config.deploy.apt.
213
+ const paths = await buildContextPaths(workspace, agentDir, agentPrefix, authPath);
214
+ await checkKeptIgnoreFiles({ workspace, agentDir, agentPrefix, force, paths }, report);
215
+ // Write-back mechanics are fastagent's (the policy is the persona's).
376
216
  const apt = shipsGit ? [...new Set(["git", ...(config.deploy?.apt ?? [])])] : config.deploy?.apt;
377
217
  const container = {
218
+ releaseId: randomUUID(),
378
219
  agentPrefix,
379
- machineryPaths,
220
+ machineryPaths: paths.machineryPaths,
380
221
  hasPackageJson,
381
222
  runtime,
382
223
  bunVersion,
383
224
  hasLockfile,
384
225
  version: await fastagentVersion(),
385
226
  apt,
227
+ ...(model.envValue !== undefined ? { modelSpec: model.envValue } : {}),
386
228
  shipsGit,
387
229
  };
388
- const port = config.http?.port ?? 8787;
389
- // `http.host` travels in the artifact (config is what deploy ships), and any non-wildcard value that
390
- // is right on a laptop is wrong in a container: the wildcard bind is what makes the published port,
391
- // the health check and webhook ingress reachable at all. `--bind` is the local-only knob; config is not.
392
- const configBind = classifyBind(config.http?.host);
393
- if (configBind !== "wildcard") {
394
- const issue = `fastagent.config.ts sets http.host: "${config.http?.host}" — it travels into the image, where ` +
395
- (configBind === "loopback"
396
- ? `nothing outside the container can reach the serve (published port, health check, webhooks).`
397
- : `that address does not exist, so the container fails to bind at start.`) +
398
- ` Drop it and use \`--bind ${config.http?.host}\` locally instead.`;
399
- // Same disposition as the model-travel issue: warn when producing artifacts (the operator may be
400
- // deploying somewhere that fronts the port), gate `--run` — which would otherwise ship a box that
401
- // answers nothing, or crash-loops on a bind that cannot resolve inside the container.
402
- if (run)
403
- return { ok: false, gate: issue };
404
- messages.push({ level: "warn", text: issue });
405
- }
406
- // What the agent declared it needs on the box (fastagent.config deploy.secrets) — carried like channel
407
- // secrets: listed in the runbook, set from the local env under --run, gated if a value is missing.
408
- const extraSecrets = [...(config.deploy?.secrets ?? [])];
409
- // The plane's bearer token is the DEPLOYMENT's secret, not the container's: minted inside the box it
410
- // is unreadable from outside and replaced on every restart, which is what makes a public /control/*
411
- // unusable. Carried like any declared secret — listed in the runbook, taken from the local env under
412
- // --run, gated when absent (never minted: a value minted per deploy rotates under its holder).
413
- if (config.sessionControl === true)
414
- extraSecrets.push(CONTROL_TOKEN_ENV);
415
- // deploy.apt only shapes the GENERATED Dockerfile. Warn ONLY when the kept Dockerfile is HAND-WRITTEN
416
- // (its apt won't include these) — a fastagent-generated one is handled by writeArtifacts. Don't suggest
417
- // --force here: it would overwrite the user's hand-written file.
418
- const dockerfileHome = join(agentDir, "Dockerfile");
419
- if (config.deploy?.apt?.length && !force && (await exists(dockerfileHome))) {
420
- if (!isGeneratedDockerfile(await readFile(dockerfileHome, "utf8"))) {
421
- messages.push({
422
- level: "warn",
423
- text: `kept your hand-written Dockerfile — deploy.apt (${config.deploy.apt.join(", ")}) is ` +
424
- `NOT applied; install those packages in your Dockerfile.`,
425
- });
426
- }
230
+ const port = config.http?.port ?? DEFAULT_HTTP_PORT;
231
+ // EVERYTHING the definition declared it needs, from wherever it was declared. Read through the SAME resolver
232
+ // dev/start mount with, so "which tool declarations count" has one answer (config.tools declare too; a shadowed
233
+ // file's declaration is dropped in both places).
234
+ const resolvedTools = await resolveAgentTools(config, agentDir, workspace);
235
+ // A code input we could not READ is a code input whose declarations we cannot carry — and the box
236
+ // WILL read it (its deps are installed there), so its gate fires after the deploy reported success:
237
+ // a crash loop, which is the failure mode this whole mechanism exists to move to build time. Under
238
+ // `--run` that is a gate, like a channel that fails to inspect; generate-only warns, since the
239
+ // operator may be producing artifacts from a machine that never installed the agent's deps.
240
+ for (const failure of [...resolvedTools.toolFailures, ...loadedRoutines.failures]) {
241
+ const issue = `${failure.label} failed to load (${failure.message}) — any secrets it declares cannot be carried ` +
242
+ `to the host, so the deployed box would refuse to start`;
243
+ report.issue(issue);
427
244
  }
245
+ const declaredSecrets = [
246
+ ...allSecrets(resolvedTools.toolSecrets),
247
+ ...allSecrets(loadedRoutines.secrets),
248
+ ...allSecrets(inspected.secrets),
249
+ ];
250
+ await checkKeptDockerfile(agentDir, config, model.envValue, valueFile, report);
428
251
  return {
429
- ok: true,
430
- messages,
431
252
  channels,
432
- hasTimeTriggers,
253
+ hasCron,
254
+ values,
255
+ valueFile,
433
256
  modelAuth,
434
257
  modelKeyInDefinition,
435
258
  authPath,
436
259
  container,
437
260
  port,
438
- extraSecrets,
261
+ declaredSecrets,
262
+ secrets: deploymentSecrets(modelAuth, declaredSecrets, values, valueFile),
439
263
  };
440
264
  }
441
265
  /**
442
- * Why the resolved model won't reach the deployed box, or undefined if it will — host-neutral. `fastagent.config.ts`
443
- * is the model's committed home (config's charter: model / tools / http) and the only source deploy ships:
444
- * a `--model`/`FASTAGENT_MODEL`/`.env` value is builder-local and doesn't travel (`.env` is dockerignored),
445
- * so a model NOT in config crash-loops the box with "missing model". The pre-flight warns (runbook) or gates
446
- * (`--run`). Single source on purpose — a host env block (fly.toml `[env]`) is NOT advertised as a second home.
266
+ * The machine's models.json is this box's environment, not the artifact: whatever the model takes from it is
267
+ * absent wherever the agent is deployed. Said in so many words here; the credential probe already reads the deployed
268
+ * registry.
269
+ */
270
+ async function checkMachineModels(agentDir, modelSpec, report) {
271
+ const machine = await machineModels(agentDir);
272
+ const provider = modelSpec ? providerOf(modelSpec) : undefined;
273
+ if (provider === undefined || !machine?.inherited.includes(provider))
274
+ return;
275
+ if (isBuiltinProvider(provider)) {
276
+ report.warn(`model "${modelSpec}" takes its "${provider}" entry from ${machine.path}, the machine's models.json, which ` +
277
+ `does not ship — the deployed agent runs pi's built-in "${provider}" without it. Declare the entry in the ` +
278
+ `agent's own ${AGENT_MODELS_FILE} to deploy it.`);
279
+ }
280
+ else {
281
+ // No built-in to fall back on: the deployed agent cannot resolve the model at all.
282
+ const issue = `model "${modelSpec}" exists only in ${machine.path}, the machine's models.json, which does not ship — the ` +
283
+ `deployed agent would fail with an unknown model. Declare "${provider}" in the agent's own ` +
284
+ `${AGENT_MODELS_FILE} to deploy it.`;
285
+ report.issue(issue);
286
+ }
287
+ }
288
+ /**
289
+ * What a KEPT hand-written Dockerfile drops. `deploy.apt` is the obvious one; the resolved model is the one that
290
+ * looks safe and is not: the manifest is always written, but only the generated Dockerfile sets
291
+ * FASTAGENT_RELEASE_FILE, and without it `prepareStartWorkspace` never reads the manifest — so a model that lives
292
+ * ONLY in the value file would be reported here and absent on the box.
293
+ *
294
+ * NOT conditioned on `!force`: `writeArtifacts` refuses a file it did not generate whatever the flag says, so a
295
+ * hand-written Dockerfile survives `--force` and drops exactly the same things. Short-circuiting here let
296
+ * `--run --force` ship the crash-loop this gate exists to stop.
297
+ */
298
+ async function checkKeptDockerfile(agentDir, config,
299
+ /** The model the release manifest carries (set only when the value file named it). */
300
+ modelFromValueFile, valueFile, report) {
301
+ const dockerfileHome = join(agentDir, "Dockerfile");
302
+ const dockerfileText = (await exists(dockerfileHome)) ? await readFile(dockerfileHome, "utf8") : undefined;
303
+ if (dockerfileText === undefined || isGeneratedDockerfile(dockerfileText))
304
+ return;
305
+ if (config.deploy?.apt?.length) {
306
+ report.warn(`kept your hand-written Dockerfile — deploy.apt (${config.deploy.apt.join(", ")}) is ` +
307
+ `NOT applied; install those packages in your Dockerfile.`);
308
+ }
309
+ // The INSTRUCTION is the question, not the file's authorship: `prepareStartWorkspace` returns early without
310
+ // FASTAGENT_RELEASE_FILE, so a Dockerfile that sets it reads the manifest whoever wrote it. This gates rather
311
+ // than warns because it is about FastAgent's OWN delivery arriving — the model would be reported here and
312
+ // missing on the box.
313
+ // Anywhere in an `ENV` instruction, not just first: `ENV A=1 FASTAGENT_RELEASE_FILE=/app/x` is ordinary
314
+ // Dockerfile style and hard-refusing it would be a false gate. A backslash continuation still reads as absent
315
+ // (covering it means joining lines first) — the remaining over-strict edge.
316
+ if (modelFromValueFile !== undefined && !/^\s*ENV\s[^\n]*\bFASTAGENT_RELEASE_FILE[=\s]/m.test(dockerfileText)) {
317
+ const issue = `your Dockerfile does not set FASTAGENT_RELEASE_FILE, and the model comes from ${valueFile} — it travels ` +
318
+ `in the release manifest, which is only read when that ENV points at it. Add it (see a generated ` +
319
+ `Dockerfile), or set \`model\` in fastagent.config.ts so it ships in the config instead.`;
320
+ report.issue(issue);
321
+ }
322
+ }
323
+ /**
324
+ * WHICH model this deployment runs on, and where that came from.
325
+ *
326
+ * There is ONE precedence chain — `flag > environment > config.model` — and this is it evaluated in the environment
327
+ * BEING DEPLOYED rather than in this machine's. That environment is declared by the value file, so the operator's
328
+ * `process.env` simply is not part of it (the same way `dev` never reads another machine's shell); it needs no rule
329
+ * of its own. `deploy` has no flag layer either, and that follows from the same model rather than from policy: a
330
+ * generated Dockerfile is rewritten on every deploy, so a flag baked into one would silently vanish on the next run
331
+ * that omits it. A file does not.
332
+ *
333
+ * The value file is the half of the deployed environment we can DECLARE. The other half — variables the platform
334
+ * already holds — is still on the box and still outranks the image's own `ENV`, which is exactly why the model is
335
+ * baked rather than delivered as one more platform variable that nothing would ever clear.
447
336
  */
448
- export function modelTravelIssue(configModel, modelSpec) {
449
- if (configModel)
450
- return undefined;
451
- return modelSpec
452
- ? `model "${modelSpec}" is set via --model/FASTAGENT_MODEL, not fastagent.config.ts — it won't reach ` +
453
- `the deployed box. Add \`model: "${modelSpec}"\` to fastagent.config.ts.`
454
- : `no model in fastagent.config.ts — the deployed box can't resolve one. Add \`model: "provider/id"\`.`;
337
+ function resolveDeployModel(config, values,
338
+ /** The value file AS READ (it follows `FASTAGENT_SECRETS_DIR`), so the reported source is the real one. */
339
+ valueFile) {
340
+ const fromEnv = values.get("FASTAGENT_MODEL");
341
+ const source = fromEnv ? valueFile : "fastagent.config";
342
+ // BOTH sources are checked, at the one point that reads them: a `provider`-less spec resolves to nothing on the
343
+ // box, so letting `config.model` through would ship exactly the crash-loop this resolution exists to prevent —
344
+ // `probeAuthSource` reports "unconfigured" for it here and the failure only appears after deployment.
345
+ const spec = fromEnv || config.model;
346
+ if (spec && !isModelSpec(spec))
347
+ return { source, invalid: spec };
348
+ // `envValue` is what the release manifest records (ContainerInput.modelSpec), so it is set only when the value file
349
+ // is the source: a `config.model` already travels in the config itself. The manifest is rewritten by every deploy,
350
+ // so deleting the line and redeploying simply drops it — nothing stale survives.
351
+ if (fromEnv)
352
+ return { spec: fromEnv, source, envValue: fromEnv };
353
+ return { spec: config.model, source };
455
354
  }