@fastagent-sh/fastagent 0.27.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 +4 -3
- package/dist/authoring.d.ts +14 -7
- package/dist/authoring.js +11 -7
- 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/content.js +1 -1
- package/dist/cli/commands/deploy.js +1 -8
- package/dist/cli/commands/info.js +4 -0
- package/dist/cli/commands/invoke.js +5 -3
- package/dist/cli/commands/mcp.d.ts +12 -0
- package/dist/cli/commands/mcp.js +156 -0
- package/dist/cli/content-view.d.ts +1 -1
- package/dist/cli/content-view.js +2 -2
- package/dist/cli/program.js +65 -9
- package/dist/content/declare.d.ts +1 -6
- package/dist/content/declare.js +13 -18
- package/dist/content/resolve.d.ts +0 -2
- package/dist/content/resolve.js +2 -4
- package/dist/content/source.d.ts +0 -1
- package/dist/content/source.js +0 -1
- 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/deploy/preflight.js +6 -14
- 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 +3 -4
- package/dist/harnesses/pi-durable/open.js +13 -10
- package/dist/harnesses/pi-durable/serve.d.ts +5 -1
- package/dist/harnesses/pi-durable/serve.js +47 -17
- package/dist/harnesses/pi-durable/text.js +7 -13
- package/dist/http/contract.d.ts +3 -3
- package/dist/index.d.ts +9 -1
- package/dist/index.js +6 -1
- 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/model-ref.d.ts +3 -0
- package/dist/model-ref.js +7 -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
|
@@ -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";
|
package/dist/cli/add-feishu.js
CHANGED
|
@@ -7,9 +7,10 @@ import { isCancel, log as clackLog, password, text as clackText } from "@clack/p
|
|
|
7
7
|
import { bootstrapFeishuVerificationToken } from "../channels/feishu/bootstrap-token.js";
|
|
8
8
|
import { FEISHU_APP_CONFIG_SCOPE, feishuAppAddons, feishuAppScopes, feishuScopeRequestUrl, scopeSatisfied, } from "../channels/feishu/setup-mode.js";
|
|
9
9
|
import { cloudFor } from "../channels/feishu/cloud.js";
|
|
10
|
-
import { createFeishuApi, isFeishuConfigApiMissing, isTransientFeishuRegistrationError, } from "../channels/feishu/feishu-api.js";
|
|
10
|
+
import { createFeishuApi, isFeishuConfigApiMissing, isFeishuCredentialsRefused, isTransientFeishuRegistrationError, } from "../channels/feishu/feishu-api.js";
|
|
11
11
|
import { registerFeishuApp } from "../channels/feishu/register-app.js";
|
|
12
|
-
import {
|
|
12
|
+
import { LarkCredentialsRefused, startLarkCredentialsPage, startLarkVerificationPage, } from "../channels/lark/credentials-page.js";
|
|
13
|
+
import { onboardLarkApp } from "../channels/lark/onboard.js";
|
|
13
14
|
import { dotEnvPath, loadEnvValues, parseEnvContent } from "../value-file.js";
|
|
14
15
|
import { openExternalUrl } from "../open-url.js";
|
|
15
16
|
import { terminalPerson } from "./person.js";
|
|
@@ -88,7 +89,7 @@ person = terminalPerson) {
|
|
|
88
89
|
return undefined;
|
|
89
90
|
}
|
|
90
91
|
// guided-console (lark): the intl cloud cannot complete the bound device flow — collect + validate.
|
|
91
|
-
const credentials = await onboardLarkApp(person === terminalPerson ? larkAtTerminal : larkThrough(person,
|
|
92
|
+
const credentials = await onboardLarkApp(person === terminalPerson ? larkAtTerminal : larkThrough(person, target), {
|
|
92
93
|
existing,
|
|
93
94
|
ingress,
|
|
94
95
|
rerun,
|
|
@@ -99,6 +100,17 @@ person = terminalPerson) {
|
|
|
99
100
|
await createFeishuApi({ kind: "lark", baseUrl: apiBase, appId, appSecret }).verifyCredentials();
|
|
100
101
|
console.error(`[fastagent] Lark App ID / Secret verified`);
|
|
101
102
|
},
|
|
103
|
+
// The same boundary `add feishu` keeps: a pair given just now is written before any step that can fail, so a
|
|
104
|
+
// rerun finds it and carries on, instead of asking for it again.
|
|
105
|
+
keep: async (appId, appSecret) => {
|
|
106
|
+
const kept = {
|
|
107
|
+
LARK_APP_ID: appId,
|
|
108
|
+
LARK_APP_SECRET: appSecret,
|
|
109
|
+
...(ingress === "webhook" ? { LARK_VERIFICATION_TOKEN: "" } : {}),
|
|
110
|
+
};
|
|
111
|
+
await appendChannelDotEnv(target, "lark", kept, Object.keys(kept), ingress);
|
|
112
|
+
console.error(`[fastagent] wrote LARK_APP_ID, LARK_APP_SECRET to ${env}`);
|
|
113
|
+
},
|
|
102
114
|
bootstrapWebhook: async (appId, appSecret) => {
|
|
103
115
|
const api = createFeishuApi({ kind: "lark", baseUrl: apiBase, appId, appSecret });
|
|
104
116
|
console.error(`[fastagent] trying Lark's webhook-mode + Verification-Token bootstrap (temporary tunnel)…`);
|
|
@@ -152,16 +164,56 @@ const larkAtTerminal = {
|
|
|
152
164
|
return isCancel(result) ? undefined : result;
|
|
153
165
|
},
|
|
154
166
|
};
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
167
|
+
async function waitForLarkPage(person, page, action) {
|
|
168
|
+
const port = new URL(page.url).port;
|
|
169
|
+
try {
|
|
170
|
+
person.waitsOn(page.url, action +
|
|
171
|
+
" If the CLI runs remotely, forward this loopback port to the same local port " +
|
|
172
|
+
`(SSH: ssh -N -L ${port}:127.0.0.1:${port} user@host), then open the same URL in your browser. ` +
|
|
173
|
+
"Keep the forwarding session open; never paste secrets into the conversation.");
|
|
174
|
+
const value = await page.credentials;
|
|
175
|
+
person.carriesOn();
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
await page.close();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function larkThrough(person, target) {
|
|
160
183
|
return {
|
|
161
184
|
note: (message) => console.error(`[fastagent] ${message}`),
|
|
162
185
|
openUrl: (url, action) => person.afterwards(action, url),
|
|
163
|
-
async prompt(
|
|
164
|
-
throw new Error(
|
|
186
|
+
async prompt() {
|
|
187
|
+
throw new Error("Lark setup must collect secrets on its page, not through terminal prompts");
|
|
188
|
+
},
|
|
189
|
+
async verificationToken(consoleUrl) {
|
|
190
|
+
const page = await startLarkVerificationPage({
|
|
191
|
+
consoleUrl,
|
|
192
|
+
save: async (token) => {
|
|
193
|
+
await appendChannelDotEnv(target, "lark", { LARK_VERIFICATION_TOKEN: token }, ["LARK_VERIFICATION_TOKEN"]);
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
return waitForLarkPage(person, page, "Paste the Lark Verification Token on this page.");
|
|
197
|
+
},
|
|
198
|
+
async collect({ consoleUrl, check }) {
|
|
199
|
+
const page = await startLarkCredentialsPage({
|
|
200
|
+
consoleUrl,
|
|
201
|
+
check: async ({ appId, appSecret }) => {
|
|
202
|
+
try {
|
|
203
|
+
await check(appId, appSecret);
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
// Only a known credential refusal is safe to retry on the form.
|
|
207
|
+
if (!isFeishuCredentialsRefused(error))
|
|
208
|
+
throw error;
|
|
209
|
+
throw new LarkCredentialsRefused("Lark refused that App ID or Secret.", {
|
|
210
|
+
fix: "Copy both again from Credentials & Basic Info.",
|
|
211
|
+
why: `Lark said: ${error instanceof Error ? error.message : String(error)}`,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
return waitForLarkPage(person, page, "Create the Lark app, then paste its App ID and App Secret on this page.");
|
|
165
217
|
},
|
|
166
218
|
};
|
|
167
219
|
}
|
|
@@ -280,34 +332,6 @@ async function createFeishuAppFlow(target, existing, ingress, rerun, person) {
|
|
|
280
332
|
person.afterwards(`after the permission work above, ${publish}: ${versionUrl}`);
|
|
281
333
|
}
|
|
282
334
|
}
|
|
283
|
-
/**
|
|
284
|
-
* Lark's app credentials, which only the person can get, from the app they create in Lark's console: with no one at a
|
|
285
|
-
* terminal to type them, the report naming where they set them (the selected environment's file), when either is
|
|
286
|
-
* absent. `again` is the command that continues once they have.
|
|
287
|
-
*/
|
|
288
|
-
export async function larkCredentialsNeeded(agentDir, again) {
|
|
289
|
-
const held = await activeDotEnvValues(agentDir, ["LARK_APP_ID", "LARK_APP_SECRET"]);
|
|
290
|
-
if (held.LARK_APP_ID && held.LARK_APP_SECRET)
|
|
291
|
-
return undefined;
|
|
292
|
-
const valueFile = dotEnvPath(agentDir);
|
|
293
|
-
return {
|
|
294
|
-
json: {
|
|
295
|
-
status: "need_user_action",
|
|
296
|
-
url: LARK_CONSOLE_URL,
|
|
297
|
-
variables: ["LARK_APP_ID", "LARK_APP_SECRET"],
|
|
298
|
-
value_file: valueFile,
|
|
299
|
-
check_command: `${again} --json`,
|
|
300
|
-
agent_next_steps: "Give the URL to the user so they can create the Lark app there, and ask them to set LARK_APP_ID and " +
|
|
301
|
-
`LARK_APP_SECRET (from its Credentials & Basic Info) in ${valueFile} themselves, not in this ` +
|
|
302
|
-
"conversation. Once they say they have, run check_command.",
|
|
303
|
-
},
|
|
304
|
-
text: [
|
|
305
|
-
`need_user_action: create the Lark app at ${LARK_CONSOLE_URL}`,
|
|
306
|
-
` and set LARK_APP_ID and LARK_APP_SECRET in ${valueFile}`,
|
|
307
|
-
`then run: ${again}`,
|
|
308
|
-
],
|
|
309
|
-
};
|
|
310
|
-
}
|
|
311
335
|
/**
|
|
312
336
|
* Active agent `.env` (`.secrets/.env`) values for the requested names — decided by THE .env parser, so this check can
|
|
313
337
|
* never disagree with what `loadDotEnv` reads.
|
package/dist/cli/commands/add.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { randomBytes } from "node:crypto";
|
|
6
6
|
import { join, relative, resolve } from "node:path";
|
|
7
|
-
import { assertIndependentFeishuApps,
|
|
7
|
+
import { assertIndependentFeishuApps, onboardFeishuCloudApp } from "../add-feishu.js";
|
|
8
8
|
import { terminalPerson } from "../person.js";
|
|
9
9
|
import { isInteractive, shellArg } from "../shared.js";
|
|
10
10
|
import { checkTicket, printReport, refusedCheck, runTicket, startTicket, ticketExitCode, ticketReport, } from "../ticket.js";
|
|
@@ -211,20 +211,13 @@ const SETUP_ACTIONS = {
|
|
|
211
211
|
lark: "finish setting up the Lark app",
|
|
212
212
|
};
|
|
213
213
|
/**
|
|
214
|
-
* `add slack|feishu|lark` with no one at a terminal to ask, for a coding agent:
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
214
|
+
* `add slack|feishu|lark` with no one at a terminal to ask, for a coding agent: the app's setup runs in a process of its
|
|
215
|
+
* own (ticket.ts), and the command returns once the person has a step to take, it is done, or it is still working at it.
|
|
216
|
+
* What only the person has (Slack's tokens, Lark's App ID and Secret) is pasted on a page of the setup's own, never asked
|
|
217
|
+
* for in the agent's conversation and never typed into a file.
|
|
218
218
|
*/
|
|
219
219
|
async function setUpUnattended(kind, opts, at) {
|
|
220
220
|
const { target } = at;
|
|
221
|
-
if (kind === "lark") {
|
|
222
|
-
const needed = await larkCredentialsNeeded(target, at.rerun).catch(failStartup);
|
|
223
|
-
if (needed !== undefined) {
|
|
224
|
-
printReport(opts.json, needed.json, needed.text);
|
|
225
|
-
process.exit(0);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
221
|
const timeout = opts.timeout === undefined ? UNATTENDED_TIMEOUT_SECONDS : Number(opts.timeout);
|
|
229
222
|
if (!Number.isInteger(timeout) || timeout <= 0)
|
|
230
223
|
failUsage(`--timeout must be a whole number of seconds`);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `fastagent content list|add|remove`: what the agent works on
|
|
2
|
+
* `fastagent content list|add|remove`: what the agent works on, as `context.json` declares it and
|
|
3
3
|
* `context.local.json` binds it on this machine.
|
|
4
4
|
*/
|
|
5
5
|
import { resolve } from "node:path";
|
|
@@ -8,7 +8,7 @@ import { FEISHU_INGRESS_SETTINGS } from "../../channels/feishu/setup-mode.js";
|
|
|
8
8
|
import { assembleSecrets, divergentSettingsGate, missingValuesGate } from "../../deploy/secrets.js";
|
|
9
9
|
import { dotEnvPath, loadEnvValues } from "../../value-file.js";
|
|
10
10
|
import { unmarkDevServe } from "../../serving-command.js";
|
|
11
|
-
import { assertIndependentFeishuApps, feishuAppSecretNames,
|
|
11
|
+
import { assertIndependentFeishuApps, feishuAppSecretNames, prepareFeishuApps, unpreparedFeishuApps, } from "../add-feishu.js";
|
|
12
12
|
import { displayPath, selectAgentEnvironment } from "../../paths.js";
|
|
13
13
|
import { preflightDeploy } from "../../deploy/preflight.js";
|
|
14
14
|
import { apiReport, deploymentApi, writeApiKey } from "../../deploy/api.js";
|
|
@@ -139,13 +139,6 @@ async function deploy(host, dirArg, opts, step) {
|
|
|
139
139
|
const unprepared = opts.run ? unpreparedFeishuApps(pre.channels, pre.values) : [];
|
|
140
140
|
if (opts.json && step === undefined) {
|
|
141
141
|
// A coding agent's deploy: what this machine can refuse is refused above, at once; the rest runs in a ticket.
|
|
142
|
-
if (unprepared.some(({ kind }) => kind === "lark")) {
|
|
143
|
-
const needed = await larkCredentialsNeeded(agentDir, rerun).catch(failStartup);
|
|
144
|
-
if (needed !== undefined) {
|
|
145
|
-
printReport(true, needed.json, needed.text);
|
|
146
|
-
process.exit(0);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
142
|
const timeout = opts.timeout === undefined ? TICKET_TIMEOUT_SECONDS : Number(opts.timeout);
|
|
150
143
|
if (!Number.isInteger(timeout) || timeout <= 0)
|
|
151
144
|
failUsage("--timeout must be a whole number of seconds");
|
|
@@ -8,6 +8,7 @@ import { enterAgentEnv } from "../../env.js";
|
|
|
8
8
|
import { modelEnvironment } from "../../harnesses/models/environment.js";
|
|
9
9
|
import { buildDefinition, modelSpecFor, prepareDefinition, secretsManifest } from "../../harnesses/pi-durable/open.js";
|
|
10
10
|
import { readCommands } from "../../harnesses/pi-durable/text.js";
|
|
11
|
+
import { inspectMcp } from "../../mcp/host.js";
|
|
11
12
|
import { resolveStateRoot } from "../../paths.js";
|
|
12
13
|
import { nextRun } from "../../schedule/cron.js";
|
|
13
14
|
import { contentLines } from "../content-view.js";
|
|
@@ -60,6 +61,7 @@ export async function runInfo(dirArg, opts) {
|
|
|
60
61
|
const commands = readCommands(agentDir).map(({ name, description }) => ({ name, description: description ?? null }));
|
|
61
62
|
const stateRoot = resolveStateRoot(agentDir);
|
|
62
63
|
const auth = models.auth;
|
|
64
|
+
const mcp = await inspectMcp(agentDir).catch(failStartup);
|
|
63
65
|
if (opts.json) {
|
|
64
66
|
console.log(JSON.stringify({
|
|
65
67
|
agentDir,
|
|
@@ -72,6 +74,7 @@ export async function runInfo(dirArg, opts) {
|
|
|
72
74
|
modelError: modelError ?? null,
|
|
73
75
|
thinkingLevel: config.thinkingLevel ?? null,
|
|
74
76
|
tools: code?.tools.map(({ name }) => name) ?? null,
|
|
77
|
+
mcp,
|
|
75
78
|
extensions: code?.extensions.map(({ name }) => name) ?? null,
|
|
76
79
|
channels: code === undefined ? null : channels,
|
|
77
80
|
schedules: code === undefined ? null : schedules,
|
|
@@ -102,6 +105,7 @@ export async function runInfo(dirArg, opts) {
|
|
|
102
105
|
line("tools", code ? code.tools.map(({ name }) => name).join(", ") || "(none)" : unbuilt);
|
|
103
106
|
if (code && code.extensions.length > 0)
|
|
104
107
|
line("extensions", code.extensions.map(({ name }) => name).join(", "));
|
|
108
|
+
line("mcp", mcp.map((server) => `${server.name} (${server.transport}, ${server.authorization})`).join(", ") || "(none)");
|
|
105
109
|
line("channels", code ? channels.map((c) => `${c.name} (${c.ingress})`).join(", ") || "(none)" : unbuilt);
|
|
106
110
|
line("schedules", code ? schedules.map((s) => `${s.name} (next ${s.next ?? "never"})`).join(", ") || "(none)" : unbuilt);
|
|
107
111
|
line("commands", commands.map(({ name }) => `/${name}`).join(", ") || "(none)");
|
|
@@ -8,14 +8,12 @@ import { AgentError } from "../../agent-api.js";
|
|
|
8
8
|
import { enterAgentEnv } from "../../env.js";
|
|
9
9
|
import { dotEnvPath, loadEnvValues } from "../../value-file.js";
|
|
10
10
|
import { installProxyFetch } from "../../proxy.js";
|
|
11
|
-
import { modelRefOf } from "../../
|
|
12
|
-
import { openServing } from "../../harnesses/pi-durable/serve.js";
|
|
11
|
+
import { modelRefOf } from "../../model-ref.js";
|
|
13
12
|
import { connectAgent } from "../../http/client.js";
|
|
14
13
|
import { API_KEY_VARIABLE, apiKeysFrom } from "../../http/server.js";
|
|
15
14
|
import { resolveStateRoot, selectAgentEnvironment } from "../../paths.js";
|
|
16
15
|
import { runInvokeJson, runInvokeStream } from "../invoke-stream.js";
|
|
17
16
|
import { failStartup, failUsage, optionalAgentDirOrExit } from "../fail.js";
|
|
18
|
-
import { enterAgentCommand, reportAuth } from "../shared.js";
|
|
19
17
|
/**
|
|
20
18
|
* `invoke`'s sessions, apart from the instance's storage, so `invoke` runs beside a serve of this directory, which holds
|
|
21
19
|
* that one; one `invoke` at a time holds this one.
|
|
@@ -112,6 +110,10 @@ export async function runInvoke(message, dirArg, opts) {
|
|
|
112
110
|
return runRemoteInvoke(message, dirArg, { ...opts, url: opts.url });
|
|
113
111
|
if (opts.key !== undefined)
|
|
114
112
|
failUsage("--key is the key --url calls with: pass --url too");
|
|
113
|
+
const [{ openServing }, { enterAgentCommand, reportAuth }] = await Promise.all([
|
|
114
|
+
import("../../harnesses/pi-durable/serve.js"),
|
|
115
|
+
import("../shared.js"),
|
|
116
|
+
]);
|
|
115
117
|
const { agentDir, modelSpec } = await enterAgentCommand(dirArg, opts);
|
|
116
118
|
const file = invokeStorageFile(resolveStateRoot(agentDir));
|
|
117
119
|
const { agent, models, definition } = await openServing(agentDir, {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface McpCommandOptions {
|
|
2
|
+
json?: boolean;
|
|
3
|
+
environment?: string;
|
|
4
|
+
scopes?: string;
|
|
5
|
+
timeout?: string;
|
|
6
|
+
clientId?: string;
|
|
7
|
+
callbackPort?: string;
|
|
8
|
+
check?: string;
|
|
9
|
+
ticket?: string;
|
|
10
|
+
input?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare function runMcp(action: "list" | "login" | "disconnect", server: string | undefined, dir: string, options: McpCommandOptions): Promise<void>;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/** MCP inspection, explicit sign-in and local binding removal. */
|
|
2
|
+
import { confirm, isCancel } from "@clack/prompts";
|
|
3
|
+
import { inspectMcp, mcpContext, writeMcpBinding, httpServer } from "../../mcp/host.js";
|
|
4
|
+
import { signInMcp } from "../../mcp/sign-in.js";
|
|
5
|
+
import { holdingInstance } from "../../harnesses/pi-durable/serve.js";
|
|
6
|
+
import { resolveMcpServers } from "../../mcp/config.js";
|
|
7
|
+
import { McpError } from "../../mcp-api.js";
|
|
8
|
+
import { selectAgentEnvironment } from "../../paths.js";
|
|
9
|
+
import { enterAgentEnv } from "../../env.js";
|
|
10
|
+
import { openExternalUrl } from "../../open-url.js";
|
|
11
|
+
import { isInteractive, shellArg } from "../shared.js";
|
|
12
|
+
import { agentDirOrExit, failStartup, failUsage } from "../fail.js";
|
|
13
|
+
import { checkTicket, refusedCheck, runTicket, startTicket, ticketExitCode, ticketReport, printReport, } from "../ticket.js";
|
|
14
|
+
const CHECK = "fastagent mcp login --check";
|
|
15
|
+
function report(ticket, json, when) {
|
|
16
|
+
const result = ticketReport(ticket, when, (value) => ({
|
|
17
|
+
fields: value,
|
|
18
|
+
text: ["MCP authorization saved and bound; reopen the agent to use it"],
|
|
19
|
+
next: "Reopen the agent to use its MCP tools.",
|
|
20
|
+
}));
|
|
21
|
+
printReport(json, result.json, result.text);
|
|
22
|
+
}
|
|
23
|
+
export async function runMcp(action, server, dir, options) {
|
|
24
|
+
if (options.check) {
|
|
25
|
+
const refusal = refusedCheck(options.check, CHECK);
|
|
26
|
+
if (refusal)
|
|
27
|
+
return failUsage(refusal);
|
|
28
|
+
const ticket = await checkTicket(options.check);
|
|
29
|
+
if (!ticket)
|
|
30
|
+
return failUsage("MCP sign-in ticket not found");
|
|
31
|
+
report(ticket, !!options.json, "checked");
|
|
32
|
+
process.exit(ticketExitCode(ticket));
|
|
33
|
+
}
|
|
34
|
+
const target = agentDirOrExit(dir);
|
|
35
|
+
const environment = options.environment ?? "dev";
|
|
36
|
+
if (environment !== "dev" && environment !== "production")
|
|
37
|
+
return failUsage("--env must be dev or production");
|
|
38
|
+
selectAgentEnvironment(environment);
|
|
39
|
+
enterAgentEnv(target);
|
|
40
|
+
if (action === "list") {
|
|
41
|
+
const servers = await inspectMcp(target).catch(failStartup);
|
|
42
|
+
if (options.json)
|
|
43
|
+
console.log(JSON.stringify(servers));
|
|
44
|
+
else
|
|
45
|
+
for (const item of servers)
|
|
46
|
+
console.log(`${item.name}\t${item.transport}\t${item.authorization}`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (!server)
|
|
50
|
+
return failUsage(`mcp ${action} needs a server name from .mcp.json`);
|
|
51
|
+
const context = mcpContext(target);
|
|
52
|
+
if (action === "disconnect") {
|
|
53
|
+
await holdingInstance(context.stateRoot, () => writeMcpBinding(context.stateRoot, server, undefined)).catch(failStartup);
|
|
54
|
+
printReport(!!options.json, {
|
|
55
|
+
status: "done",
|
|
56
|
+
server,
|
|
57
|
+
grant_revoked: false,
|
|
58
|
+
agent_next_steps: "The instance binding was removed. The stored grant was not revoked; reauthorize or update .mcp.json before reopening.",
|
|
59
|
+
}, ["Binding removed; the stored grant was not revoked. Reauthorize or update .mcp.json before reopening."]);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
httpServer(resolveMcpServers(target, context.environment, server), server);
|
|
63
|
+
const seconds = options.timeout === undefined ? 900 : Number(options.timeout);
|
|
64
|
+
if (!Number.isInteger(seconds) || seconds <= 0)
|
|
65
|
+
return failUsage("--timeout must be a positive whole number of seconds");
|
|
66
|
+
const scopes = options.scopes
|
|
67
|
+
?.split(",")
|
|
68
|
+
.map((value) => value.trim())
|
|
69
|
+
.filter(Boolean);
|
|
70
|
+
const unattended = options.json || options.input === false || !isInteractive();
|
|
71
|
+
if (unattended && scopes === undefined)
|
|
72
|
+
return failUsage("mcp login without a terminal needs --scopes <comma-separated-scopes>; consult the MCP service's authorization requirements");
|
|
73
|
+
const run = async (step) => {
|
|
74
|
+
const grant = await signInMcp({
|
|
75
|
+
agentDir: target,
|
|
76
|
+
server,
|
|
77
|
+
store: context.auth.store,
|
|
78
|
+
scopes,
|
|
79
|
+
clientId: options.clientId,
|
|
80
|
+
callbackPort: options.callbackPort === undefined ? undefined : Number(options.callbackPort),
|
|
81
|
+
timeoutSeconds: seconds,
|
|
82
|
+
interaction: {
|
|
83
|
+
signal: step?.signal,
|
|
84
|
+
async confirmAuthorization(request) {
|
|
85
|
+
const message = `Authorize ${request.server} at ${request.issuer} for ${request.scopes.join(", ") || "server-defined scopes"}?`;
|
|
86
|
+
if (step) {
|
|
87
|
+
console.error(`[fastagent] ${message}`);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
const choice = await confirm({ message, initialValue: false });
|
|
91
|
+
return !isCancel(choice) && choice;
|
|
92
|
+
},
|
|
93
|
+
notify(event) {
|
|
94
|
+
if (event.type === "auth_url") {
|
|
95
|
+
const instructions = `Authorize the MCP server ${server} in your browser; scope: ${scopes?.join(", ") ?? "as approved"}. The callback is on the machine running this command; do not paste codes into chat.`;
|
|
96
|
+
if (step)
|
|
97
|
+
step.person.waitsOn(event.url, instructions);
|
|
98
|
+
else {
|
|
99
|
+
console.error(`${instructions}\n${event.url}`);
|
|
100
|
+
openExternalUrl(event.url);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
step?.person.carriesOn();
|
|
105
|
+
console.error(`[fastagent] ${event.message}`);
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
await writeMcpBinding(context.stateRoot, server, grant.id);
|
|
111
|
+
return { server, grant_id: grant.id, scopes: grant.scopes };
|
|
112
|
+
};
|
|
113
|
+
if (options.ticket) {
|
|
114
|
+
await runTicket(options.ticket, (step) => run(step));
|
|
115
|
+
process.exit(0);
|
|
116
|
+
}
|
|
117
|
+
if (unattended && scopes !== undefined) {
|
|
118
|
+
const args = [
|
|
119
|
+
process.argv[1],
|
|
120
|
+
"mcp",
|
|
121
|
+
"login",
|
|
122
|
+
server,
|
|
123
|
+
target,
|
|
124
|
+
"--env",
|
|
125
|
+
environment,
|
|
126
|
+
"--scopes",
|
|
127
|
+
scopes.join(","),
|
|
128
|
+
"--timeout",
|
|
129
|
+
String(seconds),
|
|
130
|
+
];
|
|
131
|
+
if (options.clientId)
|
|
132
|
+
args.push("--client-id", options.clientId);
|
|
133
|
+
if (options.callbackPort)
|
|
134
|
+
args.push("--callback-port", options.callbackPort);
|
|
135
|
+
const ticket = await startTicket({
|
|
136
|
+
subject: `mcp:login:${environment}:${target}:${server}`,
|
|
137
|
+
action: `authorize MCP server ${server}`,
|
|
138
|
+
check: CHECK,
|
|
139
|
+
restart: `fastagent ${args.slice(1).map(shellArg).join(" ")}`,
|
|
140
|
+
timeoutSeconds: seconds,
|
|
141
|
+
whenRunning: "replace",
|
|
142
|
+
waitsFirst: false,
|
|
143
|
+
command: { file: process.execPath, args: [...process.execArgv, ...args], cwd: process.cwd() },
|
|
144
|
+
});
|
|
145
|
+
report(ticket, !!options.json, "started");
|
|
146
|
+
process.exit(ticketExitCode(ticket));
|
|
147
|
+
}
|
|
148
|
+
const result = await run().catch((error) => {
|
|
149
|
+
if (error instanceof McpError && error.code === "canceled")
|
|
150
|
+
return failUsage(error.message);
|
|
151
|
+
return failStartup(error);
|
|
152
|
+
});
|
|
153
|
+
printReport(!!options.json, { status: "done", ...result }, [
|
|
154
|
+
"MCP authorization saved and bound; reopen the agent to use it",
|
|
155
|
+
]);
|
|
156
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import type { BoundContent } from "../content/declare.ts";
|
|
3
3
|
import { type ResolvedContent } from "../content/resolve.ts";
|
|
4
4
|
/**
|
|
5
|
-
* The report lines for an agent's content — `works on
|
|
5
|
+
* The report lines for an agent's content — `works on`, one per entry, then a `notice` line for each
|
|
6
6
|
* thing its resolution has to say, then one for each directory this machine binds that is not here — shared by the
|
|
7
7
|
* startup report, `info` and `content list`, so the three say the same thing.
|
|
8
8
|
*/
|