@fastagent-sh/fastagent 0.20.0 → 0.21.1

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 (155) hide show
  1. package/README.md +5 -1
  2. package/dist/channels/agentcore-protocol.d.ts +112 -0
  3. package/dist/channels/agentcore-protocol.js +22 -0
  4. package/dist/channels/agentcore-service.d.ts +0 -4
  5. package/dist/channels/agentcore-service.js +1 -6
  6. package/dist/channels/agentcore-state.d.ts +5 -11
  7. package/dist/channels/agentcore-state.js +4 -1
  8. package/dist/channels/agentcore.d.ts +1 -67
  9. package/dist/channels/agentcore.js +90 -89
  10. package/dist/channels/control.d.ts +1 -1
  11. package/dist/channels/control.js +13 -62
  12. package/dist/channels/discover.d.ts +0 -1
  13. package/dist/channels/discover.js +1 -3
  14. package/dist/channels/feishu/context-buffer.d.ts +6 -0
  15. package/dist/channels/feishu/context-buffer.js +0 -38
  16. package/dist/channels/feishu/crypto.d.ts +0 -2
  17. package/dist/channels/feishu/crypto.js +3 -8
  18. package/dist/channels/feishu/feishu-api.js +2 -4
  19. package/dist/channels/feishu/feishu.js +56 -115
  20. package/dist/channels/feishu/parse.d.ts +4 -3
  21. package/dist/channels/feishu/parse.js +5 -4
  22. package/dist/channels/feishu/register-webhook.d.ts +5 -5
  23. package/dist/channels/feishu/register-webhook.js +43 -57
  24. package/dist/channels/feishu/scaffold/feishu-send.ts +12 -67
  25. package/dist/channels/feishu/setup-mode.d.ts +30 -0
  26. package/dist/channels/feishu/setup-mode.js +26 -0
  27. package/dist/channels/feishu/shared-api.d.ts +10 -0
  28. package/dist/channels/feishu/shared-api.js +38 -0
  29. package/dist/channels/http.d.ts +0 -8
  30. package/dist/channels/http.js +4 -56
  31. package/dist/channels/kit/turn-runner.d.ts +59 -0
  32. package/dist/channels/kit/turn-runner.js +84 -0
  33. package/dist/channels/lark/scaffold/lark-send.ts +12 -67
  34. package/dist/channels/registration.d.ts +36 -1
  35. package/dist/channels/registration.js +57 -1
  36. package/dist/channels/secret.d.ts +1 -0
  37. package/dist/channels/secret.js +16 -0
  38. package/dist/channels/slack/config-api.d.ts +35 -3
  39. package/dist/channels/slack/config-api.js +51 -9
  40. package/dist/channels/slack/manifest.js +5 -1
  41. package/dist/channels/slack/onboard.d.ts +5 -5
  42. package/dist/channels/slack/onboard.js +41 -20
  43. package/dist/channels/slack/register-webhook.d.ts +3 -2
  44. package/dist/channels/slack/register-webhook.js +34 -14
  45. package/dist/channels/slack/scaffold/channel.ts +3 -10
  46. package/dist/channels/slack/scaffold/slack-send.ts +18 -126
  47. package/dist/channels/slack/shared-api.d.ts +10 -0
  48. package/dist/channels/slack/shared-api.js +34 -0
  49. package/dist/channels/slack/slack-api.d.ts +20 -2
  50. package/dist/channels/slack/slack-api.js +81 -22
  51. package/dist/channels/slack/slack.d.ts +0 -10
  52. package/dist/channels/slack/slack.js +60 -99
  53. package/dist/channels/sse.d.ts +4 -0
  54. package/dist/channels/sse.js +66 -0
  55. package/dist/channels/telegram/register-webhook.d.ts +6 -9
  56. package/dist/channels/telegram/register-webhook.js +44 -42
  57. package/dist/channels/telegram/telegram.js +47 -142
  58. package/dist/channels/wait-health.js +7 -4
  59. package/dist/cli/add-feishu.js +3 -10
  60. package/dist/cli/add-slack.js +7 -20
  61. package/dist/cli/commands/add.d.ts +0 -1
  62. package/dist/cli/commands/add.js +25 -37
  63. package/dist/cli/commands/chat.js +6 -12
  64. package/dist/cli/commands/deploy/agentcore.d.ts +2 -0
  65. package/dist/cli/commands/deploy/agentcore.js +178 -0
  66. package/dist/cli/commands/deploy/docker.d.ts +2 -0
  67. package/dist/cli/commands/deploy/docker.js +119 -0
  68. package/dist/cli/commands/deploy/fly.d.ts +2 -0
  69. package/dist/cli/commands/deploy/fly.js +131 -0
  70. package/dist/cli/commands/deploy/railway.d.ts +2 -0
  71. package/dist/cli/commands/deploy/railway.js +71 -0
  72. package/dist/cli/commands/deploy/shared.d.ts +114 -0
  73. package/dist/cli/commands/deploy/shared.js +124 -0
  74. package/dist/cli/commands/deploy.d.ts +17 -49
  75. package/dist/cli/commands/deploy.js +39 -618
  76. package/dist/cli/commands/dev.js +17 -46
  77. package/dist/cli/commands/fire.js +6 -12
  78. package/dist/cli/commands/info.js +2 -1
  79. package/dist/cli/commands/invoke.js +4 -11
  80. package/dist/cli/commands/schedule.js +1 -1
  81. package/dist/cli/commands/start.js +17 -46
  82. package/dist/cli/commands/tool.js +3 -8
  83. package/dist/cli/kernel.d.ts +0 -2
  84. package/dist/cli/kernel.js +0 -2
  85. package/dist/cli/program.js +7 -12
  86. package/dist/cli/serve.d.ts +36 -19
  87. package/dist/cli/serve.js +91 -7
  88. package/dist/cli/shared.d.ts +13 -18
  89. package/dist/cli/shared.js +20 -4
  90. package/dist/deploy/agentcore/forwarder.js +250 -0
  91. package/dist/deploy/agentcore/plan.d.ts +23 -2
  92. package/dist/deploy/agentcore/plan.js +29 -244
  93. package/dist/deploy/agentcore/run.d.ts +4 -20
  94. package/dist/deploy/agentcore/run.js +22 -7
  95. package/dist/deploy/channel-ingress.js +1 -1
  96. package/dist/deploy/docker/run.d.ts +17 -3
  97. package/dist/deploy/docker/run.js +30 -8
  98. package/dist/deploy/fly/plan.js +7 -0
  99. package/dist/deploy/fly/run.d.ts +27 -0
  100. package/dist/deploy/fly/run.js +102 -20
  101. package/dist/deploy/hosts.d.ts +5 -0
  102. package/dist/deploy/hosts.js +4 -0
  103. package/dist/deploy/preflight.js +3 -3
  104. package/dist/deploy/railway/plan.d.ts +5 -0
  105. package/dist/deploy/railway/plan.js +7 -0
  106. package/dist/deploy/railway/run.d.ts +2 -1
  107. package/dist/deploy/railway/run.js +5 -4
  108. package/dist/deploy/secrets.js +0 -17
  109. package/dist/engines/pi/agent-session-factory.d.ts +37 -17
  110. package/dist/engines/pi/agent-session-factory.js +109 -85
  111. package/dist/engines/pi/config.d.ts +1 -1
  112. package/dist/engines/pi/create.d.ts +22 -39
  113. package/dist/engines/pi/create.js +58 -73
  114. package/dist/engines/pi/definition.js +8 -8
  115. package/dist/engines/pi/invoke-session.js +13 -30
  116. package/dist/engines/pi/open.d.ts +2 -2
  117. package/dist/engines/pi/open.js +31 -41
  118. package/dist/engines/pi/retry-event.d.ts +6 -0
  119. package/dist/engines/pi/retry-event.js +15 -0
  120. package/dist/engines/pi/session-builder.js +26 -113
  121. package/dist/engines/pi/session-control.d.ts +10 -18
  122. package/dist/engines/pi/session-control.js +47 -81
  123. package/dist/engines/pi/session-settings.d.ts +1 -1
  124. package/dist/engines/pi/session-settings.js +1 -1
  125. package/dist/engines/pi/session-store.d.ts +12 -17
  126. package/dist/engines/pi/session-store.js +18 -40
  127. package/dist/engines/pi/tool-context.d.ts +3 -3
  128. package/dist/engines/pi/tool.d.ts +6 -9
  129. package/dist/engines/pi/tool.js +1 -0
  130. package/dist/feishu.d.ts +1 -0
  131. package/dist/feishu.js +1 -0
  132. package/dist/lark.d.ts +1 -0
  133. package/dist/lark.js +1 -0
  134. package/dist/loader.d.ts +2 -0
  135. package/dist/loader.js +5 -0
  136. package/dist/log.d.ts +9 -17
  137. package/dist/log.js +25 -30
  138. package/dist/paths.d.ts +7 -3
  139. package/dist/paths.js +17 -4
  140. package/dist/scaffold/add-channel.d.ts +6 -1
  141. package/dist/scaffold/add-channel.js +48 -65
  142. package/dist/schedule/wake-alarm.d.ts +1 -12
  143. package/dist/schedule/wake-alarm.js +2 -3
  144. package/dist/service.d.ts +23 -22
  145. package/dist/service.js +7 -73
  146. package/dist/session-remote.d.ts +5 -5
  147. package/dist/session-remote.js +25 -28
  148. package/dist/session.d.ts +1 -1
  149. package/dist/slack.d.ts +2 -0
  150. package/dist/slack.js +1 -0
  151. package/dist/tunnel.d.ts +16 -6
  152. package/dist/tunnel.js +53 -12
  153. package/package.json +5 -4
  154. package/dist/channels/slack/bot-auth.d.ts +0 -15
  155. package/dist/channels/slack/bot-auth.js +0 -135
