@fastagent-sh/fastagent 0.22.1 → 0.23.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/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/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/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 +9 -6
- 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/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 +4 -4
- package/dist/cli/shared.js +24 -30
- 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 +74 -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 +5 -2
- package/dist/engines/pi/agent-models.d.ts +70 -0
- package/dist/engines/pi/agent-models.js +93 -0
- package/dist/engines/pi/auth.d.ts +56 -3
- package/dist/engines/pi/auth.js +63 -16
- package/dist/engines/pi/config.d.ts +0 -25
- package/dist/engines/pi/config.js +3 -37
- package/dist/engines/pi/create.d.ts +22 -16
- package/dist/engines/pi/create.js +28 -28
- 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 +6 -4
- package/dist/engines/pi/login.d.ts +20 -13
- package/dist/engines/pi/login.js +19 -12
- package/dist/engines/pi/models.d.ts +115 -36
- package/dist/engines/pi/models.js +218 -48
- package/dist/engines/pi/open.d.ts +42 -11
- package/dist/engines/pi/open.js +53 -21
- 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 +28 -58
- package/dist/engines/pi/session-control.js +14 -3
- package/dist/engines/pi/turn-kit.js +11 -5
- 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 +28 -7
- package/package.json +2 -1
|
@@ -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);
|
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|
|
@@ -5,22 +5,47 @@
|
|
|
5
5
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { basename, join } from "node:path";
|
|
8
|
-
import { FORWARDER_FILE, TEMPLATE_FILE, agentcoreName, forwarderLogGroup, ingressSessionId, isGeneratedAgentcoreTemplate, planAgentcoreDeploy, } from "../../../deploy/agentcore/plan.js";
|
|
9
|
-
import { deployAgentcoreRun } from "../../../deploy/agentcore/run.js";
|
|
10
|
-
import {
|
|
8
|
+
import { FORWARDER_FILE, TEMPLATE_FILE, agentcoreName, agentcoreStackName, forwarderLogGroup, ingressSessionId, isGeneratedAgentcoreTemplate, planAgentcoreDeploy, } from "../../../deploy/agentcore/plan.js";
|
|
9
|
+
import { deployAgentcoreRun, pickStackOutputs } from "../../../deploy/agentcore/run.js";
|
|
10
|
+
import { awsCli, awsJson } from "../../../deploy/agentcore/aws-cli.js";
|
|
11
|
+
import { agentcoreShell } from "../../../deploy/agentcore/shell.js";
|
|
12
|
+
import { awsRunner, spawnRunner } from "../../../deploy/runner.js";
|
|
11
13
|
import { SECRET_FILE_MODE, exists } from "../../../paths.js";
|
|
12
14
|
import { loadRoutines } from "../../../schedule/discover.js";
|
|
15
|
+
import { assembleSecrets } from "../../../deploy/secrets.js";
|
|
13
16
|
import { failStartup } from "../../fail.js";
|
|
14
|
-
import {
|
|
17
|
+
import { isInteractive } from "../../shared.js";
|
|
18
|
+
import { boxLoginStep, registrarsFor } from "./shared.js";
|
|
15
19
|
/** A copy/paste-safe POSIX shell argument for the command hints deploy prints. */
|
|
16
20
|
function shellArg(value) {
|
|
17
21
|
return /^[A-Za-z0-9_./:@%+=,-]+$/.test(value) ? value : `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
18
22
|
}
|
|
19
23
|
export const agentcoreHost = {
|
|
20
24
|
isOurs: (path, content) => path.endsWith(TEMPLATE_FILE) && isGeneratedAgentcoreTemplate(content),
|
|
25
|
+
artifact: TEMPLATE_FILE,
|
|
26
|
+
async shell({ workspace }) {
|
|
27
|
+
const name = agentcoreName(basename(workspace));
|
|
28
|
+
const stack = agentcoreStackName(name);
|
|
29
|
+
const aws = awsRunner(workspace);
|
|
30
|
+
const read = await awsCli(aws).read(["cloudformation", "describe-stacks", "--stack-name", stack, "--query", "Stacks[0].Outputs", "--output", "json"], awsJson(pickStackOutputs));
|
|
31
|
+
if ("absent" in read)
|
|
32
|
+
throw new Error(`no AgentCore stack ${stack} in this account/region — deploy it first`);
|
|
33
|
+
if ("unreadable" in read)
|
|
34
|
+
throw new Error(`could not read AgentCore stack ${stack}: ${read.unreadable}`);
|
|
35
|
+
const runtimeArn = read.ok.RuntimeArn;
|
|
36
|
+
if (!runtimeArn)
|
|
37
|
+
throw new Error(`stack ${stack} has no RuntimeArn output — redeploy it`);
|
|
38
|
+
return agentcoreShell(runtimeArn, ingressSessionId(name), aws);
|
|
39
|
+
},
|
|
21
40
|
async deploy(ctx) {
|
|
22
41
|
const { opts, agentDir, workspace, config, channels, longConnectionChannels, pre, write } = ctx;
|
|
23
|
-
const { modelAuth,
|
|
42
|
+
const { modelAuth, boxLogin, container, declaredSecrets, values, valueFile } = pre;
|
|
43
|
+
if (boxLogin) {
|
|
44
|
+
console.error(`[fastagent] note: AgentCore resets the runtime's storage on every deploy AND after 14 idle days, and the ` +
|
|
45
|
+
`${boxLogin} login with it: every deploy of this agent ends with a login on the runtime, and after an idle ` +
|
|
46
|
+
`reset every turn fails until \`fastagent login ${boxLogin} --deployment agentcore\` (the runtime's log says ` +
|
|
47
|
+
`so). For an agent that must keep answering unattended, set ${boxLogin}'s API key in ${valueFile}.`);
|
|
48
|
+
}
|
|
24
49
|
// Long-connection channels are STRUCTURALLY unsupported: the connection is the ingress, and a reclaimed session
|
|
25
50
|
// has nothing to wake it.
|
|
26
51
|
if (longConnectionChannels.length > 0) {
|
|
@@ -54,7 +79,7 @@ export const agentcoreHost = {
|
|
|
54
79
|
}
|
|
55
80
|
const plan = planAgentcoreDeploy({
|
|
56
81
|
name: acName,
|
|
57
|
-
|
|
82
|
+
boxLogin,
|
|
58
83
|
channels,
|
|
59
84
|
secrets: pre.secrets,
|
|
60
85
|
// ONLY THE ONES WITH A CRON become rules. A routine without one is reached by NAME, through this host's
|
|
@@ -98,8 +123,8 @@ export const agentcoreHost = {
|
|
|
98
123
|
agentPrefix: container.agentPrefix,
|
|
99
124
|
name: acName,
|
|
100
125
|
modelAuth,
|
|
101
|
-
|
|
102
|
-
|
|
126
|
+
boxLogin,
|
|
127
|
+
input: opts.input !== false && isInteractive(),
|
|
103
128
|
channels,
|
|
104
129
|
declaredSecrets,
|
|
105
130
|
values,
|
|
@@ -114,12 +139,23 @@ export const agentcoreHost = {
|
|
|
114
139
|
/** `deploy agentcore --run`: drive aws + docker to completion. */
|
|
115
140
|
async function runDeployAgentcore(params) {
|
|
116
141
|
const { agentDir, workspace, agentPrefix, name, channels, topology } = params;
|
|
117
|
-
|
|
142
|
+
// Decided before the first side effect: the deploy resets the runtime's storage and the login with it, so with nobody
|
|
143
|
+
// to log it in again it would end at "not logged in" AFTER replacing a runtime that was serving, with the webhooks
|
|
144
|
+
// a previous deploy registered still pointing at it.
|
|
145
|
+
if (params.boxLogin && !params.input) {
|
|
146
|
+
failStartup(new Error(`deploy stopped: every AgentCore deploy wipes the runtime's ${params.boxLogin} login, and without a terminal ` +
|
|
147
|
+
`nobody can log it in again — run this deploy in a terminal, or set ${params.boxLogin}'s API key in ` +
|
|
148
|
+
`${params.valueFile}`));
|
|
149
|
+
}
|
|
150
|
+
const { secrets, missingSecrets } = assembleSecrets({
|
|
151
|
+
modelAuth: params.modelAuth,
|
|
152
|
+
declared: params.declaredSecrets,
|
|
153
|
+
values: params.values,
|
|
154
|
+
});
|
|
118
155
|
// The wake-alarm shared secret (container ↔ forwarder).
|
|
119
156
|
secrets.FASTAGENT_WAKE_SECRET = crypto.randomUUID();
|
|
120
157
|
// The forwarder→runtime ingress secret: what makes an envelope the forwarder's rather than any IAM principal's.
|
|
121
158
|
secrets.FASTAGENT_INGRESS_SECRET = crypto.randomUUID();
|
|
122
|
-
gateOnModelCredential(needsModelCredential);
|
|
123
159
|
// The params temp dir holds the ONE file carrying secret values (file:// parameter-overrides — never argv);
|
|
124
160
|
// 0700/0600 and removed after the run, success or gate.
|
|
125
161
|
const paramsDir = await mkdtemp(join(tmpdir(), "fastagent-agentcore-"));
|
|
@@ -138,7 +174,8 @@ async function runDeployAgentcore(params) {
|
|
|
138
174
|
valueFile: params.valueFile,
|
|
139
175
|
channels,
|
|
140
176
|
topology,
|
|
141
|
-
|
|
177
|
+
...boxLoginStep("agentcore", params, (runtimeArn) => agentcoreShell(runtimeArn, ingressSessionId(name), awsRunner(workspace))),
|
|
178
|
+
}, awsRunner(workspace), spawnRunner("docker", workspace), (m) => console.error(`[fastagent] ${m}`), async (content) => {
|
|
142
179
|
const path = join(paramsDir, "params.json");
|
|
143
180
|
await writeFile(path, content, { mode: SECRET_FILE_MODE });
|
|
144
181
|
return path;
|