@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
package/dist/agent.d.ts CHANGED
@@ -47,6 +47,16 @@ export type AgentEvent = {
47
47
  isError: boolean;
48
48
  content: Json;
49
49
  }
50
+ /**
51
+ * Advisory, non-terminal (engines MAY emit it): the running tool `id`'s current status, one line, replacing the last.
52
+ * It may be a line of the tool's output so far (trust it like `tool_ended.content`), never the whole output;
53
+ * untruncated, so a consumer that shows it clips it.
54
+ */
55
+ | {
56
+ type: "tool_progress";
57
+ id: string;
58
+ text: string;
59
+ }
50
60
  /**
51
61
  * Advisory, non-terminal (engines MAY emit it): a transient internal failure scheduled a retry with backoff — the
52
62
  * turn is still alive.
@@ -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);
@@ -23,6 +23,8 @@ export declare const THINKING_PLACEHOLDER = "\uD83D\uDCAD Thinking\u2026";
23
23
  interface ToolLine {
24
24
  label: string;
25
25
  status: "running" | "ok" | "error";
26
+ /** The call's latest `tool_progress` text, shown while it runs. */
27
+ progress?: string;
26
28
  }
27
29
  /** The channel-neutral view STATE of one in-flight turn. */
28
30
  export interface TurnView {
@@ -38,7 +40,10 @@ export interface TurnView {
38
40
  }
39
41
  export declare function createTurnView(): TurnView;
40
42
  export declare function applyTurnEvent(view: TurnView, e: AgentEvent, now?: number): boolean;
41
- /** The tool-activity block: one `🔧 label …/✓/✗` line per call, in call order. */
43
+ /**
44
+ * The tool-activity block: one `🔧 label …/✓/✗` line per call, in call order. A running call shows its latest
45
+ * progress after the mark (`🔧 Bash npm test … › 42 passed`); a finished one drops it.
46
+ */
42
47
  export declare function toolLines(view: TurnView): string;
43
48
  /** The reasoning peek: the most recent tail of the (growing) reasoning, one line, code-point safe. */
44
49
  export declare function thinkingLine(view: TurnView, maxTail: number): string;
@@ -36,6 +36,13 @@ export function applyTurnEvent(view, e, now = Date.now()) {
36
36
  view.toolById.set(e.id, line);
37
37
  return true;
38
38
  }
39
+ case "tool_progress": {
40
+ const line = view.toolById.get(e.id);
41
+ if (line?.status !== "running")
42
+ return closedRetry;
43
+ line.progress = e.text;
44
+ return true;
45
+ }
39
46
  case "tool_ended": {
40
47
  const line = view.toolById.get(e.id);
41
48
  if (line)
@@ -50,9 +57,17 @@ export function applyTurnEvent(view, e, now = Date.now()) {
50
57
  }
51
58
  }
52
59
  const TOOL_MARK = { running: "…", ok: "✓", error: "✗" };
53
- /** The tool-activity block: one `🔧 label …/✓/✗` line per call, in call order. */
60
+ /**
61
+ * The tool-activity block: one `🔧 label …/✓/✗` line per call, in call order. A running call shows its latest
62
+ * progress after the mark (`🔧 Bash npm test … › 42 passed`); a finished one drops it.
63
+ */
54
64
  export function toolLines(view) {
55
- return view.tools.map((t) => `🔧 ${t.label} ${TOOL_MARK[t.status]}`).join("\n");
65
+ return view.tools
66
+ .map((t) => {
67
+ const progress = t.status === "running" && t.progress ? ` › ${clip(t.progress, TOOL_PROGRESS_MAX)}` : "";
68
+ return `🔧 ${t.label} ${TOOL_MARK[t.status]}${progress}`;
69
+ })
70
+ .join("\n");
56
71
  }
57
72
  /** The reasoning peek: the most recent tail of the (growing) reasoning, one line, code-point safe. */
58
73
  export function thinkingLine(view, maxTail) {
@@ -77,15 +92,17 @@ export function composeTurnBody(parts) {
77
92
  }
78
93
  /** Max length (code points) of a tool's arg preview. */
79
94
  const TOOL_ARG_MAX = 48;
95
+ /** Max length (code points) of a running tool's progress line: one status, not its output. */
96
+ const TOOL_PROGRESS_MAX = 64;
80
97
  /** Max length (code points) of a humanized tool label. */
81
98
  const TOOL_NAME_MAX = 80;
82
99
  /**
83
100
  * One-line, truncated at code-point boundaries: collapse whitespace so a multi-line command/arg stays on one line, and
84
101
  * never tear a surrogate pair mid-emoji.
85
102
  */
86
- function clip(s) {
103
+ function clip(s, max = TOOL_ARG_MAX) {
87
104
  const one = s.replace(/\s+/g, " ").trim();
88
- return truncateCodePointPrefix(one, TOOL_ARG_MAX);
105
+ return truncateCodePointPrefix(one, max);
89
106
  }
90
107
  /**
91
108
  * A compact, human-readable preview of a tool call's args so the live view reads `🔧 read AGENTS.md` rather than just
@@ -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
  }
@@ -119,7 +119,8 @@ function streamClassicSlackReply(events, api, target, formatError, initialPrevie
119
119
  let lastSent = "";
120
120
  const view = () => composeTurnBody([
121
121
  THINKING_PLACEHOLDER,
122
- toolLines(turn),
122
+ // Tool names, arguments and progress lines are untrusted text, like the answer.
123
+ sanitizeSlackMarkdown(toolLines(turn)),
123
124
  turn.retrying ? RETRY_NOTICE : "",
124
125
  sanitizeSlackMarkdown(revealedAnswer(turn, CLASSIC_UPDATE_INTERVAL_MS, now())),
125
126
  ]);
@@ -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
+ }