@@ -1,58 +1,8 @@
1
1
  import { defineTool, z } from "@fastagent-sh/fastagent";
2
+ import { larkTransport } from "@fastagent-sh/fastagent/lark";
2
3
 
3
- // Send a message to a Lark chat. In a CHAT turn the channel delivers the reply itself — this
4
- // tool is for turns NO channel is carrying: a scheduled turn (schedules/<name>.ts) or a self-scheduled
5
- // wake-up, whose plain reply is not delivered anywhere. The chatId comes from the [lark: chat …]
6
- // context line in a chat turn; a scheduled turn has no such line, so the schedule's prompt must name
7
- // the target chat id. tools/ is auto-discovered.
8
-
9
- // Embedded copy of the channel transport's discipline: a timeout so a wedged connection can't hang
10
- // the tool call (and the turn), named errors, and success gated on the body's own code===0.
11
- // Deliberately NO rate-limit retry — a tool error goes back to the agent, which can decide to retry;
12
- // fail-fast beats a silently sleeping tool.
13
- const BASE = "https://open.larksuite.com";
14
-
15
- async function callApi(path: string, body: unknown, token?: string): Promise<Record<string, unknown>> {
16
- let res: Response;
17
- let raw: string;
18
- try {
19
- res = await fetch(`${BASE}${path}`, {
20
- method: "POST",
21
- headers: {
22
- "content-type": "application/json; charset=utf-8",
23
- ...(token ? { authorization: `Bearer ${token}` } : {}),
24
- },
25
- body: JSON.stringify(body),
26
- signal: AbortSignal.timeout(30_000),
27
- });
28
- raw = await res.text();
29
- } catch (e) {
30
- throw new Error(`lark ${path}: ${String(e)}`, { cause: e });
31
- }
32
- let data: { code?: number; msg?: string; [k: string]: unknown };
33
- try {
34
- data = JSON.parse(raw) as typeof data;
35
- } catch {
36
- data = {};
37
- }
38
- if (!res.ok || data.code !== 0) {
39
- throw new Error(`lark ${path} failed: ${res.status} ${data.msg ?? "response was not the expected JSON"}`);
40
- }
41
- return data;
42
- }
43
-
44
- async function tenantToken(): Promise<string> {
45
- const appId = process.env.LARK_APP_ID;
46
- const appSecret = process.env.LARK_APP_SECRET;
47
- if (!appId || !appSecret) throw new Error("LARK_APP_ID / LARK_APP_SECRET are not set");
48
- const data = await callApi("/open-apis/auth/v3/tenant_access_token/internal", {
49
- app_id: appId,
50
- app_secret: appSecret,
51
- });
52
- const token = data.tenant_access_token;
53
- if (typeof token !== "string") throw new Error("lark tenant_access_token: response carried no token");
54
- return token;
55
- }
4
+ // Proactive delivery uses the mounted channel's credentials, gateway and token cache. With no
5
+ // channel mounted (fire/invoke/tool), it reads LARK_APP_ID / LARK_APP_SECRET from the environment.
56
6
 
