@fastagent-sh/fastagent 0.14.0 → 0.16.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 (277) hide show
  1. package/README.md +16 -6
  2. package/dist/agent.d.ts +20 -0
  3. package/dist/agent.js +9 -0
  4. package/dist/channels/agentcore-limits.d.ts +7 -0
  5. package/dist/channels/agentcore-limits.js +9 -0
  6. package/dist/channels/agentcore-state.d.ts +83 -0
  7. package/dist/channels/agentcore-state.js +258 -0
  8. package/dist/channels/agentcore.d.ts +98 -0
  9. package/dist/channels/agentcore.js +260 -0
  10. package/dist/channels/busy.d.ts +23 -0
  11. package/dist/channels/busy.js +53 -0
  12. package/dist/channels/context-buffer.d.ts +35 -0
  13. package/dist/channels/context-buffer.js +103 -0
  14. package/dist/channels/control.d.ts +28 -0
  15. package/dist/channels/control.js +214 -0
  16. package/dist/channels/feishu/card.js +1 -1
  17. package/dist/channels/feishu/context-buffer.d.ts +15 -13
  18. package/dist/channels/feishu/context-buffer.js +62 -69
  19. package/dist/channels/feishu/feishu-api.d.ts +11 -3
  20. package/dist/channels/feishu/feishu-api.js +24 -3
  21. package/dist/channels/feishu/feishu.d.ts +27 -27
  22. package/dist/channels/feishu/feishu.js +318 -207
  23. package/dist/channels/feishu/invoke-turn.d.ts +7 -20
  24. package/dist/channels/feishu/invoke-turn.js +44 -79
  25. package/dist/channels/feishu/model.d.ts +2 -2
  26. package/dist/channels/feishu/normalize.d.ts +2 -1
  27. package/dist/channels/feishu/normalize.js +0 -1
  28. package/dist/channels/feishu/parse.d.ts +22 -8
  29. package/dist/channels/feishu/parse.js +24 -7
  30. package/dist/channels/feishu/preview.d.ts +4 -7
  31. package/dist/channels/feishu/preview.js +27 -142
  32. package/dist/channels/feishu/register-app.d.ts +2 -1
  33. package/dist/channels/feishu/register-webhook.d.ts +2 -1
  34. package/dist/channels/feishu/scaffold/channel.ts +9 -8
  35. package/dist/channels/feishu/scaffold/feishu-send.ts +6 -4
  36. package/dist/channels/feishu/setup-mode.d.ts +37 -0
  37. package/dist/channels/feishu/setup-mode.js +28 -0
  38. package/dist/channels/feishu/ws-ingress.d.ts +28 -0
  39. package/dist/channels/feishu/ws-ingress.js +136 -0
  40. package/dist/channels/github/github.js +16 -7
  41. package/dist/channels/http.d.ts +14 -0
  42. package/dist/channels/http.js +36 -3
  43. package/dist/channels/invoke-turn-kit.d.ts +65 -0
  44. package/dist/channels/invoke-turn-kit.js +87 -0
  45. package/dist/channels/lark/lark.d.ts +4 -2
  46. package/dist/channels/lark/lark.js +4 -1
  47. package/dist/channels/lark/onboard.d.ts +8 -4
  48. package/dist/channels/lark/onboard.js +8 -0
  49. package/dist/channels/lark/scaffold/channel.ts +9 -8
  50. package/dist/channels/lark/scaffold/lark-send.ts +6 -4
  51. package/dist/channels/preview-kit.d.ts +115 -0
  52. package/dist/channels/preview-kit.js +184 -0
  53. package/dist/channels/seen.d.ts +5 -0
  54. package/dist/channels/seen.js +35 -0
  55. package/dist/channels/slack/bot-auth.d.ts +15 -0
  56. package/dist/channels/slack/bot-auth.js +146 -0
  57. package/dist/channels/slack/config-api.d.ts +60 -0
  58. package/dist/channels/slack/config-api.js +149 -0
  59. package/dist/channels/slack/context-buffer.d.ts +24 -0
  60. package/dist/channels/slack/context-buffer.js +37 -0
  61. package/dist/channels/slack/invoke-turn.d.ts +19 -0
  62. package/dist/channels/slack/invoke-turn.js +63 -0
  63. package/dist/channels/slack/manifest.d.ts +49 -0
  64. package/dist/channels/slack/manifest.js +69 -0
  65. package/dist/channels/slack/model.d.ts +67 -0
  66. package/dist/channels/slack/model.js +2 -0
  67. package/dist/channels/slack/onboard.d.ts +41 -0
  68. package/dist/channels/slack/onboard.js +120 -0
  69. package/dist/channels/slack/onboarding-state.d.ts +31 -0
  70. package/dist/channels/slack/onboarding-state.js +69 -0
  71. package/dist/channels/slack/parse.d.ts +38 -0
  72. package/dist/channels/slack/parse.js +124 -0
  73. package/dist/channels/slack/preview.d.ts +23 -0
  74. package/dist/channels/slack/preview.js +403 -0
  75. package/dist/channels/slack/reaction.d.ts +24 -0
  76. package/dist/channels/slack/reaction.js +62 -0
  77. package/dist/channels/slack/register-webhook.d.ts +10 -0
  78. package/dist/channels/slack/register-webhook.js +49 -0
  79. package/dist/channels/slack/scaffold/channel.ts +33 -0
  80. package/dist/channels/slack/scaffold/slack-send.ts +171 -0
  81. package/dist/channels/slack/setup-server.d.ts +17 -0
  82. package/dist/channels/slack/setup-server.js +103 -0
  83. package/dist/channels/slack/slack-api.d.ts +57 -0
  84. package/dist/channels/slack/slack-api.js +399 -0
  85. package/dist/channels/slack/slack.d.ts +51 -0
  86. package/dist/channels/slack/slack.js +496 -0
  87. package/dist/channels/slack/welcomed.d.ts +5 -0
  88. package/dist/channels/slack/welcomed.js +32 -0
  89. package/dist/channels/state.d.ts +11 -4
  90. package/dist/channels/state.js +22 -15
  91. package/dist/channels/stop-command.d.ts +6 -0
  92. package/dist/channels/stop-command.js +36 -0
  93. package/dist/channels/tasks.d.ts +7 -0
  94. package/dist/channels/tasks.js +25 -0
  95. package/dist/channels/telegram/context-buffer.d.ts +8 -17
  96. package/dist/channels/telegram/context-buffer.js +6 -85
  97. package/dist/channels/telegram/invoke-turn.d.ts +5 -22
  98. package/dist/channels/telegram/invoke-turn.js +11 -58
  99. package/dist/channels/telegram/parse.d.ts +0 -7
  100. package/dist/channels/telegram/parse.js +4 -2
  101. package/dist/channels/telegram/preview.d.ts +4 -7
  102. package/dist/channels/telegram/preview.js +24 -142
  103. package/dist/channels/telegram/scaffold/telegram-send.ts +6 -3
  104. package/dist/channels/telegram/telegram.js +24 -10
  105. package/dist/channels/{feishu/text.d.ts → text.d.ts} +15 -1
  106. package/dist/channels/{feishu/text.js → text.js} +15 -1
  107. package/dist/channels/thread-participants.d.ts +21 -0
  108. package/dist/channels/thread-participants.js +132 -0
  109. package/dist/channels/turn-queue.js +8 -1
  110. package/dist/channels/turn-store.d.ts +1 -1
  111. package/dist/channels/turn-store.js +2 -3
  112. package/dist/cli/add-feishu.d.ts +30 -0
  113. package/dist/cli/add-feishu.js +366 -0
  114. package/dist/cli/add-slack.d.ts +11 -0
  115. package/dist/cli/add-slack.js +199 -0
  116. package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
  117. package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
  118. package/dist/cli/commands/add.d.ts +4 -0
  119. package/dist/cli/commands/add.js +191 -60
  120. package/dist/cli/commands/attach.d.ts +82 -0
  121. package/dist/cli/commands/attach.js +563 -0
  122. package/dist/cli/commands/chat.d.ts +1 -0
  123. package/dist/cli/commands/chat.js +12 -7
  124. package/dist/cli/commands/deploy.d.ts +3 -1
  125. package/dist/cli/commands/deploy.js +384 -82
  126. package/dist/cli/commands/dev.js +34 -26
  127. package/dist/cli/commands/fire.js +17 -18
  128. package/dist/cli/commands/info.js +36 -29
  129. package/dist/cli/commands/init.d.ts +1 -1
  130. package/dist/cli/commands/init.js +65 -53
  131. package/dist/cli/commands/invoke.js +11 -9
  132. package/dist/cli/commands/login.js +48 -45
  133. package/dist/cli/commands/models.js +1 -1
  134. package/dist/cli/commands/schedule.js +6 -8
  135. package/dist/cli/commands/start.js +102 -39
  136. package/dist/cli/commands/tool.js +23 -18
  137. package/dist/cli/fail.d.ts +17 -2
  138. package/dist/cli/fail.js +25 -1
  139. package/dist/{invoke-stream.d.ts → cli/invoke-stream.d.ts} +1 -1
  140. package/dist/{invoke-stream.js → cli/invoke-stream.js} +4 -0
  141. package/dist/cli/kernel.d.ts +3 -2
  142. package/dist/cli/models-view.d.ts +21 -0
  143. package/dist/cli/models-view.js +66 -0
  144. package/dist/cli/program.js +135 -56
  145. package/dist/cli/serve.d.ts +60 -20
  146. package/dist/cli/serve.js +252 -47
  147. package/dist/cli/shared.d.ts +40 -7
  148. package/dist/cli/shared.js +180 -38
  149. package/dist/collect.d.ts +14 -3
  150. package/dist/collect.js +24 -0
  151. package/dist/core.d.ts +3 -1
  152. package/dist/core.js +2 -0
  153. package/dist/deploy/agentcore/plan.d.ts +117 -0
  154. package/dist/deploy/agentcore/plan.js +721 -0
  155. package/dist/deploy/agentcore/run.d.ts +73 -0
  156. package/dist/deploy/agentcore/run.js +412 -0
  157. package/dist/deploy/agentcore/zip.d.ts +17 -0
  158. package/dist/deploy/agentcore/zip.js +68 -0
  159. package/dist/deploy/container.d.ts +26 -25
  160. package/dist/deploy/container.js +93 -89
  161. package/dist/deploy/docker/plan.d.ts +3 -3
  162. package/dist/deploy/docker/plan.js +20 -23
  163. package/dist/deploy/fly/plan.d.ts +5 -1
  164. package/dist/deploy/fly/plan.js +39 -26
  165. package/dist/deploy/fly/run.d.ts +17 -5
  166. package/dist/deploy/fly/run.js +52 -7
  167. package/dist/deploy/preflight.d.ts +20 -8
  168. package/dist/deploy/preflight.js +267 -71
  169. package/dist/deploy/railway/plan.d.ts +10 -1
  170. package/dist/deploy/railway/plan.js +53 -20
  171. package/dist/deploy/railway/run.d.ts +13 -5
  172. package/dist/deploy/railway/run.js +22 -7
  173. package/dist/deploy/runner.d.ts +7 -3
  174. package/dist/deploy/runner.js +9 -3
  175. package/dist/deploy/secrets.d.ts +2 -1
  176. package/dist/deploy/secrets.js +23 -3
  177. package/dist/dev-supervisor.d.ts +11 -10
  178. package/dist/dev-supervisor.js +56 -50
  179. package/dist/engines/pi/auth.d.ts +8 -7
  180. package/dist/engines/pi/auth.js +12 -10
  181. package/dist/engines/pi/channel.d.ts +22 -16
  182. package/dist/engines/pi/channel.js +92 -62
  183. package/dist/engines/pi/chat.d.ts +4 -16
  184. package/dist/engines/pi/chat.js +8 -261
  185. package/dist/engines/pi/config.d.ts +20 -55
  186. package/dist/engines/pi/config.js +49 -121
  187. package/dist/engines/pi/create.d.ts +30 -10
  188. package/dist/engines/pi/create.js +33 -26
  189. package/dist/engines/pi/definition.d.ts +7 -26
  190. package/dist/engines/pi/definition.js +8 -54
  191. package/dist/engines/pi/harness.d.ts +49 -0
  192. package/dist/engines/pi/harness.js +90 -2
  193. package/dist/engines/pi/invoke.d.ts +55 -2
  194. package/dist/engines/pi/invoke.js +304 -19
  195. package/dist/engines/pi/login.d.ts +12 -1
  196. package/dist/engines/pi/login.js +16 -4
  197. package/dist/engines/pi/models.d.ts +59 -13
  198. package/dist/engines/pi/models.js +62 -24
  199. package/dist/engines/pi/open.d.ts +114 -0
  200. package/dist/engines/pi/open.js +127 -0
  201. package/dist/engines/pi/session-builder.d.ts +16 -0
  202. package/dist/engines/pi/session-builder.js +308 -0
  203. package/dist/engines/pi/session-control.d.ts +50 -0
  204. package/dist/engines/pi/session-control.js +604 -0
  205. package/dist/engines/pi/sessions.d.ts +17 -2
  206. package/dist/engines/pi/sessions.js +9 -0
  207. package/dist/engines/pi/tool-context.d.ts +18 -11
  208. package/dist/engines/pi/tool-context.js +3 -9
  209. package/dist/engines/pi/tool.d.ts +6 -8
  210. package/dist/engines/pi/tool.js +10 -1
  211. package/dist/engines/pi/wake-tool.d.ts +0 -3
  212. package/dist/engines/pi/wake-tool.js +9 -7
  213. package/dist/env.d.ts +16 -4
  214. package/dist/env.js +43 -5
  215. package/dist/feishu.d.ts +1 -1
  216. package/dist/feishu.js +1 -1
  217. package/dist/host/node.d.ts +25 -8
  218. package/dist/host/node.js +5 -4
  219. package/dist/index.d.ts +1 -0
  220. package/dist/index.js +1 -0
  221. package/dist/lark.d.ts +1 -1
  222. package/dist/lark.js +1 -1
  223. package/dist/loader.d.ts +2 -2
  224. package/dist/loader.js +3 -3
  225. package/dist/log.d.ts +1 -1
  226. package/dist/log.js +1 -1
  227. package/dist/observe.js +3 -0
  228. package/dist/paths.d.ts +138 -0
  229. package/dist/paths.js +326 -0
  230. package/dist/pi.d.ts +6 -5
  231. package/dist/pi.js +4 -3
  232. package/dist/runtime.d.ts +7 -5
  233. package/dist/runtime.js +2 -2
  234. package/dist/scaffold/add-channel.d.ts +26 -9
  235. package/dist/scaffold/add-channel.js +175 -40
  236. package/dist/scaffold/init.d.ts +32 -41
  237. package/dist/scaffold/init.js +161 -185
  238. package/dist/scaffold/templates/env.example +15 -6
  239. package/dist/scaffold/templates/fastagent.config.mjs +5 -4
  240. package/dist/scaffold/templates/gitignore +14 -6
  241. package/dist/scaffold/templates/persona.md +4 -2
  242. package/dist/scaffold/templates/secrets.gitignore +5 -0
  243. package/dist/scaffold/templates.d.ts +1 -7
  244. package/dist/scaffold/templates.js +3 -25
  245. package/dist/scaffold/vendor-skill.d.ts +2 -2
  246. package/dist/scaffold/vendor-skill.js +13 -13
  247. package/dist/schedule/discover.js +4 -4
  248. package/dist/schedule/scheduler.d.ts +40 -1
  249. package/dist/schedule/scheduler.js +89 -56
  250. package/dist/schedule/state.js +1 -1
  251. package/dist/schedule/wake-alarm.d.ts +47 -0
  252. package/dist/schedule/wake-alarm.js +136 -0
  253. package/dist/schedule/wakeups.d.ts +1 -3
  254. package/dist/schedule/wakeups.js +19 -1
  255. package/dist/session-remote.d.ts +53 -0
  256. package/dist/session-remote.js +336 -0
  257. package/dist/session.d.ts +265 -0
  258. package/dist/session.js +37 -0
  259. package/dist/slack.d.ts +2 -0
  260. package/dist/slack.js +2 -0
  261. package/dist/tunnel.d.ts +7 -4
  262. package/dist/tunnel.js +23 -12
  263. package/package.json +22 -7
  264. package/dist/channels/feishu/owned-threads.d.ts +0 -11
  265. package/dist/channels/feishu/owned-threads.js +0 -47
  266. package/dist/channels/feishu/seen.d.ts +0 -5
  267. package/dist/channels/feishu/seen.js +0 -47
  268. package/dist/cli-add-feishu.d.ts +0 -8
  269. package/dist/cli-add-feishu.js +0 -223
  270. package/dist/cli-models.d.ts +0 -11
  271. package/dist/cli-models.js +0 -20
  272. package/dist/engines/pi/workspace.d.ts +0 -56
  273. package/dist/engines/pi/workspace.js +0 -72
  274. package/dist/scaffold/templates/gitignore.agentdir-root +0 -5
  275. package/dist/scaffold/templates/gitignore.kit +0 -2
  276. package/dist/workspace.d.ts +0 -9
  277. package/dist/workspace.js +0 -45
