@fastagent-sh/fastagent 0.22.0 → 0.23.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 (103) hide show
  1. package/dist/channels/agentcore-protocol.d.ts +2 -1
  2. package/dist/channels/agentcore-service.js +10 -18
  3. package/dist/channels/agentcore.js +6 -3
  4. package/dist/channels/control.js +23 -0
  5. package/dist/channels/slack/config-api.d.ts +2 -2
  6. package/dist/channels/slack/config-api.js +2 -2
  7. package/dist/channels/slack/onboard.js +2 -2
  8. package/dist/channels/slack/register-webhook.js +2 -2
  9. package/dist/channels/slack/slack-api.js +4 -4
  10. package/dist/cli/add-feishu.js +4 -2
  11. package/dist/cli/auth-view.d.ts +15 -6
  12. package/dist/cli/auth-view.js +24 -8
  13. package/dist/cli/box-login.d.ts +33 -0
  14. package/dist/cli/box-login.js +162 -0
  15. package/dist/cli/commands/deploy/agentcore.js +48 -11
  16. package/dist/cli/commands/deploy/docker.js +24 -13
  17. package/dist/cli/commands/deploy/fly.js +30 -7
  18. package/dist/cli/commands/deploy/railway.js +21 -7
  19. package/dist/cli/commands/deploy/shared.d.ts +18 -17
  20. package/dist/cli/commands/deploy/shared.js +20 -21
  21. package/dist/cli/commands/destroy.js +2 -2
  22. package/dist/cli/commands/info.js +9 -6
  23. package/dist/cli/commands/invoke.js +2 -2
  24. package/dist/cli/commands/login.d.ts +7 -1
  25. package/dist/cli/commands/login.js +123 -10
  26. package/dist/cli/commands/logs.js +2 -2
  27. package/dist/cli/commands/models.d.ts +4 -1
  28. package/dist/cli/commands/models.js +37 -4
  29. package/dist/cli/commands/routine-run.js +2 -2
  30. package/dist/cli/commands/start.d.ts +0 -5
  31. package/dist/cli/commands/start.js +3 -13
  32. package/dist/cli/kernel.d.ts +2 -0
  33. package/dist/cli/kernel.js +1 -1
  34. package/dist/cli/login-relay.d.ts +38 -0
  35. package/dist/cli/login-relay.js +121 -0
  36. package/dist/cli/program.js +46 -8
  37. package/dist/cli/shared.d.ts +4 -4
  38. package/dist/cli/shared.js +24 -30
  39. package/dist/deploy/agentcore/plan.d.ts +10 -11
  40. package/dist/deploy/agentcore/plan.js +8 -14
  41. package/dist/deploy/agentcore/run.d.ts +8 -1
  42. package/dist/deploy/agentcore/run.js +20 -14
  43. package/dist/deploy/agentcore/shell.d.ts +25 -0
  44. package/dist/deploy/agentcore/shell.js +202 -0
  45. package/dist/deploy/box-shell.d.ts +43 -0
  46. package/dist/deploy/box-shell.js +28 -0
  47. package/dist/deploy/build-context.js +16 -7
  48. package/dist/deploy/channel-ingress.d.ts +20 -1
  49. package/dist/deploy/channel-ingress.js +34 -7
  50. package/dist/deploy/container.d.ts +14 -0
  51. package/dist/deploy/container.js +45 -1
  52. package/dist/deploy/docker/plan.d.ts +7 -3
  53. package/dist/deploy/docker/plan.js +9 -14
  54. package/dist/deploy/docker/run.d.ts +14 -7
  55. package/dist/deploy/docker/run.js +58 -35
  56. package/dist/deploy/fly/plan.d.ts +6 -3
  57. package/dist/deploy/fly/plan.js +6 -8
  58. package/dist/deploy/fly/run.d.ts +8 -2
  59. package/dist/deploy/fly/run.js +15 -25
  60. package/dist/deploy/preflight.d.ts +9 -7
  61. package/dist/deploy/preflight.js +74 -20
  62. package/dist/deploy/railway/plan.d.ts +6 -3
  63. package/dist/deploy/railway/plan.js +7 -9
  64. package/dist/deploy/railway/run.d.ts +10 -6
  65. package/dist/deploy/railway/run.js +29 -13
  66. package/dist/deploy/runner.d.ts +16 -0
  67. package/dist/deploy/runner.js +31 -0
  68. package/dist/deploy/secrets.d.ts +4 -13
  69. package/dist/deploy/secrets.js +8 -27
  70. package/dist/dev-supervisor.js +5 -2
  71. package/dist/engines/pi/agent-models.d.ts +70 -0
  72. package/dist/engines/pi/agent-models.js +93 -0
  73. package/dist/engines/pi/auth.d.ts +56 -3
  74. package/dist/engines/pi/auth.js +63 -16
  75. package/dist/engines/pi/config.d.ts +0 -25
  76. package/dist/engines/pi/config.js +3 -37
  77. package/dist/engines/pi/create.d.ts +22 -16
  78. package/dist/engines/pi/create.js +28 -28
  79. package/dist/engines/pi/entry-images.d.ts +14 -0
  80. package/dist/engines/pi/entry-images.js +23 -0
  81. package/dist/engines/pi/invoke-session.js +26 -0
  82. package/dist/engines/pi/login.d.ts +20 -13
  83. package/dist/engines/pi/login.js +19 -12
  84. package/dist/engines/pi/models.d.ts +115 -36
  85. package/dist/engines/pi/models.js +218 -48
  86. package/dist/engines/pi/open.d.ts +42 -11
  87. package/dist/engines/pi/open.js +53 -21
  88. package/dist/engines/pi/service.d.ts +2 -2
  89. package/dist/engines/pi/service.js +1 -0
  90. package/dist/engines/pi/session-builder.d.ts +3 -2
  91. package/dist/engines/pi/session-builder.js +28 -58
  92. package/dist/engines/pi/session-control.js +14 -3
  93. package/dist/engines/pi/turn-kit.js +11 -5
  94. package/dist/open-url.d.ts +15 -1
  95. package/dist/open-url.js +35 -3
  96. package/dist/paths.d.ts +4 -2
  97. package/dist/paths.js +6 -2
  98. package/dist/pi.d.ts +5 -3
  99. package/dist/pi.js +3 -2
  100. package/dist/service.d.ts +5 -8
  101. package/dist/session-remote.js +21 -0
  102. package/dist/session.d.ts +39 -6
  103. package/package.json +2 -1