57
7
  export default defineTool({
58
8
  description:
@@ -70,23 +20,18 @@ export default defineTool({
70
20
  text: z.string().optional().describe("plain text message to send"),
71
21
  markdown: z.string().optional().describe("markdown to send as a card"),
72
22
  }),
73
- async execute({ chatId, text, markdown }) {
23
+ async execute({ chatId, text, markdown }, ctx) {
74
24
  if ((text === undefined) === (markdown === undefined)) {
75
25
  throw new Error("pass exactly one of `text` (plain) or `markdown` (a card)");
76
26
  }
77
- const token = await tenantToken();
78
- const msg =
79
- text !== undefined
80
- ? { msg_type: "text", content: JSON.stringify({ text }) }
81
- : {
82
- msg_type: "interactive",
83
- // An inline static card: one markdown element, no entity/streaming machinery needed here.
84
- content: JSON.stringify({
85
- schema: "2.0",
86
- body: { elements: [{ tag: "markdown", content: markdown }] },
87
- }),
88
- };
89
- await callApi(`/open-apis/im/v1/messages?receive_id_type=chat_id`, { receive_id: chatId, ...msg }, token);
27
+ const api = larkTransport(ctx.cwd);
28
+ if (text !== undefined) await api.sendText({ chatId }, text);
29
+ else {
30
+ await api.sendMessage(chatId, "interactive", JSON.stringify({
31
+ schema: "2.0",
32
+ body: { elements: [{ tag: "markdown", content: markdown }] },
33
+ }));
34
+ }
90
35
  return `sent ${text !== undefined ? "message" : "card"} to chat ${chatId}`;
91
36
  },
92
37
  });
@@ -10,6 +10,41 @@
10
10
  * unreachable) and a cloud without the config API (the Lark cloud-lag 404 — no re-run can ever
11
11
  * register it; the console is the only path).
12
12
  * - "failed": this run ends with the webhook NOT registered, and acting + re-running can fix it
13
- * (health timeout, a permanent config error, exhausted retries).
13
+ * (a permanent config error, or the platform still unable to reach the URL when the retries ran out).
14
14
  */
15
15
  export type RegistrationOutcome = "registered" | "manual" | "failed";
