@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.
- package/README.md +2 -1
- package/dist/channels/feishu/feishu-api.d.ts +1 -0
- package/dist/channels/feishu/feishu-api.js +4 -0
- package/dist/channels/feishu/register-app.d.ts +2 -0
- package/dist/channels/feishu/register-app.js +1 -1
- package/dist/channels/lark/credentials-page.d.ts +25 -0
- package/dist/channels/lark/credentials-page.js +206 -0
- package/dist/channels/lark/onboard.d.ts +18 -2
- package/dist/channels/lark/onboard.js +36 -6
- package/dist/channels/slack/setup-server.js +75 -30
- package/dist/channels/wait-health.js +1 -1
- package/dist/cli/add-feishu.d.ts +0 -9
- package/dist/cli/add-feishu.js +62 -38
- package/dist/cli/commands/add.js +5 -12
- package/dist/cli/commands/deploy.js +1 -8
- package/dist/cli/commands/info.js +4 -0
- package/dist/cli/commands/mcp.d.ts +12 -0
- package/dist/cli/commands/mcp.js +156 -0
- package/dist/cli/program.js +59 -0
- package/dist/deploy/agentcore/destroy.js +1 -1
- package/dist/deploy/build-context.js +4 -0
- package/dist/deploy/container.js +1 -2
- package/dist/deploy/docker/run.d.ts +16 -3
- package/dist/deploy/docker/run.js +54 -1
- package/dist/deploy/fly/run.d.ts +0 -14
- package/dist/deploy/fly/run.js +4 -5
- package/dist/dev-supervisor.js +3 -2
- package/dist/harnesses/pi-durable/mcp.d.ts +3 -0
- package/dist/harnesses/pi-durable/mcp.js +34 -0
- package/dist/harnesses/pi-durable/open.d.ts +1 -1
- package/dist/harnesses/pi-durable/open.js +12 -3
- package/dist/harnesses/pi-durable/serve.d.ts +4 -0
- package/dist/harnesses/pi-durable/serve.js +47 -17
- package/dist/http/contract.d.ts +3 -3
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/locked-file.d.ts +2 -2
- package/dist/locked-file.js +8 -3
- package/dist/mcp/auth.d.ts +22 -0
- package/dist/mcp/auth.js +180 -0
- package/dist/mcp/client.d.ts +15 -0
- package/dist/mcp/client.js +170 -0
- package/dist/mcp/config.d.ts +34 -0
- package/dist/mcp/config.js +142 -0
- package/dist/mcp/grants.d.ts +4 -0
- package/dist/mcp/grants.js +132 -0
- package/dist/mcp/host.d.ts +16 -0
- package/dist/mcp/host.js +120 -0
- package/dist/mcp/network.d.ts +12 -0
- package/dist/mcp/network.js +150 -0
- package/dist/mcp/sign-in.d.ts +2 -0
- package/dist/mcp/sign-in.js +204 -0
- package/dist/mcp-api.d.ts +93 -0
- package/dist/mcp-api.js +10 -0
- package/dist/setup-page.d.ts +51 -0
- package/dist/setup-page.js +149 -0
- package/dist/value-file.d.ts +1 -0
- package/dist/value-file.js +15 -16
- 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
|
|
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 & 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 & 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
|
|
1
|
+
import { type FeishuSubscriptionMode } from "../feishu/setup-mode.ts";
|
|
2
2
|
/** Guided Lark-international onboarding. */
|
|
3
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
(
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
response
|
|
62
|
-
|
|
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.
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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,
|
|
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";
|
package/dist/cli/add-feishu.d.ts
CHANGED
|
@@ -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";
|