@fastagent-sh/fastagent 0.28.0 → 0.29.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 (59) hide show
  1. package/README.md +2 -1
  2. package/dist/channels/feishu/feishu-api.d.ts +1 -0
  3. package/dist/channels/feishu/feishu-api.js +4 -0
  4. package/dist/channels/feishu/register-app.d.ts +2 -0
  5. package/dist/channels/feishu/register-app.js +1 -1
  6. package/dist/channels/lark/credentials-page.d.ts +25 -0
  7. package/dist/channels/lark/credentials-page.js +206 -0
  8. package/dist/channels/lark/onboard.d.ts +18 -2
  9. package/dist/channels/lark/onboard.js +36 -6
  10. package/dist/channels/slack/setup-server.js +75 -30
  11. package/dist/channels/wait-health.js +1 -1
  12. package/dist/cli/add-feishu.d.ts +0 -9
  13. package/dist/cli/add-feishu.js +62 -38
  14. package/dist/cli/commands/add.js +5 -12
  15. package/dist/cli/commands/deploy.js +1 -8
  16. package/dist/cli/commands/info.js +4 -0
  17. package/dist/cli/commands/mcp.d.ts +12 -0
  18. package/dist/cli/commands/mcp.js +156 -0
  19. package/dist/cli/program.js +59 -0
  20. package/dist/deploy/agentcore/destroy.js +1 -1
  21. package/dist/deploy/build-context.js +4 -0
  22. package/dist/deploy/container.js +1 -2
  23. package/dist/deploy/docker/run.d.ts +16 -3
  24. package/dist/deploy/docker/run.js +54 -1
  25. package/dist/deploy/fly/run.d.ts +0 -14
  26. package/dist/deploy/fly/run.js +4 -5
  27. package/dist/dev-supervisor.js +3 -2
  28. package/dist/harnesses/pi-durable/mcp.d.ts +3 -0
  29. package/dist/harnesses/pi-durable/mcp.js +34 -0
  30. package/dist/harnesses/pi-durable/open.d.ts +1 -1
  31. package/dist/harnesses/pi-durable/open.js +12 -3
  32. package/dist/harnesses/pi-durable/serve.d.ts +4 -0
  33. package/dist/harnesses/pi-durable/serve.js +47 -17
  34. package/dist/http/contract.d.ts +3 -3
  35. package/dist/index.d.ts +4 -0
  36. package/dist/index.js +4 -0
  37. package/dist/locked-file.d.ts +2 -2
  38. package/dist/locked-file.js +8 -3
  39. package/dist/mcp/auth.d.ts +22 -0
  40. package/dist/mcp/auth.js +180 -0
  41. package/dist/mcp/client.d.ts +15 -0
  42. package/dist/mcp/client.js +170 -0
  43. package/dist/mcp/config.d.ts +34 -0
  44. package/dist/mcp/config.js +142 -0
  45. package/dist/mcp/grants.d.ts +4 -0
  46. package/dist/mcp/grants.js +132 -0
  47. package/dist/mcp/host.d.ts +16 -0
  48. package/dist/mcp/host.js +120 -0
  49. package/dist/mcp/network.d.ts +12 -0
  50. package/dist/mcp/network.js +150 -0
  51. package/dist/mcp/sign-in.d.ts +2 -0
  52. package/dist/mcp/sign-in.js +204 -0
  53. package/dist/mcp-api.d.ts +93 -0
  54. package/dist/mcp-api.js +10 -0
  55. package/dist/setup-page.d.ts +51 -0
  56. package/dist/setup-page.js +149 -0
  57. package/dist/value-file.d.ts +1 -0
  58. package/dist/value-file.js +15 -16
  59. package/package.json +3 -2
package/README.md CHANGED
@@ -153,6 +153,7 @@ The package exports the supported surface only, from one entry per kind of user,
153
153
  | Directory → agent → service | `openAgent`, `LocalAgent`, `serve()`'s `Service` (`fetch`, `listen`) | Usable now, may tighten before 1.0 |
154
154
  | Over HTTP | `connectAgent` (`/client`), the `/v1` routes ([Agent API](docs/SPEC.md) §9) | Stable within its version |
