@fastagent-sh/fastagent 0.22.1 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent.d.ts +10 -0
- package/dist/channels/agentcore-protocol.d.ts +2 -1
- package/dist/channels/agentcore-service.js +10 -18
- package/dist/channels/agentcore.js +6 -3
- package/dist/channels/control.js +23 -0
- package/dist/channels/kit/preview-kit.d.ts +6 -1
- package/dist/channels/kit/preview-kit.js +21 -4
- package/dist/channels/slack/config-api.d.ts +2 -2
- package/dist/channels/slack/config-api.js +2 -2
- package/dist/channels/slack/onboard.js +2 -2
- package/dist/channels/slack/preview.js +2 -1
- package/dist/channels/slack/register-webhook.js +2 -2
- package/dist/channels/slack/slack-api.js +4 -4
- package/dist/cli/add-feishu.js +4 -2
- package/dist/cli/auth-view.d.ts +15 -6
- package/dist/cli/auth-view.js +24 -8
- package/dist/cli/box-login.d.ts +33 -0
- package/dist/cli/box-login.js +162 -0
- package/dist/cli/commands/deploy/agentcore.js +48 -11
- package/dist/cli/commands/deploy/docker.js +24 -13
- package/dist/cli/commands/deploy/fly.js +30 -7
- package/dist/cli/commands/deploy/railway.js +21 -7
- package/dist/cli/commands/deploy/shared.d.ts +18 -17
- package/dist/cli/commands/deploy/shared.js +20 -21
- package/dist/cli/commands/destroy.js +2 -2
- package/dist/cli/commands/info.js +15 -12
- package/dist/cli/commands/invoke.js +2 -2
- package/dist/cli/commands/login.d.ts +7 -1
- package/dist/cli/commands/login.js +123 -10
- package/dist/cli/commands/logs.js +2 -2
- package/dist/cli/commands/models.d.ts +4 -1
- package/dist/cli/commands/models.js +37 -4
- package/dist/cli/commands/routine-run.js +2 -2
- package/dist/cli/commands/start.d.ts +0 -5
- package/dist/cli/commands/start.js +3 -13
- package/dist/cli/invoke-stream.js +14 -0
- package/dist/cli/kernel.d.ts +2 -0
- package/dist/cli/kernel.js +1 -1
- package/dist/cli/login-relay.d.ts +38 -0
- package/dist/cli/login-relay.js +121 -0
- package/dist/cli/program.js +46 -8
- package/dist/cli/shared.d.ts +6 -5
- package/dist/cli/shared.js +29 -36
- package/dist/deploy/agentcore/plan.d.ts +10 -11
- package/dist/deploy/agentcore/plan.js +8 -14
- package/dist/deploy/agentcore/run.d.ts +8 -1
- package/dist/deploy/agentcore/run.js +20 -14
- package/dist/deploy/agentcore/shell.d.ts +25 -0
- package/dist/deploy/agentcore/shell.js +202 -0
- package/dist/deploy/box-shell.d.ts +43 -0
- package/dist/deploy/box-shell.js +28 -0
- package/dist/deploy/build-context.js +16 -7
- package/dist/deploy/channel-ingress.d.ts +20 -1
- package/dist/deploy/channel-ingress.js +34 -7
- package/dist/deploy/container.d.ts +14 -0
- package/dist/deploy/container.js +45 -1
- package/dist/deploy/docker/plan.d.ts +7 -3
- package/dist/deploy/docker/plan.js +9 -14
- package/dist/deploy/docker/run.d.ts +14 -7
- package/dist/deploy/docker/run.js +58 -35
- package/dist/deploy/fly/plan.d.ts +6 -3
- package/dist/deploy/fly/plan.js +6 -8
- package/dist/deploy/fly/run.d.ts +8 -2
- package/dist/deploy/fly/run.js +15 -25
- package/dist/deploy/preflight.d.ts +9 -7
- package/dist/deploy/preflight.js +92 -20
- package/dist/deploy/railway/plan.d.ts +6 -3
- package/dist/deploy/railway/plan.js +7 -9
- package/dist/deploy/railway/run.d.ts +10 -6
- package/dist/deploy/railway/run.js +29 -13
- package/dist/deploy/runner.d.ts +16 -0
- package/dist/deploy/runner.js +31 -0
- package/dist/deploy/secrets.d.ts +4 -13
- package/dist/deploy/secrets.js +8 -27
- package/dist/dev-supervisor.js +7 -4
- package/dist/engines/pi/agent-models.d.ts +83 -0
- package/dist/engines/pi/agent-models.js +120 -0
- package/dist/engines/pi/agent-session-factory.d.ts +13 -13
- package/dist/engines/pi/agent-session-factory.js +132 -159
- package/dist/engines/pi/auth.d.ts +56 -3
- package/dist/engines/pi/auth.js +68 -109
- package/dist/engines/pi/config.d.ts +0 -25
- package/dist/engines/pi/config.js +3 -37
- package/dist/engines/pi/create.d.ts +34 -21
- package/dist/engines/pi/create.js +90 -57
- package/dist/engines/pi/entry-images.d.ts +14 -0
- package/dist/engines/pi/entry-images.js +23 -0
- package/dist/engines/pi/invoke-session.js +88 -20
- package/dist/engines/pi/locked-file.d.ts +9 -0
- package/dist/engines/pi/locked-file.js +102 -0
- package/dist/engines/pi/login.d.ts +27 -14
- package/dist/engines/pi/login.js +41 -18
- package/dist/engines/pi/machine.d.ts +23 -0
- package/dist/engines/pi/machine.js +24 -2
- package/dist/engines/pi/models.d.ts +120 -36
- package/dist/engines/pi/models.js +316 -66
- package/dist/engines/pi/open.d.ts +65 -20
- package/dist/engines/pi/open.js +88 -34
- package/dist/engines/pi/report.d.ts +3 -0
- package/dist/engines/pi/report.js +11 -0
- package/dist/engines/pi/service.d.ts +2 -2
- package/dist/engines/pi/service.js +1 -0
- package/dist/engines/pi/session-builder.d.ts +3 -2
- package/dist/engines/pi/session-builder.js +30 -76
- package/dist/engines/pi/session-control.d.ts +4 -4
- package/dist/engines/pi/session-control.js +86 -43
- package/dist/engines/pi/session-settings.d.ts +9 -0
- package/dist/engines/pi/session-settings.js +17 -0
- package/dist/engines/pi/session-store.d.ts +2 -2
- package/dist/engines/pi/tool-context.d.ts +3 -5
- package/dist/engines/pi/tool-context.js +4 -16
- package/dist/engines/pi/tool.d.ts +32 -21
- package/dist/engines/pi/tool.js +21 -25
- package/dist/engines/pi/turn-kit.d.ts +18 -9
- package/dist/engines/pi/turn-kit.js +115 -32
- package/dist/open-url.d.ts +15 -1
- package/dist/open-url.js +35 -3
- package/dist/paths.d.ts +4 -2
- package/dist/paths.js +6 -2
- package/dist/pi.d.ts +5 -3
- package/dist/pi.js +3 -2
- package/dist/service.d.ts +5 -8
- package/dist/session-remote.js +21 -0
- package/dist/session.d.ts +88 -15
- package/dist/session.js +5 -1
- package/package.json +6 -5
- package/dist/engines/pi/search-tools.d.ts +0 -4
- package/dist/engines/pi/search-tools.js +0 -109
package/dist/agent.d.ts
CHANGED
|
@@ -47,6 +47,16 @@ export type AgentEvent = {
|
|
|
47
47
|
isError: boolean;
|
|
48
48
|
content: Json;
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Advisory, non-terminal (engines MAY emit it): the running tool `id`'s current status, one line, replacing the last.
|
|
52
|
+
* It may be a line of the tool's output so far (trust it like `tool_ended.content`), never the whole output;
|
|
53
|
+
* untruncated, so a consumer that shows it clips it.
|
|
54
|
+
*/
|
|
55
|
+
| {
|
|
56
|
+
type: "tool_progress";
|
|
57
|
+
id: string;
|
|
58
|
+
text: string;
|
|
59
|
+
}
|
|
50
60
|
/**
|
|
51
61
|
* Advisory, non-terminal (engines MAY emit it): a transient internal failure scheduled a retry with backoff — the
|
|
52
62
|
* turn is still alive.
|
|
@@ -84,7 +84,8 @@ export type AgentcoreEnvelope = {
|
|
|
84
84
|
}
|
|
85
85
|
/**
|
|
86
86
|
* The deploy driver's post-deploy verification (relayed by the forwarder's reserved probe path, which answers on
|
|
87
|
-
* EVERY forwarder topology — schedule-only URLs refuse ordinary public traffic).
|
|
87
|
+
* EVERY forwarder topology — schedule-only URLs refuse ordinary public traffic). Also accepted from the IAM door,
|
|
88
|
+
* where it only opens the workspace (`login --deployment` needs one) and does not construct the channels.
|
|
88
89
|
*/
|
|
89
90
|
| {
|
|
90
91
|
kind: "probe";
|
|
@@ -91,26 +91,18 @@ export function deferAgentcoreService(stages) {
|
|
|
91
91
|
export async function mountAgentcoreService(opened, options = {}) {
|
|
92
92
|
const { agentDir, workspace, stateRoot, sessionControl } = opened;
|
|
93
93
|
const agent = options.wrapAgent?.(opened.agent) ?? opened.agent;
|
|
94
|
-
// NO control plane on this host, and `sessionControl: true` cannot change that.
|
|
94
|
+
// NO control plane on this host, and `sessionControl: true` cannot change that. The only public way in is the
|
|
95
|
+
// forwarder's Function URL, which relays an arbitrary `rawPath` verbatim as a webhook envelope and attaches the
|
|
96
|
+
// ingress secret ITSELF (deploy/agentcore/forwarder.js), so every anonymous caller arrives as trusted ingress. A
|
|
97
|
+
// channel route survives that because it checks its platform's signature; `/control/*` has no such check.
|
|
95
98
|
//
|
|
96
|
-
// The
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
// `DELETE /control/sessions/{id}` answerable from the public URL with no credential at all.
|
|
99
|
+
// The recipe if it is ever wanted: a `kind: "control"` envelope on the IAM-gated `InvokeAgentRuntime`, which the
|
|
100
|
+
// forwarder never emits (the way `kind: "invoke"` runs a turn without the ingress secret). The envelope is
|
|
101
|
+
// request/response with a buffered body, so the long-lived `GET /control/sessions/{id}/events` stream a client
|
|
102
|
+
// renders from could not ride it.
|
|
101
103
|
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
// kind it never sends can only have come from a direct IAM call — which is how `kind: "invoke"` runs a turn here
|
|
105
|
-
// without the ingress secret. Adding `kind: "control"` on the same footing is small, and is the recipe if this is
|
|
106
|
-
// ever wanted.
|
|
107
|
-
//
|
|
108
|
-
// It is not built because nothing asks for it: `connectSessionControl` has no caller in this repo, and the
|
|
109
|
-
// envelope is request/response with a buffered body (see the webhook reply), so the one route a GUI actually
|
|
110
|
-
// renders from — the long-lived `GET /control/sessions/{id}/events` stream — could not ride it anyway. Half a
|
|
111
|
-
// control plane, for nobody, on a third transport.
|
|
112
|
-
// Every config key that cannot mean anything on this host says so. Silence here is how an operator concludes a
|
|
113
|
-
// setting took effect — `sessionControl` was the one that already warned, and the other two were just as inert.
|
|
104
|
+
// Every config key that cannot mean anything on this host says so: silence is how an operator concludes a setting
|
|
105
|
+
// took effect.
|
|
114
106
|
if (opened.http?.cors) {
|
|
115
107
|
log.warn("[fastagent] agentcore: http.cors has no effect here — no browser reaches this container. Its ingress is the " +
|
|
116
108
|
"forwarder's Function URL (webhooks) and the Runtime's IAM-gated API, neither of which is a page.");
|
|
@@ -66,8 +66,10 @@ export function agentcoreRoutes(options) {
|
|
|
66
66
|
// The forwarder, or another IAM principal? See `fromForwarder` for what that actually decides.
|
|
67
67
|
const trusted = fromForwarder(envelope, ingressSecret);
|
|
68
68
|
if (!trusted) {
|
|
69
|
-
// The IAM door's kinds: AWS authenticated this caller, so no ingress secret is expected or wanted.
|
|
70
|
-
|
|
69
|
+
// The IAM door's kinds: AWS authenticated this caller, so no ingress secret is expected or wanted. A `probe`
|
|
70
|
+
// here only opens the workspace (the deferred wrapper did that before this line) and says so: it is how
|
|
71
|
+
// `login --deployment` gets a workspace to log in, after a reset, on a runtime nothing has invoked yet.
|
|
72
|
+
if (envelope.kind !== "invoke" && envelope.kind !== "routine-run" && envelope.kind !== "probe") {
|
|
71
73
|
log.warn(`[agentcore] rejected an unauthenticated "${envelope.kind}" envelope`);
|
|
72
74
|
return text("forbidden\n", 403);
|
|
73
75
|
}
|
|
@@ -179,7 +181,8 @@ export function agentcoreRoutes(options) {
|
|
|
179
181
|
return json({ ok: true }, 200);
|
|
180
182
|
}
|
|
181
183
|
case "probe": {
|
|
182
|
-
// The structured verdict (transport-200 — see the envelope doc).
|
|
184
|
+
// The structured verdict (transport-200 — see the envelope doc). From the IAM door (untrusted) nothing was
|
|
185
|
+
// activated, so `ok` says only that the definition opened; channel construction is the forwarder probe's.
|
|
183
186
|
return json(constructionError === undefined
|
|
184
187
|
? { ok: true }
|
|
185
188
|
: { ok: false, error: `channel construction failed: ${constructionError}` }, 200);
|
package/dist/channels/control.js
CHANGED
|
@@ -9,6 +9,11 @@ import { text } from "./respond.js";
|
|
|
9
9
|
export const CONTROL_PREFIX = "/control";
|
|
10
10
|
/** The one variable segment in this plane's paths: a percent-encoded session id. */
|
|
11
11
|
const SESSION_SEGMENT = "{session}";
|
|
12
|
+
/**
|
|
13
|
+
* What `.../image` may answer under the image's own type. `mimeType` is whatever the prompt's sender wrote, and served
|
|
14
|
+
* as-is a `text/html` or `image/svg+xml` "image" would run script on whatever origin fronts this plane.
|
|
15
|
+
*/
|
|
16
|
+
const RASTER_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
|
12
17
|
const json = (value, status = 200) => new Response(`${JSON.stringify(value)}\n`, { status, headers: { "content-type": "application/json" } });
|
|
13
18
|
/** The plane as one mounted sub-application rather than routes sharing a prefix. */
|
|
14
19
|
function planeApp(routes) {
|
|
@@ -267,6 +272,24 @@ export function controlPlaneRoutes(control) {
|
|
|
267
272
|
const since = url.searchParams.get("since") ?? undefined;
|
|
268
273
|
return json(await control.sessions.get(session).entries(since !== undefined ? { since } : undefined));
|
|
269
274
|
},
|
|
275
|
+
// Raw bytes, not JSON-wrapped base64: a client can hand the response straight to an <img>. The ref names a
|
|
276
|
+
// durable entry, so the answer never changes. `ref` rides the query because this router has one path variable.
|
|
277
|
+
[`GET /control/sessions/${SESSION_SEGMENT}/image`]: async (_req, url, session) => {
|
|
278
|
+
const ref = url.searchParams.get("ref");
|
|
279
|
+
if (ref === null)
|
|
280
|
+
return text("expected ?ref=, an images[].ref from entries()\n", 400);
|
|
281
|
+
const image = await control.sessions.get(session).image(ref);
|
|
282
|
+
// The local call RETURNS undefined, so this is a 2xx (§13); a 404 stays "this serve predates the route".
|
|
283
|
+
if (!image)
|
|
284
|
+
return new Response(null, { status: 204 });
|
|
285
|
+
return new Response(Buffer.from(image.data, "base64"), {
|
|
286
|
+
headers: {
|
|
287
|
+
"content-type": RASTER_IMAGE_TYPES.has(image.mimeType) ? image.mimeType : "application/octet-stream",
|
|
288
|
+
"x-content-type-options": "nosniff",
|
|
289
|
+
"cache-control": "private, max-age=31536000, immutable",
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
},
|
|
270
293
|
// The run actions.
|
|
271
294
|
[`POST /control/sessions/${SESSION_SEGMENT}/actions`]: async (req, _url, session) => {
|
|
272
295
|
const read = await readJson(req);
|
|
@@ -23,6 +23,8 @@ export declare const THINKING_PLACEHOLDER = "\uD83D\uDCAD Thinking\u2026";
|
|
|
23
23
|
interface ToolLine {
|
|
24
24
|
label: string;
|
|
25
25
|
status: "running" | "ok" | "error";
|
|
26
|
+
/** The call's latest `tool_progress` text, shown while it runs. */
|
|
27
|
+
progress?: string;
|
|
26
28
|
}
|
|
27
29
|
/** The channel-neutral view STATE of one in-flight turn. */
|
|
28
30
|
export interface TurnView {
|
|
@@ -38,7 +40,10 @@ export interface TurnView {
|
|
|
38
40
|
}
|
|
39
41
|
export declare function createTurnView(): TurnView;
|
|
40
42
|
export declare function applyTurnEvent(view: TurnView, e: AgentEvent, now?: number): boolean;
|
|
41
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* The tool-activity block: one `🔧 label …/✓/✗` line per call, in call order. A running call shows its latest
|
|
45
|
+
* progress after the mark (`🔧 Bash npm test … › 42 passed`); a finished one drops it.
|
|
46
|
+
*/
|
|
42
47
|
export declare function toolLines(view: TurnView): string;
|
|
43
48
|
/** The reasoning peek: the most recent tail of the (growing) reasoning, one line, code-point safe. */
|
|
44
49
|
export declare function thinkingLine(view: TurnView, maxTail: number): string;
|
|
@@ -36,6 +36,13 @@ export function applyTurnEvent(view, e, now = Date.now()) {
|
|
|
36
36
|
view.toolById.set(e.id, line);
|
|
37
37
|
return true;
|
|
38
38
|
}
|
|
39
|
+
case "tool_progress": {
|
|
40
|
+
const line = view.toolById.get(e.id);
|
|
41
|
+
if (line?.status !== "running")
|
|
42
|
+
return closedRetry;
|
|
43
|
+
line.progress = e.text;
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
39
46
|
case "tool_ended": {
|
|
40
47
|
const line = view.toolById.get(e.id);
|
|
41
48
|
if (line)
|
|
@@ -50,9 +57,17 @@ export function applyTurnEvent(view, e, now = Date.now()) {
|
|
|
50
57
|
}
|
|
51
58
|
}
|
|
52
59
|
const TOOL_MARK = { running: "…", ok: "✓", error: "✗" };
|
|
53
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* The tool-activity block: one `🔧 label …/✓/✗` line per call, in call order. A running call shows its latest
|
|
62
|
+
* progress after the mark (`🔧 Bash npm test … › 42 passed`); a finished one drops it.
|
|
63
|
+
*/
|
|
54
64
|
export function toolLines(view) {
|
|
55
|
-
return view.tools
|
|
65
|
+
return view.tools
|
|
66
|
+
.map((t) => {
|
|
67
|
+
const progress = t.status === "running" && t.progress ? ` › ${clip(t.progress, TOOL_PROGRESS_MAX)}` : "";
|
|
68
|
+
return `🔧 ${t.label} ${TOOL_MARK[t.status]}${progress}`;
|
|
69
|
+
})
|
|
70
|
+
.join("\n");
|
|
56
71
|
}
|
|
57
72
|
/** The reasoning peek: the most recent tail of the (growing) reasoning, one line, code-point safe. */
|
|
58
73
|
export function thinkingLine(view, maxTail) {
|
|
@@ -77,15 +92,17 @@ export function composeTurnBody(parts) {
|
|
|
77
92
|
}
|
|
78
93
|
/** Max length (code points) of a tool's arg preview. */
|
|
79
94
|
const TOOL_ARG_MAX = 48;
|
|
95
|
+
/** Max length (code points) of a running tool's progress line: one status, not its output. */
|
|
96
|
+
const TOOL_PROGRESS_MAX = 64;
|
|
80
97
|
/** Max length (code points) of a humanized tool label. */
|
|
81
98
|
const TOOL_NAME_MAX = 80;
|
|
82
99
|
/**
|
|
83
100
|
* One-line, truncated at code-point boundaries: collapse whitespace so a multi-line command/arg stays on one line, and
|
|
84
101
|
* never tear a surrogate pair mid-emoji.
|
|
85
102
|
*/
|
|
86
|
-
function clip(s) {
|
|
103
|
+
function clip(s, max = TOOL_ARG_MAX) {
|
|
87
104
|
const one = s.replace(/\s+/g, " ").trim();
|
|
88
|
-
return truncateCodePointPrefix(one,
|
|
105
|
+
return truncateCodePointPrefix(one, max);
|
|
89
106
|
}
|
|
90
107
|
/**
|
|
91
108
|
* A compact, human-readable preview of a tool call's args so the live view reads `🔧 read AGENTS.md` rather than just
|
|
@@ -21,8 +21,8 @@ export declare class SlackConfigApiError extends Error {
|
|
|
21
21
|
export declare function isSlackRequestUrlUnverified(error: unknown): boolean;
|
|
22
22
|
/** Whether a manifest write was refused because the app has token rotation on, which Slack never lets off again. */
|
|
23
23
|
export declare function isSlackRotationLocked(error: unknown): boolean;
|
|
24
|
-
/** The
|
|
25
|
-
export declare const
|
|
24
|
+
/** The remedy every rotating-app refusal points at. */
|
|
25
|
+
export declare const SLACK_ROTATING_APP_REMEDY = "this app has token rotation on, which FastAgent does not support and Slack cannot turn off \u2014 create a new app (docs/slack.md \u2192 \"If token rotation is on\")";
|
|
26
26
|
export interface SlackAppCredentials {
|
|
27
27
|
appId: string;
|
|
28
28
|
clientId: string;
|
|
@@ -31,8 +31,8 @@ export function isSlackRotationLocked(error) {
|
|
|
31
31
|
return (error.code === "cannot_disable_once_enabled" ||
|
|
32
32
|
error.errors.some((e) => e.message?.includes("cannot_disable_once_enabled") || e.pointer?.endsWith("token_rotation_enabled")));
|
|
33
33
|
}
|
|
34
|
-
/** The
|
|
35
|
-
export const
|
|
34
|
+
/** The remedy every rotating-app refusal points at. */
|
|
35
|
+
export const SLACK_ROTATING_APP_REMEDY = 'this app has token rotation on, which FastAgent does not support and Slack cannot turn off — create a new app (docs/slack.md → "If token rotation is on")';
|
|
36
36
|
async function slackJson(method, body, options = {}) {
|
|
37
37
|
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
38
38
|
let response;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { retryWhile } from "../registration.js";
|
|
3
|
-
import { createSlackApp, exchangeSlackOAuthCode, isSlackRequestUrlUnverified, isSlackRotationLocked,
|
|
3
|
+
import { createSlackApp, exchangeSlackOAuthCode, isSlackRequestUrlUnverified, isSlackRotationLocked, SLACK_ROTATING_APP_REMEDY, SlackConfigApiError, updateSlackAppManifest, } from "./config-api.js";
|
|
4
4
|
import { buildSlackManifest, slackBotScopes } from "./manifest.js";
|
|
5
5
|
import { configTokenExpiry, currentSlackConfigToken, writeSlackOnboardingState, } from "./onboarding-state.js";
|
|
6
6
|
/** Create/resume one internal Slack app and complete its workspace OAuth installation. */
|
|
@@ -76,7 +76,7 @@ export async function onboardSlackApp(input, io, deps = {}) {
|
|
|
76
76
|
catch (error) {
|
|
77
77
|
// A resume of an app created with rotation on: the manifest is refused for good, so say what fixes it.
|
|
78
78
|
if (isSlackRotationLocked(error))
|
|
79
|
-
throw new Error(
|
|
79
|
+
throw new Error(SLACK_ROTATING_APP_REMEDY, { cause: error });
|
|
80
80
|
throw error;
|
|
81
81
|
}
|
|
82
82
|
}
|
|
@@ -119,7 +119,8 @@ function streamClassicSlackReply(events, api, target, formatError, initialPrevie
|
|
|
119
119
|
let lastSent = "";
|
|
120
120
|
const view = () => composeTurnBody([
|
|
121
121
|
THINKING_PLACEHOLDER,
|
|
122
|
-
|
|
122
|
+
// Tool names, arguments and progress lines are untrusted text, like the answer.
|
|
123
|
+
sanitizeSlackMarkdown(toolLines(turn)),
|
|
123
124
|
turn.retrying ? RETRY_NOTICE : "",
|
|
124
125
|
sanitizeSlackMarkdown(revealedAnswer(turn, CLASSIC_UPDATE_INTERVAL_MS, now())),
|
|
125
126
|
]);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { retryWhile } from "../registration.js";
|
|
2
|
-
import {
|
|
2
|
+
import { SLACK_ROTATING_APP_REMEDY, isSlackRequestUrlUnverified, isSlackRotationLocked, updateSlackAppManifest, } from "./config-api.js";
|
|
3
3
|
import { buildSlackManifest } from "./manifest.js";
|
|
4
4
|
import { currentSlackConfigToken, readSlackOnboardingState } from "./onboarding-state.js";
|
|
5
5
|
/** Update one onboarded internal Slack app without sending its configuration credential to the host. */
|
|
@@ -53,7 +53,7 @@ export async function registerSlackWebhook(baseUrl, options) {
|
|
|
53
53
|
}
|
|
54
54
|
catch (error) {
|
|
55
55
|
if (isSlackRotationLocked(error)) {
|
|
56
|
-
note(`[fastagent] slack: ${
|
|
56
|
+
note(`[fastagent] slack: ${SLACK_ROTATING_APP_REMEDY}, or ${consoleFallback}`);
|
|
57
57
|
return "failed";
|
|
58
58
|
}
|
|
59
59
|
note(isSlackRequestUrlUnverified(error)
|
|
@@ -135,12 +135,12 @@ async function readBytesCapped(response) {
|
|
|
135
135
|
}
|
|
136
136
|
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total);
|
|
137
137
|
}
|
|
138
|
-
/** A rotating token (an app
|
|
139
|
-
*
|
|
138
|
+
/** A rotating token (an app with token rotation on) would work for up to 12 hours and then fail as
|
|
139
|
+
* `token_expired`, far from the cause — refuse it where the token enters, on both paths. */
|
|
140
140
|
function assertLongLivedBotToken(botToken) {
|
|
141
141
|
if (botToken.startsWith("xoxe.")) {
|
|
142
|
-
throw new Error("got a rotating Slack bot token (xoxe.…);
|
|
143
|
-
'create a new app (docs/slack.md → "
|
|
142
|
+
throw new Error("got a rotating Slack bot token (xoxe.…); FastAgent needs a long-lived xoxb- token — " +
|
|
143
|
+
'create a new app (docs/slack.md → "If token rotation is on")');
|
|
144
144
|
}
|
|
145
145
|
}
|
|
146
146
|
export function createSlackApi({ botToken, baseUrl = "https://slack.com/api" }) {
|
package/dist/cli/add-feishu.js
CHANGED
|
@@ -251,8 +251,10 @@ async function createFeishuAppFlow(target, existing, ingress, groupBehavior) {
|
|
|
251
251
|
const cfg = await api.getAppConfig(appId);
|
|
252
252
|
token = cfg.verificationToken;
|
|
253
253
|
}
|
|
254
|
-
catch {
|
|
255
|
-
//
|
|
254
|
+
catch (error) {
|
|
255
|
+
// Best-effort read; the bootstrap below is the real path. Said, because that path can take minutes and the reason
|
|
256
|
+
// it was needed would otherwise be invisible.
|
|
257
|
+
console.error(`[fastagent] could not read the app's Verification Token directly: ${error.message}`);
|
|
256
258
|
}
|
|
257
259
|
if (!token) {
|
|
258
260
|
console.error(`[fastagent] capturing the Verification Token — a throwaway webhook registration delivers it (spinning up a temporary tunnel; can take a few minutes on a slow edge)…`);
|
package/dist/cli/auth-view.d.ts
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
/** CLI presenter for the auth-status line `reportAuth` prints (invoke/dev/start). */
|
|
2
|
-
/**
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export declare function formatAuthReport(
|
|
2
|
+
/**
|
|
3
|
+
* Format the auth status for a provider, from `AgentModels.authStatus`'s answer. `deployed`: this process is a
|
|
4
|
+
* deployed box, which logs in from the owner's workspace (`fastagent login --deployment`), never by a login run on it
|
|
5
|
+
* by hand.
|
|
6
|
+
*/
|
|
7
|
+
export declare function formatAuthReport(status: {
|
|
8
|
+
provider: string;
|
|
9
|
+
path: string;
|
|
10
|
+
source?: string;
|
|
11
|
+
/** Why resolving the credential failed, when it did: said, because "expired" is only one of the reasons. */
|
|
12
|
+
error?: string;
|
|
13
|
+
stored?: string;
|
|
14
|
+
shadowed?: string;
|
|
15
|
+
deployed?: boolean;
|
|
16
|
+
}): {
|
|
8
17
|
line: string;
|
|
9
18
|
warn?: string;
|
|
10
19
|
};
|
package/dist/cli/auth-view.js
CHANGED
|
@@ -1,16 +1,32 @@
|
|
|
1
1
|
/** CLI presenter for the auth-status line `reportAuth` prints (invoke/dev/start). */
|
|
2
|
-
/**
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Format the auth status for a provider, from `AgentModels.authStatus`'s answer. `deployed`: this process is a
|
|
4
|
+
* deployed box, which logs in from the owner's workspace (`fastagent login --deployment`), never by a login run on it
|
|
5
|
+
* by hand.
|
|
6
|
+
*/
|
|
7
|
+
export function formatAuthReport(status) {
|
|
8
|
+
const { provider, path, source, error, stored, shadowed, deployed = false } = status;
|
|
9
|
+
const because = error === undefined ? "" : ` (${error})`;
|
|
10
|
+
const login = (name) => deployed ? `\`${name} --deployment\` from the workspace this was deployed from` : `\`${name}\``;
|
|
11
|
+
if (source !== undefined) {
|
|
12
|
+
const line = `auth: ${source} (${provider}) — ${path}`;
|
|
13
|
+
if (shadowed === undefined)
|
|
14
|
+
return { line };
|
|
15
|
+
// A stored credential owns its provider in pi, so a key added to the environment later is silently unused.
|
|
16
|
+
return {
|
|
17
|
+
line,
|
|
18
|
+
warn: `${shadowed} is set but unused: the stored ${provider} ${stored} credential in ${path} outranks it. To run ` +
|
|
19
|
+
`on ${shadowed}, log in with it instead: ${login(`fastagent login ${provider}`)}, choosing "API key"`,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
6
22
|
if (stored) {
|
|
7
23
|
return {
|
|
8
|
-
line: `auth: stored ${provider} ${stored
|
|
9
|
-
warn: `the "${provider}" login is expired or unusable — run
|
|
24
|
+
line: `auth: stored ${provider} ${stored}, expired/unusable — ${path}`,
|
|
25
|
+
warn: `the "${provider}" login is expired or unusable${because} — run ${login("fastagent login")} to replace it`,
|
|
10
26
|
};
|
|
11
27
|
}
|
|
12
28
|
return {
|
|
13
|
-
line: `auth: (none found) — ${
|
|
14
|
-
warn: `no credentials for "${provider}" — run
|
|
29
|
+
line: `auth: (none found) — ${path}`,
|
|
30
|
+
warn: `no credentials for "${provider}"${because} — run ${login("fastagent login")}, or set the provider's API key in ${deployed ? "the deployment's value file" : ".env"}; invokes will fail until then`,
|
|
15
31
|
};
|
|
16
32
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type BoxShell } from "../deploy/box-shell.ts";
|
|
2
|
+
import type { DeployHost } from "../deploy/hosts.ts";
|
|
3
|
+
import type { LoginIO } from "../engines/pi/login.ts";
|
|
4
|
+
import type { ResolvedPlacement } from "../paths.ts";
|
|
5
|
+
export interface BoxLoginRequest {
|
|
6
|
+
host: DeployHost;
|
|
7
|
+
shell: BoxShell;
|
|
8
|
+
placement: ResolvedPlacement;
|
|
9
|
+
/** Skip the provider menu. */
|
|
10
|
+
provider?: string;
|
|
11
|
+
/** Keep a credential the box already holds for `provider` (a redeploy), instead of replacing it. */
|
|
12
|
+
ifMissing?: boolean;
|
|
13
|
+
/** false: never ask; a box without the credential is a failure naming the command to run in a terminal. */
|
|
14
|
+
input: boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Run the login on the box and report its outcome. Resolves `undefined` once the box reports a credential for the
|
|
18
|
+
* provider (logged in now, or kept), else with the one line saying why not and what to run.
|
|
19
|
+
*/
|
|
20
|
+
export declare function loginOnBox(request: BoxLoginRequest): Promise<string | undefined>;
|
|
21
|
+
/**
|
|
22
|
+
* A browser OAuth flow sends the owner's browser back to its `redirect_uri`: a localhost address, which on the owner's
|
|
23
|
+
* machine has nothing of the box behind it. So catch it here. While an authorization URL naming such a redirect is
|
|
24
|
+
* open, listen on that address (the port the provider fixed, loopback only, as pi's own flows do), and answer the
|
|
25
|
+
* next text prompt with the URL the browser brought back, exactly what the person would paste. The terminal prompt
|
|
26
|
+
* stays up meanwhile: a taken port, or a browser on another machine, still leaves pasting.
|
|
27
|
+
*
|
|
28
|
+
* ponytail: "the next text prompt" is the one after the URL. pi's browser flows (Anthropic, Codex) ask exactly one,
|
|
29
|
+
* the paste; a flow that asked something else in between would get the URL as that answer and reject it.
|
|
30
|
+
*/
|
|
31
|
+
export declare function catchingRedirect(io: LoginIO): LoginIO & {
|
|
32
|
+
close(): void;
|
|
33
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logging a deployment in on the deployment itself: `fastagent login --stdio` runs on the box through the host's own
|
|
3
|
+
* owner-authenticated shell (deploy/box-shell.ts), and this terminal renders it (login-relay.ts). The box creates and
|
|
4
|
+
* keeps the credential, so it is the only holder of its grant, and logging in again replaces it there. Nothing is
|
|
5
|
+
* copied from this machine.
|
|
6
|
+
*/
|
|
7
|
+
import { createServer } from "node:http";
|
|
8
|
+
import { basename } from "node:path";
|
|
9
|
+
import { deploymentLoginCommand } from "../deploy/box-shell.js";
|
|
10
|
+
import { boxLoginCommand } from "../deploy/container.js";
|
|
11
|
+
import { relayLogin } from "./login-relay.js";
|
|
12
|
+
import { terminalLoginIO } from "./shared.js";
|
|
13
|
+
/**
|
|
14
|
+
* Run the login on the box and report its outcome. Resolves `undefined` once the box reports a credential for the
|
|
15
|
+
* provider (logged in now, or kept), else with the one line saying why not and what to run.
|
|
16
|
+
*/
|
|
17
|
+
export async function loginOnBox(request) {
|
|
18
|
+
const { host, shell, placement, provider } = request;
|
|
19
|
+
const retry = deploymentLoginCommand(host, provider);
|
|
20
|
+
const args = [
|
|
21
|
+
...(provider ? [provider] : []),
|
|
22
|
+
...(request.ifMissing ? ["--if-missing"] : []),
|
|
23
|
+
...(request.input ? [] : ["--no-input"]),
|
|
24
|
+
];
|
|
25
|
+
let command;
|
|
26
|
+
try {
|
|
27
|
+
command = boxLoginCommand(basename(placement.agentDir), args, shell.storage);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
return error.message;
|
|
31
|
+
}
|
|
32
|
+
// A wake that fails is this login's answer, in one line, like an `open` that fails below.
|
|
33
|
+
const woken = await shell.wake?.().catch((error) => error);
|
|
34
|
+
if (woken instanceof Error)
|
|
35
|
+
return woken.message;
|
|
36
|
+
const channel = await shell.open(command).catch((error) => error);
|
|
37
|
+
if (channel instanceof Error)
|
|
38
|
+
return channel.message;
|
|
39
|
+
const io = catchingRedirect(terminalLoginIO());
|
|
40
|
+
const result = await relayLogin(channel.output, channel.input, io, (line) => console.error(line)).finally(io.close);
|
|
41
|
+
const closed = await channel.closed;
|
|
42
|
+
if (!closed.opened)
|
|
43
|
+
return closed.error;
|
|
44
|
+
if (!result) {
|
|
45
|
+
return (`the ${host} shell ended without a login result (${closed.how}) — nothing on the box says it is logged in. ` +
|
|
46
|
+
`See the output above, then run \`${retry}\``);
|
|
47
|
+
}
|
|
48
|
+
if (result.ok) {
|
|
49
|
+
console.error("kept" in result
|
|
50
|
+
? `[fastagent] the deployment already authenticates ${result.provider} (${result.kept}) — kept`
|
|
51
|
+
: `[fastagent] logged in to ${result.provider} (${result.method}) on the deployment — saved to ${result.path} there`);
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
if (result.reason === "missing")
|
|
55
|
+
return `not logged in: ${result.message} — run \`${retry}\` in a terminal`;
|
|
56
|
+
if (result.reason === "cancelled")
|
|
57
|
+
return `login on the deployment cancelled — run \`${retry}\` to log it in`;
|
|
58
|
+
return `login on the deployment failed: ${result.message}`;
|
|
59
|
+
}
|
|
60
|
+
const CAUGHT_PAGE = "<!doctype html><meta charset=utf-8><title>fastagent</title>" +
|
|
61
|
+
"<p>fastagent received the sign-in and sent it to your deployment. Return to your terminal.</p>";
|
|
62
|
+
/**
|
|
63
|
+
* A browser OAuth flow sends the owner's browser back to its `redirect_uri`: a localhost address, which on the owner's
|
|
64
|
+
* machine has nothing of the box behind it. So catch it here. While an authorization URL naming such a redirect is
|
|
65
|
+
* open, listen on that address (the port the provider fixed, loopback only, as pi's own flows do), and answer the
|
|
66
|
+
* next text prompt with the URL the browser brought back, exactly what the person would paste. The terminal prompt
|
|
67
|
+
* stays up meanwhile: a taken port, or a browser on another machine, still leaves pasting.
|
|
68
|
+
*
|
|
69
|
+
* ponytail: "the next text prompt" is the one after the URL. pi's browser flows (Anthropic, Codex) ask exactly one,
|
|
70
|
+
* the paste; a flow that asked something else in between would get the URL as that answer and reject it.
|
|
71
|
+
*/
|
|
72
|
+
export function catchingRedirect(io) {
|
|
73
|
+
let server;
|
|
74
|
+
let caught;
|
|
75
|
+
let deliver;
|
|
76
|
+
const close = () => {
|
|
77
|
+
server?.close();
|
|
78
|
+
server = undefined;
|
|
79
|
+
};
|
|
80
|
+
const listen = (authUrl) => {
|
|
81
|
+
let redirect;
|
|
82
|
+
let state;
|
|
83
|
+
try {
|
|
84
|
+
const authorize = new URL(authUrl);
|
|
85
|
+
redirect = new URL(authorize.searchParams.get("redirect_uri") ?? "");
|
|
86
|
+
state = authorize.searchParams.get("state");
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return; // no redirect to catch (a device-code page, or not a URL we can read)
|
|
90
|
+
}
|
|
91
|
+
if (redirect.protocol !== "http:" || !["localhost", "127.0.0.1"].includes(redirect.hostname))
|
|
92
|
+
return;
|
|
93
|
+
// The flow's `state` is what tells its redirect from anything else that can reach a loopback port: any page open
|
|
94
|
+
// in the browser can request it, and a forged hit taken as the answer would end this login in "State mismatch" and
|
|
95
|
+
// close the port before the real one arrives. Without one there is nothing to tell them apart: leave it to pasting.
|
|
96
|
+
if (!state)
|
|
97
|
+
return;
|
|
98
|
+
close();
|
|
99
|
+
const listening = createServer((req, res) => {
|
|
100
|
+
const got = new URL(req.url ?? "/", redirect);
|
|
101
|
+
if (got.pathname !== redirect.pathname ||
|
|
102
|
+
!got.searchParams.has("code") ||
|
|
103
|
+
got.searchParams.get("state") !== state) {
|
|
104
|
+
res.writeHead(404).end();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(CAUGHT_PAGE);
|
|
108
|
+
close();
|
|
109
|
+
if (deliver)
|
|
110
|
+
deliver(got.toString());
|
|
111
|
+
else
|
|
112
|
+
caught = got.toString();
|
|
113
|
+
});
|
|
114
|
+
listening.on("error", (error) => {
|
|
115
|
+
if (server === listening)
|
|
116
|
+
server = undefined;
|
|
117
|
+
io.note(`could not listen on ${redirect.host} for the browser's redirect (${error.code ?? error.message}) — after ` +
|
|
118
|
+
`signing in, paste the address the browser lands on`);
|
|
119
|
+
});
|
|
120
|
+
listening.listen(Number(redirect.port || 80), "127.0.0.1");
|
|
121
|
+
listening.unref();
|
|
122
|
+
server = listening;
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
select: io.select,
|
|
126
|
+
note: io.note,
|
|
127
|
+
close,
|
|
128
|
+
openUrl(url) {
|
|
129
|
+
listen(url);
|
|
130
|
+
io.openUrl(url);
|
|
131
|
+
},
|
|
132
|
+
async prompt(message, opts) {
|
|
133
|
+
if (caught !== undefined) {
|
|
134
|
+
const url = caught;
|
|
135
|
+
caught = undefined;
|
|
136
|
+
return url;
|
|
137
|
+
}
|
|
138
|
+
if (!server)
|
|
139
|
+
return io.prompt(message, opts);
|
|
140
|
+
const typed = new AbortController();
|
|
141
|
+
const signal = opts?.signal ? AbortSignal.any([opts.signal, typed.signal]) : typed.signal;
|
|
142
|
+
const fromBrowser = new Promise((resolve) => {
|
|
143
|
+
deliver = resolve;
|
|
144
|
+
});
|
|
145
|
+
try {
|
|
146
|
+
// Settle the race BEFORE aborting the terminal prompt: aborting it settles its promise too.
|
|
147
|
+
const won = await Promise.race([
|
|
148
|
+
io.prompt(message, { ...opts, signal }).then((value) => ({ typed: value })),
|
|
149
|
+
fromBrowser.then((url) => ({ url })),
|
|
150
|
+
]);
|
|
151
|
+
if ("typed" in won)
|
|
152
|
+
return won.typed;
|
|
153
|
+
typed.abort();
|
|
154
|
+
io.note("received the browser's redirect");
|
|
155
|
+
return won.url;
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
deliver = undefined;
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|