@@ -6,13 +6,15 @@ 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";
14
16
  import { allSecrets } from "../declared-secrets.js";
15
- import { createPiModelRuntime, literalKeyProviders, isBuiltinProvider, machineModels, modelCredentialCarry, probeAuthSource, } from "../engines/pi/models.js";
17
+ import { createPiModelRuntime, definitionKeyOf, environmentAuthSource, literalKeyProviders, isBuiltinProvider, machineModels, globalCatalogPath, inGlobalCatalog, interactiveLoginKind, loginProviders, } from "../engines/pi/models.js";
16
18
  import { CHANNEL_KINDS } from "../scaffold/add-channel.js";
17
19
  import { detectRuntime, readPackageJson } from "../runtime.js";
18
20
  import { fastagentVersion } from "../version.js";
@@ -147,21 +149,30 @@ async function gatherFacts(input, report) {
147
149
  : ""));
148
150
  }
149
151
  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.
152
+ // The registry the DEPLOYED agent has (its own models.json and model catalog, nothing of the machine's), built first
153
+ // so a malformed file stops the deploy with pi's own reason before anything below reads the file raw. No credential
154
+ // is read for that.
155
+ const deployed = await createPiModelRuntime({
156
+ agentDir,
157
+ credentials: new InMemoryCredentialStore(),
158
+ machineLayer: false,
159
+ });
160
+ if (modelSpec)
161
+ await checkGlobalCatalogModel(modelSpec, deployed, report);
153
162
  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;