155
155
  | A host embedding agents | `createAgent`, `addContent`, `removeContent`, `listContent`, `CredentialStore`, `credentialFile`, `signIn`, `signInOptions`, `providerAuth` ([embedding](docs/embedding.md#6-a-desktop-host)) | Usable now, may tighten before 1.0 |
156
+ | MCP tools and authorization | `inspectMcp`, `signInMcp`, `mcpGrantFile`, `McpGrantStore` ([MCP servers](docs/mcp.md)) | Usable now, may tighten before 1.0 |
156
157
  | Authoring | `defineAgent`, `defineChannel`, `defineSchedule`, `needs`, `sessionIdOf`, `readBodyCapped`, `text`, `secretEquals` | Usable now, may tighten before 1.0 |
157
158
  | Not exported | The assembly's parts (`router`, `createScheduler`), the environment's edit (`writeEnvironment`), the model environment, harness internals | No compatibility promise |
158
159
 
@@ -189,7 +190,7 @@ The Agent API leaves room for capabilities that are not complete product feature
189
190
  - **Scale-out**: one process holds an instance's storage today; spreading one instance over several processes is future work.
190
191
  - **Sandboxed execution** — the coding tools, channels, and author-written code reach the local process directly; a sandbox around the complete agent process is future work.
191
192
  - **Observability export** — leveled logs exist today; an OpenTelemetry exporter does not.
192
- - **More channels and connectors** — community channels can use the channel kit; MCP servers as connectors are planned.
193
+ - **More channels** — community channels can use the channel kit. External tools can also use [MCP servers](docs/mcp.md).
193
194
  - **More deploy targets** — local Docker, Fly, Railway, and AWS Bedrock AgentCore ship today; the generated container is the portable path for other hosts.
194
195
 
195
196
  See [Contributing](https://github.com/fastagent-sh/fastagent/blob/main/CONTRIBUTING.md) if one of these is the problem you want to work on.
@@ -19,6 +19,7 @@ export interface DownloadedFile {
19
19
  * streaming once when it sees this. A plain code check on the typed error, exported instead of the
20
20
  * class so no caller can construct/throw one. */
21
21
  export declare function isCardStreamingClosed(e: unknown): boolean;
22
+ export declare function isFeishuCredentialsRefused(e: unknown): boolean;
22
23
  /** Whether the platform origin has no application-config route at all. Onboarding uses this narrow
23
24
  * signal to fall back to a manual token/mode setup; auth/scope/network failures must remain visible. */
24
25
  export declare function isFeishuConfigApiMissing(e: unknown): boolean;
@@ -61,6 +61,10 @@ class FeishuApiError extends Error {
61
61
  export function isCardStreamingClosed(e) {
62
62
  return e instanceof FeishuApiError && (e.code === 200850 || e.code === 300309);
63
63
  }
64
+ // Lark's credential endpoint returns code 10003 for invalid app credentials; other errors remain fatal.
65
+ export function isFeishuCredentialsRefused(e) {
66
+ return e instanceof FeishuApiError && e.call === "tenant_access_token" && e.status === 200 && e.code === 10003;
67
+ }
64
68
  /** Whether the platform origin has no application-config route at all. Onboarding uses this narrow
65
69
  * signal to fall back to a manual token/mode setup; auth/scope/network failures must remain visible. */
66
70
  export function isFeishuConfigApiMissing(e) {
@@ -24,6 +24,8 @@ interface FeishuAppAddons {
24
24
  items?: string[];
25
25
  };
26
26
  }
27
+ /** JSON → gzip → base64url — the platform's fixed addons encoding. */
28
+ export declare function encodeAddons(addons: FeishuAppAddons): string;
27
29
  export interface RegisterFeishuAppOptions {
28
30
  /** Pre-filled app name shown on the confirm page (`{user}` expands to the scanning user's name). */
29
31
  name?: string;
@@ -13,7 +13,7 @@ const ENDPOINT = "/oauth/v1/app/registration";
13
13
  /** Per-attempt timeout for one registration POST — small form/JSON round-trips. */
14
14
  const REQUEST_TIMEOUT_MS = 30_000;
15
15
  /** JSON → gzip → base64url — the platform's fixed addons encoding. */
16
- function encodeAddons(addons) {
16
+ export function encodeAddons(addons) {
17
17
  return gzipSync(Buffer.from(JSON.stringify(addons), "utf8"))
18
18
  .toString("base64")
19
19
  .replace(/\+/g, "-")
@@ -0,0 +1,25 @@
1
+ export interface LarkCredentials {
2
+ appId: string;
3
+ appSecret: string;
4
+ }
5
+ export declare class LarkCredentialsRefused extends Error {
6
+ readonly fix: string | undefined;
7
+ readonly why: string | undefined;
8
+ constructor(headline: string, options?: {
9
+ fix?: string;
10
+ why?: string;
11
+ });
12
+ }
13
+ export interface LarkCredentialsPage<T = LarkCredentials> {
14
+ url: string;
15
+ credentials: Promise<T>;
16
+ close(): Promise<void>;
17
+ }
18
+ export declare function startLarkCredentialsPage(input: {
19
+ consoleUrl: string;
20
+ check(credentials: LarkCredentials): Promise<void>;
21
+ }): Promise<LarkCredentialsPage>;
22
+ export declare function startLarkVerificationPage(input: {
23
+ consoleUrl: string;
24
+ save(token: string): Promise<void>;
25
+ }): Promise<LarkCredentialsPage<string>>;
@@ -0,0 +1,206 @@
1
+ /** Loopback-only, single-use forms for Lark setup secrets; values never pass through the agent's conversation. */
2
+ import { randomBytes } from "node:crypto";
3
+ import { createServer } from "node:http";
4
+ import { field, formCard, openButton, privacy, prose, refusal, sendPage, setupRequestUrl, statusCard, step, submit, } from "../../setup-page.js";
5
+ export class LarkCredentialsRefused extends Error {
6
+ fix;
7
+ why;
8
+ constructor(headline, options = {}) {
9
+ super(headline);
10
+ this.name = "LarkCredentialsRefused";
11
+ this.fix = options.fix;
12
+ this.why = options.why;
13
+ }
14
+ }
15
+ function readBody(request) {
16
+ return new Promise((resolve) => {
17
+ let body = "";
18
+ let tooLarge = false;
19
+ request.on("data", (chunk) => {
20
+ if (tooLarge)
21
+ return;
22
+ body += String(chunk);
23
+ if (Buffer.byteLength(body) > 16 * 1024)
24
+ tooLarge = true;
25
+ });
26
+ request.on("end", () => resolve(tooLarge ? undefined : body));
27
+ request.on("error", () => resolve(undefined));
28
+ });
29
+ }
30
+ export function startLarkCredentialsPage(input) {
31
+ return startPage({
32
+ heading: "Lark credentials verified",
33
+ form: (pasted, problem) => formCard("Connect Lark", [
34
+ step(1, "Create the app in Lark", prose("It opens with the permissions and event your agent needs already set.") +
35
+ openButton("Open Lark", input.consoleUrl)),
36
+ step(2, "Paste its credentials", (problem
37
+ ? refusal(problem.message, problem.fix, problem.why)
38
+ : prose("Find them under <b>Credentials &amp; Basic Info</b>.")) +
39
+ field({
40
+ id: "app_id",
41
+ label: "App ID",
42
+ placeholder: "cli_…",
43
+ value: pasted.get("app_id") ?? "",
44
+ invalid: !!problem,
45
+ }) +
46
+ field({ id: "app_secret", label: "App Secret", secret: true, invalid: !!problem, autofocus: !!problem }) +
47
+ submit("Save and check") +
48
+ privacy()),
49
+ ].join("")),
50
+ accept: async (pasted) => {
51
+ const pair = { appId: (pasted.get("app_id") ?? "").trim(), appSecret: (pasted.get("app_secret") ?? "").trim() };
52
+ if (!pair.appId || !pair.appSecret) {
53
+ throw new LarkCredentialsRefused("Both values are needed.", {
54
+ fix: "Copy the App ID and the App Secret from Credentials & Basic Info.",
55
+ });
56
+ }
57
+ await input.check(pair);
58
+ return pair;
59
+ },
60
+ });
61
+ }
62
+ export function startLarkVerificationPage(input) {
63
+ return startPage({
64
+ heading: "Verification Token saved",
65
+ form: (_pasted, problem) => formCard("Finish Lark webhook setup", [
66
+ step(1, "Copy the Verification Token", prose("Find it under <b>Events &amp; Callbacks</b> → Encryption Strategy. Choose webhook mode when setting the Request URL.") + openButton("Open Lark", input.consoleUrl)),
67
+ step(2, "Paste the token here", (problem ? refusal(problem.message, problem.fix) : "") +
68
+ field({
69
+ id: "verification_token",
70
+ label: "Verification Token",
71
+ secret: true,
72
+ invalid: !!problem,
73
+ autofocus: !!problem,
74
+ }) +
75
+ submit("Save token") +
76
+ privacy()),
77
+ ].join("")),
78
+ accept: async (pasted) => {
79
+ const token = (pasted.get("verification_token") ?? "").trim();
80
+ if (!token)
81
+ throw new LarkCredentialsRefused("Verification Token is required.");
82
+ await input.save(token);
83
+ return token;
84
+ },
85
+ });
86
+ }
87
+ // A browser can leave during validation, before either response event can be subscribed to.
88
+ function afterResponse(response, complete) {
89
+ if (response.destroyed || response.writableFinished)
90
+ complete();
91
+ else
92
+ response.once("close", complete);
93
+ }
94
+ async function startPage(input) {
95
+ const path = `/lark/credentials/${randomBytes(24).toString("hex")}`;
96
+ const { promise: credentials, resolve, reject } = Promise.withResolvers();
97
+ let checking = false;
98
+ let used = false;
99
+ let port = 0;
100
+ const server = createServer(async (request, response) => {
101
+ const url = setupRequestUrl(request, response);
102
+ if (url === undefined)
103
+ return;
104
+ const host = request.headers.host ?? "";
105
+ if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) {
106
+ response.writeHead(421).end("misdirected request\n");
107
+ return;
108
+ }
109
+ if (url.pathname !== path || (request.method !== "GET" && request.method !== "POST")) {
110
+ response.writeHead(404).end("not found\n");
111
+ return;
112
+ }
113
+ const expired = () => sendPage(response, 404, {
114
+ title: "FastAgent setup",
115
+ card: statusCard({
116
+ state: "failed",
117
+ heading: "This page is no longer in use",
118
+ next: "Go back to your coding agent.",
119
+ }),
120
+ });
121
+ if (used) {
122
+ expired();
123
+ return;
124
+ }
125
+ if (request.method === "GET") {
126
+ sendPage(response, 200, { title: "Connect Lark — FastAgent", card: input.form(new URLSearchParams()) });
127
+ return;
128
+ }
129
+ const site = request.headers["sec-fetch-site"];
130
+ if (site !== undefined && site !== "same-origin") {
131
+ response.writeHead(403).end("forbidden\n");
132
+ return;
133
+ }
134
+ const body = await readBody(request);
135
+ if (body === undefined) {
136
+ response.writeHead(413).end("payload too large\n");
137
+ return;
138
+ }
139
+ if (used) {
140
+ expired();
141
+ return;
142
+ }
143
+ if (checking) {
144
+ response.writeHead(409).end("another submission is being checked\n");
145
+ return;
146
+ }
147
+ checking = true;
148
+ const pasted = new URLSearchParams(body);
149
+ let accepted;
150
+ try {
151
+ accepted = await input.accept(pasted);
152
+ }
153
+ catch (error) {
154
+ checking = false;
155
+ // Only explicit input refusals stay on the form. Infrastructure and persistence errors fail the ticket.
156
+ if (error instanceof LarkCredentialsRefused) {
157
+ sendPage(response, 200, { title: "Connect Lark — FastAgent", card: input.form(pasted, error) });
158
+ return;
159
+ }
160
+ used = true;
161
+ afterResponse(response, () => reject(error));
162
+ sendPage(response, 200, {
163
+ title: "Setup stopped — FastAgent",
164
+ card: statusCard({
165
+ state: "failed",
166
+ heading: "Setup stopped",
167
+ lede: error instanceof Error ? error.message : String(error),
168
+ next: "Go back to your coding agent.",
169
+ }),
170
+ });
171
+ return;
172
+ }
173
+ used = true;
174
+ afterResponse(response, () => resolve(accepted));
175
+ sendPage(response, 200, {
176
+ title: `${input.heading} — FastAgent`,
177
+ card: statusCard({
178
+ state: "done",
179
+ heading: input.heading,
180
+ lede: "Your coding agent will say if anything is left to do in Lark.",
181
+ next: "Go back to your coding agent.",
182
+ }),
183
+ });
184
+ });
185
+ await new Promise((resolve, reject) => {
186
+ server.once("error", reject);
187
+ server.listen(0, "127.0.0.1", () => {
188
+ server.off("error", reject);
189
+ resolve();
190
+ });
191
+ });
192
+ const address = server.address();
193
+ if (!address || typeof address === "string") {
194
+ server.close();
195
+ throw new Error("the Lark credentials page did not receive a local TCP port");
196
+ }
197
+ port = address.port;
198
+ return {
199
+ url: `http://127.0.0.1:${port}${path}`,
200
+ credentials,
201
+ close: () => new Promise((resolve) => {
202
+ server.close(() => resolve());
203
+ server.closeAllConnections();
204
+ }),
205
+ };
206
+ }
@@ -1,6 +1,12 @@
1
- import type { FeishuSubscriptionMode } from "../feishu/setup-mode.ts";
1
+ import { type FeishuSubscriptionMode } from "../feishu/setup-mode.ts";
2
2
  /** Guided Lark-international onboarding. */
3
- export declare const LARK_CONSOLE_URL = "https://open.larksuite.com/page/launcher?from=backend_oneclick";
3
+ /**
4
+ * Lark's one-click launcher, where the person creates the app. The launcher is unbound (the bound confirm page is broken
5
+ * on the international cloud), but it merges the `addons` query parameter onto the app it creates, as Feishu's does: the
6
+ * scopes and the event the channel needs arrive with the app, so nothing is ticked in the console afterwards. A tenant
7
+ * may still withhold a scope; the permission check that follows creation names it.
8
+ */
9
+ export declare function larkConsoleUrl(ingress: FeishuSubscriptionMode): string;
4
10
  /** Directly where the fallback token lives (and where the successful automatic mode change is visible). */
5
11
  export declare function larkEventSecurityUrl(appId: string): string;
6
12
  export interface LarkOnboardIO {
@@ -10,6 +16,15 @@ export interface LarkOnboardIO {
10
16
  prompt(message: string, opts?: {
11
17
  hidden?: boolean;
12
18
  }): Promise<string | undefined>;
19
+ verificationToken?(consoleUrl: string): Promise<string>;
20
+ collect?(input: {
21
+ /** Lark's launcher, with what the channel needs set on the app it creates. */
22
+ consoleUrl: string;
23
+ check(appId: string, appSecret: string): Promise<void>;
24
+ }): Promise<{
25
+ appId: string;
26
+ appSecret: string;
27
+ }>;
13
28
  }
14
29
  interface LarkBootstrapResult {
15
30
  /** Challenge-captured token: the PATCH also switched Subscription mode to webhook. */
@@ -22,6 +37,7 @@ export interface LarkOnboardOptions {
22
37
  existing?: Readonly<Record<string, string | undefined>>;
23
38
  ingress?: FeishuSubscriptionMode;
24
39
  verifyCredentials(appId: string, appSecret: string): Promise<void>;
40
+ keep?(appId: string, appSecret: string): Promise<void>;
25
41
  bootstrapWebhook?(appId: string, appSecret: string): Promise<LarkBootstrapResult>;
26
42
  /** The command that continues an interrupted setup: the one that started it (`add lark`, `deploy <host> --run`). */
27
43
  rerun?: string;
@@ -1,5 +1,18 @@
1
+ import { encodeAddons } from "../feishu/register-app.js";
2
+ import { feishuAppAddons } from "../feishu/setup-mode.js";
1
3
  /** Guided Lark-international onboarding. */
2
- export const LARK_CONSOLE_URL = "https://open.larksuite.com/page/launcher?from=backend_oneclick";
4
+ /**
5
+ * Lark's one-click launcher, where the person creates the app. The launcher is unbound (the bound confirm page is broken
6
+ * on the international cloud), but it merges the `addons` query parameter onto the app it creates, as Feishu's does: the
7
+ * scopes and the event the channel needs arrive with the app, so nothing is ticked in the console afterwards. A tenant
8
+ * may still withhold a scope; the permission check that follows creation names it.
9
+ */
10
+ export function larkConsoleUrl(ingress) {
11
+ const url = new URL("https://open.larksuite.com/page/launcher");
12
+ url.searchParams.set("from", "backend_oneclick");
13
+ url.searchParams.set("addons", encodeAddons(feishuAppAddons(ingress)));
14
+ return url.toString();
15
+ }
3
16
  /** Directly where the fallback token lives (and where the successful automatic mode change is visible). */
4
17
  export function larkEventSecurityUrl(appId) {
5
18
  return `https://open.larksuite.com/app/${encodeURIComponent(appId)}/event?tab=safe`;
@@ -17,15 +30,30 @@ export async function onboardLarkApp(io, opts) {
17
30
  const existingSecret = opts.existing?.LARK_APP_SECRET?.trim();
18
31
  const reuseExistingApp = Boolean(existingId && existingSecret);
19
32
  // Credentials are one App-scoped set.
33
+ // No ingress named is the webhook path below (a deploy's production app).
34
+ const consoleUrl = larkConsoleUrl(opts.ingress ?? "webhook");
35
+ let appId;
36
+ let appSecret;
37
+ const verify = async (appId, appSecret) => {
38
+ await opts.verifyCredentials(appId, appSecret);
39
+ if (!reuseExistingApp)
40
+ await opts.keep?.(appId, appSecret);
41
+ };
20
42
  if (reuseExistingApp) {
21
43
  io.note(`Reusing Lark app ${existingId}.`);
44
+ appId = required(existingId, "LARK_APP_ID", rerun);
45
+ appSecret = required(existingSecret, "LARK_APP_SECRET", rerun);
46
+ await verify(appId, appSecret);
47
+ }
48
+ else if (io.collect) {
49
+ ({ appId, appSecret } = await io.collect({ consoleUrl, check: verify }));
22
50
  }
23
51
  else {
24
- io.openUrl(LARK_CONSOLE_URL, "create the app on Lark's one-click launcher; LARK_APP_ID and LARK_APP_SECRET are under Credentials & Basic Info");
52
+ io.openUrl(consoleUrl, "create the app on Lark's one-click launcher; LARK_APP_ID and LARK_APP_SECRET are under Credentials & Basic Info");
53
+ appId = required(await io.prompt("LARK_APP_ID (Credentials & Basic Info)", { hidden: false }), "LARK_APP_ID", rerun);
54
+ appSecret = required(await io.prompt("LARK_APP_SECRET (Credentials & Basic Info)", { hidden: true }), "LARK_APP_SECRET", rerun);
55
+ await verify(appId, appSecret);
25
56
  }
26
- const appId = required(reuseExistingApp ? existingId : await io.prompt("LARK_APP_ID (Credentials & Basic Info)", { hidden: false }), "LARK_APP_ID", rerun);
27
- const appSecret = required(reuseExistingApp ? existingSecret : await io.prompt("LARK_APP_SECRET (Credentials & Basic Info)", { hidden: true }), "LARK_APP_SECRET", rerun);
28
- await opts.verifyCredentials(appId, appSecret);
29
57
  const eventSecurityUrl = larkEventSecurityUrl(appId);
30
58
  io.note("App ID / Secret verified.");
31
59
  if (opts.ingress === "websocket") {
@@ -47,7 +75,9 @@ export async function onboardLarkApp(io, opts) {
47
75
  }
48
76
  const verificationToken = required(bootstrap.token ||
49
77
  (reuseExistingApp ? opts.existing?.LARK_VERIFICATION_TOKEN?.trim() : undefined) ||
50
- (await io.prompt("LARK_VERIFICATION_TOKEN (Events & Callbacks → Encryption Strategy)", { hidden: true })), "LARK_VERIFICATION_TOKEN", rerun);
78
+ (io.verificationToken
79
+ ? await io.verificationToken(eventSecurityUrl)
80
+ : await io.prompt("LARK_VERIFICATION_TOKEN (Events & Callbacks → Encryption Strategy)", { hidden: true })), "LARK_VERIFICATION_TOKEN", rerun);
51
81
  return {
52
82
  LARK_APP_ID: appId,
53
83
  LARK_APP_SECRET: appSecret,
@@ -1,21 +1,41 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { createServer } from "node:http";
3
- const escapeHtml = (text) => text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
4
- const page = (body, head = "") => `<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width">${head}` +
5
- `<title>FastAgent Slack setup</title><body style="font:16px system-ui;max-width:40em;margin:3em auto;padding:0 1em">${body}`;
3
+ import { field, formCard, openButton, privacy, prose, refusal, sendPage, setupRequestUrl, statusCard, step, submit, } from "../../setup-page.js";
4
+ /** Where Slack's App Configuration tokens are generated: the page names the section, and links to the page it is on. */
5
+ const SLACK_APPS_URL = "https://api.slack.com/apps";
6
+ const TITLE = "Connect Slack — FastAgent";
6
7
  function tokensForm(error) {
7
- return page(`<h1>Slack App Configuration token</h1>` +
8
- `<ol><li>Open <a href="https://api.slack.com/apps" target=_blank rel=noopener>api.slack.com/apps</a>, then ` +
9
- `<b>Your App Configuration Tokens</b> → <b>Generate Token</b> for the workspace the agent joins.</li>` +
10
- `<li>Paste both tokens here.</li></ol>` +
11
- `<p>FastAgent keeps them on this machine only (owner-readable), uses them to create the app and update its ` +
12
- `Request URL, and never deploys them.</p>` +
13
- (error ? `<p style="color:#b00"><b>${escapeHtml(error)}</b></p>` : "") +
14
- `<form method=post><p><label>Access token<br><input name=access type=password required ` +
15
- `placeholder="xoxe.xoxp-…" style="width:100%"></label></p><p><label>Refresh token<br><input name=refresh ` +
16
- `type=password required placeholder="xoxe-…" style="width:100%"></label></p>` +
17
- `<p><button>Create the Slack app</button></p></form>`);
8
+ return {
9
+ title: TITLE,
10
+ card: formCard("Connect Slack", [
11
+ step(1, "Generate two tokens in Slack", prose("Under <b>Your App Configuration Tokens</b>, choose <b>Generate Token</b> for your workspace.") +
12
+ openButton("Open Slack", SLACK_APPS_URL)),
13
+ step(2, "Paste them here", (error ? refusal("Those tokens were not accepted.", ` ${error}`) : "") +
14
+ field({
15
+ id: "access",
16
+ label: "Access Token",
17
+ placeholder: "xoxe.xoxp-…",
18
+ secret: true,
19
+ invalid: error !== undefined,
20
+ autofocus: error !== undefined,
21
+ }) +
22
+ field({
23
+ id: "refresh",
24
+ label: "Refresh Token",
25
+ placeholder: "xoxe-…",
26
+ secret: true,
27
+ invalid: error !== undefined,
28
+ }) +
29
+ submit("Create Slack app") +
30
+ privacy(), { bare: error === undefined }),
31
+ ].join("")),
32
+ };
18
33
  }
34
+ /** A page that says where the setup stands. */
35
+ const status = (input, title = "FastAgent setup") => ({
36
+ title,
37
+ card: statusCard(input),
38
+ });
19
39
  /** Temporary onboarding-only responder. */
20
40
  export async function startSlackSetupServer() {
21
41
  const nonce = randomBytes(24).toString("hex");
@@ -45,39 +65,53 @@ export async function startSlackSetupServer() {
45
65
  settleOAuth = resolve;
46
66
  });
47
67
  const server = createServer((request, response) => {
48
- const url = new URL(request.url ?? "/", "http://127.0.0.1");
68
+ const url = setupRequestUrl(request, response);
69
+ if (url === undefined)
70
+ return;
49
71
  if (request.method === "GET" && url.pathname === "/health") {
50
72
  response.end("ok\n");
51
73
  return;
52
74
  }
53
75
  if (request.method === "GET" && url.pathname === redirectPath) {
54
- settleOAuth?.({
76
+ const callback = {
55
77
  code: url.searchParams.get("code") ?? undefined,
56
78
  state: url.searchParams.get("state") ?? undefined,
57
79
  error: url.searchParams.get("error") ?? undefined,
58
- });
80
+ };
81
+ settleOAuth?.(callback);
59
82
  settleOAuth = undefined;
60
- response.statusCode = 200;
61
- response.setHeader("content-type", "text/html; charset=utf-8");
62
- response.end(page("<p>Slack authorization received: FastAgent is finishing the install. You can close this page.</p>"));
83
+ // The onboarding flow still has to validate state and exchange the code.
84
+ sendPage(response, 200, status(callback.error !== undefined
85
+ ? {
86
+ state: "failed",
87
+ heading: "Slack authorization was not approved",
88
+ lede: callback.error
89
+ ? `OAuth error: ${callback.error}`
90
+ : "The callback reported an authorization error.",
91
+ next: "Go back to your coding agent to restart setup.",
92
+ }
93
+ : {
94
+ state: "working",
95
+ heading: "OAuth response received",
96
+ lede: "FastAgent still needs to verify this response and finish the installation.",
97
+ next: "Go back to your coding agent to check the result. You can close this tab.",
98
+ }));
63
99
  return;
64
100
  }
65
101
  if (url.pathname === tokensPath && (request.method === "GET" || request.method === "POST")) {
66
- response.setHeader("content-type", "text/html; charset=utf-8");
67
102
  if (takeTokens === undefined) {
68
103
  // Taken already, or not asked for: the page is one-time.
69
- response.statusCode = 404;
70
- response.end(page("<p>This page is no longer in use.</p>"));
104
+ sendPage(response, 404, status({ state: "failed", heading: "This page is no longer in use", next: "Go back to your coding agent." }));
71
105
  return;
72
106
  }
73
107
  if (request.method === "GET") {
74
- response.end(tokensForm());
108
+ sendPage(response, 200, tokensForm());
75
109
  return;
76
110
  }
77
111
  readBody(request, (body) => {
78
112
  if (body === undefined) {
79
113
  response.statusCode = 413;
80
- response.end(page("<p>Too large.</p>"));
114
+ response.end("payload too large\n");
81
115
  return;
82
116
  }
83
117
  const form = new URLSearchParams(body);
@@ -85,7 +119,7 @@ export async function startSlackSetupServer() {
85
119
  takeTokens?.({ access: (form.get("access") ?? "").trim(), refresh: (form.get("refresh") ?? "").trim() });
86
120
  }
87
121
  catch (error) {
88
- response.end(tokensForm(error.message));
122
+ sendPage(response, 200, tokensForm(error.message));
89
123
  return;
90
124
  }
91
125
  response.statusCode = 303;
@@ -95,9 +129,16 @@ export async function startSlackSetupServer() {
95
129
  return;
96
130
  }
97
131
  if (request.method === "GET" && url.pathname === nextPath) {
98
- response.setHeader("content-type", "text/html; charset=utf-8");
99
132
  if (after === undefined) {
100
- response.end(page("<p>Creating the Slack app…</p>", '<meta http-equiv="refresh" content="2">'));
133
+ // Says what comes next, so the redirect to Slack's page is no surprise.
134
+ sendPage(response, 200, {
135
+ ...status({
136
+ state: "working",
137
+ heading: "Creating your Slack app",
138
+ lede: "In a moment Slack will ask you to click Allow.",
139
+ }),
140
+ head: '<meta http-equiv="refresh" content="2">',
141
+ });
101
142
  }
102
143
  else if ("url" in after) {
103
144
  response.statusCode = 303;
@@ -105,8 +146,12 @@ export async function startSlackSetupServer() {
105
146
  response.end();
106
147
  }
107
148
  else {
108
- const style = after.failed ? ' style="color:#b00"' : "";
109
- response.end(page(`<p${style}><b>${escapeHtml(after.said)}</b></p><p>Return to your agent.</p>`));
149
+ sendPage(response, 200, status({
150
+ state: after.failed ? "failed" : "done",
151
+ heading: after.failed ? "Setup stopped" : "Done",
152
+ lede: after.said,
153
+ next: "Go back to your coding agent.",
154
+ }));
110
155
  lastShown?.();
111
156
  }
112
157
  return;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Readiness probe for a server THIS process can reach over HTTP — the local port `deploy docker --run` just
3
- * published, the live probes' own origins, and the public URL a host deploy must clear before it registers a webhook
3
+ * published, and the public URL a host deploy must clear before it registers a webhook
4
4
  * (`publicHealthGate` in deploy/channel-ingress.ts).
5
5
  */
6
6
  import { setTimeout as sleep } from "node:timers/promises";
@@ -30,15 +30,6 @@ export declare function onboardFeishuCloudApp(target: string, kind: "feishu" | "
30
30
  rerun?: string,
31
31
  /** Who takes the steps only a person can: at this terminal, or a coding agent's person through a ticket. */
32
32
  person?: Person): Promise<Record<string, string> | undefined>;
33
- /**
34
- * Lark's app credentials, which only the person can get, from the app they create in Lark's console: with no one at a
35
- * terminal to type them, the report naming where they set them (the selected environment's file), when either is
36
- * absent. `again` is the command that continues once they have.
37
- */
38
- export declare function larkCredentialsNeeded(agentDir: string, again: string): Promise<{
39
- json: Record<string, unknown>;
40
- text: string[];
41
- } | undefined>;
42
33
  export declare function feishuAppSecretNames(kind: "feishu" | "lark", ingress: FeishuSubscriptionMode): string[];
43
34
  interface FeishuAppSetup {
44
35
  kind: "feishu" | "lark";