@@ -7,6 +7,26 @@ import { registrationGate } from "../registration-gate.js";
7
7
  export function authSeedBytes(seed, fileExists) {
8
8
  return !seed || fileExists ? undefined : Buffer.from(seed, "base64");
9
9
  }
10
+ /**
11
+ * Collect the (possibly CHUNKED) auth seed from the environment: `FASTAGENT_AUTH_SEED` plus numbered
12
+ * continuations (`_2`, `_3`, …) concatenated in order. Hosts whose env values carry a small max
13
+ * length (AgentCore: 2048 chars — a real OAuth auth.json's base64 exceeds it) split the seed across
14
+ * them at deploy time; single-var hosts (Fly/Railway) never set a continuation and are unchanged.
15
+ * Collection stops at the first absent/empty continuation — the writer fills them contiguously.
16
+ */
17
+ export function collectAuthSeed(env) {
18
+ const first = env.FASTAGENT_AUTH_SEED;
19
+ if (!first)
20
+ return undefined;
21
+ let seed = first;
22
+ for (let i = 2;; i++) {
23
+ const part = env[`FASTAGENT_AUTH_SEED_${i}`];
24
+ if (!part)
25
+ break;
26
+ seed += part;
27
+ }
28
+ return seed;
29
+ }
10
30
  /** Whether a `fly … list --json` array contains an object named `name` (Fly capitalizes `Name`; accept both). */
