@fastagent-sh/fastagent 0.22.1 → 0.24.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 (128) hide show
  1. package/dist/agent.d.ts +10 -0
  2. package/dist/channels/agentcore-protocol.d.ts +2 -1
  3. package/dist/channels/agentcore-service.js +10 -18
  4. package/dist/channels/agentcore.js +6 -3
  5. package/dist/channels/control.js +23 -0
  6. package/dist/channels/kit/preview-kit.d.ts +6 -1
  7. package/dist/channels/kit/preview-kit.js +21 -4
  8. package/dist/channels/slack/config-api.d.ts +2 -2
  9. package/dist/channels/slack/config-api.js +2 -2
  10. package/dist/channels/slack/onboard.js +2 -2
  11. package/dist/channels/slack/preview.js +2 -1
  12. package/dist/channels/slack/register-webhook.js +2 -2
  13. package/dist/channels/slack/slack-api.js +4 -4
  14. package/dist/cli/add-feishu.js +4 -2
  15. package/dist/cli/auth-view.d.ts +15 -6
  16. package/dist/cli/auth-view.js +24 -8
  17. package/dist/cli/box-login.d.ts +33 -0
  18. package/dist/cli/box-login.js +162 -0
  19. package/dist/cli/commands/deploy/agentcore.js +48 -11
  20. package/dist/cli/commands/deploy/docker.js +24 -13
  21. package/dist/cli/commands/deploy/fly.js +30 -7
  22. package/dist/cli/commands/deploy/railway.js +21 -7
  23. package/dist/cli/commands/deploy/shared.d.ts +18 -17
  24. package/dist/cli/commands/deploy/shared.js +20 -21
  25. package/dist/cli/commands/destroy.js +2 -2
  26. package/dist/cli/commands/info.js +15 -12
  27. package/dist/cli/commands/invoke.js +2 -2
  28. package/dist/cli/commands/login.d.ts +7 -1
  29. package/dist/cli/commands/login.js +123 -10
  30. package/dist/cli/commands/logs.js +2 -2
  31. package/dist/cli/commands/models.d.ts +4 -1
  32. package/dist/cli/commands/models.js +37 -4
  33. package/dist/cli/commands/routine-run.js +2 -2
  34. package/dist/cli/commands/start.d.ts +0 -5
  35. package/dist/cli/commands/start.js +3 -13
  36. package/dist/cli/invoke-stream.js +14 -0
  37. package/dist/cli/kernel.d.ts +2 -0
  38. package/dist/cli/kernel.js +1 -1
  39. package/dist/cli/login-relay.d.ts +38 -0
  40. package/dist/cli/login-relay.js +121 -0
  41. package/dist/cli/program.js +46 -8
  42. package/dist/cli/shared.d.ts +6 -5
  43. package/dist/cli/shared.js +29 -36
  44. package/dist/deploy/agentcore/plan.d.ts +10 -11
  45. package/dist/deploy/agentcore/plan.js +8 -14
  46. package/dist/deploy/agentcore/run.d.ts +8 -1
  47. package/dist/deploy/agentcore/run.js +20 -14
  48. package/dist/deploy/agentcore/shell.d.ts +25 -0
  49. package/dist/deploy/agentcore/shell.js +202 -0
  50. package/dist/deploy/box-shell.d.ts +43 -0
  51. package/dist/deploy/box-shell.js +28 -0
  52. package/dist/deploy/build-context.js +16 -7
  53. package/dist/deploy/channel-ingress.d.ts +20 -1
  54. package/dist/deploy/channel-ingress.js +34 -7
  55. package/dist/deploy/container.d.ts +14 -0
  56. package/dist/deploy/container.js +45 -1
  57. package/dist/deploy/docker/plan.d.ts +7 -3
  58. package/dist/deploy/docker/plan.js +9 -14
  59. package/dist/deploy/docker/run.d.ts +14 -7
  60. package/dist/deploy/docker/run.js +58 -35
  61. package/dist/deploy/fly/plan.d.ts +6 -3
  62. package/dist/deploy/fly/plan.js +6 -8
  63. package/dist/deploy/fly/run.d.ts +8 -2
  64. package/dist/deploy/fly/run.js +15 -25
  65. package/dist/deploy/preflight.d.ts +9 -7
  66. package/dist/deploy/preflight.js +92 -20
  67. package/dist/deploy/railway/plan.d.ts +6 -3
  68. package/dist/deploy/railway/plan.js +7 -9
  69. package/dist/deploy/railway/run.d.ts +10 -6
  70. package/dist/deploy/railway/run.js +29 -13
  71. package/dist/deploy/runner.d.ts +16 -0
  72. package/dist/deploy/runner.js +31 -0
  73. package/dist/deploy/secrets.d.ts +4 -13
  74. package/dist/deploy/secrets.js +8 -27
  75. package/dist/dev-supervisor.js +7 -4
  76. package/dist/engines/pi/agent-models.d.ts +83 -0
  77. package/dist/engines/pi/agent-models.js +120 -0
  78. package/dist/engines/pi/agent-session-factory.d.ts +13 -13
  79. package/dist/engines/pi/agent-session-factory.js +132 -159
  80. package/dist/engines/pi/auth.d.ts +56 -3
  81. package/dist/engines/pi/auth.js +68 -109
  82. package/dist/engines/pi/config.d.ts +0 -25
  83. package/dist/engines/pi/config.js +3 -37
  84. package/dist/engines/pi/create.d.ts +34 -21
  85. package/dist/engines/pi/create.js +90 -57
  86. package/dist/engines/pi/entry-images.d.ts +14 -0
  87. package/dist/engines/pi/entry-images.js +23 -0
  88. package/dist/engines/pi/invoke-session.js +88 -20
  89. package/dist/engines/pi/locked-file.d.ts +9 -0
  90. package/dist/engines/pi/locked-file.js +102 -0
  91. package/dist/engines/pi/login.d.ts +27 -14
  92. package/dist/engines/pi/login.js +41 -18
  93. package/dist/engines/pi/machine.d.ts +23 -0
  94. package/dist/engines/pi/machine.js +24 -2
  95. package/dist/engines/pi/models.d.ts +120 -36
  96. package/dist/engines/pi/models.js +316 -66
  97. package/dist/engines/pi/open.d.ts +65 -20
  98. package/dist/engines/pi/open.js +88 -34
  99. package/dist/engines/pi/report.d.ts +3 -0
  100. package/dist/engines/pi/report.js +11 -0
  101. package/dist/engines/pi/service.d.ts +2 -2
  102. package/dist/engines/pi/service.js +1 -0
  103. package/dist/engines/pi/session-builder.d.ts +3 -2
  104. package/dist/engines/pi/session-builder.js +30 -76
  105. package/dist/engines/pi/session-control.d.ts +4 -4
  106. package/dist/engines/pi/session-control.js +86 -43
  107. package/dist/engines/pi/session-settings.d.ts +9 -0
  108. package/dist/engines/pi/session-settings.js +17 -0
  109. package/dist/engines/pi/session-store.d.ts +2 -2
  110. package/dist/engines/pi/tool-context.d.ts +3 -5
  111. package/dist/engines/pi/tool-context.js +4 -16
  112. package/dist/engines/pi/tool.d.ts +32 -21
  113. package/dist/engines/pi/tool.js +21 -25
  114. package/dist/engines/pi/turn-kit.d.ts +18 -9
  115. package/dist/engines/pi/turn-kit.js +115 -32
  116. package/dist/open-url.d.ts +15 -1
  117. package/dist/open-url.js +35 -3
  118. package/dist/paths.d.ts +4 -2
  119. package/dist/paths.js +6 -2
  120. package/dist/pi.d.ts +5 -3
  121. package/dist/pi.js +3 -2
  122. package/dist/service.d.ts +5 -8
  123. package/dist/session-remote.js +21 -0
  124. package/dist/session.d.ts +88 -15
  125. package/dist/session.js +5 -1
  126. package/package.json +6 -5
  127. package/dist/engines/pi/search-tools.d.ts +0 -4
  128. package/dist/engines/pi/search-tools.js +0 -109