16
+ export declare const REGISTRATION_RETRY_MS = 10000;
17
+ /**
18
+ * What `deploy --run` spends instead: 180s, because a host CLI returns before the deployment serves —
19
+ * the same gap a tunnel has, on a scale no local signal reports (`docker compose --tunnel` is the one
20
+ * host whose ingress does report it, and its driver waits on it the way the tunnel does).
21
+ *
22
+ * `railway up --ci` returns when the BUILD ends — container start, healthcheck and a freshly minted
23
+ * domain's DNS all happen after that, and railway-deploy.live.test.ts allows 180s for exactly this.
24
+ * Registration failure GATES the deploy (registration-gate.ts), so a budget shorter than the host's
25
+ * own start-up turns a working deployment into a re-run instruction.
26
+ *
27
+ * N attempts buy (N - 1) waits — {@link retryWhile} only waits BETWEEN calls — so 180s of patience is
28
+ * 19, not 18. At 18 the last call went out at t=170s and a host that started answering in the final
29
+ * ten seconds was still reported as a deploy to re-run.
30
+ */
31
+ export declare const DEPLOY_REGISTRATION_ATTEMPTS = 19;
32
+ /**
33
+ * Call, and while `retryable` says the failure is the platform not reaching the URL YET, call again —
34
+ * the one retry loop every registrar spends {@link REGISTRATION_ATTEMPTS} through. Retryability and
35
+ * what a final failure MEANS stay with each platform (their vocabularies differ, and one registrar's
36
+ * terminal state is another's `"manual"`); the counting, the announcement and the wait do not.
37
+ *
38
+ * `onRetry` runs BEFORE the wait — registrars announce there (a silent minute reads as a hang), and
39
+ * `add slack` also drops its duplicate-guard marker so it never spans a sleep. The last error is
40
+ * thrown, so a caller can tell "still unreachable" from "a config error" in one place.
41
+ */
42
+ export declare function retryWhile<T>(call: () => Promise<T>, retryable: (error: unknown) => boolean, options?: {
43
+ attempts?: number;
44
+ retryMs?: number;
45
+ onRetry?: (info: {
46
+ attempt: number;
47
+ attempts: number;
48
+ error: unknown;
49
+ }) => void;
50
+ }): Promise<T>;
@@ -1 +1,57 @@
1
- export {};
1
+ /**
2
+ * SHARED: how long a registrar waits for the PLATFORM to be able to reach a freshly minted public URL.
3
+ * One judgement (a new tunnel's warm-up), so one pair of numbers — telegram, feishu, slack registration
4
+ * and `add slack` all spend it.
5
+ *
6
+ * Sized for a tunnel, which is live before its URL is HANDED OVER — not before it is printed, which is
7
+ * what this budget once had to absorb: a quick tunnel's hostname is published when the tunnel registers
8
+ * an edge connection, seconds after cloudflared prints it, and no budget covers a platform that already
9
+ * answered "cannot resolve" (#435). `startCloudflareTunnel` waits for that connection, so what is left
10
+ * here is the platform's own warm-up. Applied by {@link retryWhile} as its default; a DEPLOY is slower
11
+ * and its callers pass {@link DEPLOY_REGISTRATION_ATTEMPTS} instead.
12
+ */
13
+ const REGISTRATION_ATTEMPTS = 8;
14
+ export const REGISTRATION_RETRY_MS = 10_000;
15
+ /**
16
+ * What `deploy --run` spends instead: 180s, because a host CLI returns before the deployment serves —
17
+ * the same gap a tunnel has, on a scale no local signal reports (`docker compose --tunnel` is the one
18
+ * host whose ingress does report it, and its driver waits on it the way the tunnel does).
19
+ *
20
+ * `railway up --ci` returns when the BUILD ends — container start, healthcheck and a freshly minted
21
+ * domain's DNS all happen after that, and railway-deploy.live.test.ts allows 180s for exactly this.
22
+ * Registration failure GATES the deploy (registration-gate.ts), so a budget shorter than the host's
23
+ * own start-up turns a working deployment into a re-run instruction.
24
+ *
25
+ * N attempts buy (N - 1) waits — {@link retryWhile} only waits BETWEEN calls — so 180s of patience is
26
+ * 19, not 18. At 18 the last call went out at t=170s and a host that started answering in the final
27
+ * ten seconds was still reported as a deploy to re-run.
28
+ */
29
+ export const DEPLOY_REGISTRATION_ATTEMPTS = 19;
30
+ /** Sleep on the GLOBAL timer (not `node:timers/promises`) so tests can drive it with fake timers — the
31
+ * same reason feishu-api.ts does. What must happen BEFORE this wait is the point of `onRetry`. */
32
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
33
+ /**
34
+ * Call, and while `retryable` says the failure is the platform not reaching the URL YET, call again —
35
+ * the one retry loop every registrar spends {@link REGISTRATION_ATTEMPTS} through. Retryability and
36
+ * what a final failure MEANS stay with each platform (their vocabularies differ, and one registrar's
37
+ * terminal state is another's `"manual"`); the counting, the announcement and the wait do not.
38
+ *
39
+ * `onRetry` runs BEFORE the wait — registrars announce there (a silent minute reads as a hang), and
40
+ * `add slack` also drops its duplicate-guard marker so it never spans a sleep. The last error is
41
+ * thrown, so a caller can tell "still unreachable" from "a config error" in one place.
42
+ */
43
+ export async function retryWhile(call, retryable, options = {}) {
44
+ const attempts = options.attempts ?? REGISTRATION_ATTEMPTS;
45
+ const retryMs = options.retryMs ?? REGISTRATION_RETRY_MS;
46
+ for (let attempt = 1;; attempt++) {
47
+ try {
48
+ return await call();
49
+ }
50
+ catch (error) {
51
+ if (attempt >= attempts || !retryable(error))
52
+ throw error;
53
+ options.onRetry?.({ attempt, attempts, error });
54
+ await wait(retryMs);
55
+ }
56
+ }
57
+ }
@@ -0,0 +1 @@
1
+ export declare function secretEquals(given: unknown, expected: string | undefined): boolean;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The ONE constant-time comparison every shared-secret gate uses: a webhook secret token, a
3
+ * signature, a bearer token, an envelope secret. Timing-safe on equal lengths; a length mismatch
4
+ * answers false without leaking a prefix. Non-string input reads as no secret — and an EMPTY
5
+ * expected value never matches, so a gate whose secret was never configured cannot be passed by
6
+ * sending none.
7
+ */
8
+ import { Buffer } from "node:buffer";
9
+ import { timingSafeEqual } from "node:crypto";
10
+ export function secretEquals(given, expected) {
11
+ if (typeof given !== "string" || !expected)
12
+ return false;
13
+ const a = Buffer.from(given);
14
+ const b = Buffer.from(expected);
15
+ return a.length === b.length && timingSafeEqual(a, b);
16
+ }
@@ -10,8 +10,40 @@ interface SlackErrorShape {
10
10
  export declare class SlackConfigApiError extends Error {
11
11
  readonly code: string;
12
12
  readonly status: number;
13
+ /** Kept, not just formatted into the message: which FIELD Slack rejected is how a caller tells a
14
+ * not-yet-reachable URL from a malformed one, and a message match cannot see the difference. */
15
+ readonly errors: {
16
+ message?: string;
17
+ pointer?: string;
18
+ }[];
13
19
  constructor(method: string, status: number, data: SlackErrorShape, fallback: string);
14
20
  }
21
+ /**
22
+ * Whether a manifest call failed because Slack could not verify the `request_url` it carries — the
23
+ * platform's own readiness verdict on a freshly minted public URL, and the reason no local `/health`
24
+ * poll precedes these calls (#421). Slack validates the manifest BEFORE acting on it, so a call that
25
+ * ends here changed nothing and is safe to repeat.
26
+ *
27
+ * Read from the rejected FIELD (`invalid_manifest` + {@link rejectsRequestUrl}), not from `request_url`
28
+ * appearing anywhere in the formatted string: that also matched errors which merely mention the field.
29
+ *
30
+ * KNOWN IMPRECISION: this cannot separate "not reachable yet" from "this URL is unacceptable" — both
31
+ * arrive the same way, and the wording that would tell them apart has not been observed against the
32
+ * real API, so guessing at it would risk never retrying at all. A permanently bad URL therefore costs
33
+ * the full budget before it is reported. Every URL here is a tunnel's or a host's, so it is well-formed
34
+ * https by construction; narrow this the day a real rejection is captured.
35
+ */
36
+ export declare function isSlackRequestUrlUnverified(error: unknown): boolean;
37
+ /**
38
+ * Whether a manifest write was refused because the app has token rotation on, which Slack never lets
39
+ * off again — the refusal every app created by a release up to 0.20 meets, since this release sends
40
+ * `token_rotation_enabled: false`. Slack documents field rejections as `invalid_manifest` plus an
41
+ * `errors[]` of message + pointer; the bare code was what a real refusal once showed. Both shapes are
42
+ * read: a miss here sends the operator to repair configuration tokens that are not the problem.
43
+ */
44
+ export declare function isSlackRotationLocked(error: unknown): boolean;
45
+ /** The upgrade every rotating-app refusal points at. */
46
+ 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\")";
15
47
  export interface SlackAppCredentials {
16
48
  appId: string;
17
49
  clientId: string;
@@ -19,7 +51,9 @@ export interface SlackAppCredentials {
19
51
  signingSecret: string;
20
52
  oauthAuthorizeUrl?: string;
21
53
  }
22
- /** Create is intentionally single-attempt: an ambiguous response may already have created the app. */
54
+ /** Single-attempt BY DESIGN: an ambiguous response may already have created the app, so this never
55
+ * retries itself. The one repeatable failure — a `request_url` Slack could not verify, which it
56
+ * rejects before creating anything — is retried by the caller ({@link isSlackRequestUrlUnverified}). */
23
57
  export declare function createSlackApp(configToken: string, manifest: SlackAppManifest, options?: {
24
58
  apiBaseUrl?: string;
25
59
  fetch?: typeof fetch;
@@ -40,8 +74,6 @@ export declare function rotateSlackConfigToken(refreshToken: string, options?: {
40
74
  }): Promise<RotatedSlackConfigToken>;
41
75
  export interface SlackOAuthResult {
42
76
  botToken: string;
43
- botRefreshToken: string;
44
- botTokenExpiresAt: number;
45
77
  appId: string;
46
78
  teamId: string;
47
79
  teamName?: string;
@@ -1,8 +1,20 @@
1
1
  const SLACK_API = "https://slack.com/api";
2
2
  const REQUEST_TIMEOUT_MS = 30_000;
3
+ /** One `errors[]` entry that means "Slack could not verify this URL", by either signal it may carry.
4
+ * The pointer is the precise one but an INFERENCE — the only reply captured from the real API is the
5
+ * PERMANENT shape error, which reports on the parent pointer (`/settings/event_subscriptions`). If the
6
+ * unverifiable case reports there too, a pointer-only check would retry zero times and gate the deploy
7
+ * immediately, which is the failure this whole path exists to prevent. So a message about VERIFICATION
8
+ * (challenge wording included) counts as well, while one that merely names the field does not — the
9
+ * captured permanent error says "Event Subscription requires a Request URL". */
10
+ const rejectsRequestUrl = (e) => e.pointer?.endsWith("/request_url") === true ||
11
+ (/request.?url/i.test(e.message ?? "") && /verif|challenge/i.test(e.message ?? ""));
3
12
  export class SlackConfigApiError extends Error {
4
13
  code;
5
14
  status;
15
+ /** Kept, not just formatted into the message: which FIELD Slack rejected is how a caller tells a
16
+ * not-yet-reachable URL from a malformed one, and a message match cannot see the difference. */
17
+ errors;
6
18
  constructor(method, status, data, fallback) {
7
19
  const code = data.error ?? fallback;
8
20
  const fields = data.errors
@@ -12,8 +24,42 @@ export class SlackConfigApiError extends Error {
12
24
  this.name = "SlackConfigApiError";
13
25
  this.code = code;
14
26
  this.status = status;
27
+ this.errors = data.errors ?? [];
15
28
  }
16
29
  }
30
+ /**
31
+ * Whether a manifest call failed because Slack could not verify the `request_url` it carries — the
32
+ * platform's own readiness verdict on a freshly minted public URL, and the reason no local `/health`
33
+ * poll precedes these calls (#421). Slack validates the manifest BEFORE acting on it, so a call that
34
+ * ends here changed nothing and is safe to repeat.
35
+ *
36
+ * Read from the rejected FIELD (`invalid_manifest` + {@link rejectsRequestUrl}), not from `request_url`
37
+ * appearing anywhere in the formatted string: that also matched errors which merely mention the field.
38
+ *
39
+ * KNOWN IMPRECISION: this cannot separate "not reachable yet" from "this URL is unacceptable" — both
40
+ * arrive the same way, and the wording that would tell them apart has not been observed against the
41
+ * real API, so guessing at it would risk never retrying at all. A permanently bad URL therefore costs
42
+ * the full budget before it is reported. Every URL here is a tunnel's or a host's, so it is well-formed
43
+ * https by construction; narrow this the day a real rejection is captured.
44
+ */
45
+ export function isSlackRequestUrlUnverified(error) {
46
+ return (error instanceof SlackConfigApiError && error.code === "invalid_manifest" && error.errors.some(rejectsRequestUrl));
47
+ }
48
+ /**
49
+ * Whether a manifest write was refused because the app has token rotation on, which Slack never lets
50
+ * off again — the refusal every app created by a release up to 0.20 meets, since this release sends
51
+ * `token_rotation_enabled: false`. Slack documents field rejections as `invalid_manifest` plus an
52
+ * `errors[]` of message + pointer; the bare code was what a real refusal once showed. Both shapes are
53
+ * read: a miss here sends the operator to repair configuration tokens that are not the problem.
54
+ */
55
+ export function isSlackRotationLocked(error) {
56
+ if (!(error instanceof SlackConfigApiError))
57
+ return false;
58
+ return (error.code === "cannot_disable_once_enabled" ||
59
+ error.errors.some((e) => e.message?.includes("cannot_disable_once_enabled") || e.pointer?.endsWith("token_rotation_enabled")));
60
+ }
61
+ /** The upgrade every rotating-app refusal points at. */
62
+ 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")';
17
63
  async function slackJson(method, body, options = {}) {
18
64
  const fetchFn = options.fetch ?? globalThis.fetch;
19
65
  let response;
@@ -45,7 +91,9 @@ async function slackJson(method, body, options = {}) {
45
91
  }
46
92
  return data;
47
93
  }
48
- /** Create is intentionally single-attempt: an ambiguous response may already have created the app. */
94
+ /** Single-attempt BY DESIGN: an ambiguous response may already have created the app, so this never
95
+ * retries itself. The one repeatable failure — a `request_url` Slack could not verify, which it
96
+ * rejects before creating anything — is retried by the caller ({@link isSlackRequestUrlUnverified}). */
49
97
  export async function createSlackApp(configToken, manifest, options = {}) {
50
98
  const data = await slackJson("apps.manifest.create", { manifest: JSON.stringify(manifest) }, { ...options, token: configToken });
51
99
  const credentials = data.credentials;
@@ -126,17 +174,11 @@ export async function exchangeSlackOAuthCode(input, options = {}) {
126
174
  }
127
175
  if (!response.ok || data.ok !== true)
128
176
  throw slackOAuthFailure(response.status, data.error);
129
- if (!data.access_token ||
130
- !data.refresh_token ||
131
- typeof data.expires_in !== "number" ||
132
- !data.app_id ||
133
- !data.team?.id) {
134
- throw new Error("Slack oauth.v2.access succeeded but returned incomplete rotating bot credentials/app identity");
177
+ if (!data.access_token || !data.app_id || !data.team?.id) {
178
+ throw new Error("Slack oauth.v2.access succeeded but returned incomplete bot credentials/app identity");
135
179
  }
136
180
  return {
137
181
  botToken: data.access_token,
138
- botRefreshToken: data.refresh_token,
139
- botTokenExpiresAt: Date.now() + data.expires_in * 1_000,
140
182
  appId: data.app_id,
141
183
  teamId: data.team.id,
142
184
  teamName: data.team.name,
@@ -63,7 +63,11 @@ export function buildSlackManifest(input) {
63
63
  : {}),
64
64
  org_deploy_enabled: false,
65
65
  socket_mode_enabled: false,
66
- token_rotation_enabled: true,
66
+ // Off, and Slack makes the alternative permanent: an app with rotation on can never turn it off
67
+ // (`cannot_disable_once_enabled`, verified against the API). Rotation would ship the refresh token
68
+ // and the client secret to every host beside the access token — the same blast radius as one
69
+ // long-lived bot token, plus a 12-hour refresh lineage to keep single and durable.
70
+ token_rotation_enabled: false,
67
71
  },
68
72
  };
69
73
  }
@@ -10,13 +10,10 @@ export interface SlackOnboardIO {
10
10
  state?: string;
11
11
  error?: string;
12
12
  }>;
13
- /** Stage runtime-only credentials into the gitignored .env. */
13
+ /** Stage the runtime secrets into the gitignored .env — the bot token and the signing secret, nothing
14
+ * else: the client credentials are setup-only (the OAuth code exchange) and stay in onboarding state. */
14
15
  writeRuntimeSecrets(values: {
15
16
  botToken?: string;
16
- botRefreshToken?: string;
17
- botTokenExpiresAt?: number;
18
- clientId?: string;
19
- clientSecret?: string;
20
17
  signingSecret?: string;
21
18
  }): Promise<void>;
22
19
  }
@@ -31,6 +28,9 @@ export declare function onboardSlackApp(input: SlackOnboardInput, io: SlackOnboa
31
28
  createApp?: typeof createSlackApp;
32
29
  updateManifest?: typeof updateSlackAppManifest;
33
30
  exchangeCode?: typeof exchangeSlackOAuthCode;
31
+ /** Attempts absorbing the setup tunnel's warm-up before Slack can verify its URL. */
32
+ attempts?: number;
33
+ retryMs?: number;
34
34
  }): Promise<SlackOnboardingState>;
35
35
  export declare function newSlackOnboardingState(input: {
36
36
  appName: string;
@@ -1,5 +1,6 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { createSlackApp, exchangeSlackOAuthCode, SlackConfigApiError, updateSlackAppManifest } from "./config-api.js";
2
+ import { retryWhile } from "../registration.js";
3
+ import { createSlackApp, exchangeSlackOAuthCode, isSlackRequestUrlUnverified, isSlackRotationLocked, SLACK_ROTATING_APP_UPGRADE, SlackConfigApiError, updateSlackAppManifest, } from "./config-api.js";
3
4
  import { buildSlackManifest, slackBotScopes } from "./manifest.js";
4
5
  import { currentSlackConfigToken, writeSlackOnboardingState } from "./onboarding-state.js";
5
6
  /** Create/resume one internal Slack app and complete its workspace OAuth installation. */
@@ -7,6 +8,16 @@ export async function onboardSlackApp(input, io, deps = {}) {
7
8
  let state = input.state;
8
9
  const current = await currentSlackConfigToken(input.stateRoot, state);
9
10
  state = current.state;
11
+ /** Slack challenges request_url from ITS network during a manifest call and validates the whole
12
+ * manifest before acting, so an unverifiable URL created nothing and the call is safe to repeat. */
13
+ const whileUnverified = (call, onRetry) => retryWhile(call, isSlackRequestUrlUnverified, {
14
+ attempts: deps.attempts,
15
+ retryMs: deps.retryMs,
16
+ onRetry: ({ attempt, attempts }) => {
17
+ onRetry?.();
18
+ io.note(`Slack cannot reach the temporary setup URL yet (attempt ${attempt}/${attempts}); retrying…`);
19
+ },
20
+ });
10
21
  const manifest = buildSlackManifest({
11
22
  name: state.appName,
12
23
  groupBehavior: state.groupBehavior,
@@ -19,21 +30,26 @@ export async function onboardSlackApp(input, io, deps = {}) {
19
30
  "Inspect https://api.slack.com/apps before retrying; if an app exists, delete that incomplete app first, then remove the local Slack onboarding state and re-run");
20
31
  }
21
32
  io.note("Creating the internal Slack app from its FastAgent manifest…");
22
- // Record BEFORE the non-idempotent API call. A transport/internal failure may have created the app;
23
- // refusing a blind retry is safer than silently producing duplicates.
24
- state = { ...state, createAttemptedAt: new Date().toISOString() };
25
- writeSlackOnboardingState(input.stateRoot, state);
33
+ const setMarker = (value) => {
34
+ state = { ...state, createAttemptedAt: value };
35
+ writeSlackOnboardingState(input.stateRoot, state);
36
+ };
26
37
  let created;
27
38
  try {
28
- created = await (deps.createApp ?? createSlackApp)(current.token, manifest);
39
+ created = await whileUnverified(() => {
40
+ // Record BEFORE the non-idempotent API call. A transport/internal failure may have created the
41
+ // app; refusing a blind retry is safer than silently producing duplicates. The marker spans
42
+ // exactly one in-flight call: an unverifiable URL created nothing, so it comes off again before
43
+ // the wait — a Ctrl-C during that wait must not wedge the next run on an app that never existed.
44
+ setMarker(new Date().toISOString());
45
+ return (deps.createApp ?? createSlackApp)(current.token, manifest);
46
+ }, () => setMarker(undefined));
29
47
  }
30
48
  catch (error) {
31
49
  const ambiguous = !(error instanceof SlackConfigApiError) ||
32
50
  ["fatal_error", "internal_error", "request_timeout", "service_unavailable", "failed_creating_app"].includes(error.code);
33
- if (!ambiguous) {
34
- state = { ...state, createAttemptedAt: undefined };
35
- writeSlackOnboardingState(input.stateRoot, state);
36
- }
51
+ if (!ambiguous)
52
+ setMarker(undefined);
37
53
  throw error;
38
54
  }
39
55
  state = {
@@ -53,8 +69,17 @@ export async function onboardSlackApp(input, io, deps = {}) {
53
69
  throw new Error(`Slack app ${state.appId} exists but OAuth client credentials are no longer available — ` +
54
70
  "the app appears already installed; set SLACK_BOT_TOKEN/SLACK_SIGNING_SECRET in .env or remove the app and onboarding state to start over");
55
71
  }
56
- io.note(`Resuming Slack app ${state.appId}; refreshing its temporary setup URLs…`);
57
- await (deps.updateManifest ?? updateSlackAppManifest)(current.token, state.appId, manifest);
72
+ const appId = state.appId;
73
+ io.note(`Resuming Slack app ${appId}; refreshing its temporary setup URLs…`);
74
+ try {
75
+ await whileUnverified(() => (deps.updateManifest ?? updateSlackAppManifest)(current.token, appId, manifest));
76
+ }
77
+ catch (error) {
78
+ // A resume of an app created with rotation on: the manifest is refused for good, so say what fixes it.
79
+ if (isSlackRotationLocked(error))
80
+ throw new Error(SLACK_ROTATING_APP_UPGRADE, { cause: error });
81
+ throw error;
82
+ }
58
83
  }
59
84
  if (state.signingSecret) {
60
85
  await io.writeRuntimeSecrets({ signingSecret: state.signingSecret });
@@ -70,7 +95,9 @@ export async function onboardSlackApp(input, io, deps = {}) {
70
95
  authorize.searchParams.set("scope", slackBotScopes(state.groupBehavior).join(","));
71
96
  authorize.searchParams.set("redirect_uri", input.redirectUrl);
72
97
  authorize.searchParams.set("state", oauthState);
73
- io.note(`Approve the internal app installation in Slack: ${authorize}`);
98
+ // The redirect lands on the tunnel hostname, which THIS machine may not resolve yet (#421) — a broken
99
+ // page beside a terminal that looks hung, unless the reload is named before it happens.
100
+ io.note(`Click Allow in Slack to install the app: ${authorize}\nIf the page it returns to does not load, reload it.`);
74
101
  io.openUrl(authorize.toString());
75
102
  const callback = await io.waitForOAuth();
76
103
  if (callback.error)
@@ -90,13 +117,7 @@ export async function onboardSlackApp(input, io, deps = {}) {
90
117
  if (slackBotScopes(state.groupBehavior).some((scope) => !oauth.scopes.includes(scope))) {
91
118
  throw new Error("Slack OAuth completed without all required bot scopes; re-run fastagent add slack to reinstall");
92
119
  }
93
- await io.writeRuntimeSecrets({
94
- botToken: oauth.botToken,
95
- botRefreshToken: oauth.botRefreshToken,
96
- botTokenExpiresAt: oauth.botTokenExpiresAt,
97
- clientId: state.clientId,
98
- clientSecret: state.clientSecret,
99
- });
120
+ await io.writeRuntimeSecrets({ botToken: oauth.botToken });
100
121
  state = {
101
122
  ...state,
102
123
  clientSecret: undefined,
@@ -1,8 +1,9 @@
1
- import type { RegistrationOutcome } from "../registration.ts";
1
+ import { type RegistrationOutcome } from "../registration.ts";
2
2
  export interface RegisterSlackWebhookOptions {
3
3
  stateRoot: string;
4
4
  log?: (message: string) => void;
5
- healthTimeoutMs?: number;
5
+ attempts?: number;
6
+ retryMs?: number;
6
7
  apiBaseUrl?: string;
7
8
  fetch?: typeof fetch;
8
9
  }
@@ -1,5 +1,5 @@
1
- import { waitForHealth } from "../wait-health.js";
2
- import { updateSlackAppManifest } from "./config-api.js";
1
+ import { retryWhile } from "../registration.js";
2
+ import { SLACK_ROTATING_APP_UPGRADE, 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. */
@@ -20,30 +20,50 @@ export async function registerSlackWebhook(baseUrl, options) {
20
20
  `(repair its expired/revoked tokens with \`fastagent add slack --replace-config\`)`);
21
21
  return "manual";
22
22
  }
23
- const healthy = await waitForHealth(`${publicBaseUrl}/health`, options.healthTimeoutMs ?? 45_000, 500);
24
- if (!healthy) {
25
- note(`[fastagent] slack: ${publicBaseUrl}/health did not become reachable; Request URL was not changed`);
26
- return "failed";
27
- }
23
+ const consoleFallback = `set ${publicBaseUrl}/slack as the Event Subscriptions Request URL in the Slack console`;
24
+ let current;
28
25
  try {
29
- const current = await currentSlackConfigToken(options.stateRoot, state, {
26
+ // Rotation is not retried with the manifest below: each rotate invalidates the previous pair, so a
27
+ // repeat would spend tokens on a failure that is about the URL, not about the credential.
28
+ current = await currentSlackConfigToken(options.stateRoot, state, {
30
29
  apiBaseUrl: options.apiBaseUrl,
31
30
  fetch: options.fetch,
32
31
  });
33
- await updateSlackAppManifest(current.token, current.state.appId, buildSlackManifest({
32
+ }
33
+ catch (error) {
34
+ note(`[fastagent] slack: could not refresh the App Configuration token: ${String(error)} — ` +
35
+ `re-run \`fastagent add slack --replace-config\` to repair it, or ${consoleFallback}`);
36
+ return "failed";
37
+ }
38
+ // Slack verifies the new request_url with a challenge DURING apps.manifest.update, from Slack's own
39
+ // network — that verdict IS the readiness signal, so it is retried while a fresh tunnel/container
40
+ // warms up. No local `/health` poll precedes it: this machine's reach is the wrong question (#421).
41
+ try {
42
+ await retryWhile(() => updateSlackAppManifest(current.token, current.state.appId, buildSlackManifest({
34
43
  name: current.state.appName,
35
44
  groupBehavior: current.state.groupBehavior,
36
45
  requestUrl: `${publicBaseUrl}/slack`,
37
- // Token-rotation manifests require at least one OAuth redirect URL even after installation.
38
- // Actual reinstall flows replace this placeholder with their one-shot local setup callback.
46
+ // A manifest update replaces the whole manifest and redirect_urls may not be empty, so a
47
+ // placeholder is declared; a reinstall replaces it with its one-shot local setup callback.
39
48
  redirectUrl: `${publicBaseUrl}/slack/oauth/callback`,
40
- }), { apiBaseUrl: options.apiBaseUrl, fetch: options.fetch });
49
+ }), { apiBaseUrl: options.apiBaseUrl, fetch: options.fetch }), isSlackRequestUrlUnverified, {
50
+ attempts: options.attempts,
51
+ retryMs: options.retryMs,
52
+ onRetry: ({ attempt, attempts }) => note(`[fastagent] slack: Slack cannot verify ${publicBaseUrl}/slack yet (attempt ${attempt}/${attempts}); retrying…`),
53
+ });
41
54
  note(`[fastagent] slack: Event Subscriptions Request URL registered → ${publicBaseUrl}/slack`);
42
55
  return "registered";
43
56
  }
44
57
  catch (error) {
45
- note(`[fastagent] slack: automatic Request URL registration failed: ${String(error)} — ` +
46
- `re-run \`fastagent add slack --replace-config\` to repair the configuration tokens, or set ${publicBaseUrl}/slack in the Slack console`);
58
+ if (isSlackRotationLocked(error)) {
59
+ note(`[fastagent] slack: ${SLACK_ROTATING_APP_UPGRADE}, or ${consoleFallback}`);
60
+ return "failed";
61
+ }
62
+ note(isSlackRequestUrlUnverified(error)
63
+ ? `[fastagent] slack: Slack could not verify ${publicBaseUrl}/slack after retries (last error: ${String(error)}) — ` +
64
+ `once the app is up, ${consoleFallback}`
65
+ : `[fastagent] slack: automatic Request URL registration failed: ${String(error)} — ` +
66
+ `re-run \`fastagent add slack --replace-config\` to repair the configuration tokens, or ${consoleFallback}`);
47
67
  return "failed";
48
68
  }
49
69
  }
@@ -8,18 +8,11 @@ import { slackChannel } from "@fastagent-sh/fastagent/slack";
8
8
  // 3. Event Subscriptions: app_home_opened, app_context_changed, app_mention, message.im,
9
9
  // message.channels, message.groups, and message.mpim. Mention-only may omit the last three.
10
10
  // Set Request URL to https://<host>/slack.
11
- // 4. Enable token rotation and install/reinstall the app after changing scopes. Rotating credentials
12
- // are written by `fastagent add slack`; manual long-lived tokens may omit the four rotation fields.
11
+ // 4. Install the app (reinstall after changing scopes) and leave token rotation OFF: it cannot be
12
+ // turned off again, and this channel takes one long-lived Bot User OAuth Token.
13
13
  export default slackChannel({
14
- botToken: process.env.SLACK_BOT_TOKEN ?? "", // Bot User OAuth Token (xoxb-… or rotating xoxe.xoxb-…)
14
+ botToken: process.env.SLACK_BOT_TOKEN ?? "", // OAuth & Permissions → Bot User OAuth Token (xoxb-…)
15
15
  signingSecret: process.env.SLACK_SIGNING_SECRET ?? "", // Basic Information → App Credentials
16
- botRefreshToken: process.env.SLACK_BOT_REFRESH_TOKEN || undefined,
17
- clientId: process.env.SLACK_CLIENT_ID || undefined,
18
- clientSecret: process.env.SLACK_CLIENT_SECRET || undefined,
19
- botTokenExpiresAt: process.env.SLACK_BOT_TOKEN_EXPIRES_AT
20
- ? Number(process.env.SLACK_BOT_TOKEN_EXPIRES_AT)
21
- : undefined,
22
- groupBehavior: "context", // default; use "mentions" only for an explicit least-privilege setup
23
16
  // Slack Agent stream; its inline tool traces show each call's first argument and stay in the
24
17
  // delivered message. "classic" settles into the answer alone (and gives up native streaming).
25
18
  rendering: "native",