163
+ const route = modelSpec ? await credentialRoute(agentDir, modelSpec, values) : {};
164
+ const modelAuth = route.envVar;
165
+ const boxLogin = route.boxLogin;
166
+ if (boxLogin !== undefined) {
167
+ if (!hasLogin(boxLogin)) {
168
+ report.issue(`no credential for ${modelSpec} reaches the deployment, and "${boxLogin}" has no login to run there — set ` +
169
+ `its API key in ${valueFile}`);
170
+ }
171
+ else {
172
+ report.note(`${modelSpec}: no credential ships with this deploy (this machine's logins and shell stay here) — once the ` +
173
+ `box is up, \`--run\` asks it: it keeps what it already authenticates ${boxLogin} with, else logs in ` +
174
+ `(\`fastagent login ${boxLogin} --deployment\`). Or set ${boxLogin}'s API key in ${valueFile}`);
175
+ }
165
176
  }
166
177
  // Reported, not refused. Whether a string is a credential is the AUTHOR's knowledge: pi's docs prescribe
167
178
  // `"apiKey": "ollama"` for a keyless local server, and no static rule separates that from a leaked key. The
@@ -254,14 +265,43 @@ async function gatherFacts(input, report) {
254
265
  values,
255
266
  valueFile,
256
267
  modelAuth,
257
- modelKeyInDefinition,
258
- authPath,
268
+ boxLogin,
259
269
  container,
260
270
  port,
261
271
  declaredSecrets,
262
272
  secrets: deploymentSecrets(modelAuth, declaredSecrets, values, valueFile),
263
273
  };
264
274
  }
275
+ /**
276
+ * HOW THE MODEL'S CREDENTIAL REACHES THE BOX, from what the deploy ships and nothing else. The box is the one authority
277
+ * on what it authenticates with (pi ranks a stored login above the environment, and only the box knows what it has
278
+ * stored, which platform variables it was given, or what role it runs as), so this never asks what authenticates the
279
+ * model on THIS machine: its stored logins and the shell running deploy do not travel. In order:
280
+ *
281
+ * 1. the definition's own models.json: a `"$NAME"` reference is a variable the value file must hold (the values gate
282
+ * asks for it by name), and a literal or `"!command"` travels in the image;
283
+ * 2. a credential pi reads for the provider from the value file alone: its key variable (which the values gate then
284
+ * requires, as it does every declared name), or a keyless one such as `AWS_ACCESS_KEY_ID` — never one that needs a
285
+ * file, which would be this machine's;
286
+ * 3. otherwise the box answers — `boxLogin`: after readiness, `--run` asks it, and it logs in only if it cannot
287
+ * already authenticate the provider.
288
+ */
289
+ async function credentialRoute(agentDir, spec, values) {
290
+ const provider = providerOf(spec);
291
+ const declared = await definitionKeyOf(agentDir, provider);
292
+ if (declared)
293
+ return "reference" in declared ? { envVar: declared.reference } : {};
294
+ // No file of this machine's travels, so a source that needs one (Google ADC, an AWS profile) is the box's to find.
295
+ const fromValues = await environmentAuthSource(provider, Object.fromEntries(values), async () => false);
296
+ if (fromValues !== undefined)
297
+ return isEnvKey(fromValues) ? { envVar: fromValues } : {};
298
+ return { boxLogin: provider };
299
+ }
300
+ /** Does `provider` offer an interactive login, i.e. can `fastagent login --deployment` authenticate it on the box? */
301
+ function hasLogin(providerId) {
302
+ const provider = loginProviders().find((p) => p.id === providerId);
303
+ return provider !== undefined && interactiveLoginKind(provider) !== "none";
304
+ }
265
305
  /**
266
306
  * The machine's models.json is this box's environment, not the artifact: whatever the model takes from it is
267
307
  * absent wherever the agent is deployed. Said in so many words here; the credential probe already reads the deployed
@@ -285,6 +325,20 @@ async function checkMachineModels(agentDir, modelSpec, report) {
285
325
  report.issue(issue);
286
326
  }
287
327
  }
328
+ /**
329
+ * The machine's model catalog is this box's environment, like its models.json: a model only it knows (newer than the
330
+ * catalog bundled with pi) is unknown wherever the agent is deployed. The agent's own catalog ships, so the remedy is
331
+ * one refresh in the agent.
332
+ */
333
+ async function checkGlobalCatalogModel(modelSpec, deployed, report) {
334
+ const provider = providerOf(modelSpec);
335
+ const id = modelSpec.slice(provider.length + 1);
336
+ if (deployed.getModel(provider, id) || !(await inGlobalCatalog(provider, id)))
337
+ return;
338
+ report.issue(`model "${modelSpec}" is known only from ${globalCatalogPath()}, the machine's model catalog, which does not ` +
339
+ "ship — the deployed agent would fail with an unknown model. Run `fastagent models --refresh` in the agent " +
340
+ `to record it in its own ${AGENT_MODEL_CATALOG_FILE}, which ships.`);
341
+ }
288
342
  /**
289
343
  * What a KEPT hand-written Dockerfile drops. `deploy.apt` is the obvious one; the resolved model is the one that
290
344
  * looks safe and is not: the manifest is always written, but only the generated Dockerfile sets
@@ -341,7 +395,7 @@ valueFile) {
341
395
  const source = fromEnv ? valueFile : "fastagent.config";
342
396
  // BOTH sources are checked, at the one point that reads them: a `provider`-less spec resolves to nothing on the
343
397
  // 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.
398
+ // the auth probe reports "unconfigured" for it here and the failure only appears after deployment.
345
399
  const spec = fromEnv || config.model;
346
400
  if (spec && !isModelSpec(spec))
347
401
  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.
@@ -30,7 +30,7 @@ const DEPLOY_OWNED = new Set([
30
30
  ]);
31
31
  /** Is `name` one the deployment sets itself (see {@link DEPLOY_OWNED}), including the chunked carriers? */