@@ -1,10 +1,11 @@
1
1
  /** `fastagent deploy fly --run` — drive flyctl to completion. */
2
+ import type { BoxLoginStep } from "../box-shell.ts";
2
3
  import { type PublicHealthProbe, type Registrars } from "../channel-ingress.ts";
3
4
  import type { DeclaredChannel } from "../../channels/discover.ts";
4
- import type { CliRunner } from "../runner.ts";
5
+ import { type CliRunner } from "../runner.ts";
5
6
  export interface FlyRunPlan {
6
7
  appName: string;
7
- /** `KEY=value` secrets to set on Fly: the value file's variables + `FASTAGENT_AUTH_SEED` for a file credential. */
8
+ /** `KEY=value` secrets to set on Fly: the value file's variables. */
8
9
  secrets: Record<string, string>;
9
10
  /** Declared names the value file supplies no value for — the run gates on these before any side effect. */
10
11
  missingSecrets: string[];
@@ -19,6 +20,11 @@ export interface FlyRunPlan {
19
20
  * resolve it relative to the config's own directory).
20
21
  */
21
22
  dockerfile: string;
23
+ /**
24
+ * Log the box in (`fastagent login --deployment`) once it is up and before any webhook is pointed at it: a channel pointed at
25
+ * a box with no model credential answers every message with a failure. Resolves a gate line, or undefined.
26
+ */
27
+ boxLogin?: BoxLoginStep;
22
28
  }
23
29
  /** Done, or a gate the operator must clear before re-running (printed + non-zero exit by the CLI). */
24
30
  export type FlyRunOutcome = {
@@ -1,5 +1,5 @@
1
- /** `fastagent deploy fly --run` — drive flyctl to completion. */
2
- import { publicHealthGate, registerWebhooks } from "../channel-ingress.js";
1
+ import { loginGate, publicHealthGate, registerWebhooks, } from "../channel-ingress.js";
2
+ import { readOutput } from "../runner.js";
3
3
  import { missingValuesGate } from "../secrets.js";
4
4
  /**
5
5
  * The `Type` values that put an app on `https://<app>.fly.dev`, BY FAMILY — one entry per allocate command, because
@@ -28,27 +28,6 @@ export function listHasName(stdout, name) {
28
28
  throw new Error(`expected a JSON array, got ${typeof entries}`);
29
29
  return entries.some((o) => o.Name === name);
30
30
  }
31
- /**
32
- * A read-only `fly … list --json`, reduced to the question the next step asks of it — or the gate for a list we cannot
33
- * act on.
34
- */
35
- async function readList(fly, args, read) {
36
- // The command as RUN, not a restatement of it.
37
- const cmd = `fly ${args.join(" ")}`;
38
- const result = await fly(args, { capture: true });
39
- if (result.code !== 0)
40
- return { gate: `\`${cmd}\` failed — see the flyctl output above; fix and re-run` };
41
- try {
42
- return { value: read(result.stdout) };
43
- }
44
- catch (error) {
45
- // The one place a parse failure is allowed to stop being an exception.
46
- return {
47
- gate: `\`${cmd}\` was unreadable (${error instanceof Error ? error.message : String(error)}) — ` +
48
- `run it yourself and check the flyctl version; fix and re-run`,
49
- };
50
- }
51
- }
52
31
  export async function deployFlyRun(plan, fly, log, registrars, healthProbe) {
53
32
  const gate = (g) => ({ ok: false, gate: g });
54
33
  // 1.
@@ -60,7 +39,7 @@ export async function deployFlyRun(plan, fly, log, registrars, healthProbe) {
60
39
  if (missingValues)
61
40
  return gate(missingValues);
62
41
  // 3.
63
- const appExists = await readList(fly, ["apps", "list", "--json"], (out) => listHasName(out, plan.appName));
42
+ const appExists = await readOutput(fly, "fly", ["apps", "list", "--json"], (out) => listHasName(out, plan.appName));
64
43
  if ("gate" in appExists)
65
44
  return gate(appExists.gate);
66
45
  if (appExists.value) {
@@ -77,7 +56,7 @@ export async function deployFlyRun(plan, fly, log, registrars, healthProbe) {
77
56
  // which machine (guest size + image) must fit on the host the volume is pinned to. Pre-creating one here placed it
78
57
  // blind, and a host with disk but no compute failed the deploy with `insufficient resources … with existing volume`.
79
58
  // 5.
80
- const addresses = await readList(fly, ["ips", "list", "-a", plan.appName, "--json"], ingressAddresses);
59
+ const addresses = await readOutput(fly, "fly", ["ips", "list", "-a", plan.appName, "--json"], ingressAddresses);
81
60
  if ("gate" in addresses)
82
61
  return gate(addresses.gate);
83
62
  // Check-then-act PER FAMILY: an app that already holds one must still be given the other, or the gate between the
@@ -131,9 +110,20 @@ export async function deployFlyRun(plan, fly, log, registrars, healthProbe) {
131
110
  inspectHint: `the app itself deployed — inspect \`fly logs -a ${plan.appName}\`, then re-run once it answers ` +
132
111
  `(a re-run repeats the remote build)`,
133
112
  probe: healthProbe,
113
+ ...(plan.boxLogin ? { login: plan.boxLogin.command } : {}),
134
114
  });
135
115
  if (healthGate)
136
116
  return gate(healthGate);
117
+ const notLoggedIn = await plan.boxLogin?.run();
118
+ if (notLoggedIn) {
119
+ return gate(loginGate({
120
+ notLoggedIn,
121
+ channels: plan.channels,
122
+ log,
123
+ baseUrl,
124
+ afterLogin: "re-run `fastagent deploy fly --run` (it keeps the login)",
125
+ }));
126
+ }
137
127
  // 9.
138
128
  const registrationGateMsg = await registerWebhooks({
139
129
  baseUrl,
@@ -26,20 +26,22 @@ interface DeployFacts {
26
26
  values: ReadonlyMap<string, string>;
27
27
  /** That file, workspace-relative — the name every "set it here" message must use. */
28
28
  valueFile: string;
29
- /** What satisfies model auth locally — an env-var name, an OAuth/stored label, or undefined. */
29
+ /**
30
+ * The variable the value file carries the model's key in (the provider's own, or a models.json `"$NAME"`), or
31
+ * undefined when no key travels that way ({@link credentialRoute}).
32
+ */
30
33
  modelAuth: string | undefined;
31
34
  /**
32
- * The definition itself carries the model key (a models.json literal `apiKey`, or a `!command` run on the host), so
33
- * there is nothing for `--run` to carry AND nothing to gate: `fastagent login` cannot serve a custom provider, so
34
- * gating on it would strand a correctly configured agent.
35
+ * The provider the deployment must log in to itself (`fastagent login --deployment`): the model's credential does
36
+ * not travel, because it is neither a variable in the value file nor carried by the definition (a models.json
37
+ * literal `apiKey` or `!command`). Nothing from this machine's credentials file is ever copied, so a login on the
38
+ * box is its only holder.
35
39
  */
36
- modelKeyInDefinition: boolean;
40
+ boxLogin: string | undefined;
37
41
  /** Every tool/routine/channel declaration — the names the value file must supply, by declaring file. */
38
42
  declaredSecrets: DeclaredSecret[];
39
43
  /** The runbook's variable list: the declared names, then everything else the value file carries. */
40
44
  secrets: DeploymentSecret[];
41
- /** The project-level auth file `--run` reads to carry the credential (probed with the same path). */
42
- authPath: string;
43
45
  /** Container facts shared by the plan and the generated Dockerfile — ONE source, so they can't drift. */
44
46
  container: ContainerInput;
45
47
  port: number;
@@ -6,13 +6,16 @@ import { readFile } from "node:fs/promises";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { basename, join, relative } from "node:path";
8
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";
9
+ import { providerOf } from "../engines/pi/config.js";
10
+ import { resolveAuthPath } from "../engines/pi/auth.js";
11
+ import { InMemoryCredentialStore } from "@earendil-works/pi-ai";
12
+ import { AGENT_MODEL_CATALOG_FILE, AGENT_MODELS_FILE, exists } from "../paths.js";
11
13
  import { inspectChannels } from "../channels/discover.js";
12
14
  import { loadRoutines } from "../schedule/discover.js";
13
15
  import { resolveAgentTools } from "../engines/pi/create.js";
16
+ import { agentModels } from "../engines/pi/agent-models.js";
14
17
  import { allSecrets } from "../declared-secrets.js";
15
- import { createPiModelRuntime, literalKeyProviders, isBuiltinProvider, machineModels, modelCredentialCarry, probeAuthSource, } from "../engines/pi/models.js";
18
+ import { createPiModelRuntime, definitionKeyOf, environmentAuthSource, literalKeyProviders, isBuiltinProvider, machineModels, globalCatalogPath, inGlobalCatalog, interactiveLoginKind, loginProviders, } from "../engines/pi/models.js";
16
19
  import { CHANNEL_KINDS } from "../scaffold/add-channel.js";
17
20
  import { detectRuntime, readPackageJson } from "../runtime.js";
18
21
  import { fastagentVersion } from "../version.js";
@@ -147,21 +150,38 @@ async function gatherFacts(input, report) {
147
150
  : ""));
148
151
  }
149
152
  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
+ // The registry the DEPLOYED agent has (its own models.json and model catalog, nothing of the machine's), built first
154
+ // so a malformed file stops the deploy with pi's own reason before anything below reads the file raw. No credential
155
+ // is read for that.
156
+ const deployed = await createPiModelRuntime({
157
+ agentDir,
158
+ credentials: new InMemoryCredentialStore(),
159
+ machineLayer: false,
160
+ });
161
+ if (modelSpec)
162
+ await checkGlobalCatalogModel(modelSpec, deployed, report);
153
163
  const authPath = resolveAuthPath(agentDir);
154
- const models = await createPiModelRuntime({ agentDir, auth: { path: authPath }, machineLayer: false });
155
- let modelAuth = modelSpec ? await probeAuthSource(models, modelSpec) : undefined;
156
- let modelKeyInDefinition = false;
157
- // probeAuthSource answers "is it authenticated here", which is not the deploy question ("how does the credential
158
- // REACH the host").
159
- if (modelSpec && !isEnvKey(modelAuth)) {
160
- const carry = modelCredentialCarry(models, modelSpec);
161
- if (carry.envVar)
162
- modelAuth = carry.envVar;
163
- else
164
- modelKeyInDefinition = carry.inDefinition;
164
+ // A model only the definition's extensions declare (a virtual model, or a provider one registers) is authenticated
165
+ // by that code on the box, per request: a virtual model's credential is whichever physical model it routes to.
166
+ // Asked of the catalog the box builds, extensions included, so deploy judges the model the box will run.
167
+ const fromExtension = modelSpec ? await extensionDeclared(agentDir, workspace, modelSpec, deployed) : false;
168
+ if (fromExtension && modelSpec) {
169
+ report.note(`${modelSpec} is declared by the definition's extensions, which resolve its credentials on the box — deploy ` +
170
+ `cannot check them. Set the keys the models it uses need in ${valueFile}`);
171
+ }
172
+ const route = modelSpec && !fromExtension ? await credentialRoute(agentDir, modelSpec, values) : {};
173
+ const modelAuth = route.envVar;
174
+ const boxLogin = route.boxLogin;
175
+ if (boxLogin !== undefined) {
176
+ if (!hasLogin(boxLogin)) {
177
+ report.issue(`no credential for ${modelSpec} reaches the deployment, and "${boxLogin}" has no login to run there — set ` +
178
+ `its API key in ${valueFile}`);
179
+ }
180
+ else {
181
+ report.note(`${modelSpec}: no credential ships with this deploy (this machine's logins and shell stay here) — once the ` +
182
+ `box is up, \`--run\` asks it: it keeps what it already authenticates ${boxLogin} with, else logs in ` +
183
+ `(\`fastagent login ${boxLogin} --deployment\`). Or set ${boxLogin}'s API key in ${valueFile}`);
184
+ }
165
185
  }
166
186
  // Reported, not refused. Whether a string is a credential is the AUTHOR's knowledge: pi's docs prescribe
167
187
  // `"apiKey": "ollama"` for a keyless local server, and no static rule separates that from a leaked key. The
@@ -254,14 +274,52 @@ async function gatherFacts(input, report) {
254
274
  values,
255
275
  valueFile,
256
276
  modelAuth,
257
- modelKeyInDefinition,
258
- authPath,
277
+ boxLogin,
259
278
  container,
260
279
  port,
261
280
  declaredSecrets,
262
281
  secrets: deploymentSecrets(modelAuth, declaredSecrets, values, valueFile),
263
282
  };
264
283
  }
284
+ /**
285
+ * HOW THE MODEL'S CREDENTIAL REACHES THE BOX, from what the deploy ships and nothing else. The box is the one authority
286
+ * on what it authenticates with (pi ranks a stored login above the environment, and only the box knows what it has
287
+ * stored, which platform variables it was given, or what role it runs as), so this never asks what authenticates the
288
+ * model on THIS machine: its stored logins and the shell running deploy do not travel. In order:
289
+ *
290
+ * 1. the definition's own models.json: a `"$NAME"` reference is a variable the value file must hold (the values gate
291
+ * asks for it by name), and a literal or `"!command"` travels in the image;
292
+ * 2. a credential pi reads for the provider from the value file alone: its key variable (which the values gate then
293
+ * requires, as it does every declared name), or a keyless one such as `AWS_ACCESS_KEY_ID` — never one that needs a
294
+ * file, which would be this machine's;
295
+ * 3. otherwise the box answers — `boxLogin`: after readiness, `--run` asks it, and it logs in only if it cannot
296
+ * already authenticate the provider.
297
+ */
298
+ async function credentialRoute(agentDir, spec, values) {
299
+ const provider = providerOf(spec);
300
+ const declared = await definitionKeyOf(agentDir, provider);
301
+ if (declared)
302
+ return "reference" in declared ? { envVar: declared.reference } : {};
303
+ // No file of this machine's travels, so a source that needs one (Google ADC, an AWS profile) is the box's to find.
304
+ const fromValues = await environmentAuthSource(provider, Object.fromEntries(values), async () => false);
305
+ if (fromValues !== undefined)
306
+ return isEnvKey(fromValues) ? { envVar: fromValues } : {};
307
+ return { boxLogin: provider };
308
+ }
309
+ /** Whether `spec` exists only once the definition's extensions have registered their models. */
310
+ async function extensionDeclared(agentDir, workspace, spec, deployed) {
311
+ const provider = providerOf(spec);
312
+ const id = spec.slice(provider.length + 1);
313
+ if (deployed.getModel(provider, id))
314
+ return false;
315
+ const catalog = await agentModels(agentDir, { credentialStore: new InMemoryCredentialStore() }, { machineLayer: false, cwd: workspace }).runtime();
316
+ return catalog.getModel(provider, id) !== undefined;
317
+ }
318
+ /** Does `provider` offer an interactive login, i.e. can `fastagent login --deployment` authenticate it on the box? */
319
+ function hasLogin(providerId) {
320
+ const provider = loginProviders().find((p) => p.id === providerId);
321
+ return provider !== undefined && interactiveLoginKind(provider) !== "none";
322
+ }
265
323
  /**
266
324
  * The machine's models.json is this box's environment, not the artifact: whatever the model takes from it is
267
325
  * absent wherever the agent is deployed. Said in so many words here; the credential probe already reads the deployed
@@ -285,6 +343,20 @@ async function checkMachineModels(agentDir, modelSpec, report) {
285
343
  report.issue(issue);
286
344
  }
287
345
  }
346
+ /**
347
+ * The machine's model catalog is this box's environment, like its models.json: a model only it knows (newer than the
348
+ * catalog bundled with pi) is unknown wherever the agent is deployed. The agent's own catalog ships, so the remedy is
349
+ * one refresh in the agent.
350
+ */
351
+ async function checkGlobalCatalogModel(modelSpec, deployed, report) {
352
+ const provider = providerOf(modelSpec);
353
+ const id = modelSpec.slice(provider.length + 1);
354
+ if (deployed.getModel(provider, id) || !(await inGlobalCatalog(provider, id)))
355
+ return;
356
+ report.issue(`model "${modelSpec}" is known only from ${globalCatalogPath()}, the machine's model catalog, which does not ` +
357
+ "ship — the deployed agent would fail with an unknown model. Run `fastagent models --refresh` in the agent " +
358
+ `to record it in its own ${AGENT_MODEL_CATALOG_FILE}, which ships.`);
359
+ }
288
360
  /**
289
361
  * What a KEPT hand-written Dockerfile drops. `deploy.apt` is the obvious one; the resolved model is the one that
290
362
  * looks safe and is not: the manifest is always written, but only the generated Dockerfile sets
@@ -341,7 +413,7 @@ valueFile) {
341
413
  const source = fromEnv ? valueFile : "fastagent.config";
342
414
  // BOTH sources are checked, at the one point that reads them: a `provider`-less spec resolves to nothing on the
343
415
  // 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.
416
+ // the auth probe reports "unconfigured" for it here and the failure only appears after deployment.
345
417
  const spec = fromEnv || config.model;
346
418
  if (spec && !isModelSpec(spec))
347
419
  return { source, invalid: spec };
@@ -1,12 +1,15 @@
1
1
  /** `fastagent deploy railway` — the Railway deploy PLAN, computed from the resolved definition. */
2
2
  import type { DeclaredChannel } from "../../channels/discover.ts";
3
3
  import { type Artifact, type ContainerInput } from "../container.ts";
4
- import { type DeploymentSecret } from "../secrets.ts";
4
+ import type { DeploymentSecret } from "../secrets.ts";
5
5
  export interface RailwayPlanInput extends ContainerInput {
6
6
  /** The service name to create (`railway add --service`). */
7
7
  serviceName: string;
8
- /** What satisfies model auth locally: an env-var name, an OAuth/stored label, or undefined. */
9
- modelAuth: string | undefined;
8
+ /**
9
+ * The provider the deployment logs in to itself (`fastagent login --deployment`): its credential is not a variable
10
+ * the plan carries.
11
+ */
12
+ boxLogin?: string;
10
13
  /**
11
14
  * Every declared channel and its ingress — the source of the secret list, the webhook steps, and whether App
12
15
  * Sleeping must stay off for an outbound connection.
@@ -1,7 +1,7 @@
1
1
  import { webhookRunbook } from "../channel-ingress.js";
2
2
  import { containerArtifacts } from "../container.js";
3
+ import { deploymentLoginCommand } from "../box-shell.js";
3
4
  import { CRON_CAN_BE_EXTERNAL, WAKEUPS_WHEN_ASLEEP, residencyFor } from "../residency.js";
4
- import { isEnvKey } from "../secrets.js";
5
5
  /** State root = the volume mount path, kept in lockstep. */
6
6
  const MOUNT = "/data";
7
7
  /** The `RAILWAY_DOCKERFILE_PATH` value for an agent under `prefix`. */
@@ -33,7 +33,7 @@ function railwayJson(prefix) {
33
33
  }
34
34
  /** Compute the Railway deploy plan from the resolved definition. */
35
35
  export function planRailwayDeploy(input) {
36
- const { serviceName, modelAuth, channels } = input;
36
+ const { serviceName, channels } = input;
37
37
  // railway.json is namespaced under the agent dir too (the workspace may carry its own railway.toml/json for the
38
38
  // product).
39
39
  const configPath = `${input.agentPrefix}railway.json`;
@@ -60,7 +60,7 @@ export function planRailwayDeploy(input) {
60
60
  `# the later commands resolve it without --service (--run passes --service to stay non-interactive).`,
61
61
  `railway add --service ${serviceName}`,
62
62
  ``,
63
- `# Persistent volume at ${MOUNT} — .state (sessions, channel state) + .secrets (seeded auth).`,
63
+ `# Persistent volume at ${MOUNT} — .state (sessions, channel state) + .secrets (the box's own login).`,
64
64
  `railway volume add --mount-path ${MOUNT}`,
65
65
  ``,
66
66
  `# Variables — set BEFORE the first deploy so the box boots with them. Railway injects PORT itself.`,
@@ -71,14 +71,12 @@ export function planRailwayDeploy(input) {
71
71
  if (secrets.length > 0) {
72
72
  runbook.push(`# Secrets:`, `# ${secrets.map((s) => `${s.name}: ${s.hint}`).join("\n# ")}`, `railway variables set ${secrets.map((s) => `${s.name}=<value>`).join(" ")}`);
73
73
  }
74
- // Model-auth guidance: an env key becomes a variable above.
75
- if (!isEnvKey(modelAuth)) {
76
- runbook.push(modelAuth === undefined
77
- ? `# Model auth: none found at the local auth path — a global \`fastagent login\` isn't read here; set FASTAGENT_AUTH_PATH (e.g. ~/.fastagent/.secrets/auth.json), or \`--run\` carries it automatically.`
78
- : `# Model auth: your local auth is "${modelAuth}" — the plan can't read its value to set as a variable.`, `# Set your provider API key as a variable (railway variables set KEY=...), OR place auth.json on the ${MOUNT} volume.`);
79
- }
80
74
  runbook.push(``, `# OPTIONAL — the build already uses the agent's Dockerfile via RAILWAY_DOCKERFILE_PATH (set above),`, `# and Railway's default restart policy equals what ${configPath} declares (ON_FAILURE).`, `# Pointing the service at ${configPath} (Service → Settings → Config-as-code — dashboard-only) adds`, `# the /health healthcheck gate: a boot-crashing deploy is marked FAILED instead of going live dead.`, `# (Zero-downtime switching doesn't apply either way — the ${MOUNT} volume allows one active deployment.)`);
81
75
  runbook.push(``, `# Before a new definition release, run \`fastagent deploy railway\` to refresh the release manifest.`, `# Upload this workspace and build on Railway (no local Docker needed):`, `railway up`);
76
+ // The credential is created on the box, never carried: the box is then the only holder of its grant.
77
+ if (input.boxLogin) {
78
+ runbook.push(``, `# Model auth: once it is up, the deployment logs in to ${input.boxLogin} itself (from this workspace):`, `${deploymentLoginCommand("railway", input.boxLogin)}`);
79
+ }
82
80
  runbook.push(``, `# The volume keeps /data/base (including uncommitted work), .state and .secrets across restarts and deploys.`, `# Each generated release replaces only /data/base/${input.agentPrefix}; other workspace files are initialized once.`, input.shipsGit
83
81
  ? `# Railway uploads may strip .git. Clone inside the persistent workspace when collaboration needs history.`
84
82
  : `# To use Git for collaboration, add deploy: { apt: ["git"] }. Storage durability does not require Git.`);
@@ -1,7 +1,8 @@
1
1
  /** `fastagent deploy railway --run` — drive the Railway CLI to completion. */
2
+ import type { BoxLoginStep } from "../box-shell.ts";
2
3
  import { type PublicHealthProbe, type Registrars } from "../channel-ingress.ts";
3
4
  import type { DeclaredChannel } from "../../channels/discover.ts";
4
- import type { CliRunner } from "../runner.ts";
5
+ import { type CliRunner } from "../runner.ts";
5
6
  export interface RailwayRunPlan {
6
7
  /** Names both the project (`railway init --name`) and the service (`railway add --service`). */
7
8
  name: string;
@@ -10,10 +11,7 @@ export interface RailwayRunPlan {
10
11
  * in lockstep).
11
12
  */
12
13
  mountPath: string;
13
- /**
14
- * `KEY=value` secrets set one-per-`variable set --stdin`: the value file's variables + `FASTAGENT_AUTH_SEED` for a
15
- * file credential.
16
- */
14
+ /** `KEY=value` secrets set one-per-`variable set --stdin`: the value file's variables. */
17
15
  secrets: Record<string, string>;
18
16
  /** Declared names the value file supplies no value for — the run gates on these before any side effect. */
19
17
  missingSecrets: string[];
@@ -25,6 +23,11 @@ export interface RailwayRunPlan {
25
23
  intoLinked: boolean;
26
24
  /** `RAILWAY_DOCKERFILE_PATH` value (`/fastagent/Dockerfile`). */
27
25
  dockerfilePath: string;
26
+ /**
27
+ * Log the box in (`fastagent login --deployment`) once it is up and before any webhook is pointed at it: a channel pointed at
28
+ * a box with no model credential answers every message with a failure. Resolves a gate line, or undefined.
29
+ */
30
+ boxLogin?: BoxLoginStep;
28
31
  }
29
32
  /**
30
33
  * Done (with the live URL), or a gate the operator must clear before re-running (printed + non-zero exit by the CLI).
@@ -49,7 +52,8 @@ export declare function parseDomainUrl(stdout: string): string | undefined;
49
52
  * The status of the volume `railway volume list --json` shows mounted at `mountPath` ON `service`, or undefined when
50
53
  * that service has none there. The list is the whole PROJECT's: another service's volume, or one a deleted service
51
54
  * left behind (`serviceName: null` — deleting a service keeps its volume), sits at the same path and is not this
52
- * one. Read from any object carrying `mountPath` and `serviceName`, so a wrapper key moving does not matter.
55
+ * one. Read from any object carrying `mountPath` and `serviceName`, so a wrapper key moving does not matter. Output
56
+ * that is not JSON throws: read as "no volume", it would add a second one.
53
57
  */
54
58
  export declare function volumeOn(stdout: string, service: string, mountPath: string): string | undefined;
55
59
  export declare function deployRailwayRun(plan: RailwayRunPlan, railway: CliRunner, log: (msg: string) => void, registrars: Registrars, healthProbe?: PublicHealthProbe, sleep?: (ms: number) => Promise<void>): Promise<RailwayRunOutcome>;
@@ -1,5 +1,5 @@
1
- /** `fastagent deploy railway --run` — drive the Railway CLI to completion. */
2
- import { publicHealthGate, registerWebhooks } from "../channel-ingress.js";
1
+ import { loginGate, publicHealthGate, registerWebhooks, } from "../channel-ingress.js";
2
+ import { readOutput } from "../runner.js";
3
3
  import { missingValuesGate } from "../secrets.js";
4
4
  /** Whether `railway status --json` shows a linked project: non-empty stdout. */
5
5
  export function isLinked(stdout) {
@@ -49,7 +49,8 @@ export function parseDomainUrl(stdout) {
49
49
  * The status of the volume `railway volume list --json` shows mounted at `mountPath` ON `service`, or undefined when
50
50
  * that service has none there. The list is the whole PROJECT's: another service's volume, or one a deleted service
51
51
  * left behind (`serviceName: null` — deleting a service keeps its volume), sits at the same path and is not this
52
- * one. Read from any object carrying `mountPath` and `serviceName`, so a wrapper key moving does not matter.
52
+ * one. Read from any object carrying `mountPath` and `serviceName`, so a wrapper key moving does not matter. Output
53
+ * that is not JSON throws: read as "no volume", it would add a second one.
53
54
  */
54
55
  export function volumeOn(stdout, service, mountPath) {
55
56
  const walk = (v) => {
@@ -62,12 +63,7 @@ export function volumeOn(stdout, service, mountPath) {
62
63
  return typeof o.status === "string" ? o.status : "";
63
64
  return walk(Object.values(o));
64
65
  };
65
- try {
66
- return walk(JSON.parse(stdout));
67
- }
68
- catch {
69
- return undefined;
70
- }
66
+ return walk(JSON.parse(stdout));
71
67
  }
72
68
  /**
73
69
  * How long `--run` waits for a new volume to reach its service before deploying. Railway's `volume add` returns
@@ -113,7 +109,10 @@ export async function deployRailwayRun(plan, railway, log, registrars, healthPro
113
109
  // Create + link the service (init makes only a project); it precedes the volume, which has no --service flag and
114
110
  // rides the linked service.
115
111
  log(`creating service ${plan.name}…`);
116
- if ((await railway(["add", "--service", plan.name])).code !== 0) {
112
+ // `--variables` with one of the machinery variables set just below: in a terminal, a bare `railway add` stops at
113
+ // "Enter a variable <esc to skip>" (5.62.1) and waits for a key nobody was told to press.
114
+ const addArgs = ["add", "--service", plan.name, "--variables", `FASTAGENT_STATE_DIR=${plan.mountPath}/.state`];
115
+ if ((await railway(addArgs)).code !== 0) {
117
116
  // Precise recovery, not "fix and re-run": init already created + linked the project, so a plain re-run hits the
118
117
  // linked-gate, and --into-linked SKIPS `add` and then fails at the volume (no service to ride).
119
118
  return gate(`\`railway add --service\` failed — \`railway init\` already created the project (this directory is now ` +
@@ -141,8 +140,11 @@ export async function deployRailwayRun(plan, railway, log, registrars, healthPro
141
140
  }
142
141
  }
143
142
  // 3c.
144
- const volumeStatus = async () => volumeOn((await railway(["volume", "list", "--json"], { capture: true })).stdout, plan.name, plan.mountPath);
145
- if ((await volumeStatus()) !== undefined) {
143
+ const volumeStatus = () => readOutput(railway, "railway", ["volume", "list", "--json"], (out) => volumeOn(out, plan.name, plan.mountPath));
144
+ const existing = await volumeStatus();
145
+ if ("gate" in existing)
146
+ return gate(existing.gate);
147
+ if (existing.value !== undefined) {
146
148
  log(`volume at ${plan.mountPath} exists on ${plan.name} — skipping`);
147
149
  }
148
150
  else {
@@ -154,7 +156,10 @@ export async function deployRailwayRun(plan, railway, log, registrars, healthPro
154
156
  // never seed a workspace onto a disk that vanishes on restart).
155
157
  let status;
156
158
  for (let waited = 0;; waited += VOLUME_POLL_MS) {
157
- status = await volumeStatus();
159
+ const read = await volumeStatus();
160
+ if ("gate" in read)
161
+ return gate(read.gate);
162
+ status = read.value;
158
163
  if (status === "Ready" || waited >= VOLUME_ATTACH_TIMEOUT_MS)
159
164
  break;
160
165
  await sleep(VOLUME_POLL_MS);
@@ -183,9 +188,20 @@ export async function deployRailwayRun(plan, railway, log, registrars, healthPro
183
188
  log,
184
189
  inspectHint: "the service itself deployed — inspect `railway logs`, then re-run once it answers",
185
190
  probe: healthProbe,
191
+ ...(plan.boxLogin ? { login: plan.boxLogin.command } : {}),
186
192
  });
187
193
  if (healthGate)
188
194
  return gate(healthGate);
195
+ const notLoggedIn = await plan.boxLogin?.run();
196
+ if (notLoggedIn) {
197
+ return gate(loginGate({
198
+ notLoggedIn,
199
+ channels: plan.channels,
200
+ log,
201
+ baseUrl: url,
202
+ afterLogin: "re-run `fastagent deploy railway --run --into-linked` (it keeps the login)",
203
+ }));
204
+ }
189
205
  // 7.
190
206
  const registrationGateMsg = await registerWebhooks({
191
207
  baseUrl: url,
@@ -18,9 +18,25 @@ export type CliRunner = (args: string[], opts?: {
18
18
  input?: string;
19
19
  env?: NodeJS.ProcessEnv;
20
20
  }) => Promise<RunResult>;
21
+ /**
22
+ * A read-only host-CLI query (`… list --json`), reduced to the question the next step asks of it — or the gate for an
23
+ * answer we cannot act on. A failed command and unreadable output both stop the run: read as "nothing there", either
24
+ * one would send the next step to create what already exists.
25
+ */
26
+ export declare function readOutput<T>(run: CliRunner, bin: string, args: string[], read: (stdout: string) => T): Promise<{
27
+ value: T;
28
+ } | {
29
+ gate: string;
30
+ }>;
21
31
  /**
22
32
  * Production {@link CliRunner}: spawn `bin` in `cwd` (the workspace, so a build/upload context is the agent). stderr
23
33
  * is always inherited to the terminal.
24
34
  */
25
35
  export declare function spawnRunner(bin: string, cwd: string): CliRunner;
36
+ /**
37
+ * The AWS CLI, as every command here runs it: with its pager off. Given a terminal, AWS CLI v2 pipes any output it
38
+ * prints through `less`, so `deploy agentcore --run` stopped at `(END)` after `ecr create-repository` until someone
39
+ * pressed `q`. ONE runner rather than a `--no-cli-pager` at each call: a call site that forgets is how that hang ships.
40
+ */
41
+ export declare function awsRunner(cwd: string): CliRunner;
26
42
  export {};
@@ -1,6 +1,28 @@
1
1
  /** The host-CLI dispatcher seam, shared by every `deploy <host> --run` driver (fly/run.ts, railway/run.ts). */
2
2
  import { spawn } from "node:child_process";
3
3
  import { log } from "../log.js";
4
+ /**
5
+ * A read-only host-CLI query (`… list --json`), reduced to the question the next step asks of it — or the gate for an
6
+ * answer we cannot act on. A failed command and unreadable output both stop the run: read as "nothing there", either
7
+ * one would send the next step to create what already exists.
8
+ */
9
+ export async function readOutput(run, bin, args, read) {
10
+ // The command as RUN, not a restatement of it.
11
+ const cmd = `${bin} ${args.join(" ")}`;
12
+ const result = await run(args, { capture: true });
13
+ if (result.code !== 0)
14
+ return { gate: `\`${cmd}\` failed — see the ${bin} output above; fix and re-run` };
15
+ try {
16
+ return { value: read(result.stdout) };
17
+ }
18
+ catch (error) {
19
+ // The one place a parse failure is allowed to stop being an exception.
20
+ return {
21
+ gate: `\`${cmd}\` was unreadable (${error instanceof Error ? error.message : String(error)}) — ` +
22
+ `run it yourself and check the ${bin} CLI version; fix and re-run`,
23
+ };
24
+ }
25
+ }
4
26
  /**
5
27
  * Production {@link CliRunner}: spawn `bin` in `cwd` (the workspace, so a build/upload context is the agent). stderr
6
28
  * is always inherited to the terminal.
@@ -39,3 +61,12 @@ export function spawnRunner(bin, cwd) {
39
61
  child.on("error", () => res({ code: 127, stdout: "", stderr: opts?.captureStderr ? "" : undefined })); // ENOENT
40
62
  });
41
63
  }
64
+ /**
65
+ * The AWS CLI, as every command here runs it: with its pager off. Given a terminal, AWS CLI v2 pipes any output it
66
+ * prints through `less`, so `deploy agentcore --run` stopped at `(END)` after `ecr create-repository` until someone
67
+ * pressed `q`. ONE runner rather than a `--no-cli-pager` at each call: a call site that forgets is how that hang ships.
68
+ */
69
+ export function awsRunner(cwd) {
70
+ const aws = spawnRunner("aws", cwd);
71
+ return (args, opts) => aws(args, { ...opts, env: { ...opts?.env, AWS_PAGER: "" } });
72
+ }
@@ -19,21 +19,17 @@ export interface DeploymentSecret {
19
19
  */
20
20
  export declare function deploymentSecrets(modelAuth: string | undefined, declared: readonly DeclaredSecret[], values: ReadonlyMap<string, string>, valueFile: string): DeploymentSecret[];
21
21
  /**
22
- * Assemble the VALUES a `--run` deploy sets on the host: every variable the value file carries, plus the local
23
- * credential. Never minted here.
22
+ * Assemble the VALUES a `--run` deploy sets on the host: every variable the value file carries. Never minted here.
24
23
  *
25
24
  * `values` is the selected value file, read as data — **the environment running `deploy` is not a source**. A
26
25
  * deployment must be reproducible from what it carries, and a variable that happens to be exported on the builder is
27
26
  * written down nowhere. CI supplies values by writing the file before running the command.
27
+ *
28
+ * No credentials file travels: a model that does not authenticate through a variable (or its own definition) logs in
29
+ * on the deployment itself (`fastagent login --deployment`), so the box is the only holder of its grant.
28
30
  */
29
31
  export declare function assembleSecrets(input: {
30
32
  modelAuth: string | undefined;
31
- /**
32
- * The definition carries the model key itself (a models.json literal `apiKey` / `!command`): there is no value to
33
- * carry and no gate to raise.
34
- */
35
- modelKeyInDefinition?: boolean;
36
- authFile: Buffer | undefined;
37
33
  /** Every name the definition declared it needs — what must have a value. */
38
34
  declared?: readonly DeclaredSecret[];
39
35
  /** The selected value file's contents (`loadEnvValues`). The ONLY source of operator-supplied values. */
@@ -41,7 +37,6 @@ export declare function assembleSecrets(input: {
41
37
  }): {
42
38
  secrets: Record<string, string>;
43
39
  missingSecrets: string[];
44
- needsModelCredential: boolean;
45
40
  };
46
41
  /**
47
42
  * The ONE refusal for declared names the deployed environment does not supply a value for — every host reaches it
@@ -49,10 +44,6 @@ export declare function assembleSecrets(input: {
49
44
  * name missing from it is missing from the deployment, whatever platform receives it.
50
45
  */
51
46
  export declare function missingValuesGate(missing: readonly string[], valueFile: string): string | undefined;
52
- /** The bytes to seed to the auth file, or undefined to leave it alone. */
53
- export declare function authSeedBytes(seed: string | undefined, fileExists: boolean): Buffer | undefined;
54
- /** `FASTAGENT_AUTH_SEED` (+ `_2`, …): the base64 auth.json a deploy carries. */
55
- export declare function collectAuthSeed(env: NodeJS.ProcessEnv): string | undefined;
56
47
  /**
57
48
  * Expand `FASTAGENT_ENV` into the process environment — what `start` does first on a host that carries the value file
58
49
  * that way. The platform's own variables still win, as they do over a value file anywhere.