@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
@@ -84,7 +84,8 @@ export type AgentcoreEnvelope = {
84
84
  }
85
85
  /**
86
86
  * The deploy driver's post-deploy verification (relayed by the forwarder's reserved probe path, which answers on
87
- * EVERY forwarder topology — schedule-only URLs refuse ordinary public traffic).
87
+ * EVERY forwarder topology — schedule-only URLs refuse ordinary public traffic). Also accepted from the IAM door,
88
+ * where it only opens the workspace (`login --deployment` needs one) and does not construct the channels.
88
89
  */
89
90
  | {
90
91
  kind: "probe";
@@ -91,26 +91,18 @@ export function deferAgentcoreService(stages) {
91
91
  export async function mountAgentcoreService(opened, options = {}) {
92
92
  const { agentDir, workspace, stateRoot, sessionControl } = opened;
93
93
  const agent = options.wrapAgent?.(opened.agent) ?? opened.agent;
94
- // NO control plane on this host, and `sessionControl: true` cannot change that.
94
+ // NO control plane on this host, and `sessionControl: true` cannot change that. The only public way in is the
95
+ // forwarder's Function URL, which relays an arbitrary `rawPath` verbatim as a webhook envelope and attaches the
96
+ // ingress secret ITSELF (deploy/agentcore/forwarder.js), so every anonymous caller arrives as trusted ingress. A
97
+ // channel route survives that because it checks its platform's signature; `/control/*` has no such check.
95
98
  //
96
- // The only public way in is the forwarder's Function URL, which relays an arbitrary `rawPath` verbatim as a
97
- // webhook envelope (deploy/agentcore/forwarder.js) and attaches the ingress secret ITSELF — so every anonymous
98
- // caller arrives as trusted ingress. A channel route survives that because the platform's signature is checked
99
- // inside it; `/control/*` has no such check, and mounting it here made `GET /control/sessions` and
100
- // `DELETE /control/sessions/{id}` answerable from the public URL with no credential at all.
99
+ // The recipe if it is ever wanted: a `kind: "control"` envelope on the IAM-gated `InvokeAgentRuntime`, which the
100
+ // forwarder never emits (the way `kind: "invoke"` runs a turn without the ingress secret). The envelope is
101
+ // request/response with a buffered body, so the long-lived `GET /control/sessions/{id}/events` stream a client
102
+ // renders from could not ride it.
101
103
  //
102
- // It is NOT that the two callers cannot be told apart. AWS already separates them and we already use that:
103
- // `InvokeAgentRuntime` is IAM-gated, the forwarder builds its own envelopes and emits exactly four kinds, so a
104
- // kind it never sends can only have come from a direct IAM call — which is how `kind: "invoke"` runs a turn here
105
- // without the ingress secret. Adding `kind: "control"` on the same footing is small, and is the recipe if this is
106
- // ever wanted.
107
- //
108
- // It is not built because nothing asks for it: `connectSessionControl` has no caller in this repo, and the
109
- // envelope is request/response with a buffered body (see the webhook reply), so the one route a GUI actually
110
- // renders from — the long-lived `GET /control/sessions/{id}/events` stream — could not ride it anyway. Half a
111
- // control plane, for nobody, on a third transport.
112
- // Every config key that cannot mean anything on this host says so. Silence here is how an operator concludes a
113
- // setting took effect — `sessionControl` was the one that already warned, and the other two were just as inert.
104
+ // Every config key that cannot mean anything on this host says so: silence is how an operator concludes a setting
105
+ // took effect.
114
106
  if (opened.http?.cors) {
115
107
  log.warn("[fastagent] agentcore: http.cors has no effect here — no browser reaches this container. Its ingress is the " +
116
108
  "forwarder's Function URL (webhooks) and the Runtime's IAM-gated API, neither of which is a page.");
@@ -66,8 +66,10 @@ export function agentcoreRoutes(options) {
66
66
  // The forwarder, or another IAM principal? See `fromForwarder` for what that actually decides.
67
67
  const trusted = fromForwarder(envelope, ingressSecret);
68
68
  if (!trusted) {
69
- // The IAM door's kinds: AWS authenticated this caller, so no ingress secret is expected or wanted.
70
- if (envelope.kind !== "invoke" && envelope.kind !== "routine-run") {
69
+ // The IAM door's kinds: AWS authenticated this caller, so no ingress secret is expected or wanted. A `probe`
70
+ // here only opens the workspace (the deferred wrapper did that before this line) and says so: it is how
71
+ // `login --deployment` gets a workspace to log in, after a reset, on a runtime nothing has invoked yet.
72
+ if (envelope.kind !== "invoke" && envelope.kind !== "routine-run" && envelope.kind !== "probe") {
71
73
  log.warn(`[agentcore] rejected an unauthenticated "${envelope.kind}" envelope`);
72
74
  return text("forbidden\n", 403);
73
75
  }
@@ -179,7 +181,8 @@ export function agentcoreRoutes(options) {
179
181
  return json({ ok: true }, 200);
180
182
  }
181
183
  case "probe": {
182
- // The structured verdict (transport-200 — see the envelope doc).
184
+ // The structured verdict (transport-200 — see the envelope doc). From the IAM door (untrusted) nothing was
185
+ // activated, so `ok` says only that the definition opened; channel construction is the forwarder probe's.
183
186
  return json(constructionError === undefined
184
187
  ? { ok: true }
185
188
  : { ok: false, error: `channel construction failed: ${constructionError}` }, 200);
@@ -9,6 +9,11 @@ import { text } from "./respond.js";
9
9
  export const CONTROL_PREFIX = "/control";
10
10
  /** The one variable segment in this plane's paths: a percent-encoded session id. */
11
11
  const SESSION_SEGMENT = "{session}";
12
+ /**
13
+ * What `.../image` may answer under the image's own type. `mimeType` is whatever the prompt's sender wrote, and served
14
+ * as-is a `text/html` or `image/svg+xml` "image" would run script on whatever origin fronts this plane.
15
+ */
16
+ const RASTER_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
12
17
  const json = (value, status = 200) => new Response(`${JSON.stringify(value)}\n`, { status, headers: { "content-type": "application/json" } });
13
18
  /** The plane as one mounted sub-application rather than routes sharing a prefix. */
14
19
  function planeApp(routes) {
@@ -267,6 +272,24 @@ export function controlPlaneRoutes(control) {
267
272
  const since = url.searchParams.get("since") ?? undefined;
268
273
  return json(await control.sessions.get(session).entries(since !== undefined ? { since } : undefined));
269
274
  },
275
+ // Raw bytes, not JSON-wrapped base64: a client can hand the response straight to an <img>. The ref names a
276
+ // durable entry, so the answer never changes. `ref` rides the query because this router has one path variable.
277
+ [`GET /control/sessions/${SESSION_SEGMENT}/image`]: async (_req, url, session) => {
278
+ const ref = url.searchParams.get("ref");
279
+ if (ref === null)
280
+ return text("expected ?ref=, an images[].ref from entries()\n", 400);
281
+ const image = await control.sessions.get(session).image(ref);
282
+ // The local call RETURNS undefined, so this is a 2xx (§13); a 404 stays "this serve predates the route".
283
+ if (!image)
284
+ return new Response(null, { status: 204 });
285
+ return new Response(Buffer.from(image.data, "base64"), {
286
+ headers: {
287
+ "content-type": RASTER_IMAGE_TYPES.has(image.mimeType) ? image.mimeType : "application/octet-stream",
288
+ "x-content-type-options": "nosniff",
289
+ "cache-control": "private, max-age=31536000, immutable",
290
+ },
291
+ });
292
+ },
270
293
  // The run actions.
271
294
  [`POST /control/sessions/${SESSION_SEGMENT}/actions`]: async (req, _url, session) => {
272
295
  const read = await readJson(req);
@@ -21,8 +21,8 @@ export declare class SlackConfigApiError extends Error {
21
21
  export declare function isSlackRequestUrlUnverified(error: unknown): boolean;
22
22
  /** Whether a manifest write was refused because the app has token rotation on, which Slack never lets off again. */
23
23
  export declare function isSlackRotationLocked(error: unknown): boolean;
24
- /** The upgrade every rotating-app refusal points at. */
25
- export declare const SLACK_ROTATING_APP_UPGRADE = "this app has token rotation on, which this release no longer uses and Slack cannot turn off \u2014 create a new app (docs/slack.md \u2192 \"Upgrading from a rotating-token app\")";
24
+ /** The remedy every rotating-app refusal points at. */
25
+ export declare const SLACK_ROTATING_APP_REMEDY = "this app has token rotation on, which FastAgent does not support and Slack cannot turn off \u2014 create a new app (docs/slack.md \u2192 \"If token rotation is on\")";
26
26
  export interface SlackAppCredentials {
27
27
  appId: string;
28
28
  clientId: string;
@@ -31,8 +31,8 @@ export function isSlackRotationLocked(error) {
31
31
  return (error.code === "cannot_disable_once_enabled" ||
32
32
  error.errors.some((e) => e.message?.includes("cannot_disable_once_enabled") || e.pointer?.endsWith("token_rotation_enabled")));
33
33
  }
34
- /** The upgrade every rotating-app refusal points at. */
35
- export const SLACK_ROTATING_APP_UPGRADE = 'this app has token rotation on, which this release no longer uses and Slack cannot turn off — create a new app (docs/slack.md → "Upgrading from a rotating-token app")';
34
+ /** The remedy every rotating-app refusal points at. */
35
+ export const SLACK_ROTATING_APP_REMEDY = 'this app has token rotation on, which FastAgent does not support and Slack cannot turn off — create a new app (docs/slack.md → "If token rotation is on")';
36
36
  async function slackJson(method, body, options = {}) {
37
37
  const fetchFn = options.fetch ?? globalThis.fetch;
38
38
  let response;
@@ -1,6 +1,6 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { retryWhile } from "../registration.js";
3
- import { createSlackApp, exchangeSlackOAuthCode, isSlackRequestUrlUnverified, isSlackRotationLocked, SLACK_ROTATING_APP_UPGRADE, SlackConfigApiError, updateSlackAppManifest, } from "./config-api.js";
3
+ import { createSlackApp, exchangeSlackOAuthCode, isSlackRequestUrlUnverified, isSlackRotationLocked, SLACK_ROTATING_APP_REMEDY, SlackConfigApiError, updateSlackAppManifest, } from "./config-api.js";
4
4
  import { buildSlackManifest, slackBotScopes } from "./manifest.js";
5
5
  import { configTokenExpiry, currentSlackConfigToken, writeSlackOnboardingState, } from "./onboarding-state.js";
6
6
  /** Create/resume one internal Slack app and complete its workspace OAuth installation. */
@@ -76,7 +76,7 @@ export async function onboardSlackApp(input, io, deps = {}) {
76
76
  catch (error) {
77
77
  // A resume of an app created with rotation on: the manifest is refused for good, so say what fixes it.
78
78
  if (isSlackRotationLocked(error))
79
- throw new Error(SLACK_ROTATING_APP_UPGRADE, { cause: error });
79
+ throw new Error(SLACK_ROTATING_APP_REMEDY, { cause: error });
80
80
  throw error;
81
81
  }
82
82
  }
@@ -1,5 +1,5 @@
1
1
  import { retryWhile } from "../registration.js";
2
- import { SLACK_ROTATING_APP_UPGRADE, isSlackRequestUrlUnverified, isSlackRotationLocked, updateSlackAppManifest, } from "./config-api.js";
2
+ import { SLACK_ROTATING_APP_REMEDY, isSlackRequestUrlUnverified, isSlackRotationLocked, updateSlackAppManifest, } from "./config-api.js";
3
3
  import { buildSlackManifest } from "./manifest.js";
4
4
  import { currentSlackConfigToken, readSlackOnboardingState } from "./onboarding-state.js";
5
5
  /** Update one onboarded internal Slack app without sending its configuration credential to the host. */
@@ -53,7 +53,7 @@ export async function registerSlackWebhook(baseUrl, options) {
53
53
  }
54
54
  catch (error) {
55
55
  if (isSlackRotationLocked(error)) {
56
- note(`[fastagent] slack: ${SLACK_ROTATING_APP_UPGRADE}, or ${consoleFallback}`);
56
+ note(`[fastagent] slack: ${SLACK_ROTATING_APP_REMEDY}, or ${consoleFallback}`);
57
57
  return "failed";
58
58
  }
59
59
  note(isSlackRequestUrlUnverified(error)
@@ -135,12 +135,12 @@ async function readBytesCapped(response) {
135
135
  }
136
136
  return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total);
137
137
  }
138
- /** A rotating token (an app created by a release up to 0.20) would work for up to 12 hours and then
139
- * fail as `token_expired`, far from the cause — refuse it where the token enters, on both paths. */
138
+ /** A rotating token (an app with token rotation on) would work for up to 12 hours and then fail as
139
+ * `token_expired`, far from the cause — refuse it where the token enters, on both paths. */
140
140
  function assertLongLivedBotToken(botToken) {
141
141
  if (botToken.startsWith("xoxe.")) {
142
- throw new Error("got a rotating Slack bot token (xoxe.…); this release uses a long-lived xoxb- token — " +
143
- 'create a new app (docs/slack.md → "Upgrading from a rotating-token app")');
142
+ throw new Error("got a rotating Slack bot token (xoxe.…); FastAgent needs a long-lived xoxb- token — " +
143
+ 'create a new app (docs/slack.md → "If token rotation is on")');
144
144
  }
145
145
  }
146
146
  export function createSlackApi({ botToken, baseUrl = "https://slack.com/api" }) {
@@ -251,8 +251,10 @@ async function createFeishuAppFlow(target, existing, ingress, groupBehavior) {
251
251
  const cfg = await api.getAppConfig(appId);
252
252
  token = cfg.verificationToken;
253
253
  }
254
- catch {
255
- // the read surface is best-effort — the bootstrap below is the real path
254
+ catch (error) {
255
+ // Best-effort read; the bootstrap below is the real path. Said, because that path can take minutes and the reason
256
+ // it was needed would otherwise be invisible.
257
+ console.error(`[fastagent] could not read the app's Verification Token directly: ${error.message}`);
256
258
  }
257
259
  if (!token) {
258
260
  console.error(`[fastagent] capturing the Verification Token — a throwaway webhook registration delivers it (spinning up a temporary tunnel; can take a few minutes on a slow edge)…`);
@@ -1,10 +1,19 @@
1
1
  /** CLI presenter for the auth-status line `reportAuth` prints (invoke/dev/start). */
2
- /** A stored credential as `reportAuth` needs it: just its kind, for the status line. */
3
- export interface StoredCredentialInfo {
4
- type: string;
5
- }
6
- /** Format the auth status for `spec`'s provider. */
7
- export declare function formatAuthReport(provider: string, authPath: string, source: string | undefined, stored: StoredCredentialInfo | undefined): {
2
+ /**
3
+ * Format the auth status for a provider, from `AgentModels.authStatus`'s answer. `deployed`: this process is a
4
+ * deployed box, which logs in from the owner's workspace (`fastagent login --deployment`), never by a login run on it
5
+ * by hand.
6
+ */
7
+ export declare function formatAuthReport(status: {
8
+ provider: string;
9
+ path: string;
10
+ source?: string;
11
+ /** Why resolving the credential failed, when it did: said, because "expired" is only one of the reasons. */
12
+ error?: string;
13
+ stored?: string;
14
+ shadowed?: string;
15
+ deployed?: boolean;
16
+ }): {
8
17
  line: string;
9
18
  warn?: string;
10
19
  };
@@ -1,16 +1,32 @@
1
1
  /** CLI presenter for the auth-status line `reportAuth` prints (invoke/dev/start). */
2
- /** Format the auth status for `spec`'s provider. */
3
- export function formatAuthReport(provider, authPath, source, stored) {
4
- if (source !== undefined)
5
- return { line: `auth: ${source} (${provider}) — ${authPath}` };
2
+ /**
3
+ * Format the auth status for a provider, from `AgentModels.authStatus`'s answer. `deployed`: this process is a
4
+ * deployed box, which logs in from the owner's workspace (`fastagent login --deployment`), never by a login run on it
5
+ * by hand.
6
+ */
7
+ export function formatAuthReport(status) {
8
+ const { provider, path, source, error, stored, shadowed, deployed = false } = status;
9
+ const because = error === undefined ? "" : ` (${error})`;
10
+ const login = (name) => deployed ? `\`${name} --deployment\` from the workspace this was deployed from` : `\`${name}\``;
11
+ if (source !== undefined) {
12
+ const line = `auth: ${source} (${provider}) — ${path}`;
13
+ if (shadowed === undefined)
14
+ return { line };
15
+ // A stored credential owns its provider in pi, so a key added to the environment later is silently unused.
16
+ return {
17
+ line,
18
+ warn: `${shadowed} is set but unused: the stored ${provider} ${stored} credential in ${path} outranks it. To run ` +
19
+ `on ${shadowed}, log in with it instead: ${login(`fastagent login ${provider}`)}, choosing "API key"`,
20
+ };
21
+ }
6
22
  if (stored) {
7
23
  return {
8
- line: `auth: stored ${provider} ${stored.type}, expired/unusable — ${authPath}`,
9
- warn: `the "${provider}" login is expired or unusable — run \`fastagent login\` to refresh it`,
24
+ line: `auth: stored ${provider} ${stored}, expired/unusable — ${path}`,
25
+ warn: `the "${provider}" login is expired or unusable${because} — run ${login("fastagent login")} to replace it`,
10
26
  };
11
27
  }
12
28
  return {
13
- line: `auth: (none found) — ${authPath}`,
14
- warn: `no credentials for "${provider}" — run \`fastagent login\`, or set the provider's API key in .env; invokes will fail until then`,
29
+ line: `auth: (none found) — ${path}`,
30
+ warn: `no credentials for "${provider}"${because} — run ${login("fastagent login")}, or set the provider's API key in ${deployed ? "the deployment's value file" : ".env"}; invokes will fail until then`,
15
31
  };
16
32
  }
@@ -0,0 +1,33 @@
1
+ import { type BoxShell } from "../deploy/box-shell.ts";
2
+ import type { DeployHost } from "../deploy/hosts.ts";
3
+ import type { LoginIO } from "../engines/pi/login.ts";
4
+ import type { ResolvedPlacement } from "../paths.ts";
5
+ export interface BoxLoginRequest {
6
+ host: DeployHost;
7
+ shell: BoxShell;
8
+ placement: ResolvedPlacement;
9
+ /** Skip the provider menu. */
10
+ provider?: string;
11
+ /** Keep a credential the box already holds for `provider` (a redeploy), instead of replacing it. */
12
+ ifMissing?: boolean;
13
+ /** false: never ask; a box without the credential is a failure naming the command to run in a terminal. */
14
+ input: boolean;
15
+ }
16
+ /**
17
+ * Run the login on the box and report its outcome. Resolves `undefined` once the box reports a credential for the
18
+ * provider (logged in now, or kept), else with the one line saying why not and what to run.
19
+ */
20
+ export declare function loginOnBox(request: BoxLoginRequest): Promise<string | undefined>;
21
+ /**
22
+ * A browser OAuth flow sends the owner's browser back to its `redirect_uri`: a localhost address, which on the owner's
23
+ * machine has nothing of the box behind it. So catch it here. While an authorization URL naming such a redirect is
24
+ * open, listen on that address (the port the provider fixed, loopback only, as pi's own flows do), and answer the
25
+ * next text prompt with the URL the browser brought back, exactly what the person would paste. The terminal prompt
26
+ * stays up meanwhile: a taken port, or a browser on another machine, still leaves pasting.
27
+ *
28
+ * ponytail: "the next text prompt" is the one after the URL. pi's browser flows (Anthropic, Codex) ask exactly one,
29
+ * the paste; a flow that asked something else in between would get the URL as that answer and reject it.
30
+ */
31
+ export declare function catchingRedirect(io: LoginIO): LoginIO & {
32
+ close(): void;
33
+ };
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Logging a deployment in on the deployment itself: `fastagent login --stdio` runs on the box through the host's own
3
+ * owner-authenticated shell (deploy/box-shell.ts), and this terminal renders it (login-relay.ts). The box creates and
4
+ * keeps the credential, so it is the only holder of its grant, and logging in again replaces it there. Nothing is
5
+ * copied from this machine.
6
+ */
7
+ import { createServer } from "node:http";
8
+ import { basename } from "node:path";
9
+ import { deploymentLoginCommand } from "../deploy/box-shell.js";
10
+ import { boxLoginCommand } from "../deploy/container.js";
11
+ import { relayLogin } from "./login-relay.js";
12
+ import { terminalLoginIO } from "./shared.js";
13
+ /**
14
+ * Run the login on the box and report its outcome. Resolves `undefined` once the box reports a credential for the
15
+ * provider (logged in now, or kept), else with the one line saying why not and what to run.
16
+ */
17
+ export async function loginOnBox(request) {
18
+ const { host, shell, placement, provider } = request;
19
+ const retry = deploymentLoginCommand(host, provider);
20
+ const args = [
21
+ ...(provider ? [provider] : []),
22
+ ...(request.ifMissing ? ["--if-missing"] : []),
23
+ ...(request.input ? [] : ["--no-input"]),
24
+ ];
25
+ let command;
26
+ try {
27
+ command = boxLoginCommand(basename(placement.agentDir), args, shell.storage);
28
+ }
29
+ catch (error) {
30
+ return error.message;
31
+ }
32
+ // A wake that fails is this login's answer, in one line, like an `open` that fails below.
33
+ const woken = await shell.wake?.().catch((error) => error);
34
+ if (woken instanceof Error)
35
+ return woken.message;
36
+ const channel = await shell.open(command).catch((error) => error);
37
+ if (channel instanceof Error)
38
+ return channel.message;
39
+ const io = catchingRedirect(terminalLoginIO());
40
+ const result = await relayLogin(channel.output, channel.input, io, (line) => console.error(line)).finally(io.close);
41
+ const closed = await channel.closed;
42
+ if (!closed.opened)
43
+ return closed.error;
44
+ if (!result) {
45
+ return (`the ${host} shell ended without a login result (${closed.how}) — nothing on the box says it is logged in. ` +
46
+ `See the output above, then run \`${retry}\``);
47
+ }
48
+ if (result.ok) {
49
+ console.error("kept" in result
50
+ ? `[fastagent] the deployment already authenticates ${result.provider} (${result.kept}) — kept`
51
+ : `[fastagent] logged in to ${result.provider} (${result.method}) on the deployment — saved to ${result.path} there`);
52
+ return undefined;
53
+ }
54
+ if (result.reason === "missing")
55
+ return `not logged in: ${result.message} — run \`${retry}\` in a terminal`;
56
+ if (result.reason === "cancelled")
57
+ return `login on the deployment cancelled — run \`${retry}\` to log it in`;
58
+ return `login on the deployment failed: ${result.message}`;
59
+ }
60
+ const CAUGHT_PAGE = "<!doctype html><meta charset=utf-8><title>fastagent</title>" +
61
+ "<p>fastagent received the sign-in and sent it to your deployment. Return to your terminal.</p>";
62
+ /**
63
+ * A browser OAuth flow sends the owner's browser back to its `redirect_uri`: a localhost address, which on the owner's
64
+ * machine has nothing of the box behind it. So catch it here. While an authorization URL naming such a redirect is
65
+ * open, listen on that address (the port the provider fixed, loopback only, as pi's own flows do), and answer the
66
+ * next text prompt with the URL the browser brought back, exactly what the person would paste. The terminal prompt
67
+ * stays up meanwhile: a taken port, or a browser on another machine, still leaves pasting.
68
+ *
69
+ * ponytail: "the next text prompt" is the one after the URL. pi's browser flows (Anthropic, Codex) ask exactly one,
70
+ * the paste; a flow that asked something else in between would get the URL as that answer and reject it.
71
+ */
72
+ export function catchingRedirect(io) {
73
+ let server;
74
+ let caught;
75
+ let deliver;
76
+ const close = () => {
77
+ server?.close();
78
+ server = undefined;
79
+ };
80
+ const listen = (authUrl) => {
81
+ let redirect;
82
+ let state;
83
+ try {
84
+ const authorize = new URL(authUrl);
85
+ redirect = new URL(authorize.searchParams.get("redirect_uri") ?? "");
86
+ state = authorize.searchParams.get("state");
87
+ }
88
+ catch {
89
+ return; // no redirect to catch (a device-code page, or not a URL we can read)
90
+ }
91
+ if (redirect.protocol !== "http:" || !["localhost", "127.0.0.1"].includes(redirect.hostname))
92
+ return;
93
+ // The flow's `state` is what tells its redirect from anything else that can reach a loopback port: any page open
94
+ // in the browser can request it, and a forged hit taken as the answer would end this login in "State mismatch" and
95
+ // close the port before the real one arrives. Without one there is nothing to tell them apart: leave it to pasting.
96
+ if (!state)
97
+ return;
98
+ close();
99
+ const listening = createServer((req, res) => {
100
+ const got = new URL(req.url ?? "/", redirect);
101
+ if (got.pathname !== redirect.pathname ||
102
+ !got.searchParams.has("code") ||
103
+ got.searchParams.get("state") !== state) {
104
+ res.writeHead(404).end();
105
+ return;
106
+ }
107
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(CAUGHT_PAGE);
108
+ close();
109
+ if (deliver)
110
+ deliver(got.toString());
111
+ else
112
+ caught = got.toString();
113
+ });
114
+ listening.on("error", (error) => {
115
+ if (server === listening)
116
+ server = undefined;
117
+ io.note(`could not listen on ${redirect.host} for the browser's redirect (${error.code ?? error.message}) — after ` +
118
+ `signing in, paste the address the browser lands on`);
119
+ });
120
+ listening.listen(Number(redirect.port || 80), "127.0.0.1");
121
+ listening.unref();
122
+ server = listening;
123
+ };
124
+ return {
125
+ select: io.select,
126
+ note: io.note,
127
+ close,
128
+ openUrl(url) {
129
+ listen(url);
130
+ io.openUrl(url);
131
+ },
132
+ async prompt(message, opts) {
133
+ if (caught !== undefined) {
134
+ const url = caught;
135
+ caught = undefined;
136
+ return url;
137
+ }
138
+ if (!server)
139
+ return io.prompt(message, opts);
140
+ const typed = new AbortController();
141
+ const signal = opts?.signal ? AbortSignal.any([opts.signal, typed.signal]) : typed.signal;
142
+ const fromBrowser = new Promise((resolve) => {
143
+ deliver = resolve;
144
+ });
145
+ try {
146
+ // Settle the race BEFORE aborting the terminal prompt: aborting it settles its promise too.
147
+ const won = await Promise.race([
148
+ io.prompt(message, { ...opts, signal }).then((value) => ({ typed: value })),
149
+ fromBrowser.then((url) => ({ url })),
150
+ ]);
151
+ if ("typed" in won)
152
+ return won.typed;
153
+ typed.abort();
154
+ io.note("received the browser's redirect");
155
+ return won.url;
156
+ }
157
+ finally {
158
+ deliver = undefined;
159
+ }
160
+ },
161
+ };
162
+ }
@@ -5,22 +5,47 @@
5
5
  import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
6
6
  import { tmpdir } from "node:os";
7
7
  import { basename, join } from "node:path";
8
- import { FORWARDER_FILE, TEMPLATE_FILE, agentcoreName, forwarderLogGroup, ingressSessionId, isGeneratedAgentcoreTemplate, planAgentcoreDeploy, } from "../../../deploy/agentcore/plan.js";
9
- import { deployAgentcoreRun } from "../../../deploy/agentcore/run.js";
10
- import { spawnRunner } from "../../../deploy/runner.js";
8
+ import { FORWARDER_FILE, TEMPLATE_FILE, agentcoreName, agentcoreStackName, forwarderLogGroup, ingressSessionId, isGeneratedAgentcoreTemplate, planAgentcoreDeploy, } from "../../../deploy/agentcore/plan.js";
9
+ import { deployAgentcoreRun, pickStackOutputs } from "../../../deploy/agentcore/run.js";
10
+ import { awsCli, awsJson } from "../../../deploy/agentcore/aws-cli.js";
11
+ import { agentcoreShell } from "../../../deploy/agentcore/shell.js";
12
+ import { awsRunner, spawnRunner } from "../../../deploy/runner.js";
11
13
  import { SECRET_FILE_MODE, exists } from "../../../paths.js";
12
14
  import { loadRoutines } from "../../../schedule/discover.js";
15
+ import { assembleSecrets } from "../../../deploy/secrets.js";
13
16
  import { failStartup } from "../../fail.js";
14
- import { carryCredentials, gateOnModelCredential, registrarsFor } from "./shared.js";
17
+ import { isInteractive } from "../../shared.js";
18
+ import { boxLoginStep, registrarsFor } from "./shared.js";
15
19
  /** A copy/paste-safe POSIX shell argument for the command hints deploy prints. */
16
20
  function shellArg(value) {
17
21
  return /^[A-Za-z0-9_./:@%+=,-]+$/.test(value) ? value : `'${value.replaceAll("'", `'"'"'`)}'`;
18
22
  }
19
23
  export const agentcoreHost = {
20
24
  isOurs: (path, content) => path.endsWith(TEMPLATE_FILE) && isGeneratedAgentcoreTemplate(content),
25
+ artifact: TEMPLATE_FILE,
26
+ async shell({ workspace }) {
27
+ const name = agentcoreName(basename(workspace));
28
+ const stack = agentcoreStackName(name);
29
+ const aws = awsRunner(workspace);
30
+ const read = await awsCli(aws).read(["cloudformation", "describe-stacks", "--stack-name", stack, "--query", "Stacks[0].Outputs", "--output", "json"], awsJson(pickStackOutputs));
31
+ if ("absent" in read)
32
+ throw new Error(`no AgentCore stack ${stack} in this account/region — deploy it first`);
33
+ if ("unreadable" in read)
34
+ throw new Error(`could not read AgentCore stack ${stack}: ${read.unreadable}`);
35
+ const runtimeArn = read.ok.RuntimeArn;
36
+ if (!runtimeArn)
37
+ throw new Error(`stack ${stack} has no RuntimeArn output — redeploy it`);
38
+ return agentcoreShell(runtimeArn, ingressSessionId(name), aws);
39
+ },
21
40
  async deploy(ctx) {
22
41
  const { opts, agentDir, workspace, config, channels, longConnectionChannels, pre, write } = ctx;
23
- const { modelAuth, modelKeyInDefinition, authPath, container, declaredSecrets, values, valueFile } = pre;
42
+ const { modelAuth, boxLogin, container, declaredSecrets, values, valueFile } = pre;
43
+ if (boxLogin) {
44
+ console.error(`[fastagent] note: AgentCore resets the runtime's storage on every deploy AND after 14 idle days, and the ` +
45
+ `${boxLogin} login with it: every deploy of this agent ends with a login on the runtime, and after an idle ` +
46
+ `reset every turn fails until \`fastagent login ${boxLogin} --deployment agentcore\` (the runtime's log says ` +
47
+ `so). For an agent that must keep answering unattended, set ${boxLogin}'s API key in ${valueFile}.`);
48
+ }
24
49
  // Long-connection channels are STRUCTURALLY unsupported: the connection is the ingress, and a reclaimed session
25
50
  // has nothing to wake it.
26
51
  if (longConnectionChannels.length > 0) {
@@ -54,7 +79,7 @@ export const agentcoreHost = {
54
79
  }
55
80
  const plan = planAgentcoreDeploy({
56
81
  name: acName,
57
- modelAuth,
82
+ boxLogin,
58
83
  channels,
59
84
  secrets: pre.secrets,
60
85
  // ONLY THE ONES WITH A CRON become rules. A routine without one is reached by NAME, through this host's
@@ -98,8 +123,8 @@ export const agentcoreHost = {
98
123
  agentPrefix: container.agentPrefix,
99
124
  name: acName,
100
125
  modelAuth,
101
- modelKeyInDefinition,
102
- authPath,
126
+ boxLogin,
127
+ input: opts.input !== false && isInteractive(),
103
128
  channels,
104
129
  declaredSecrets,
105
130
  values,
@@ -114,12 +139,23 @@ export const agentcoreHost = {
114
139
  /** `deploy agentcore --run`: drive aws + docker to completion. */
115
140
  async function runDeployAgentcore(params) {
116
141
  const { agentDir, workspace, agentPrefix, name, channels, topology } = params;
117
- const { secrets, missingSecrets, needsModelCredential } = await carryCredentials(params);
142
+ // Decided before the first side effect: the deploy resets the runtime's storage and the login with it, so with nobody
143
+ // to log it in again it would end at "not logged in" AFTER replacing a runtime that was serving, with the webhooks
144
+ // a previous deploy registered still pointing at it.
145
+ if (params.boxLogin && !params.input) {
146
+ failStartup(new Error(`deploy stopped: every AgentCore deploy wipes the runtime's ${params.boxLogin} login, and without a terminal ` +
147
+ `nobody can log it in again — run this deploy in a terminal, or set ${params.boxLogin}'s API key in ` +
148
+ `${params.valueFile}`));
149
+ }
150
+ const { secrets, missingSecrets } = assembleSecrets({
151
+ modelAuth: params.modelAuth,
152
+ declared: params.declaredSecrets,
153
+ values: params.values,
154
+ });
118
155
  // The wake-alarm shared secret (container ↔ forwarder).
119
156
  secrets.FASTAGENT_WAKE_SECRET = crypto.randomUUID();
120
157
  // The forwarder→runtime ingress secret: what makes an envelope the forwarder's rather than any IAM principal's.
121
158
  secrets.FASTAGENT_INGRESS_SECRET = crypto.randomUUID();
122
- gateOnModelCredential(needsModelCredential);
123
159
  // The params temp dir holds the ONE file carrying secret values (file:// parameter-overrides — never argv);
124
160
  // 0700/0600 and removed after the run, success or gate.
125
161
  const paramsDir = await mkdtemp(join(tmpdir(), "fastagent-agentcore-"));
@@ -138,7 +174,8 @@ async function runDeployAgentcore(params) {
138
174
  valueFile: params.valueFile,
139
175
  channels,
140
176
  topology,
141
- }, spawnRunner("aws", workspace), spawnRunner("docker", workspace), (m) => console.error(`[fastagent] ${m}`), async (content) => {
177
+ ...boxLoginStep("agentcore", params, (runtimeArn) => agentcoreShell(runtimeArn, ingressSessionId(name), awsRunner(workspace))),
178
+ }, awsRunner(workspace), spawnRunner("docker", workspace), (m) => console.error(`[fastagent] ${m}`), async (content) => {
142
179
  const path = join(paramsDir, "params.json");
143
180
  await writeFile(path, content, { mode: SECRET_FILE_MODE });
144
181
  return path;