11
31
  function listHasName(stdout, name) {
12
32
  try {
@@ -19,12 +39,12 @@ function listHasName(stdout, name) {
19
39
  }
20
40
  }
21
41
  /**
22
- * Run the deploy through `fly`. `log` reports progress; `registerTelegram(baseUrl)` /
23
- * `registerFeishu(baseUrl, kind)` perform the post-deploy webhook steps (the CLI passes its canonical
24
- * Feishu registrar, which also serves the Lark compatibility profile). Absent, the manual console
42
+ * Run the deploy through `fly`. `log` reports progress; the injected Telegram/Feishu/Slack registrars
43
+ * perform post-deploy webhook steps from the builder machine (Slack's control credential never travels
44
+ * to the host). Absent, the manual console
25
45
  * instruction is printed. Every gate is fail-visible.
26
46
  */
27
- export async function deployFlyRun(plan, fly, log, registerTelegram, registerFeishu) {
47
+ export async function deployFlyRun(plan, fly, log, registerTelegram, registerFeishu, registerSlack) {
28
48
  const gate = (g) => ({ ok: false, gate: g });
29
49
  // 1. Auth is the one gate a coding agent can't clear itself (browser OAuth). `whoami` succeeds with
30
50
  // either an interactive login or FLY_API_TOKEN, so one check covers both.
@@ -74,9 +94,23 @@ export async function deployFlyRun(plan, fly, log, registerTelegram, registerFei
74
94
  return gate("`fly secrets import` failed — see the flyctl output above");
75
95
  }
76
96
  }
77
- // 6. Deploy — remote builder (no local Docker), one machine.
97
+ // 6. Deploy — remote builder (no local Docker), one machine. Context + Dockerfile are passed
98
+ // explicitly (the workspace root is the context; the Dockerfile lives under fastagent/).
78
99
  log("deploying (remote build)…");
79
- if ((await fly(["deploy", "-a", plan.appName, "-c", plan.flyConfig, "--remote-only", "--yes", "--ha=false"])).code !== 0) {
100
+ const deployArgs = [
101
+ "deploy",
102
+ ".",
103
+ "-a",
104
+ plan.appName,
105
+ "-c",
106
+ plan.flyConfig,
107
+ "--dockerfile",
108
+ plan.dockerfile,
109
+ "--remote-only",
110
+ "--yes",
111
+ "--ha=false",
112
+ ];
113
+ if ((await fly(deployArgs)).code !== 0) {
80
114
  return gate("`fly deploy` failed — see the flyctl output above; fix and re-run");
81
115
  }
82
116
  // 7. Post-deploy webhook — telegram end-to-end (fastagent has the token + the live URL); github is a
@@ -91,8 +125,19 @@ export async function deployFlyRun(plan, fly, log, registerTelegram, registerFei
91
125
  log(`github: set the webhook in the repo (Settings → Webhooks) → https://${plan.appName}.fly.dev/webhook`);
92
126
  reg.track("github", "manual"); // always a human step — re-surface it after the registrar output
93
127
  }
128
+ if (plan.channels.includes("slack")) {
129
+ const baseUrl = `https://${plan.appName}.fly.dev`;
130
+ if (registerSlack) {
131
+ log("registering slack event URL…");
132
+ reg.track("slack", await registerSlack(baseUrl));
133
+ }
134
+ else {
135
+ log(`slack: set Event Subscriptions → Request URL → ${baseUrl}/slack`);
136
+ reg.track("slack", "manual");
137
+ }
138
+ }
94
139
  for (const kind of ["feishu", "lark"]) {
95
- if (!plan.channels.includes(kind))
140
+ if (!plan.channels.includes(kind) || plan.longConnectionChannels?.includes(kind))
96
141
  continue;
97
142
  if (registerFeishu) {
98
143
  log(`registering ${kind} event URL…`);
@@ -1,15 +1,20 @@
1
1
  import type { FastagentConfig } from "../engines/pi/config.ts";
2
+ import { type ResolvedPlacement } from "../paths.ts";
2
3
  import { type ChannelKind } from "../scaffold/add-channel.ts";
3
4
  import { type ContainerInput } from "./container.ts";
4
5
  /** A stderr line the CLI prints (`[fastagent] warn: …` / `[fastagent] note: …`). Host-neutral advisories. */
5
- export interface DeployMessage {
6
+ interface DeployMessage {
6
7
  level: "warn" | "note";
7
8
  text: string;
8
9
  }
9
10
  /** The resolved facts every host plan needs (the container shape, channels, model auth, ports/secrets). */
10
- export interface DeployFacts {
11
+ interface DeployFacts {
11
12
  messages: DeployMessage[];
12
13
  channels: ChannelKind[];
14
+ /** Every structurally detected HTTP-route channel basename, including custom channels. */
15
+ routeChannels: string[];
16
+ /** Every structurally detected long-connection channel basename, including custom channels. */
17
+ longConnectionChannels: string[];
13
18
  /** Whether the agent has TIME triggers — `schedules/` files or `selfSchedule` (the wake tool). Cron/wake
14
19
  * has no external wake-up, so the deployment must keep one machine running: the fly plan forces
15
20
  * `min_machines_running=1`, the railway runbook forbids App Sleeping. */
@@ -36,18 +41,24 @@ export type DeployPreflight = {
36
41
  * provider) — the CLI wraps the call in its `failStartup` so the fault surfaces and exits, never silently.
37
42
  */
38
43
  export declare function preflightDeploy(input: {
39
- target: string;
40
- /** The agent-definition dir (config.agentDir resolved against target; = target when unset) where
41
- * channels are discovered. Container facts (package.json/lockfile) still read `target`, the run root. */
42
- agentDir: string;
44
+ /** The resolved placement. `agentDir` is where channels/schedules are discovered and where the
45
+ * container facts (package.json/lockfile) are read the AGENT's manifest drives the image's install
46
+ * step, never the workspace's (whose manifest belongs to its own deploy); `workspace` is the build
47
+ * context (the whole tree is baked). One value, because one derives from the other: two loose
48
+ * strings could be handed in disagreeing, and nothing would notice. */
49
+ placement: ResolvedPlacement;
43
50
  config: FastagentConfig;
44
51
  modelSpec: string | undefined;
45
52
  /** `--run` fully deploys, so a model that won't travel is a GATE (a known crash-loop); else it warns. */
46
53
  run: boolean;
47
54
  /** `--force` regenerates artifacts, so the kept-hand-written-Dockerfile apt warning does not apply. */
48
55
  force: boolean;
49
- /** `--auth-path` / `FASTAGENT_AUTH_PATH`; falls back to the project default `<state root>/auth.json`. */
50
- authPathOverride: string | undefined;
56
+ /** The target delivers cron slots from an external clock and holds no resident process (AgentCore):
57
+ * resident-host keep-alive notes do not apply; the host branch owns its capability gates. */
58
+ externalClock?: boolean;
59
+ /** The raw `--auth-path` flag; the chain (flag > FASTAGENT_AUTH_PATH > `<agentDir>/.secrets/auth.json`)
60
+ * is resolved HERE via {@link resolveAuthPath} — the one owner, same as every serving command. */
61
+ authPathFlag: string | undefined;
51
62
  }): Promise<DeployPreflight>;
52
63
  /**
53
64
  * Why the resolved model won't reach the deployed box, or undefined if it will — host-neutral. `fastagent.config.ts`
@@ -57,3 +68,4 @@ export declare function preflightDeploy(input: {
57
68
  * (`--run`). Single source on purpose — a host env block (fly.toml `[env]`) is NOT advertised as a second home.
58
69
  */
59
70
  export declare function modelTravelIssue(configModel: string | undefined, modelSpec: string | undefined): string | undefined;
71
+ export {};
@@ -9,23 +9,59 @@
9
9
  * the run modules' {@link import("./fly/run.ts").FlyRunOutcome}: a model that won't travel is a GATE the
10
10
  * CLI stops on, distinct from the advisory warnings/notes it prints and proceeds past.
11
11
  */
12
- import { readFile } from "node:fs/promises";
13
- import { join, relative, sep } from "node:path";
14
- import { defaultAuthPath, resolveStateRoot } from "../engines/pi/config.js";
15
- import { discoverChannelFiles } from "../engines/pi/channel.js";
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 { resolveAuthPath } from "../engines/pi/config.js";
16
+ import { resolveSecretsDir, resolveStateRoot } from "../paths.js";
17
+ import { inspectChannels } from "../engines/pi/channel.js";
16
18
  import { discoverScheduleFiles } from "../schedule/discover.js";
17
19
  import { createPiModels, probeAuthSource } from "../engines/pi/models.js";
18
20
  import { CHANNEL_KINDS } from "../scaffold/add-channel.js";
19
- import { exists } from "../scaffold/init.js";
21
+ import { exists } from "../paths.js";
20
22
  import { detectRuntime, readPackageJson } from "../runtime.js";
21
23
  import { fastagentVersion } from "../version.js";
22
- import { isGeneratedDockerfile } from "./container.js";
24
+ import { isGeneratedDockerfile, isGeneratedDockerignore } from "./container.js";
25
+ /**
26
+ * "Would docker's packer drop this path?" — built from a `.dockerignore`'s text via the `ignore`
27
+ * matcher (the same library the workspace ignore files use), so `!` negation and last-match-wins are
28
+ * the library's problem, not ours. Anchoring is normalized: dockerignore patterns are root-anchored
29
+ * while .gitignore's match at any depth, so a bare `foo` becomes `/foo` — without that, a root-only
30
+ * `.secrets` line would read as covering `fastagent/.secrets` and hand back a false all-clear on the
31
+ * exact check that guards credentials.
32
+ *
33
+ * Known dialect gap: `ignore` keeps git's rule that a path under an EXCLUDED directory cannot be
34
+ * re-included, which docker does not have — so an allowlist file (`*` + `!fastagent` + `!fastagent/**`)
35
+ * can read as excluding a path docker would ship. The callers below absorb that: the drop-the-agent
36
+ * gate requires the agent DIRECTORY itself to read as excluded too, which an allowlist re-includes.
37
+ */
38
+ function dockerignoreMatcher(text) {
39
+ const anchored = text
40
+ .split("\n")
41
+ .map((raw) => {
42
+ const line = raw.trim();
43
+ if (line === "" || line.startsWith("#"))
44
+ return line;
45
+ const negated = line.startsWith("!");
46
+ const pattern = negated ? line.slice(1) : line;
47
+ if (pattern.startsWith("/") || pattern.startsWith("**/"))
48
+ return line;
49
+ return `${negated ? "!" : ""}/${pattern}`;
50
+ })
51
+ .join("\n");
52
+ const matcher = ignore({ ignorecase: false }).add(anchored);
53
+ return (path) => matcher.ignores(path);
54
+ }
23
55
  /**
24
56
  * Run the host-neutral pre-flight. Throws on a real fault (an unreadable channels/ dir, a throwing
25
57
  * provider) — the CLI wraps the call in its `failStartup` so the fault surfaces and exits, never silently.
26
58
  */
27
59
  export async function preflightDeploy(input) {
28
- const { target, agentDir, config, modelSpec, run, force, authPathOverride } = input;
60
+ const { placement: { agentDir, workspace }, config, modelSpec, run, force, externalClock, authPathFlag, } = input;
61
+ // The ONE derived placement fact every host plan needs: where the agent's files sit relative to the
62
+ // build context (the workspace). Nested → "fastagent/"; flat → "" (the agent IS the workspace root).
63
+ const nested = agentDir !== workspace;
64
+ const agentPrefix = nested ? `${basename(agentDir)}/` : "";
29
65
  const messages = [];
30
66
  // The deployed box resolves the model from fastagent.config.ts ONLY (in the image); a model set via
31
67
  // env/flag/.env doesn't travel. `--run` would ship a known crash-loop — hard gate; generate-only warns.
@@ -35,21 +71,53 @@ export async function preflightDeploy(input) {
35
71
  return { ok: false, gate: modelIssue };
36
72
  messages.push({ level: "warn", text: modelIssue });
37
73
  }
74
+ // The control plane on a deployed box: `start` honors `sessionControl: true`, so `/control/*`
75
+ // (steer/abort/set_model) rides the PUBLIC host URL — protected only by a per-boot bearer token
76
+ // minted INSIDE the container (`<stateRoot>/control.json`), which external consumers cannot read.
77
+ // Publicly reachable yet unusable is the worst of both; the tunnel path warns loudly and deploy
78
+ // must not be the silent second way to break the loopback trust story.
79
+ if (config.sessionControl === true) {
80
+ messages.push({
81
+ level: "warn",
82
+ text: `sessionControl: true — the deployed box serves /control/* (steer/abort/set_model) at its public URL, ` +
83
+ `protected only by a per-boot token written inside the container. Read the TOKEN from ` +
84
+ `<stateRoot>/control.json on the box (its url field is container-loopback — pair the token with the ` +
85
+ `public host URL: attach --url <public-url> --token …), or front the endpoint with real auth (design §14)`,
86
+ });
87
+ }
38
88
  // Known channel kinds only — a custom channel's secrets/webhook are unknown to us; note and let the
39
89
  // author wire them.
40
- const discovered = await discoverChannelFiles(agentDir);
90
+ const inspected = await inspectChannels(agentDir);
91
+ if (inspected.failures.length > 0) {
92
+ throw new Error(`cannot inspect channel modules: ${inspected.failures.map((failure) => `${failure.label}: ${failure.message}`).join("; ")}`);
93
+ }
94
+ const discovered = inspected.channels;
41
95
  const channels = discovered.filter((c) => CHANNEL_KINDS.includes(c));
96
+ const routeChannels = inspected.routeChannels;
97
+ const longConnectionChannels = inspected.longConnectionChannels;
42
98
  for (const c of discovered) {
43
- if (!channels.includes(c)) {
44
- messages.push({ level: "note", text: `channel "${c}" is custom — set its secrets and webhook yourself` });
45
- }
99
+ if (channels.includes(c))
100
+ continue;
101
+ messages.push({
102
+ level: "note",
103
+ text: longConnectionChannels.includes(c)
104
+ ? `long-connection channel "${c}" is custom — configure its secrets yourself; generated deploy plans keep the process running and skip webhook registration`
105
+ : `route channel "${c}" is custom — configure its secrets and webhook yourself`,
106
+ });
46
107
  }
47
108
  // Time triggers (static schedules or self-scheduling) need a machine kept running — unlike a webhook,
48
109
  // nothing external wakes a scale-to-zero box for a cron instant or a wake-up. The note is CONDITIONAL
49
110
  // ("the generated plan…"): in KEEP mode an existing fly.toml is not rewritten — the CLI warns separately
50
111
  // when a kept fly.toml still scales to zero.
51
112
  const hasTimeTriggers = (await discoverScheduleFiles(agentDir)).length > 0 || !!config.selfSchedule;
52
- if (hasTimeTriggers) {
113
+ if (longConnectionChannels.length > 0 && !externalClock) {
114
+ messages.push({
115
+ level: "note",
116
+ text: `long-connection channel present (${longConnectionChannels.join(", ")}) — a GENERATED plan keeps one machine running ` +
117
+ `(an outbound connection cannot wake a scaled-to-zero service).`,
118
+ });
119
+ }
120
+ if (hasTimeTriggers && !externalClock) {
53
121
  messages.push({
54
122
  level: "note",
55
123
  text: `schedules/self-scheduling present — a GENERATED plan keeps one machine running (cron/wake has ` +
@@ -58,46 +126,37 @@ export async function preflightDeploy(input) {
58
126
  }
59
127
  // Probe auth from the SAME project-level file the opener/login use — not the global default, which would
60
128
  // miss a `fastagent login` credential and falsely report "none configured".
61
- const authPath = authPathOverride ?? defaultAuthPath(resolveStateRoot(target));
129
+ const authPath = resolveAuthPath(agentDir, authPathFlag);
62
130
  const modelAuth = modelSpec ? await probeAuthSource(createPiModels({ authPath }), modelSpec) : undefined;
63
- // Container facts (shared by every host) + the warnings that follow. Repo-as-workspace layout
64
- // (agentDir target): the facts describe the KIT its package.json/runtime/lockfile drive the
65
- // image's install step never the host repo's (whose manifest belongs to the host's own deploy).
66
- // POSIX-normalized: kitDir lands verbatim in Dockerfile COPY/CMD lines and fly/railway commands,
67
- // which all require forward slashes (a Windows `relative()` would emit backslashes).
68
- const kitDir = agentDir === target ? undefined : relative(target, agentDir).split(sep).join("/");
69
- const factsDir = kitDir ? agentDir : target;
70
- if (kitDir && run) {
71
- // The repo-as-workspace deployment shape remains experimental for every target. Generation +
72
- // runbook are supported; automated runners stay gated until an explicit end-to-end smoke validates
73
- // context packing, ignore rules, installed deps, state, and write-back for this layout.
74
- return {
75
- ok: false,
76
- gate: `--run is not yet supported for the agentDir layout — run the same deploy without --run and follow the printed runbook`,
77
- };
78
- }
79
- const hasPackageJson = await exists(join(factsDir, "package.json"));
80
- const pkg = await readPackageJson(factsDir);
81
- const { runtime, bunVersion, hasLockfile } = detectRuntime(factsDir, pkg);
131
+ // Container facts (shared by every host) + the warnings that follow. The facts describe the AGENT —
132
+ // its package.json/runtime/lockfile drive the image's install stepnever the workspace's (the bake
133
+ // ships the whole tree, but the workspace's own manifest belongs to its own deploy).
134
+ const hasPackageJson = await exists(join(agentDir, "package.json"));
135
+ const pkg = await readPackageJson(agentDir);
136
+ const { runtime, bunVersion, hasLockfile } = detectRuntime(agentDir, pkg);
82
137
  const install = runtime === "bun" ? "bun install" : "npm install";
83
138
  const runner = runtime === "bun" ? "bun run fastagent" : "./node_modules/.bin/fastagent";
84
139
  const hasOtherLock = runtime === "node" &&
85
- ((await exists(join(factsDir, "pnpm-lock.yaml"))) || (await exists(join(factsDir, "yarn.lock"))));
86
- if (kitDir) {
87
- // After the facts: the deps sentence must match the kit's actual shape (a markdown-only kit has no
88
- // package.json and installs nothing — the note must not point at a file that doesn't exist).
89
- const deps = hasPackageJson
90
- ? `only the kit's deps (${kitDir}/package.json) are installed — the host repo's own deps are the agent's runtime concern`
91
- : `the kit has no package.json, so no deps are installed (the pinned global CLI serves the repo)`;
92
- messages.push({
93
- level: "note",
94
- text: `repo-as-workspace image (EXPERIMENTAL — not yet verified end-to-end on a real host): the whole ` +
95
- `repo is baked as the agent's cwd; ${deps}. Un-pushed changes on the box do not survive a redeploy ` +
96
- `(the image is a snapshot); write-back goes through git (persona owns the policy; GH_TOKEN etc. go ` +
97
- `in config.deploy.secrets see the runbook's caveat on .git surviving the host's upload).`,
98
- });
99
- }
100
- // A code workspace with no lockfile builds via a non-frozen install (ranges resolve at build time) — not
140
+ ((await exists(join(agentDir, "pnpm-lock.yaml"))) || (await exists(join(agentDir, "yarn.lock"))));
141
+ // Does the baked workspace ship a `.git`? ONE fact driving both the image's git install (below)
142
+ // and the plans' runbook wording the write-back loop needs the history AND the binary together.
143
+ const shipsGit = await exists(join(workspace, ".git"));
144
+ // After the facts: the deps sentence must match the agent's actual shape (a markdown-only agent has
145
+ // no package.json and installs nothing — the note must not point at a file that doesn't exist).
146
+ const deps = hasPackageJson
147
+ ? `only the agent's deps (${agentPrefix}package.json) are installed${nested ? " — the workspace's own deps are the agent's runtime concern" : ""}`
148
+ : `the agent has no package.json, so no deps are installed (the pinned global CLI serves the directory)`;
149
+ const durability = shipsGit
150
+ ? `Un-pushed changes on the box do not survive a redeploy; freshness and write-back run through git, ` +
151
+ `driven by the agent itself (persona owns the policy; GH_TOKEN etc. go in config.deploy.secrets)`
152
+ : `no .git here, so no history ships and the image does not install git changes on the box are ` +
153
+ `ephemeral and do not survive a redeploy`;
154
+ messages.push({
155
+ level: "note",
156
+ text: `the whole directory is baked as the agent's workspace (WYSIWYG — what you see is what ships, ` +
157
+ `git or not, clean or not); ${deps}. ${durability}.`,
158
+ });
159
+ // A code agent with no lockfile builds via a non-frozen install (ranges resolve at build time) — not
101
160
  // reproducible. A pnpm/yarn user gets an accurate message (their lockfile is ignored by the npm Dockerfile).
102
161
  if (hasPackageJson && !hasLockfile) {
103
162
  const lock = runtime === "bun" ? "bun.lock" : "package-lock.json";
@@ -110,7 +169,7 @@ export async function preflightDeploy(input) {
110
169
  `Run \`${install}\` and commit the lockfile for pinned redeploys.`,
111
170
  });
112
171
  }
113
- // The code-path Dockerfile runs `${runner}` — the workspace's OWN local dependency, never the
172
+ // The code-path Dockerfile runs `${runner}` — the agent's OWN local dependency, never the
114
173
  // registry — so a package.json missing it means the container fails at start (no bin to run).
115
174
  if (hasPackageJson && !("@fastagent-sh/fastagent" in { ...pkg.dependencies, ...pkg.devDependencies })) {
116
175
  messages.push({
@@ -119,42 +178,167 @@ export async function preflightDeploy(input) {
119
178
  `so the container fails at start. Add it to dependencies and re-run \`${install}\`.`,
120
179
  });
121
180
  }
122
- // A kept host root .dockerignore silently replaces KIT_DOCKERIGNORE's two protections — read it and
123
- // warn SPECIFICALLY (the generic "kept" line suggests --force, which would clobber the host's file):
124
- // (a) a .git exclude kills the baked write-back (the runtime-clone fallback applies); (b) without a
125
- // recursive **/node_modules the build machine's kit deps (native binaries) clobber the image's.
126
- // Not force-gated: the host's root .dockerignore is kept even under --force (never ours to clobber),
127
- // so these warnings apply regardless.
128
- if (kitDir && (await exists(join(target, ".dockerignore")))) {
129
- const lines = (await readFile(join(target, ".dockerignore"), "utf8")).split("\n").map((l) => l.trim());
130
- if (lines.some((l) => l === ".git" || l === "/.git" || l === ".git/" || l === "**/.git")) {
181
+ // A KEPT workspace-root .dockerignore silently replaces the generated one's protections — so ASK IT
182
+ // about the exact paths that matter (the generic "kept" line suggests --force, which never clobbers
183
+ // the workspace's own file). Two are GATES under --run, same discipline as the model-travel gate:
184
+ // dropping the agent dir ships a context with no persona/config (the box crash-loops), and an
185
+ // unexcluded secrets path BAKES CREDENTIALS INTO THE IMAGE. The other two are advisory: the build
186
+ // machine's node_modules (native binaries for YOUR OS) clobbering the image's, and an excluded .git
187
+ // killing the agent's pull/push loop (a legitimate slimming choice). Not force-gated — the file is
188
+ // kept even under --force.
189
+ // Which paths INSIDE the build context hold secrets resolved, then made workspace-relative (the
190
+ // context root). An external secrets dir (a mounted volume) is outside the context: nothing to check
191
+ // and nothing to exclude. Also fed to the generated .dockerignore, so a custom in-tree dir is
192
+ // excluded by PATH even though its name is not `.secrets`.
193
+ const inContext = (p) => {
194
+ const rel = relative(workspace, p);
195
+ return rel === "" || rel.startsWith("..") || isAbsolute(rel) ? undefined : rel.split(sep).join("/");
196
+ };
197
+ // The secrets DIR is the unit, not the two filenames we happen to know: an atomic-write temp beside
198
+ // auth.json, a second key file, an editor backup of `.env` — all of it must stay out of the image, and
199
+ // `resolveSecretsDir` says as much ("everything fastagent manages that must never leave the machine").
200
+ // The auth path adds an entry only when an override puts it OUTSIDE that dir. An external secrets dir
201
+ // (the deployed posture: a mounted volume) is outside the context — nothing to check, nothing to
202
+ // exclude.
203
+ const secretsRel = inContext(resolveSecretsDir(agentDir));
204
+ const authRel = inContext(authPath);
205
+ const authElsewhere = authRel !== undefined && (secretsRel === undefined || !authRel.startsWith(`${secretsRel}/`));
206
+ const secretPaths = [...(secretsRel ? [secretsRel] : []), ...(authElsewhere ? [authRel] : [])];
207
+ // ONE rule for every checked path: a file that is not there cannot be baked, so gating on it would
208
+ // be a refusal about a spelling rather than about what would ship (an agent that has never run
209
+ // `login` has no auth.json). The generated .dockerignore still excludes them unconditionally —
210
+ // cheap, and correct the moment they appear.
211
+ const present = async (rels) => {
212
+ const found = [];
213
+ for (const rel of rels)
214
+ if (await exists(join(workspace, rel)))
215
+ found.push(rel);
216
+ return found;
217
+ };
218
+ // State gets the same treatment (a custom in-tree FASTAGENT_STATE_DIR is invisible to the
219
+ // name-based `**/.state`), at warn level: shipping stale sessions is waste, not a credential leak.
220
+ const stateRel = inContext(resolveStateRoot(agentDir));
221
+ // Existence gates the WARNING, never the generated exclude (same split as secretPaths vs
222
+ // leakCandidates): an agent that has never run has no `.state/`, so telling its author a kept ignore
223
+ // file fails to exclude one is a remark about a spelling — while the file we generate must still carry
224
+ // the line, since it is written once and correct the moment the directory appears.
225
+ const stateShips = stateRel !== undefined && (await exists(join(workspace, stateRel))) ? stateRel : undefined;
226
+ // The `.env` family at the two levels fastagent is RESPONSIBLE for: the agent dir and the workspace
227
+ // root. Asking only about a root-level `.env` missed both halves that matter — an `<agent>/.env`, the
228
+ // file habit puts there (env.ts warns about it by name), and the `.env.local` / `.env.production`
229
+ // spellings. DISCOVERED rather than spelled, so the existing rule still holds: only a file that is
230
+ // there can be baked, so only it is gated.
231
+ //
232
+ // Deliberately NOT the recursive `**/.env` the generated file carries: walking a whole monorepo for
233
+ // credential files is a secret scanner, not a placement pre-flight, and a bounded walk would be a
234
+ // heuristic pretending to be a guarantee. The division of responsibility is the honest one — the file
235
+ // WE generate covers every level; an author who keeps their own owns its coverage of their own tree,
236
+ // and this gate speaks only for the paths fastagent itself puts credentials in.
237
+ const dotEnvFiles = async (relDir) => {
238
+ const names = await readdir(join(workspace, relDir || ".")).catch(() => []);
239
+ // POSIX separators, like every other context-relative path here (`inContext`): these strings are
240
+ // matched against dockerignore patterns, and a Windows `fastagent\.env` would match none of them —
241
+ // silently turning the one check whose failure mode is "credentials in a published image" into a
242
+ // no-op.
243
+ return names
244
+ .filter((n) => (n === ".env" || n.startsWith(".env.")) && n !== ".env.example")
245
+ .map((n) => join(relDir, n).split(sep).join("/"));
246
+ };
247
+ const envFiles = (await Promise.all([...new Set(["", agentPrefix])].map(dotEnvFiles))).flat();
248
+ const leakCandidates = [...(await present(secretPaths)), ...envFiles];
249
+ // Same existence rule: a node_modules that is not there cannot be uploaded.
250
+ const depDirs = await present([...new Set([`${agentPrefix}node_modules`, "node_modules"])]);
251
+ const machineryPaths = [...secretPaths, ...(stateRel ? [stateRel] : [])];
252
+ // BOTH ignore files deploy emits get the same interrogation. The workspace-root one is what
253
+ // flyctl/railway's packers read; the per-Dockerfile one is what BuildKit PREFERS for a plain
254
+ // `docker build` (so it is the file that actually decides `deploy docker`). Checking only the root one
255
+ // left the credential gate not covering the path it was written for.
256
+ for (const rel of [".dockerignore", `${agentPrefix}Dockerfile.dockerignore`]) {
257
+ const kept = await readFile(join(workspace, rel), "utf8").catch(() => undefined);
258
+ if (kept === undefined)
259
+ continue;
260
+ // One WE generated is regenerated by this very run under --force, so checking the stale content on
261
+ // disk would gate a deploy on a file about to be replaced. Ours + --force: skip. Ours WITHOUT --force
262
+ // is still checked (it is what would ship), but the remedy differs: hand-adding lines to fastagent's
263
+ // own output is not the fix — regenerating it is.
264
+ const keptIsOurs = isGeneratedDockerignore(kept);
265
+ if (force && keptIsOurs)
266
+ continue;
267
+ const remedy = (lines) => keptIsOurs
268
+ ? `Re-run with --force to regenerate it.`
269
+ : `Add ${lines.map((p) => `\`${p}\``).join(" and ")} before deploying (the same lines the generated ${rel} writes).`;
270
+ const excluded = dockerignoreMatcher(kept);
271
+ // Asked as a DIRECTORY (trailing slash), which is what it is. A bare-name test answers `false` for
272
+ // the `fastagent/` spelling — a directory-only pattern, and the one a hand-written ignore file is
273
+ // most likely to carry — so the agent would be dropped from the context with no warning at all. The
274
+ // pairing this replaced (the directory AND a file inside it) was dead weight rather than a
275
+ // safeguard: git's rule that a file under an excluded directory cannot be re-included is
276
+ // implemented by the matcher, so `excluded(dir)` already implies `excluded(dir/persona.md)` and the
277
+ // second test could never change the answer. What it was aimed at — an allowlist (`*` +
278
+ // `!fastagent` + `!fastagent/**`) that re-includes the agent — is handled by the first test alone,
279
+ // which reads `false` there, as it should.
280
+ if (nested && excluded(`${basename(agentDir)}/`)) {
281
+ const text = `your ${rel} (kept) excludes \`${basename(agentDir)}\` — the build context would ship WITHOUT the ` +
282
+ `agent entirely (the deployed box has no persona/config and crash-loops). Remove that rule ` +
283
+ `before deploying.`;
284
+ if (run)
285
+ return { ok: false, gate: text };
286
+ messages.push({ level: "warn", text });
287
+ }
288
+ // Resolved paths, not spellings: dockerignore patterns are root-anchored (unlike .gitignore), so a
289
+ // bare `.secrets` line does not cover `fastagent/.secrets` — and FASTAGENT_SECRETS_DIR /
290
+ // FASTAGENT_AUTH_PATH can put credentials anywhere in the baked tree, where the name-based excludes
291
+ // never reach. This is the one check whose failure mode is "credentials in a published image".
292
+ const leaks = leakCandidates.filter((p) => !excluded(p));
293
+ if (leaks.length > 0) {
294
+ const text = `your ${rel} (kept) does not exclude ${leaks.map((p) => `\`${p}\``).join(", ")} — the build ` +
295
+ `context would BAKE SECRETS INTO THE IMAGE. ${remedy(leaks.map((p) => `/${p}`))}`;
296
+ if (run)
297
+ return { ok: false, gate: text };
298
+ messages.push({ level: "warn", text });
299
+ }
300
+ if (stateShips && !excluded(`${stateShips}/sessions`)) {
131
301
  messages.push({
132
302
  level: "warn",
133
- text: `your .dockerignore excludes .git — the baked repo ships WITHOUT history/remote, so the agent ` +
134
- `cannot commit/push the baked copy; it must \`git clone\` its repo in the workspace instead ` +
135
- `(or remove the .git line).`,
303
+ text: `your ${rel} (kept) does not exclude \`${stateRel}\` — the build machine's sessions/channel state would ship in the image. ${remedy([`/${stateRel}`])}`,
136
304
  });
137
305
  }
138
- if (!lines.some((l) => l === "**/node_modules" || l === "**/node_modules/")) {
306
+ // Both the agent's own node_modules and the workspace's: either would upload the build machine's
307
+ // deps (native binaries for YOUR OS) and clobber the image's freshly-installed ones. Named by the
308
+ // PATHS actually found unexcluded, like every other check here — not by a rule's spelling.
309
+ const unexcludedDeps = depDirs.filter((p) => !excluded(`${p}/.package-lock.json`));
310
+ if (unexcludedDeps.length > 0) {
139
311
  messages.push({
140
312
  level: "warn",
141
- text: `your .dockerignore lacks \`**/node_modules\` the build machine's ${kitDir}/node_modules ` +
142
- `(native binaries for YOUR OS) would be uploaded and clobber the image's freshly-installed ones. ` +
143
- `Add \`**/node_modules\` to it.`,
313
+ text: `your ${rel} does not exclude ${unexcludedDeps.map((p) => `\`${p}\``).join(" or ")} — the ` +
314
+ `build machine's deps (native binaries for YOUR OS) would be uploaded and clobber the image's ` +
315
+ `freshly-installed ones. ${remedy(unexcludedDeps.map((p) => `/${p}`))}`,
316
+ });
317
+ }
318
+ if (excluded(".git/HEAD")) {
319
+ messages.push({
320
+ level: "note",
321
+ text: `your ${rel} excludes .git — the baked copy ships WITHOUT history/remote, so the agent ` +
322
+ `cannot pull/commit/push it; it must \`git clone\` its repo in the workspace instead (or remove the .git line).`,
144
323
  });
145
324
  }
146
325
  }
147
- // Write-back mechanics are fastagent's (the policy is the persona's): a kit-layout image always
148
- // carries git, so commit/push can work at all. Merged with (never duplicating) config.deploy.apt.
149
- const apt = kitDir ? [...new Set(["git", ...(config.deploy?.apt ?? [])])] : config.deploy?.apt;
326
+ // Write-back mechanics are fastagent's (the policy is the persona's): the image carries the git
327
+ // BINARY iff the baked workspace ships a `.git` (history without the binary is a
328
+ // dead loop; the binary without history is dead weight). A non-git workspace that still needs git
329
+ // (the agent clones repos as its job) declares config.deploy.apt: ["git"] explicitly. Merged with
330
+ // (never duplicating) config.deploy.apt.
331
+ const apt = shipsGit ? [...new Set(["git", ...(config.deploy?.apt ?? [])])] : config.deploy?.apt;
150
332
  const container = {
333
+ agentPrefix,
334
+ machineryPaths,
151
335
  hasPackageJson,
152
336
  runtime,
153
337
  bunVersion,
154
338
  hasLockfile,
155
339
  version: await fastagentVersion(),
156
340
  apt,
157
- kitDir,
341
+ shipsGit,
158
342
  };
159
343
  const port = config.http?.port ?? 8787;
160
344
  // What the agent declared it needs on the box (fastagent.config deploy.secrets) — carried like channel
@@ -163,7 +347,7 @@ export async function preflightDeploy(input) {
163
347
  // deploy.apt only shapes the GENERATED Dockerfile. Warn ONLY when the kept Dockerfile is HAND-WRITTEN
164
348
  // (its apt won't include these) — a fastagent-generated one is handled by writeArtifacts. Don't suggest
165
349
  // --force here: it would overwrite the user's hand-written file.
166
- const dockerfileHome = kitDir ? join(agentDir, "Dockerfile") : join(target, "Dockerfile");
350
+ const dockerfileHome = join(agentDir, "Dockerfile");
167
351
  if (config.deploy?.apt?.length && !force && (await exists(dockerfileHome))) {
168
352
  if (!isGeneratedDockerfile(await readFile(dockerfileHome, "utf8"))) {
169
353
  messages.push({
@@ -173,7 +357,19 @@ export async function preflightDeploy(input) {
173
357
  });
174
358
  }
175
359
  }
176
- return { ok: true, messages, channels, hasTimeTriggers, modelAuth, authPath, container, port, extraSecrets };
360
+ return {
361
+ ok: true,
362
+ messages,
363
+ channels,
364
+ routeChannels,
365
+ longConnectionChannels,
366
+ hasTimeTriggers,
367
+ modelAuth,
368
+ authPath,
369
+ container,
370
+ port,
371
+ extraSecrets,
372
+ };
177
373
  }
178
374
  /**
179
375
  * Why the resolved model won't reach the deployed box, or undefined if it will — host-neutral. `fastagent.config.ts`
@@ -29,8 +29,10 @@ export interface RailwayPlanInput extends ContainerInput {
29
29
  serviceName: string;
30
30
  /** What satisfies model auth locally: an env-var name, an OAuth/stored label, or undefined. */
31
31
  modelAuth: string | undefined;
32
- /** Channels discovered in the workspace — each contributes its secret metadata + webhook step. */
32
+ /** Known first-party channels — each contributes its secret metadata + webhook step. */
33
33
  channels: ChannelKind[];
34
+ /** All long-connection channel basenames, including custom channels — no App Sleeping. */
35
+ longConnectionChannels?: string[];
34
36
  /** Extra secret env-var names (fastagent.config deploy.secrets) — added to the runbook's secret list. */
35
37
  extraSecrets?: string[];
36
38
  /** Time triggers present (schedules/ or selfSchedule) — the runbook forbids App Sleeping: cron/wake has
@@ -44,5 +46,12 @@ export interface RailwayPlan {
44
46
  /** The ordered, values-resolved deploy runbook — printed to stdout for the coding agent to execute. */
45
47
  runbook: string[];
46
48
  }
49
+ /** The `RAILWAY_DOCKERFILE_PATH` value for an agent under `prefix` — repo-root-anchored with a leading
50
+ * slash, the form Railway's builds/dockerfiles docs use for a Dockerfile in another directory. The
51
+ * config file's `dockerfilePath` spells it WITHOUT the slash (the config-as-code schema's own
52
+ * convention); two mechanisms, two documented spellings, one fact each. */
53
+ export declare const dockerfilePathVar: (prefix: string) => string;
54
+ /** Did fastagent generate this `railway.json`? Unparseable or unmarked reads as the author's. */
55
+ export declare function isGeneratedRailwayJson(content: string): boolean;
47
56
  /** Compute the Railway deploy plan from the resolved definition. */
48
57
  export declare function planRailwayDeploy(input: RailwayPlanInput): RailwayPlan;