32
32
  function isDeployOwned(name) {
33
- return DEPLOY_OWNED.has(name) || /^FASTAGENT_(AUTH_SEED|ENV)(_\d+)?$/.test(name);
33
+ return DEPLOY_OWNED.has(name) || /^FASTAGENT_ENV(_\d+)?$/.test(name);
34
34
  }
35
35
  /** The value-file variables that travel: everything with a value that the deployment does not set itself. */
36
36
  function carriedValues(values) {
@@ -59,36 +59,25 @@ export function deploymentSecrets(modelAuth, declared, values, valueFile) {
59
59
  return secrets;
60
60
  }
61
61
  /**
62
- * Assemble the VALUES a `--run` deploy sets on the host: every variable the value file carries, plus the local
63
- * credential. Never minted here.
62
+ * Assemble the VALUES a `--run` deploy sets on the host: every variable the value file carries. Never minted here.
64
63
  *
65
64
  * `values` is the selected value file, read as data — **the environment running `deploy` is not a source**. A
66
65
  * deployment must be reproducible from what it carries, and a variable that happens to be exported on the builder is
67
66
  * written down nowhere. CI supplies values by writing the file before running the command.
67
+ *
68
+ * No credentials file travels: a model that does not authenticate through a variable (or its own definition) logs in
69
+ * on the deployment itself (`fastagent login --deployment`), so the box is the only holder of its grant.
68
70
  */
69
71
  export function assembleSecrets(input) {
70
72
  const secrets = carriedValues(input.values);
71
73
  const missingSecrets = [];
72
- let needsModelCredential = false;
73
- if (isEnvKey(input.modelAuth)) {
74
- if (!secrets[input.modelAuth])
75
- missingSecrets.push(input.modelAuth); // `.env` remediation fits
76
- }
77
- else if (input.authFile) {
78
- secrets.FASTAGENT_AUTH_SEED = input.authFile.toString("base64");
79
- }
80
- else if (input.modelKeyInDefinition) {
81
- // The definition authenticates itself (models.json literal key, or a command run on the host), so it travels in
82
- // the image with everything else.
83
- }
84
- else {
85
- needsModelCredential = true; // no env key, no auth.json — `fastagent login` remediation
86
- }
74
+ if (isEnvKey(input.modelAuth) && !secrets[input.modelAuth])
75
+ missingSecrets.push(input.modelAuth);
87
76
  for (const { name } of dedupeSecrets(input.declared ?? [])) {
88
77
  if (!secrets[name] && !missingSecrets.includes(name))
89
78
  missingSecrets.push(name);
90
79
  }
91
- return { secrets, missingSecrets, needsModelCredential };
80
+ return { secrets, missingSecrets };
92
81
  }
93
82
  /**
94
83
  * The ONE refusal for declared names the deployed environment does not supply a value for — every host reaches it
@@ -101,10 +90,6 @@ export function missingValuesGate(missing, valueFile) {
101
90
  return (`no value for: ${missing.join(", ")} — the deployed environment is declared by ${valueFile}, and this deploy ` +
102
91
  `reads only that file (exporting the variable here does not reach the deployment). Add them there and re-run`);
103
92
  }
104
- /** The bytes to seed to the auth file, or undefined to leave it alone. */
105
- export function authSeedBytes(seed, fileExists) {
106
- return !seed || fileExists ? undefined : Buffer.from(seed, "base64");
107
- }
108
93
  /**
109
94
  * Collect a (possibly CHUNKED) carrier from the environment: `<name>` plus numbered continuations (`_2`, `_3`, …)
110
95
  * concatenated in order. A host whose env values have a length cap splits a long value this way.
@@ -122,10 +107,6 @@ function collectChunked(env, name) {
122
107
  }
123
108
  return value;
124
109
  }
125
- /** `FASTAGENT_AUTH_SEED` (+ `_2`, …): the base64 auth.json a deploy carries. */
126
- export function collectAuthSeed(env) {
127
- return collectChunked(env, "FASTAGENT_AUTH_SEED");
128
- }
129
110
  /**
130
111
  * `FASTAGENT_ENV` (+ `_2`, …): the carried variables as ONE base64 JSON object, for a host whose deployment artifact
131
112
  * must not depend on which names the value file holds (AgentCore's template is committed and gated on drift). The
@@ -5,7 +5,7 @@
5
5
  import { spawn } from "node:child_process";
6
6
  import { relative, sep } from "node:path";
7
7
  import { watch as watchTree } from "chokidar";
8
- import { AGENT_CONFIG_FILE, AGENT_MODELS_FILE, resolveStateRoot, isUnderDir } from "./paths.js";
8
+ import { AGENT_CONFIG_FILE, AGENT_MODEL_CATALOG_FILE, AGENT_MODELS_FILE, resolveStateRoot, isUnderDir, } from "./paths.js";
9
9
  import { dotEnvPath } from "./env.js";
10
10
  import { log } from "./log.js";
11
11
  import { openExternalUrl } from "./open-url.js";
@@ -14,7 +14,7 @@ import { announceWebhooks, startCloudflareTunnel } from "./tunnel.js";
14
14
  /** What the dev watcher restarts on (agent-dir-relative): the process-bound code inputs only. */
15
15
  /** The agent-dir directories loaded ONCE per worker: a restart is their only re-read. */
16
16
  const CODE_INPUT_DIRS = ["tools", "channels", "routines", "extensions"];
17
- const WATCHED_HINT = `${CODE_INPUT_DIRS.map((dir) => `${dir}/`).join(", ")}, package.json, fastagent.config.ts, models.json, .secrets/.env`;
17
+ const WATCHED_HINT = `${CODE_INPUT_DIRS.map((dir) => `${dir}/`).join(", ")}, package.json, fastagent.config.ts, models.json, models-store.json, .secrets/.env`;
18
18
  /** chokidar `ignored` matcher for the narrow watch scope (true = ignore), rooted at the AGENT DIR. */
19
19
  export function devWatchIgnored(root, envFile) {
20
20
  // The `.env` is allow-listed by its RESOLVED path, not by the `.secrets` name.
@@ -33,6 +33,9 @@ export function devWatchIgnored(root, envFile) {
33
33
  // any other code input.
34
34
  if (rel === AGENT_MODELS_FILE)
35
35
  return false;
36
+ // So is the model catalog: a `models --refresh` during `dev` restarts the worker onto the new models.
37
+ if (rel === AGENT_MODEL_CATALOG_FILE)
38
+ return false;
36
39
  const segments = rel.split(sep);
37
40
  if (CODE_INPUT_DIRS.includes(segments[0]))
38
41
  return false;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * An agent's MODEL ENVIRONMENT as one value: the credential store its turns authenticate through, the registry they
3
+ * resolve models against, and what authenticates a provider there. Every reader builds it through {@link agentModels}
4
+ * — the opener and L2, the startup report and a deployed box's `login --if-missing`, `info`, the first-run picker,
5
+ * login, the model list — so none of them can read other credential layers or another registry than the runtime
6
+ * does. Each used to compose these parts itself, and two of those copies drifted from the rule (#636, #660).
7
+ */
8
+ import type { Credential, CredentialStore, Models, Provider } from "@earendil-works/pi-ai";
9
+ import { ModelRuntime } from "@earendil-works/pi-coding-agent";
10
+ import { type AuthLayers, type CredentialSourceOptions, type FastagentAuthOptions } from "./auth.ts";
11
+ /** What authenticates one provider for an agent ({@link AgentModels.authStatus}). */
12
+ interface AuthStatus {
13
+ /** What satisfies it now (`OAuth`, `stored credential`, an env variable's name), after a due refresh. */
14
+ source?: string;
15
+ /** Why resolving it failed (a corrupt entry, a refresh the provider refused), when it did. */
16
+ error?: string;
17
+ /** The file that holds the stored credential, or the one a login would write; absent over a caller's store. */
18
+ path?: string;
19
+ /** The kind of credential the store holds for it, usable or not. */
20
+ stored?: Credential["type"];
21
+ /**
22
+ * An env variable that also authenticates it but goes unused, because pi lets a stored credential own its provider
23
+ * — a key added to a deployment's value file after the box was logged in, typically.
24
+ */
25
+ shadowed?: string;
26
+ }
27
+ export interface AgentModels {
28
+ /** The credentials files behind {@link credentials}; absent when the caller supplied its own store. */
29
+ auth?: AuthLayers;
30
+ credentials: CredentialStore;
31
+ /**
32
+ * The registry: pi's built-ins, plus (with a directory) the agent's `models.json` and model catalog over the
33
+ * machine's, plus `providers`. Built on first use and shared after, so a malformed file fails the first reader.
34
+ */
35
+ runtime(): Promise<ModelRuntime>;
36
+ /**
37
+ * What authenticates `provider` here, resolved exactly as a turn resolves it: the ONE answer the startup report and a
38
+ * deployed box's `login --if-missing` both give. `modelId` picks the model to probe with; the provider's first one
39
+ * otherwise (auth is provider-scoped).
40
+ */
41
+ authStatus(provider: string, modelId?: string): Promise<AuthStatus>;
42
+ }
43
+ export interface AgentModelsOptions {
44
+ /** Extra providers registered on top of the built-ins (a same id replaces one). */
45
+ providers?: readonly Provider[];
46
+ /**
47
+ * Layer the machine's models.json and model catalog under the agent's (default). Off for the registry a DEPLOYED
48
+ * agent has, since neither ships.
49
+ */
50
+ machineLayer?: boolean;
51
+ }
52
+ /**
53
+ * The model environment of the agent in `agentDir` (or, without one, of a caller with no directory: pi's built-ins
54
+ * over the named or global credentials file). A supplied `credentialStore` is used as given; otherwise the files:
55
+ * the directory's own layers ({@link resolveAuthLayers}), or the named file or the global one.
56
+ */
57
+ export declare function agentModels(agentDir: string | undefined, source?: {
58
+ authPath?: string;
59
+ credentialStore?: undefined;
60
+ } & FastagentAuthOptions, options?: AgentModelsOptions): AgentModels & {
61
+ auth: AuthLayers;
62
+ };
63
+ export declare function agentModels(agentDir: string | undefined, source?: CredentialSourceOptions & FastagentAuthOptions, options?: AgentModelsOptions): AgentModels;
64
+ export interface CreatePiModelsOptions extends FastagentAuthOptions, CredentialSourceOptions {
65
+ /** Extra providers registered on top of the built-ins (same id overrides a built-in). */
66
+ providers?: Provider[];
67
+ }
68
+ /** A `Models` with every built-in pi provider, wired to fastagent's auth. */
69
+ export declare function createPiModels(options?: CreatePiModelsOptions): Models;
70
+ export {};