@fastagent-sh/fastagent 0.19.0 → 0.21.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 +5 -1
- package/dist/atomic-write.d.ts +11 -7
- package/dist/atomic-write.js +16 -11
- package/dist/channels/agentcore-limits.d.ts +9 -0
- package/dist/channels/agentcore-limits.js +9 -0
- package/dist/channels/agentcore-protocol.d.ts +112 -0
- package/dist/channels/agentcore-protocol.js +22 -0
- package/dist/channels/agentcore-service.d.ts +12 -15
- package/dist/channels/agentcore-service.js +15 -24
- package/dist/channels/agentcore-state.d.ts +5 -11
- package/dist/channels/agentcore-state.js +4 -1
- package/dist/channels/agentcore.d.ts +9 -83
- package/dist/channels/agentcore.js +101 -93
- package/dist/channels/control.d.ts +50 -12
- package/dist/channels/control.js +251 -157
- package/dist/channels/discover.d.ts +26 -5
- package/dist/channels/discover.js +13 -26
- package/dist/channels/feishu/context-buffer.d.ts +6 -0
- package/dist/channels/feishu/context-buffer.js +0 -38
- package/dist/channels/feishu/crypto.d.ts +0 -2
- package/dist/channels/feishu/crypto.js +3 -8
- package/dist/channels/feishu/feishu-api.js +7 -12
- package/dist/channels/feishu/feishu.js +86 -128
- package/dist/channels/feishu/parse.d.ts +4 -3
- package/dist/channels/feishu/parse.js +5 -4
- package/dist/channels/feishu/register-webhook.d.ts +5 -5
- package/dist/channels/feishu/register-webhook.js +43 -57
- package/dist/channels/feishu/scaffold/feishu-send.ts +12 -67
- package/dist/channels/feishu/setup-mode.d.ts +30 -0
- package/dist/channels/feishu/setup-mode.js +26 -0
- package/dist/channels/feishu/shared-api.d.ts +10 -0
- package/dist/channels/feishu/shared-api.js +38 -0
- package/dist/channels/http.d.ts +0 -8
- package/dist/channels/http.js +4 -56
- package/dist/channels/kit/attachment-path.d.ts +12 -0
- package/dist/channels/kit/attachment-path.js +43 -0
- package/dist/channels/kit/context-buffer.d.ts +9 -0
- package/dist/channels/kit/context-buffer.js +11 -0
- package/dist/channels/kit/signature.d.ts +12 -0
- package/dist/channels/kit/signature.js +17 -0
- package/dist/channels/kit/stop-command.js +3 -3
- package/dist/channels/kit/tasks.d.ts +1 -1
- package/dist/channels/kit/tasks.js +10 -4
- package/dist/channels/kit/turn-runner.d.ts +59 -0
- package/dist/channels/kit/turn-runner.js +84 -0
- package/dist/channels/kit/turn-store.d.ts +19 -3
- package/dist/channels/kit/turn-store.js +29 -2
- package/dist/channels/lark/scaffold/lark-send.ts +12 -67
- package/dist/channels/registration.d.ts +36 -1
- package/dist/channels/registration.js +57 -1
- package/dist/channels/secret.d.ts +1 -0
- package/dist/channels/secret.js +16 -0
- package/dist/channels/slack/config-api.d.ts +35 -3
- package/dist/channels/slack/config-api.js +51 -9
- package/dist/channels/slack/invoke-turn.d.ts +1 -1
- package/dist/channels/slack/invoke-turn.js +5 -1
- package/dist/channels/slack/manifest.js +5 -1
- package/dist/channels/slack/onboard.d.ts +5 -5
- package/dist/channels/slack/onboard.js +44 -23
- package/dist/channels/slack/onboarding-state.d.ts +7 -3
- package/dist/channels/slack/onboarding-state.js +13 -20
- package/dist/channels/slack/register-webhook.d.ts +3 -2
- package/dist/channels/slack/register-webhook.js +35 -15
- package/dist/channels/slack/scaffold/channel.ts +3 -10
- package/dist/channels/slack/scaffold/slack-send.ts +18 -141
- package/dist/channels/slack/shared-api.d.ts +10 -0
- package/dist/channels/slack/shared-api.js +34 -0
- package/dist/channels/slack/slack-api.d.ts +20 -2
- package/dist/channels/slack/slack-api.js +100 -73
- package/dist/channels/slack/slack.d.ts +0 -10
- package/dist/channels/slack/slack.js +68 -107
- package/dist/channels/sse.d.ts +4 -0
- package/dist/channels/sse.js +66 -0
- package/dist/channels/telegram/parse.d.ts +21 -1
- package/dist/channels/telegram/parse.js +65 -11
- package/dist/channels/telegram/register-webhook.d.ts +6 -9
- package/dist/channels/telegram/register-webhook.js +44 -42
- package/dist/channels/telegram/scaffold/channel.ts +7 -3
- package/dist/channels/telegram/telegram-api.js +5 -6
- package/dist/channels/telegram/telegram.d.ts +2 -2
- package/dist/channels/telegram/telegram.js +97 -204
- package/dist/channels/wait-health.js +7 -4
- package/dist/cli/add-feishu.js +3 -10
- package/dist/cli/add-slack.js +13 -25
- package/dist/cli/commands/add.d.ts +0 -1
- package/dist/cli/commands/add.js +22 -35
- package/dist/cli/commands/attach.d.ts +2 -4
- package/dist/cli/commands/attach.js +17 -15
- package/dist/cli/commands/chat.js +6 -12
- package/dist/cli/commands/deploy/agentcore.d.ts +2 -0
- package/dist/cli/commands/deploy/agentcore.js +178 -0
- package/dist/cli/commands/deploy/docker.d.ts +2 -0
- package/dist/cli/commands/deploy/docker.js +119 -0
- package/dist/cli/commands/deploy/fly.d.ts +2 -0
- package/dist/cli/commands/deploy/fly.js +131 -0
- package/dist/cli/commands/deploy/railway.d.ts +2 -0
- package/dist/cli/commands/deploy/railway.js +71 -0
- package/dist/cli/commands/deploy/shared.d.ts +114 -0
- package/dist/cli/commands/deploy/shared.js +124 -0
- package/dist/cli/commands/deploy.d.ts +38 -25
- package/dist/cli/commands/deploy.js +101 -631
- package/dist/cli/commands/dev.js +20 -76
- package/dist/cli/commands/fire.js +6 -12
- package/dist/cli/commands/info.js +1 -1
- package/dist/cli/commands/init.js +1 -2
- package/dist/cli/commands/invoke.js +4 -11
- package/dist/cli/commands/schedule.js +1 -1
- package/dist/cli/commands/start.js +41 -80
- package/dist/cli/commands/tool.js +3 -8
- package/dist/cli/kernel.d.ts +0 -2
- package/dist/cli/kernel.js +0 -2
- package/dist/cli/program.js +7 -12
- package/dist/cli/serve.d.ts +42 -34
- package/dist/cli/serve.js +107 -28
- package/dist/cli/shared.d.ts +52 -27
- package/dist/cli/shared.js +69 -6
- package/dist/deploy/agentcore/forwarder.js +250 -0
- package/dist/deploy/agentcore/logs.d.ts +2 -2
- package/dist/deploy/agentcore/logs.js +2 -2
- package/dist/deploy/agentcore/plan.d.ts +34 -11
- package/dist/deploy/agentcore/plan.js +52 -259
- package/dist/deploy/agentcore/run.d.ts +9 -24
- package/dist/deploy/agentcore/run.js +35 -43
- package/dist/deploy/channel-ingress.d.ts +73 -0
- package/dist/deploy/channel-ingress.js +101 -0
- package/dist/deploy/docker/plan.d.ts +3 -7
- package/dist/deploy/docker/plan.js +4 -15
- package/dist/deploy/docker/run.d.ts +35 -4
- package/dist/deploy/docker/run.js +40 -8
- package/dist/deploy/fly/plan.d.ts +4 -5
- package/dist/deploy/fly/plan.js +14 -22
- package/dist/deploy/fly/run.d.ts +36 -23
- package/dist/deploy/fly/run.js +129 -83
- package/dist/deploy/hosts.d.ts +5 -0
- package/dist/deploy/hosts.js +4 -0
- package/dist/deploy/preflight.d.ts +6 -7
- package/dist/deploy/preflight.js +27 -23
- package/dist/deploy/railway/plan.d.ts +9 -5
- package/dist/deploy/railway/plan.js +18 -26
- package/dist/deploy/railway/run.d.ts +11 -10
- package/dist/deploy/railway/run.js +44 -43
- package/dist/deploy/runner.js +22 -2
- package/dist/deploy/secrets.d.ts +18 -9
- package/dist/deploy/secrets.js +63 -28
- package/dist/dev-supervisor.js +3 -4
- package/dist/engines/pi/agent-session-factory.d.ts +37 -17
- package/dist/engines/pi/agent-session-factory.js +111 -106
- package/dist/engines/pi/auth.js +43 -43
- package/dist/engines/pi/config.d.ts +2 -2
- package/dist/engines/pi/create.d.ts +22 -27
- package/dist/engines/pi/create.js +59 -74
- package/dist/engines/pi/definition.d.ts +1 -1
- package/dist/engines/pi/definition.js +11 -12
- package/dist/engines/pi/invoke-session.js +13 -30
- package/dist/engines/pi/login.js +32 -16
- package/dist/engines/pi/open.d.ts +3 -4
- package/dist/engines/pi/open.js +32 -42
- package/dist/engines/pi/retry-event.d.ts +6 -0
- package/dist/engines/pi/retry-event.js +15 -0
- package/dist/engines/pi/search-tools.js +1 -1
- package/dist/engines/pi/service.d.ts +1 -1
- package/dist/engines/pi/service.js +8 -0
- package/dist/engines/pi/session-builder.js +26 -140
- package/dist/engines/pi/session-control.d.ts +27 -22
- package/dist/engines/pi/session-control.js +551 -490
- package/dist/engines/pi/session-inheritance.d.ts +8 -22
- package/dist/engines/pi/session-inheritance.js +95 -76
- package/dist/engines/pi/session-markers.d.ts +48 -0
- package/dist/engines/pi/session-markers.js +59 -0
- package/dist/engines/pi/session-settings.d.ts +5 -5
- package/dist/engines/pi/session-settings.js +8 -5
- package/dist/engines/pi/session-store.d.ts +91 -26
- package/dist/engines/pi/session-store.js +413 -82
- package/dist/engines/pi/tool-context.d.ts +24 -11
- package/dist/engines/pi/tool-context.js +29 -4
- package/dist/engines/pi/tool.d.ts +6 -9
- package/dist/engines/pi/tool.js +3 -2
- package/dist/env.js +1 -2
- package/dist/feishu.d.ts +1 -0
- package/dist/feishu.js +1 -0
- package/dist/lark.d.ts +1 -0
- package/dist/lark.js +1 -0
- package/dist/loader.d.ts +51 -7
- package/dist/loader.js +84 -18
- package/dist/log.d.ts +9 -17
- package/dist/log.js +25 -30
- package/dist/paths.d.ts +26 -3
- package/dist/paths.js +43 -5
- package/dist/scaffold/add-channel.d.ts +6 -1
- package/dist/scaffold/add-channel.js +50 -67
- package/dist/scaffold/init.js +7 -2
- package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
- package/dist/schedule/discover.js +3 -15
- package/dist/schedule/wake-alarm.d.ts +14 -19
- package/dist/schedule/wake-alarm.js +89 -48
- package/dist/schedule/wakeups.d.ts +1 -1
- package/dist/schedule/wakeups.js +10 -7
- package/dist/service.d.ts +24 -24
- package/dist/service.js +33 -77
- package/dist/session-remote.d.ts +12 -7
- package/dist/session-remote.js +185 -118
- package/dist/session.d.ts +227 -93
- package/dist/session.js +61 -23
- package/dist/slack.d.ts +2 -0
- package/dist/slack.js +1 -0
- package/dist/telegram.d.ts +1 -1
- package/dist/telegram.js +1 -1
- package/dist/tunnel.d.ts +34 -9
- package/dist/tunnel.js +83 -50
- package/package.json +5 -4
- package/dist/channels/slack/bot-auth.d.ts +0 -15
- package/dist/channels/slack/bot-auth.js +0 -135
package/dist/cli/serve.js
CHANGED
|
@@ -1,27 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* What `dev` (its worker) and `start` need beyond the service itself: binding a port, the shutdown
|
|
3
|
-
* order, the startup report,
|
|
3
|
+
* order, the startup report, the optional Cloudflare quick tunnel, and the options the CLI hands the
|
|
4
|
+
* assembly (`cliMountOptions`, `resolveBindHost` — policy ABOUT the assembly, decided per command).
|
|
4
5
|
*
|
|
5
|
-
* The ASSEMBLY is not here — it lives in `src/service.ts`, which a public entry may import and
|
|
6
|
-
* directory may not be (it decides process-level things: `fail.ts` calls `process.exit`).
|
|
6
|
+
* The ASSEMBLY itself is not here — it lives in `src/service.ts`, which a public entry may import and
|
|
7
|
+
* this directory may not be (it decides process-level things: `fail.ts` calls `process.exit`).
|
|
7
8
|
*/
|
|
9
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
8
11
|
import { INVOKE_EXAMPLE_BODY } from "../channels/http.js";
|
|
9
|
-
import { answersLocalhost, bindLabel, classifyBind, clientHost } from "../bind.js";
|
|
12
|
+
import { answersLocalhost, bindAddress, bindLabel, classifyBind, clientHost } from "../bind.js";
|
|
13
|
+
import { writeFileAtomic } from "../atomic-write.js";
|
|
10
14
|
import { serveNode } from "../channels/serve.js";
|
|
11
15
|
import { log } from "../log.js";
|
|
12
16
|
import { openExternalUrl } from "../open-url.js";
|
|
17
|
+
import { declaredChannels } from "../channels/discover.js";
|
|
13
18
|
import { announceWebhooks, startCloudflareTunnel } from "../tunnel.js";
|
|
14
19
|
import { failStartup, failUsage } from "./fail.js";
|
|
15
|
-
/**
|
|
16
|
-
* Mount the session control plane (`/control/*`) when the agent enabled it
|
|
17
|
-
* (`config.sessionControl`): merge the bearer-authenticated routes and return an announcer that
|
|
18
|
-
* writes `<stateRoot>/control.json` — `{ url, token }`, 0600 — once the port is known. The file is
|
|
19
|
-
* the LOCAL discovery channel (`fastagent attach`, a local desktop app); filesystem permissions are
|
|
20
|
-
* its trust boundary, and each boot overwrites it with a fresh per-boot token. A user channel
|
|
21
|
-
* colliding on `/control/*` fails startup — the same disposition as a channel-channel collision
|
|
22
|
-
* (routesFor): `sessionControl` is an explicit opt-in, so declaring both is a configuration error,
|
|
23
|
-
* and silently shadowing either side would serve a surface the author didn't write.
|
|
24
|
-
*/
|
|
25
20
|
/**
|
|
26
21
|
* Refuse `--tunnel` with a bind that cloudflared cannot reach: it dials the NAME `localhost:<port>`
|
|
27
22
|
* (the dev supervisor's tunnel too), so anything outside `127.0.0.1`/`::1`/wildcard — including a
|
|
@@ -45,14 +40,44 @@ export function assertTunnelBindable(host, tunnel, source) {
|
|
|
45
40
|
failStartup(new Error(message));
|
|
46
41
|
}
|
|
47
42
|
/**
|
|
48
|
-
* The
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* neither can be changed without the other in view, and the address has a single derivation.
|
|
52
|
-
*
|
|
53
|
-
* A wildcard bind is every interface, and naming one address there would understate it — but the curl
|
|
54
|
-
* still needs one to dial, which is what `clientHost` gives (loopback for a wildcard, itself otherwise).
|
|
43
|
+
* The bind address a serve uses: the flag, else `http.host` from the config — through `bindAddress`,
|
|
44
|
+
* so a configured `localhost` is an ADDRESS by the time anything binds, renders or dials it — checked
|
|
45
|
+
* against `--tunnel` with the exit code the SOURCE of the value earns (serve.ts assertTunnelBindable).
|
|
55
46
|
*/
|
|
47
|
+
export function resolveBindHost(bindFlag, configured, tunnel) {
|
|
48
|
+
const host = bindFlag ?? (configured === undefined ? undefined : bindAddress(configured));
|
|
49
|
+
assertTunnelBindable(host, tunnel, bindFlag ? "flag" : "config");
|
|
50
|
+
return host;
|
|
51
|
+
}
|
|
52
|
+
/** What the CLI adds to the assembly: its shutdown grace, and exit on a connection that drops. */
|
|
53
|
+
export function cliMountOptions(wrapAgent) {
|
|
54
|
+
return {
|
|
55
|
+
wrapAgent,
|
|
56
|
+
closeTimeoutMs: SHUTDOWN_GRACE_MS,
|
|
57
|
+
onChannelClosed: (name, error) => failStartup(new Error(`${name} ${error === undefined ? "closed unexpectedly" : `failed: ${String(error)}`}`)),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Bind, report, announce the control plane, open the tunnel, and close in order on a signal — the
|
|
62
|
+
* tail dev's worker and start share once the service is assembled.
|
|
63
|
+
*/
|
|
64
|
+
export function serveService(service, bind, posture) {
|
|
65
|
+
const { host } = bind;
|
|
66
|
+
const { tunnel, agentDir, stateRoot } = posture;
|
|
67
|
+
let unannounce = () => { };
|
|
68
|
+
serve(service.handler, bind, {
|
|
69
|
+
ready: service.ready,
|
|
70
|
+
onListening: (p) => {
|
|
71
|
+
reportServing(service, host, p);
|
|
72
|
+
unannounce = announceControl(service.control, stateRoot, { host, tunnel }, p);
|
|
73
|
+
maybeTunnel(agentDir, service.channels.routes, p, tunnel, stateRoot);
|
|
74
|
+
},
|
|
75
|
+
onShutdown: () => {
|
|
76
|
+
unannounce();
|
|
77
|
+
return service.close();
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
56
81
|
/**
|
|
57
82
|
* The "we are serving" report: the supervisor message `dev`'s watcher waits for, the addresses, and
|
|
58
83
|
* what mounted. One function because both commands must say the same thing at the same moment —
|
|
@@ -67,6 +92,15 @@ export function reportServing(service, host, boundPort) {
|
|
|
67
92
|
log.info(`[fastagent] long connections: ${service.channels.longConnections.join(", ")}`);
|
|
68
93
|
}
|
|
69
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* The startup lines that name WHERE the serve is: the bind report, and the curl the reader copies.
|
|
97
|
+
* ONE function because they are one message — they were two, and `--bind` updated the first while the
|
|
98
|
+
* second went on dialing `localhost`, which is precisely what a non-wildcard bind stops answering. Now
|
|
99
|
+
* neither can be changed without the other in view, and the address has a single derivation.
|
|
100
|
+
*
|
|
101
|
+
* A wildcard bind is every interface, and naming one address there would understate it — but the curl
|
|
102
|
+
* still needs one to dial, which is what `clientHost` gives (loopback for a wildcard, itself otherwise).
|
|
103
|
+
*/
|
|
70
104
|
export function readyAddressLines(host, boundPort, builtinInvoke) {
|
|
71
105
|
const dial = `${clientHost(host)}:${boundPort}`;
|
|
72
106
|
const lines = [
|
|
@@ -78,15 +112,57 @@ export function readyAddressLines(host, boundPort, builtinInvoke) {
|
|
|
78
112
|
return lines;
|
|
79
113
|
}
|
|
80
114
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
115
|
+
* Write `<stateRoot>/control.json` so a LOCAL client (`fastagent attach`) can find the control plane
|
|
116
|
+
* once the port is known, and say what reaches it. Returns the file's removal — wired into the
|
|
117
|
+
* shutdown so a stale file cannot point a client at a dead port (`attach` then fails with "cannot
|
|
118
|
+
* read", accurate, instead of a stale token's misleading 401/ECONNREFUSED). A hard exit leaves the
|
|
119
|
+
* file behind: advisory, overwritten by the next boot.
|
|
120
|
+
*
|
|
121
|
+
* Here and not in the assembly: the file, its permissions and the warnings are how THIS process's
|
|
122
|
+
* operator finds and protects the plane. An embedder distributes `service.control` itself.
|
|
84
123
|
*/
|
|
124
|
+
export function announceControl(control, stateRoot, bind, boundPort) {
|
|
125
|
+
if (!control)
|
|
126
|
+
return () => { };
|
|
127
|
+
mkdirSync(stateRoot, { recursive: true, mode: 0o700 });
|
|
128
|
+
const path = join(stateRoot, "control.json");
|
|
129
|
+
const url = `http://${clientHost(bind.host)}:${boundPort}`;
|
|
130
|
+
writeFileAtomic(path, `${JSON.stringify({ url, token: control.token })}\n`, 0o600);
|
|
131
|
+
log.info(`[fastagent] session control on ${control.prefix}/* (token in ${path})`);
|
|
132
|
+
// LAN-reachable with the bearer token as the only protection — the tunnel and deploy paths warn
|
|
133
|
+
// loudly, and the LAN path must not be the silent third way past the local trust story. A
|
|
134
|
+
// loopback bind closes exactly that reach, so it earns silence.
|
|
135
|
+
const reach = classifyBind(bind.host);
|
|
136
|
+
if (reach !== "loopback") {
|
|
137
|
+
log.warn(`[fastagent] the port binds ${reach === "wildcard" ? "all interfaces" : `${bind.host} (off this machine)`}: ` +
|
|
138
|
+
"/control/* is reachable on your LAN, protected only by the bearer token — bind loopback " +
|
|
139
|
+
"(--bind 127.0.0.1), firewall the port, or wrap it for real exposure (docs/design/session-control.md §14)");
|
|
140
|
+
}
|
|
141
|
+
if (bind.tunnel) {
|
|
142
|
+
// Local trust = the token + its file permissions; --tunnel takes the whole port PUBLIC.
|
|
143
|
+
log.warn("[fastagent] --tunnel exposes /control/* (steer, stop, rewrite or delete a session) at the public tunnel URL, " +
|
|
144
|
+
"protected ONLY by the bearer token — wrap it with real auth before sharing that URL (docs/design/session-control.md §14)");
|
|
145
|
+
}
|
|
146
|
+
return () => {
|
|
147
|
+
try {
|
|
148
|
+
rmSync(path, { force: true });
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
/* the file is advisory — shutdown must not fail on it */
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
85
155
|
/** What the CLI gives a service to stop in, and the hard exit that follows it. The order matters:
|
|
86
156
|
* a forced exit before the service answers would report a clean shutdown over a stuck channel. */
|
|
87
|
-
|
|
157
|
+
const SHUTDOWN_GRACE_MS = 800;
|
|
88
158
|
const FORCED_EXIT_MS = 1_500;
|
|
89
|
-
|
|
159
|
+
/**
|
|
160
|
+
* Bind HTTP and report ready — but only once the SERVICE is, which is not the same moment: a bound
|
|
161
|
+
* socket is not a serving agent while a declared long-connection channel is still dialling, so this
|
|
162
|
+
* awaits `hooks.ready` (mountAgentService owns the connections themselves) before announcing
|
|
163
|
+
* anything. Signals are the sole clean-shutdown command; `host` unset binds all interfaces.
|
|
164
|
+
*/
|
|
165
|
+
function serve(handler, bind, hooks = {}) {
|
|
90
166
|
const { port, host } = bind;
|
|
91
167
|
const hosted = serveNode(handler, { port, host });
|
|
92
168
|
let stopping = false;
|
|
@@ -149,13 +225,16 @@ export function serve(handler, bind, hooks = {}) {
|
|
|
149
225
|
});
|
|
150
226
|
}
|
|
151
227
|
/** Start a Cloudflare tunnel for route channels only. */
|
|
152
|
-
|
|
228
|
+
function maybeTunnel(agentDir, routeChannels, boundPort, tunnel, stateRoot) {
|
|
153
229
|
if (!tunnel || process.env.FASTAGENT_DEV_WORKER === "1")
|
|
154
230
|
return;
|
|
155
231
|
void startCloudflareTunnel(boundPort).then((instance) => {
|
|
156
232
|
if (!instance)
|
|
157
233
|
return;
|
|
158
|
-
void announceWebhooks(agentDir, instance.url,
|
|
234
|
+
void announceWebhooks(agentDir, instance.url, declaredChannels(routeChannels), {
|
|
235
|
+
openUrl: openExternalUrl,
|
|
236
|
+
stateRoot,
|
|
237
|
+
});
|
|
159
238
|
const cleanup = () => instance.close();
|
|
160
239
|
process.once("SIGINT", cleanup);
|
|
161
240
|
process.once("SIGTERM", cleanup);
|
package/dist/cli/shared.d.ts
CHANGED
|
@@ -1,16 +1,58 @@
|
|
|
1
1
|
import { type LoginIO, type LoginMethod, type LoginResult } from "../engines/pi/login.ts";
|
|
2
|
+
import type { LoadedDefinition } from "../engines/pi/definition.ts";
|
|
3
|
+
import { type ModuleLoadFailure } from "../loader.ts";
|
|
4
|
+
import type { ToolCollision } from "../engines/pi/tool.ts";
|
|
5
|
+
import { type ResolvedPlacement } from "../paths.ts";
|
|
2
6
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* policy (pad, never hand-space), not a constant.
|
|
7
|
+
* How every command that runs the model enters its agent directory, in the one order that works:
|
|
8
|
+
* placement decides whose `.env` to read; `.env` may carry the proxy and the provider keys the
|
|
9
|
+
* picker's auth probe needs; the picker runs last. Six commands wrote these steps out by hand, and
|
|
10
|
+
* one carried the order as a comment. The result is the placement every later step reads.
|
|
8
11
|
*/
|
|
9
|
-
export declare function
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
export declare function enterAgentCommand(dirArg: string, opts: {
|
|
13
|
+
model?: string;
|
|
14
|
+
authPath?: string;
|
|
15
|
+
input?: boolean;
|
|
16
|
+
}): Promise<ResolvedPlacement>;
|
|
17
|
+
/** What the startup report reads off an opened directory — a structural subset of the opener's return,
|
|
18
|
+
* spelled out so this module does not depend on that function's whole shape. */
|
|
19
|
+
export interface ReportableAssembly {
|
|
20
|
+
agentDir: string;
|
|
21
|
+
workspace: string;
|
|
22
|
+
modelSpec: string;
|
|
23
|
+
authPath: string;
|
|
24
|
+
config: {
|
|
25
|
+
thinkingLevel?: string;
|
|
26
|
+
};
|
|
27
|
+
definition: LoadedDefinition;
|
|
28
|
+
toolNames: string[];
|
|
29
|
+
deferredToolNames: string[];
|
|
30
|
+
toolCollisions: ToolCollision[];
|
|
31
|
+
toolFailures: ModuleLoadFailure[];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* What `dev` and `start` say about the directory they just opened, in the order they say it.
|
|
35
|
+
*
|
|
36
|
+
* ONE function because it is one report: the two commands wrote it out line by line, and the copies
|
|
37
|
+
* had already diverged over which lines exist at all — `dev` naming the config file, `start` naming
|
|
38
|
+
* state and sessions. A line added to one of two hand-written copies is invisible in the other.
|
|
39
|
+
*
|
|
40
|
+
* The divergence itself is PRESERVED, not resolved: `start`'s `state:`/`sessions:` pair introduces the
|
|
41
|
+
* persistence warnings that follow it in production posture, and `dev`'s `config:` has no counterpart
|
|
42
|
+
* there. Both are passed as explicit extras by the caller, so the asymmetry is visible at the call
|
|
43
|
+
* site rather than buried in two copies of a list. Whether it is RIGHT is a separate question from
|
|
44
|
+
* whether it has one owner.
|
|
45
|
+
*
|
|
46
|
+
* Findings (skill collisions, definition diagnostics) CLOSE the report — they are about the assembly
|
|
47
|
+
* just printed. What a command says next is its own posture talk, not report: `start`'s persistence
|
|
48
|
+
* notes now follow them rather than precede them.
|
|
49
|
+
*/
|
|
50
|
+
export declare function reportAssembly(a: ReportableAssembly, extras?: {
|
|
51
|
+
/** Printed between `workspace:`/`hint:` and `model:` (`dev` names the config file here). */
|
|
52
|
+
beforeModel?: [label: string, value: string][];
|
|
53
|
+
/** Printed after the tool lines, before findings (`start` names state + sessions here). */
|
|
54
|
+
afterTools?: [label: string, value: string][];
|
|
55
|
+
}): Promise<void>;
|
|
14
56
|
/** Both stdin and stdout are a terminal — the precondition for an interactive prompt. */
|
|
15
57
|
export declare function isInteractive(): boolean;
|
|
16
58
|
/**
|
|
@@ -30,23 +72,6 @@ export declare function parseBind(value: string | undefined): string | undefined
|
|
|
30
72
|
* Probes through the AGENT's model surface (`agentDir` carries its models.json), so a custom endpoint is
|
|
31
73
|
* reported like any built-in rather than as an unknown provider. */
|
|
32
74
|
export declare function reportAuth(agentDir: string, modelSpec: string, authPath: string): Promise<void>;
|
|
33
|
-
/**
|
|
34
|
-
* First-run model resolution for every assembly command (dev/start/invoke/fire/chat/deploy): ONE
|
|
35
|
-
* funnel, no dead ends. When no model is set (flag/env/config) and we're on a TTY, show the FULL
|
|
36
|
-
* catalog annotated per provider — ready (with the credential source, so which account pays is
|
|
37
|
-
* visible at the decision point) or login-required — and, when the choice needs auth, run the login
|
|
38
|
-
* flow INLINE instead of exiting with "run `fastagent login` and come back". A no-op when a model is
|
|
39
|
-
* already set; on a non-TTY (CI, a piped stdin), with `--no-input`, on cancel, or on a failed login
|
|
40
|
-
* it stays quiet and lets the caller raise its own clear error (`missing model`, or deploy's
|
|
41
|
-
* model-travel gate). The pick is exported to FASTAGENT_MODEL so a spawned `dev` worker inherits it,
|
|
42
|
-
* and best-effort written back to the config so the next run is quiet. `agentDir` is the resolved
|
|
43
|
-
* AGENT DIR (resolvePlacement().agentDir) — config and auth both live there.
|
|
44
|
-
*/
|
|
45
|
-
export declare function resolveFirstRunModel(agentDir: string, options?: {
|
|
46
|
-
model?: string;
|
|
47
|
-
authPath?: string;
|
|
48
|
-
input?: boolean;
|
|
49
|
-
}): Promise<void>;
|
|
50
75
|
/**
|
|
51
76
|
* Interactive login with the api_key quick-fail probe closed into a LOOP: a definitively rejected key
|
|
52
77
|
* (HTTP 401) deletes the bad credential and RE-PROMPTS immediately — the user's hands are on the
|
package/dist/cli/shared.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* module-scoped flag access (`values.*`) became parameters.
|
|
5
5
|
*/
|
|
6
6
|
import { readFile, writeFile } from "node:fs/promises";
|
|
7
|
-
import { relative } from "node:path";
|
|
7
|
+
import { relative, resolve } from "node:path";
|
|
8
8
|
import { autocomplete, isCancel, log as clackLog, password, select, text as clackText } from "@clack/prompts";
|
|
9
9
|
import { buildModelPickerOptions } from "./models-view.js";
|
|
10
10
|
import { fastagentCredentialStore } from "../engines/pi/auth.js";
|
|
@@ -12,27 +12,90 @@ import { isValidPort, listModels, loadConfig, providerOf, resolveAuthPath, resol
|
|
|
12
12
|
import { LoginCancelled, loginFlow } from "../engines/pi/login.js";
|
|
13
13
|
import { createPiModelRuntime, createPiModels, probeApiKey, probeAuthSource, providerAuthStatuses, } from "../engines/pi/models.js";
|
|
14
14
|
import { formatAuthReport } from "./auth-view.js";
|
|
15
|
+
import { CODING_TOOL_NAMES } from "../engines/pi/create.js";
|
|
16
|
+
import { reportModuleLoadFailures } from "../loader.js";
|
|
17
|
+
import { reportFindingsIfChanged, reportToolCollisions } from "../engines/pi/report.js";
|
|
18
|
+
import { workspaceHint } from "../paths.js";
|
|
15
19
|
import { log } from "../log.js";
|
|
20
|
+
import { loadDotEnv } from "../env.js";
|
|
16
21
|
import { openExternalUrl } from "../open-url.js";
|
|
22
|
+
import { installProxyFetch } from "../proxy.js";
|
|
17
23
|
import { bindAddress, isBindAddress } from "../bind.js";
|
|
18
|
-
import { failStartup, failUsage } from "./fail.js";
|
|
24
|
+
import { failStartup, failUsage, placementOrExit } from "./fail.js";
|
|
25
|
+
/**
|
|
26
|
+
* How every command that runs the model enters its agent directory, in the one order that works:
|
|
27
|
+
* placement decides whose `.env` to read; `.env` may carry the proxy and the provider keys the
|
|
28
|
+
* picker's auth probe needs; the picker runs last. Six commands wrote these steps out by hand, and
|
|
29
|
+
* one carried the order as a comment. The result is the placement every later step reads.
|
|
30
|
+
*/
|
|
31
|
+
export async function enterAgentCommand(dirArg, opts) {
|
|
32
|
+
const placement = placementOrExit(resolve(dirArg));
|
|
33
|
+
loadDotEnv(placement.agentDir);
|
|
34
|
+
installProxyFetch();
|
|
35
|
+
await resolveFirstRunModel(placement.agentDir, opts);
|
|
36
|
+
return placement;
|
|
37
|
+
}
|
|
19
38
|
/**
|
|
20
39
|
* The padded label writer for the STARTUP report (`dev`/`start`, stderr via the log level). Hand-spaced
|
|
21
40
|
* labels drift out of alignment the moment a longer one appears — which is exactly what happened when
|
|
22
41
|
* `codingTools:` joined `workspace:`/`config:`/`model:`/`state:`. `info` keeps its own writer on purpose: its report is
|
|
23
42
|
* stdout DATA (pipeable, its own label set, its own width), not a log line — the shared thing is the
|
|
24
43
|
* policy (pad, never hand-space), not a constant.
|
|
44
|
+
*
|
|
45
|
+
* Private to this module: {@link reportAssembly} is the report, and a command reaching past it for a
|
|
46
|
+
* line of its own is how the two copies of that report came to differ.
|
|
25
47
|
*/
|
|
26
|
-
|
|
48
|
+
function reportLine(label, value) {
|
|
27
49
|
log.info(`[fastagent] ${`${label}:`.padEnd(13)}${value}`);
|
|
28
50
|
}
|
|
29
51
|
/** The workspace hint under the `agent:`/`workspace:` pair, when there is one ({@link workspaceHint}):
|
|
30
52
|
* you pointed at the agent, and the project around it is probably what you meant. A hint, so it renders
|
|
31
|
-
* as one and is silent otherwise
|
|
32
|
-
|
|
53
|
+
* as one and is silent otherwise. */
|
|
54
|
+
function reportWorkspaceHint(hint) {
|
|
33
55
|
if (hint)
|
|
34
56
|
reportLine("hint", hint);
|
|
35
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* What `dev` and `start` say about the directory they just opened, in the order they say it.
|
|
60
|
+
*
|
|
61
|
+
* ONE function because it is one report: the two commands wrote it out line by line, and the copies
|
|
62
|
+
* had already diverged over which lines exist at all — `dev` naming the config file, `start` naming
|
|
63
|
+
* state and sessions. A line added to one of two hand-written copies is invisible in the other.
|
|
64
|
+
*
|
|
65
|
+
* The divergence itself is PRESERVED, not resolved: `start`'s `state:`/`sessions:` pair introduces the
|
|
66
|
+
* persistence warnings that follow it in production posture, and `dev`'s `config:` has no counterpart
|
|
67
|
+
* there. Both are passed as explicit extras by the caller, so the asymmetry is visible at the call
|
|
68
|
+
* site rather than buried in two copies of a list. Whether it is RIGHT is a separate question from
|
|
69
|
+
* whether it has one owner.
|
|
70
|
+
*
|
|
71
|
+
* Findings (skill collisions, definition diagnostics) CLOSE the report — they are about the assembly
|
|
72
|
+
* just printed. What a command says next is its own posture talk, not report: `start`'s persistence
|
|
73
|
+
* notes now follow them rather than precede them.
|
|
74
|
+
*/
|
|
75
|
+
export async function reportAssembly(a, extras = {}) {
|
|
76
|
+
reportLine("agent", a.agentDir);
|
|
77
|
+
reportLine("workspace", a.workspace);
|
|
78
|
+
reportWorkspaceHint(workspaceHint(a));
|
|
79
|
+
for (const [label, value] of extras.beforeModel ?? [])
|
|
80
|
+
reportLine(label, value);
|
|
81
|
+
reportLine("model", `${a.modelSpec}${a.config.thinkingLevel ? ` (thinking: ${a.config.thinkingLevel})` : ""}`);
|
|
82
|
+
await reportAuth(a.agentDir, a.modelSpec, a.authPath);
|
|
83
|
+
reportLine("context", a.definition.contextFiles.map((f) => f.path).join(", ") || "(none)");
|
|
84
|
+
if (a.definition.persona)
|
|
85
|
+
reportLine("persona", "persona.md");
|
|
86
|
+
reportLine("skills", a.definition.skills.map((s) => s.name).join(", ") || "(none)");
|
|
87
|
+
reportLine("codingTools", CODING_TOOL_NAMES.join(", "));
|
|
88
|
+
if (a.toolNames.length > 0)
|
|
89
|
+
reportLine("tools", a.toolNames.join(", "));
|
|
90
|
+
if (a.deferredToolNames.length > 0) {
|
|
91
|
+
reportLine("deferred", `${a.deferredToolNames.join(", ")} (activated via search_tools)`);
|
|
92
|
+
}
|
|
93
|
+
reportToolCollisions(a.toolCollisions);
|
|
94
|
+
reportModuleLoadFailures(a.toolFailures);
|
|
95
|
+
for (const [label, value] of extras.afterTools ?? [])
|
|
96
|
+
reportLine(label, value);
|
|
97
|
+
reportFindingsIfChanged(a.definition.dir, a.definition);
|
|
98
|
+
}
|
|
36
99
|
/** Both stdin and stdout are a terminal — the precondition for an interactive prompt. */
|
|
37
100
|
export function isInteractive() {
|
|
38
101
|
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
@@ -101,7 +164,7 @@ export async function reportAuth(agentDir, modelSpec, authPath) {
|
|
|
101
164
|
* and best-effort written back to the config so the next run is quiet. `agentDir` is the resolved
|
|
102
165
|
* AGENT DIR (resolvePlacement().agentDir) — config and auth both live there.
|
|
103
166
|
*/
|
|
104
|
-
|
|
167
|
+
async function resolveFirstRunModel(agentDir, options = {}) {
|
|
105
168
|
const { config, path: configPath } = await loadConfig(agentDir).catch(failStartup);
|
|
106
169
|
if (resolveModelSpec(options.model, config))
|
|
107
170
|
return; // already set (flag > FASTAGENT_MODEL > config)
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Generated by `fastagent deploy agentcore` — the deployment's only ingress.
|
|
2
|
+
// (Inside fastagent this file IS the source; the sentence below applies to the copy it is emitted as,
|
|
3
|
+
// the workspace's `lambda/index.js`.)
|
|
4
|
+
// REGENERATED ON EVERY DEPLOY; edits here are overwritten and never deployed. `--run` builds the
|
|
5
|
+
// Lambda package from fastagent's own copy of this source, so this file is the readable MIRROR of
|
|
6
|
+
// what runs (and what the manual runbook zips) — not an input you can change.
|
|
7
|
+
// Webhooks (Function URL) and EventBridge Scheduler fires are forwarded as envelopes to the
|
|
8
|
+
// AgentCore Runtime over SigV4 InvokeAgentRuntime, all on ONE fixed ingress session (fastagent
|
|
9
|
+
// channel state is single-writer; one session = at most one microVM). With selfSchedule, this
|
|
10
|
+
// Lambda also OWNS the wake alarms: the container POSTs its pending wake-ups to /__fastagent/
|
|
11
|
+
// wake-alarm (shared secret) and each becomes a self-deleting one-shot EventBridge schedule that
|
|
12
|
+
// pokes this Lambda — which wakes the container, whose wake pump fires the due entry.
|
|
13
|
+
// CommonJS on purpose: the deployment package's entry lands as index.js, where ESM import is invalid.
|
|
14
|
+
"use strict";
|
|
15
|
+
const crypto = require("node:crypto");
|
|
16
|
+
const { BedrockAgentCoreClient, InvokeAgentRuntimeCommand } = require("@aws-sdk/client-bedrock-agentcore");
|
|
17
|
+
const client = new BedrockAgentCoreClient({});
|
|
18
|
+
let ownUrl; // self-resolved once per cold start; rides on every envelope for the wake-alarm callback
|
|
19
|
+
|
|
20
|
+
// Presigned S3 URLs for the container's state snapshot. AgentCore wipes the /mnt/state mount on
|
|
21
|
+
// every runtime version update (= every deploy), so the durable copy lives in S3 — but the
|
|
22
|
+
// container is given NO AWS credentials by the platform, so the only reachable form is a URL that
|
|
23
|
+
// carries its own authorization. SigV4 query signing, node:crypto only (no SDK, nothing to install).
|
|
24
|
+
const enc = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
25
|
+
const hmac = (key, data) => crypto.createHmac("sha256", key).update(data).digest();
|
|
26
|
+
|
|
27
|
+
// Every shared-secret gate on this public URL goes through here. Constant-time, and an unset
|
|
28
|
+
// expected secret NEVER matches — otherwise a topology that did not configure one would accept a
|
|
29
|
+
// request that sent none. Non-string input is coerced to "" first: Buffer.from(8) allocates eight
|
|
30
|
+
// bytes rather than throwing, which would compare a number against a secret by length alone.
|
|
31
|
+
const secretEq = (given, expected) => {
|
|
32
|
+
const a = Buffer.from(typeof given === "string" ? given : ""),
|
|
33
|
+
b = Buffer.from(expected || "");
|
|
34
|
+
return b.length > 0 && a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function presign(method, seconds) {
|
|
38
|
+
const bucket = process.env.STATE_BUCKET,
|
|
39
|
+
key = process.env.STATE_KEY,
|
|
40
|
+
region = process.env.AWS_REGION;
|
|
41
|
+
const host = `${bucket}.s3.${region}.amazonaws.com`;
|
|
42
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+/, "");
|
|
43
|
+
const scope = `${stamp.slice(0, 8)}/${region}/s3/aws4_request`;
|
|
44
|
+
const pairs = [
|
|
45
|
+
["X-Amz-Algorithm", "AWS4-HMAC-SHA256"],
|
|
46
|
+
["X-Amz-Credential", `${process.env.AWS_ACCESS_KEY_ID}/${scope}`],
|
|
47
|
+
["X-Amz-Date", stamp],
|
|
48
|
+
["X-Amz-Expires", String(seconds)],
|
|
49
|
+
["X-Amz-SignedHeaders", "host"],
|
|
50
|
+
];
|
|
51
|
+
if (process.env.AWS_SESSION_TOKEN) pairs.push(["X-Amz-Security-Token", process.env.AWS_SESSION_TOKEN]);
|
|
52
|
+
// The canonical query must be byte-identical to the one on the wire — build it ONCE, reuse below.
|
|
53
|
+
const query = pairs
|
|
54
|
+
.map(([k, v]) => [enc(k), enc(v)])
|
|
55
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
|
56
|
+
.map((p) => p.join("="))
|
|
57
|
+
.join("&");
|
|
58
|
+
const path = `/${key.split("/").map(enc).join("/")}`;
|
|
59
|
+
const canonical = [method, path, query, `host:${host}\n`, "host", "UNSIGNED-PAYLOAD"].join("\n");
|
|
60
|
+
const sts = ["AWS4-HMAC-SHA256", stamp, scope, crypto.createHash("sha256").update(canonical).digest("hex")].join(
|
|
61
|
+
"\n",
|
|
62
|
+
);
|
|
63
|
+
let k = hmac(`AWS4${process.env.AWS_SECRET_ACCESS_KEY}`, stamp.slice(0, 8));
|
|
64
|
+
for (const part of [region, "s3", "aws4_request"]) k = hmac(k, part);
|
|
65
|
+
return `https://${host}${path}?${query}&X-Amz-Signature=${hmac(k, sts).toString("hex")}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function invoke(envelope) {
|
|
69
|
+
if ((process.env.WAKE_SECRET || process.env.STATE_REFRESH_SECRET) && !ownUrl) {
|
|
70
|
+
const { LambdaClient, GetFunctionUrlConfigCommand } = require("@aws-sdk/client-lambda");
|
|
71
|
+
ownUrl = (
|
|
72
|
+
await new LambdaClient({}).send(
|
|
73
|
+
new GetFunctionUrlConfigCommand({ FunctionName: process.env.AWS_LAMBDA_FUNCTION_NAME }),
|
|
74
|
+
)
|
|
75
|
+
).FunctionUrl;
|
|
76
|
+
}
|
|
77
|
+
if (ownUrl) envelope.wake = { url: ownUrl };
|
|
78
|
+
// Authenticates this envelope as coming from the forwarder (see the template's FastagentIngressSecret).
|
|
79
|
+
envelope.auth = process.env.INGRESS_SECRET;
|
|
80
|
+
// Keep each capability short-lived. Function-URL deployments also carry an authenticated refresh
|
|
81
|
+
// endpoint, so a background turn settling hours after its webhook never depends on the temporary
|
|
82
|
+
// Lambda credentials that signed the original pair still being alive.
|
|
83
|
+
if (process.env.STATE_BUCKET)
|
|
84
|
+
envelope.state = {
|
|
85
|
+
getUrl: presign("GET", 3600),
|
|
86
|
+
putUrl: presign("PUT", 3600),
|
|
87
|
+
...(ownUrl && process.env.STATE_REFRESH_SECRET
|
|
88
|
+
? {
|
|
89
|
+
refresh: {
|
|
90
|
+
url: `${ownUrl.replace(/\/$/, "")}/__fastagent/state-urls`,
|
|
91
|
+
auth: process.env.STATE_REFRESH_SECRET,
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
: {}),
|
|
95
|
+
};
|
|
96
|
+
const res = await client.send(
|
|
97
|
+
new InvokeAgentRuntimeCommand({
|
|
98
|
+
agentRuntimeArn: process.env.RUNTIME_ARN,
|
|
99
|
+
runtimeSessionId: process.env.INGRESS_SESSION_ID,
|
|
100
|
+
contentType: "application/json",
|
|
101
|
+
accept: "application/json",
|
|
102
|
+
payload: new TextEncoder().encode(JSON.stringify(envelope)),
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
const body = Buffer.from(await res.response.transformToByteArray());
|
|
106
|
+
return { status: res.statusCode ?? 200, body };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Mirror the container's pending wake-ups into one-shot schedules: at(fireAt), poke me, delete
|
|
110
|
+
// after firing. Upsert (create → conflict → update). The container pre-filters DUE alarms (it is
|
|
111
|
+
// awake handling those), so every failure here is REAL — counted and propagated: a swallowed error
|
|
112
|
+
// would leave a pending wake with no alarm, exactly the reliability hole this mechanism closes.
|
|
113
|
+
// Cancelled wakes are NOT deleted here: their poke fires, finds nothing due, and the schedule
|
|
114
|
+
// self-deletes (lazy cleanup by design).
|
|
115
|
+
async function syncAlarms(alarms, ctx) {
|
|
116
|
+
const { SchedulerClient, CreateScheduleCommand, UpdateScheduleCommand } = require("@aws-sdk/client-scheduler");
|
|
117
|
+
const sch = new SchedulerClient({});
|
|
118
|
+
let failed = 0;
|
|
119
|
+
// Alarm name = a stable hash of the WHOLE wake id. A prefix of the id would collide (two wakes
|
|
120
|
+
// sharing 8 hex chars), and a collision is INDISTINGUISHABLE from the legitimate re-arm below:
|
|
121
|
+
// the second wake would "update" the first's alarm and silently steal its fire time.
|
|
122
|
+
const names = new Map();
|
|
123
|
+
for (const a of alarms) {
|
|
124
|
+
const name = process.env.WAKE_PREFIX + crypto.createHash("sha256").update(a.id).digest("hex").slice(0, 16);
|
|
125
|
+
if (names.has(name)) {
|
|
126
|
+
failed += 1;
|
|
127
|
+
console.log(`alarm name collision ${name}: ${names.get(name)} vs ${a.id}`);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
names.set(name, a.id);
|
|
131
|
+
const p = {
|
|
132
|
+
Name: name,
|
|
133
|
+
ScheduleExpression: `at(${a.at.slice(0, 19)})`,
|
|
134
|
+
ScheduleExpressionTimezone: "UTC",
|
|
135
|
+
FlexibleTimeWindow: { Mode: "OFF" },
|
|
136
|
+
ActionAfterCompletion: "DELETE",
|
|
137
|
+
Target: { Arn: ctx.invokedFunctionArn, RoleArn: process.env.WAKE_ROLE_ARN, Input: '{"wakePoke":true}' },
|
|
138
|
+
};
|
|
139
|
+
try {
|
|
140
|
+
await sch.send(new CreateScheduleCommand(p));
|
|
141
|
+
} catch (e) {
|
|
142
|
+
try {
|
|
143
|
+
if (e.name !== "ConflictException") throw e;
|
|
144
|
+
await sch.send(new UpdateScheduleCommand(p));
|
|
145
|
+
} catch (u) {
|
|
146
|
+
failed += 1;
|
|
147
|
+
console.log(`alarm ${p.Name}: ${u}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return failed;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
exports.handler = async (event, ctx) => {
|
|
155
|
+
// EventBridge wake-up poke: the invocation itself wakes the container; its pump does the rest.
|
|
156
|
+
if (event?.wakePoke) {
|
|
157
|
+
const r = await invoke({ kind: "wake-poke" });
|
|
158
|
+
console.log(`wake-poke: ${r.status}`);
|
|
159
|
+
return { status: r.status };
|
|
160
|
+
}
|
|
161
|
+
// EventBridge Scheduler fire — throw on failure so the miss lands in CloudWatch, never silently.
|
|
162
|
+
if (event?.scheduleFire) {
|
|
163
|
+
const { name, slot } = event.scheduleFire;
|
|
164
|
+
const r = await invoke({ kind: "schedule-fire", name, slot });
|
|
165
|
+
const out = r.body.toString();
|
|
166
|
+
console.log(`schedule-fire ${name} (${slot}): ${r.status} ${out}`);
|
|
167
|
+
if (r.status >= 400) throw new Error(`schedule-fire ${name} failed: ${r.status} ${out}`);
|
|
168
|
+
return { status: r.status };
|
|
169
|
+
}
|
|
170
|
+
const http = event?.requestContext?.http;
|
|
171
|
+
if (!http) throw new Error("unrecognized event shape");
|
|
172
|
+
// Refresh the snapshot capabilities with THIS Lambda invocation's current temporary credentials.
|
|
173
|
+
// The container may settle long after the webhook Lambda (and its credentials) expired.
|
|
174
|
+
if (event.rawPath === "/__fastagent/state-urls") {
|
|
175
|
+
const req = JSON.parse(event.isBase64Encoded ? Buffer.from(event.body, "base64").toString() : event.body || "{}");
|
|
176
|
+
if (!secretEq(req.auth, process.env.STATE_REFRESH_SECRET)) return { statusCode: 403, body: "forbidden\n" };
|
|
177
|
+
return {
|
|
178
|
+
statusCode: 200,
|
|
179
|
+
headers: { "content-type": "application/json" },
|
|
180
|
+
body: JSON.stringify({ getUrl: presign("GET", 3600), putUrl: presign("PUT", 3600) }),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
// The container's wake-alarm callback (reserved path, shared secret) — handled HERE, never forwarded.
|
|
184
|
+
if (event.rawPath === "/__fastagent/wake-alarm") {
|
|
185
|
+
const req = JSON.parse(event.isBase64Encoded ? Buffer.from(event.body, "base64").toString() : event.body || "{}");
|
|
186
|
+
if (!secretEq(req.secret, process.env.WAKE_SECRET)) return { statusCode: 403, body: "forbidden\n" };
|
|
187
|
+
const failed = await syncAlarms(req.alarms || [], ctx);
|
|
188
|
+
// Partial failure IS failure: the container retries the whole (idempotent) set until every
|
|
189
|
+
// pending wake really has its alarm.
|
|
190
|
+
if (failed > 0) return { statusCode: 500, body: `${failed} alarm(s) failed\n` };
|
|
191
|
+
return { statusCode: 200, body: "ok\n" };
|
|
192
|
+
}
|
|
193
|
+
// The deploy driver's probe (reserved path, ingress secret): wake the runtime through the SAME
|
|
194
|
+
// trusted envelope pipeline (state URLs included — a direct InvokeAgentRuntime call could not mint
|
|
195
|
+
// them, and would make the runtime construct against a pre-restore mount) and pass its structured
|
|
196
|
+
// transport-200 verdict back VERBATIM. The ordinary webhook path below folds a non-200 transport
|
|
197
|
+
// into an opaque 502, which would strip exactly the diagnostics the probe exists to carry — and it
|
|
198
|
+
// sits BEFORE the WEBHOOKS_ENABLED gate so schedule-only topologies (whose URLs refuse ordinary
|
|
199
|
+
// public traffic) are probeable too.
|
|
200
|
+
if (event.rawPath === "/__fastagent/probe") {
|
|
201
|
+
const req = JSON.parse(event.isBase64Encoded ? Buffer.from(event.body, "base64").toString() : event.body || "{}");
|
|
202
|
+
if (!secretEq(req.auth, process.env.INGRESS_SECRET)) return { statusCode: 403, body: "forbidden\n" };
|
|
203
|
+
const r = await invoke({ kind: "probe" });
|
|
204
|
+
if (r.status !== 200) {
|
|
205
|
+
console.log(`probe transport error ${r.status}: ${r.body}`);
|
|
206
|
+
return { statusCode: 502, body: "upstream error\n" };
|
|
207
|
+
}
|
|
208
|
+
return { statusCode: 200, headers: { "content-type": "application/json" }, body: r.body.toString() };
|
|
209
|
+
}
|
|
210
|
+
// Enforce the advertised ORIGINAL-body ceiling before base64 adds another 4/3 inside the runtime
|
|
211
|
+
// envelope. This also leaves deterministic room for headers/query/JSON under Lambda's 6 MB cap.
|
|
212
|
+
const webhookBytes =
|
|
213
|
+
event.body === undefined
|
|
214
|
+
? 0
|
|
215
|
+
: event.isBase64Encoded
|
|
216
|
+
? Buffer.byteLength(event.body, "base64")
|
|
217
|
+
: Buffer.byteLength(event.body);
|
|
218
|
+
// The ceiling rides in as an env var (the template's MAX_WEBHOOK_BODY_BYTES), so a stack whose
|
|
219
|
+
// template predates it would compare against NaN — every comparison false, the limit silently gone.
|
|
220
|
+
const maxWebhookBytes = Number(process.env.MAX_WEBHOOK_BODY_BYTES);
|
|
221
|
+
if (!(maxWebhookBytes > 0))
|
|
222
|
+
throw new Error("MAX_WEBHOOK_BODY_BYTES is missing or invalid — redeploy to regenerate the stack template");
|
|
223
|
+
if (webhookBytes > maxWebhookBytes) return { statusCode: 413, body: "payload too large\n" };
|
|
224
|
+
// A schedule-only deployment has a Function URL solely for the authenticated refresh callback.
|
|
225
|
+
// Reject arbitrary public traffic BEFORE it can wake AgentCore (cost/DoS) or reach an inner route.
|
|
226
|
+
if (process.env.WEBHOOKS_ENABLED !== "1") return { statusCode: 404, body: "not found\n" };
|
|
227
|
+
// Function URL webhook — forward the original request verbatim (signature material included).
|
|
228
|
+
const r = await invoke({
|
|
229
|
+
kind: "webhook",
|
|
230
|
+
method: http.method,
|
|
231
|
+
path: event.rawPath || "/",
|
|
232
|
+
query: event.rawQueryString || undefined,
|
|
233
|
+
headers: event.headers || {},
|
|
234
|
+
bodyB64:
|
|
235
|
+
event.body === undefined
|
|
236
|
+
? undefined
|
|
237
|
+
: event.isBase64Encoded
|
|
238
|
+
? event.body
|
|
239
|
+
: Buffer.from(event.body).toString("base64"),
|
|
240
|
+
});
|
|
241
|
+
if (r.status !== 200) {
|
|
242
|
+
console.log(`transport error ${r.status}: ${r.body}`);
|
|
243
|
+
return { statusCode: 502, body: "upstream error\n" };
|
|
244
|
+
}
|
|
245
|
+
const reply = JSON.parse(r.body.toString()); // { status, headers, bodyB64 } from the adapter
|
|
246
|
+
for (const k of Object.keys(reply.headers)) {
|
|
247
|
+
if (/^(content-length|transfer-encoding|connection)$/i.test(k)) delete reply.headers[k];
|
|
248
|
+
}
|
|
249
|
+
return { statusCode: reply.status, headers: reply.headers, body: reply.bodyB64, isBase64Encoded: true };
|
|
250
|
+
};
|