@fastagent-sh/fastagent 0.15.0 → 0.16.1
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 +14 -4
- package/dist/bind.d.ts +34 -0
- package/dist/bind.js +74 -0
- package/dist/channels/agentcore-limits.d.ts +7 -0
- package/dist/channels/agentcore-limits.js +9 -0
- package/dist/channels/agentcore-state.d.ts +83 -0
- package/dist/channels/agentcore-state.js +266 -0
- package/dist/channels/agentcore.d.ts +98 -0
- package/dist/channels/agentcore.js +260 -0
- package/dist/channels/busy.d.ts +23 -0
- package/dist/channels/busy.js +53 -0
- package/dist/channels/context-buffer.d.ts +5 -0
- package/dist/channels/context-buffer.js +5 -0
- package/dist/channels/feishu/context-buffer.d.ts +13 -8
- package/dist/channels/feishu/context-buffer.js +60 -6
- package/dist/channels/feishu/feishu-api.js +4 -1
- package/dist/channels/feishu/feishu.d.ts +0 -12
- package/dist/channels/feishu/feishu.js +143 -54
- package/dist/channels/feishu/invoke-turn.js +35 -18
- package/dist/channels/feishu/model.d.ts +0 -1
- package/dist/channels/feishu/normalize.js +0 -1
- package/dist/channels/feishu/parse.d.ts +21 -7
- package/dist/channels/feishu/parse.js +24 -7
- package/dist/channels/feishu/preview.js +3 -2
- package/dist/channels/feishu/scaffold/channel.ts +9 -8
- package/dist/channels/feishu/scaffold/feishu-send.ts +6 -4
- package/dist/channels/feishu/setup-mode.d.ts +30 -1
- package/dist/channels/feishu/setup-mode.js +27 -1
- package/dist/channels/github/github.js +8 -1
- package/dist/channels/http.js +1 -1
- package/dist/channels/lark/scaffold/channel.ts +9 -8
- package/dist/channels/lark/scaffold/lark-send.ts +6 -4
- package/dist/channels/preview-kit.d.ts +7 -1
- package/dist/channels/preview-kit.js +3 -2
- package/dist/channels/slack/parse.d.ts +16 -1
- package/dist/channels/slack/parse.js +46 -3
- package/dist/channels/slack/preview.d.ts +1 -2
- package/dist/channels/slack/preview.js +68 -24
- package/dist/channels/slack/scaffold/channel.ts +5 -5
- package/dist/channels/slack/slack-api.d.ts +3 -23
- package/dist/channels/slack/slack-api.js +6 -22
- package/dist/channels/slack/slack.d.ts +13 -20
- package/dist/channels/slack/slack.js +95 -50
- package/dist/channels/state.d.ts +11 -4
- package/dist/channels/state.js +19 -12
- package/dist/channels/tasks.d.ts +0 -6
- package/dist/channels/tasks.js +16 -1
- package/dist/channels/telegram/parse.d.ts +0 -7
- package/dist/channels/telegram/parse.js +4 -2
- package/dist/channels/telegram/scaffold/telegram-send.ts +6 -3
- package/dist/channels/telegram/telegram.js +1 -1
- package/dist/channels/text.d.ts +14 -0
- package/dist/channels/text.js +14 -0
- package/dist/channels/thread-participants.d.ts +21 -0
- package/dist/channels/thread-participants.js +132 -0
- package/dist/channels/turn-queue.js +7 -0
- package/dist/cli/add-feishu.d.ts +7 -4
- package/dist/cli/add-feishu.js +57 -37
- package/dist/cli/add-slack.d.ts +2 -1
- package/dist/cli/add-slack.js +6 -11
- package/dist/cli/commands/add.js +50 -51
- package/dist/cli/commands/attach.js +8 -4
- package/dist/cli/commands/chat.js +8 -8
- package/dist/cli/commands/deploy.d.ts +14 -1
- package/dist/cli/commands/deploy.js +330 -78
- package/dist/cli/commands/dev.d.ts +1 -0
- package/dist/cli/commands/dev.js +38 -25
- package/dist/cli/commands/fire.js +15 -16
- package/dist/cli/commands/info.js +36 -29
- package/dist/cli/commands/init.d.ts +1 -1
- package/dist/cli/commands/init.js +65 -53
- package/dist/cli/commands/invoke.js +9 -6
- package/dist/cli/commands/login.js +35 -21
- package/dist/cli/commands/schedule.js +6 -8
- package/dist/cli/commands/start.d.ts +1 -0
- package/dist/cli/commands/start.js +101 -37
- package/dist/cli/commands/tool.js +30 -18
- package/dist/cli/fail.d.ts +17 -0
- package/dist/cli/fail.js +24 -0
- package/dist/cli/program.js +66 -36
- package/dist/cli/serve.d.ts +52 -8
- package/dist/cli/serve.js +133 -32
- package/dist/cli/shared.d.ts +21 -2
- package/dist/cli/shared.js +44 -18
- package/dist/deploy/agentcore/plan.d.ts +117 -0
- package/dist/deploy/agentcore/plan.js +721 -0
- package/dist/deploy/agentcore/run.d.ts +73 -0
- package/dist/deploy/agentcore/run.js +412 -0
- package/dist/deploy/agentcore/zip.d.ts +17 -0
- package/dist/deploy/agentcore/zip.js +68 -0
- package/dist/deploy/container.d.ts +26 -25
- package/dist/deploy/container.js +93 -89
- package/dist/deploy/docker/plan.d.ts +1 -1
- package/dist/deploy/docker/plan.js +12 -17
- package/dist/deploy/fly/plan.d.ts +2 -0
- package/dist/deploy/fly/plan.js +27 -19
- package/dist/deploy/fly/run.d.ts +12 -1
- package/dist/deploy/fly/run.js +36 -2
- package/dist/deploy/preflight.d.ts +11 -5
- package/dist/deploy/preflight.js +235 -65
- package/dist/deploy/railway/plan.d.ts +7 -0
- package/dist/deploy/railway/plan.js +41 -16
- package/dist/deploy/railway/run.d.ts +8 -1
- package/dist/deploy/railway/run.js +7 -2
- package/dist/deploy/runner.d.ts +5 -2
- package/dist/deploy/runner.js +9 -3
- package/dist/dev-supervisor.d.ts +11 -8
- package/dist/dev-supervisor.js +53 -51
- package/dist/engines/pi/auth.d.ts +8 -7
- package/dist/engines/pi/auth.js +12 -10
- package/dist/engines/pi/channel.d.ts +1 -1
- package/dist/engines/pi/channel.js +5 -5
- package/dist/engines/pi/chat.js +2 -2
- package/dist/engines/pi/config.d.ts +12 -48
- package/dist/engines/pi/config.js +29 -110
- package/dist/engines/pi/create.d.ts +32 -24
- package/dist/engines/pi/create.js +47 -23
- package/dist/engines/pi/definition.d.ts +7 -26
- package/dist/engines/pi/definition.js +8 -54
- package/dist/engines/pi/harness.d.ts +19 -5
- package/dist/engines/pi/harness.js +3 -5
- package/dist/engines/pi/login.d.ts +1 -1
- package/dist/engines/pi/models.d.ts +3 -3
- package/dist/engines/pi/models.js +1 -1
- package/dist/engines/pi/{workspace.d.ts → open.d.ts} +31 -25
- package/dist/engines/pi/{workspace.js → open.js} +27 -29
- package/dist/engines/pi/read-image.d.ts +4 -0
- package/dist/engines/pi/read-image.js +62 -0
- package/dist/engines/pi/search-tools.d.ts +6 -4
- package/dist/engines/pi/search-tools.js +3 -1
- package/dist/engines/pi/session-builder.d.ts +2 -2
- package/dist/engines/pi/session-builder.js +18 -13
- package/dist/engines/pi/tool.d.ts +13 -5
- package/dist/engines/pi/tool.js +4 -0
- package/dist/engines/pi/wake-tool.d.ts +3 -3
- package/dist/env.d.ts +16 -4
- package/dist/env.js +43 -5
- package/dist/host/node.d.ts +4 -2
- package/dist/host/node.js +2 -1
- package/dist/loader.d.ts +2 -2
- package/dist/loader.js +3 -3
- package/dist/log.d.ts +1 -1
- package/dist/log.js +1 -1
- package/dist/paths.d.ts +138 -0
- package/dist/paths.js +326 -0
- package/dist/pi.d.ts +2 -2
- package/dist/pi.js +2 -2
- package/dist/runtime.d.ts +7 -5
- package/dist/runtime.js +2 -2
- package/dist/scaffold/add-channel.d.ts +7 -3
- package/dist/scaffold/add-channel.js +55 -29
- package/dist/scaffold/init.d.ts +32 -41
- package/dist/scaffold/init.js +161 -185
- package/dist/scaffold/templates/env.example +15 -6
- package/dist/scaffold/templates/fastagent.config.mjs +1 -1
- package/dist/scaffold/templates/gitignore +14 -6
- package/dist/scaffold/templates/persona.md +4 -2
- package/dist/scaffold/templates/secrets.gitignore +5 -0
- package/dist/scaffold/templates.d.ts +1 -7
- package/dist/scaffold/templates.js +3 -25
- package/dist/scaffold/vendor-skill.d.ts +2 -2
- package/dist/scaffold/vendor-skill.js +13 -13
- package/dist/schedule/discover.js +4 -4
- package/dist/schedule/scheduler.d.ts +40 -1
- package/dist/schedule/scheduler.js +89 -56
- package/dist/schedule/state.js +1 -1
- package/dist/schedule/wake-alarm.d.ts +47 -0
- package/dist/schedule/wake-alarm.js +136 -0
- package/dist/schedule/wakeups.d.ts +1 -0
- package/dist/schedule/wakeups.js +18 -0
- package/dist/tunnel.d.ts +3 -3
- package/dist/tunnel.js +7 -7
- package/package.json +7 -4
- package/dist/channels/feishu/owned-threads.d.ts +0 -7
- package/dist/channels/feishu/owned-threads.js +0 -47
- package/dist/channels/slack/owned-threads.d.ts +0 -6
- package/dist/channels/slack/owned-threads.js +0 -43
- package/dist/scaffold/templates/gitignore.agentdir-root +0 -5
- package/dist/scaffold/templates/gitignore.kit +0 -2
- package/dist/workspace.d.ts +0 -9
- package/dist/workspace.js +0 -45
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fastagent deploy agentcore` — the AWS Bedrock AgentCore deploy PLAN, computed from the resolved
|
|
3
|
+
* definition. Pure: facts in, artifact contents + an ordered runbook out; the CLI writes the files
|
|
4
|
+
* and prints the runbook. AgentCore is the fourth target, and its shape differs from Fly/Railway in
|
|
5
|
+
* kind, not degree:
|
|
6
|
+
*
|
|
7
|
+
* 1. **No public URL, no resident process.** The Runtime's only ingress is the SigV4
|
|
8
|
+
* `InvokeAgentRuntime` API, and compute is per-session microVMs that stop when idle. So the
|
|
9
|
+
* topology carries TWO extra pieces a Fly box never needs: a forwarder Lambda (public Function
|
|
10
|
+
* URL → envelope → InvokeAgentRuntime) fronting the webhooks, and EventBridge Scheduler rules
|
|
11
|
+
* delivering each cron slot (the container arms no resident timers — serve's externalClock mode).
|
|
12
|
+
* 2. **One template is the whole topology.** CloudFormation (`AWS::BedrockAgentCore::Runtime` is a
|
|
13
|
+
* first-class resource type) declares Runtime + roles + forwarder + schedules in one stack —
|
|
14
|
+
* unlike Railway, identity DOES live in a committed file; the stack name pins it.
|
|
15
|
+
* 3. **All ingress traffic shares ONE fixed runtime session** (`ingressSessionId`): fastagent's
|
|
16
|
+
* channel state is single-writer by design, and one session = at most one microVM at a time.
|
|
17
|
+
* AgentCore keeps a stopped session's id valid until the Runtime is deleted (a new compute is
|
|
18
|
+
* provisioned on the next invoke), so the fixed id needs no rotation. State lives on the
|
|
19
|
+
* platform's SessionStorage mount (`/mnt/state`) — persistent across compute stop/resume, no
|
|
20
|
+
* VPC/EFS required. Named trade-off: that state is tied to THIS Runtime resource — a stack
|
|
21
|
+
* replacement (renaming the runtime) starts blank. EFS (VPC mode) is the upgrade path when
|
|
22
|
+
* state must outlive the runtime; the runbook says so instead of silently shipping a VPC+NAT
|
|
23
|
+
* bill (~$35/mo) every deployment.
|
|
24
|
+
*
|
|
25
|
+
* The image is the SAME portable container every host ships (containerArtifacts) — AgentCore's
|
|
26
|
+
* extras (PORT=8080, FASTAGENT_AGENTCORE=1, the state dir) ride the Runtime resource's environment,
|
|
27
|
+
* never a forked Dockerfile. The build must be linux/arm64 (the platform requirement) — the ONE
|
|
28
|
+
* host where the build runs on the operator's machine (docker buildx) instead of remotely.
|
|
29
|
+
*/
|
|
30
|
+
import { createHash } from "node:crypto";
|
|
31
|
+
import { MAX_WEBHOOK_BODY_BYTES } from "../../channels/agentcore-limits.js";
|
|
32
|
+
import { containerArtifacts } from "../container.js";
|
|
33
|
+
import { deploymentSecrets, isEnvKey } from "../secrets.js";
|
|
34
|
+
/** SessionStorage mount = FASTAGENT_STATE_DIR (AgentCore requires exactly `/mnt/<one-level>`). It is
|
|
35
|
+
* a fast LOCAL disk only: the platform wipes it on every runtime version update (= every deploy).
|
|
36
|
+
* Durability across deploys comes from the S3 snapshot (channels/agentcore-state.ts). */
|
|
37
|
+
export const MOUNT = "/mnt/state";
|
|
38
|
+
/**
|
|
39
|
+
* How long an idle session keeps its microVM. Memory is billed per second across the WHOLE session
|
|
40
|
+
* — idle included, at the peak level reached — so this tail is the standing cost of every burst of
|
|
41
|
+
* activity, while CPU stops billing the moment the agent stops working. 3 minutes rather than the
|
|
42
|
+
* platform's 15: the tail shrinks 5×, and the cost is a cold start (image + Node + snapshot restore)
|
|
43
|
+
* for anyone who returns after a longer gap. `/ping` reports HealthyBusy while work is in flight, so
|
|
44
|
+
* this timer only ever starts once the agent has genuinely settled — a long turn is never cut short.
|
|
45
|
+
* AWS accepts 60–28800.
|
|
46
|
+
*/
|
|
47
|
+
export const IDLE_TIMEOUT_SECONDS = 180;
|
|
48
|
+
/** The platform ceiling on one session's compute (8 h). The session ID outlives it: the next invoke
|
|
49
|
+
* simply gets fresh compute with the same storage. */
|
|
50
|
+
export const MAX_LIFETIME_SECONDS = 28800;
|
|
51
|
+
/** The state snapshot's object key in the deployment bucket (one object; see agentcore-state.ts). */
|
|
52
|
+
export const STATE_KEY = "state/snapshot.json.gz";
|
|
53
|
+
/** The forwarder artifact. Named `index.js` because it IS the Lambda deployment package's entry:
|
|
54
|
+
* zipping it as-is produces a valid package (`Handler: index.handler`), with nothing to rename. */
|
|
55
|
+
export const FORWARDER_FILE = "lambda/index.js";
|
|
56
|
+
/** The deployment bucket: forwarder code + the state snapshot. Account-suffixed for S3's GLOBAL
|
|
57
|
+
* namespace, and created OUTSIDE the stack (like the ECR repo) so a `delete-stack` cannot take the
|
|
58
|
+
* agent's memory with it. Bucket names cap at 63 chars; `name` is already gated to 40. */
|
|
59
|
+
export function stateBucketName(name, account) {
|
|
60
|
+
return `fa-${name}-${account}`;
|
|
61
|
+
}
|
|
62
|
+
/** AgentCore env values max 2048 chars — a real OAuth auth.json's base64 exceeds it, so the seed is
|
|
63
|
+
* CHUNKED across FASTAGENT_AUTH_SEED + _2… (collectAuthSeed reassembles at boot). 2000 keeps margin. */
|
|
64
|
+
export const AUTH_SEED_CHUNK_SIZE = 2000;
|
|
65
|
+
export const AUTH_SEED_MAX_CHUNKS = 4;
|
|
66
|
+
/** The generated template's filename (namespaced under the kit in the agentDir layout). */
|
|
67
|
+
export const TEMPLATE_FILE = "agentcore.template.yaml";
|
|
68
|
+
/** The generated template's first-line marker — the ONE source for both the generator and the
|
|
69
|
+
* "did fastagent generate this?" check (deploy's drift gate), so they cannot drift apart. */
|
|
70
|
+
export const GENERATED_TEMPLATE_MARKER = "# Generated by `fastagent deploy agentcore`";
|
|
71
|
+
/** Whether an on-disk template is fastagent-generated (vs hand-written — kept, never gated). */
|
|
72
|
+
export function isGeneratedAgentcoreTemplate(content) {
|
|
73
|
+
return content.startsWith(GENERATED_TEMPLATE_MARKER);
|
|
74
|
+
}
|
|
75
|
+
/** Runtime name (`[a-zA-Z][a-zA-Z0-9_]{0,47}`) from a dir basename. */
|
|
76
|
+
export function toRuntimeName(basename) {
|
|
77
|
+
const slug = basename.replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
78
|
+
return (/^[a-zA-Z]/.test(slug) ? slug : `agent_${slug || "fastagent"}`).slice(0, 48);
|
|
79
|
+
}
|
|
80
|
+
/** The ONE fixed ingress session id (webhooks + schedule fires) — ≥ 33 chars (the API minimum),
|
|
81
|
+
* deterministic (the Lambda holds it in env), padded so any name clears the floor. */
|
|
82
|
+
export function ingressSessionId(name) {
|
|
83
|
+
return `fastagent-ingress-${name}`.padEnd(33, "0").slice(0, 128);
|
|
84
|
+
}
|
|
85
|
+
/** CFN parameter logical id for a secret env-var name: TELEGRAM_BOT_TOKEN → TelegramBotToken
|
|
86
|
+
* (parameter names must be alphanumeric). Deterministic — run.ts builds the same mapping. */
|
|
87
|
+
export function cfnParamName(envName) {
|
|
88
|
+
return envName
|
|
89
|
+
.toLowerCase()
|
|
90
|
+
.split("_")
|
|
91
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
92
|
+
.join("");
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Remap ONE day-of-week field from standard cron numbering (0–7, 0/7 = Sunday) to EventBridge's
|
|
96
|
+
* (1–7, 1 = Sunday). Parsed, not regex-replaced: only VALUES and RANGE ENDPOINTS are renumbered — a
|
|
97
|
+
* step divisor (`*\/2`, `1-5/2`) is a count, not a weekday, and must pass through untouched. Names
|
|
98
|
+
* (SUN..SAT) pass through. A numeric range whose endpoints INVERT under renumbering (`5-7` → `6-1`)
|
|
99
|
+
* wraps across the week — not expressible as an EventBridge range — and is refused, never silently
|
|
100
|
+
* reordered.
|
|
101
|
+
*/
|
|
102
|
+
function mapDowField(dow) {
|
|
103
|
+
const items = [];
|
|
104
|
+
for (const item of dow.split(",")) {
|
|
105
|
+
const slash = item.split("/");
|
|
106
|
+
if (slash.length > 2 || slash.some((part) => part === "")) {
|
|
107
|
+
return { error: `malformed day-of-week token "${item}"` };
|
|
108
|
+
}
|
|
109
|
+
const [body, step] = slash;
|
|
110
|
+
if (step !== undefined && !/^\d+$/.test(step))
|
|
111
|
+
return { error: `malformed day-of-week step "${item}"` };
|
|
112
|
+
let mapped;
|
|
113
|
+
if (body === "*") {
|
|
114
|
+
mapped = "*";
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
const endpoints = body.split("-");
|
|
118
|
+
if (endpoints.length > 2 || endpoints.some((part) => part === "")) {
|
|
119
|
+
return { error: `malformed day-of-week token "${item}"` };
|
|
120
|
+
}
|
|
121
|
+
const remapped = endpoints.map((p) => (/^\d+$/.test(p) ? String((Number(p) % 7) + 1) : p));
|
|
122
|
+
if (remapped.length === 2 &&
|
|
123
|
+
remapped.every((p) => /^\d+$/.test(p)) &&
|
|
124
|
+
Number(remapped[0]) > Number(remapped[1])) {
|
|
125
|
+
return {
|
|
126
|
+
error: `day-of-week range "${body}" wraps across the week under EventBridge numbering (1 = Sunday) — ` +
|
|
127
|
+
`split it into an explicit list`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
mapped = remapped.join("-");
|
|
131
|
+
}
|
|
132
|
+
items.push(step !== undefined ? `${mapped}/${step}` : mapped);
|
|
133
|
+
}
|
|
134
|
+
return { value: items.join(",") };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Translate a 5-field cron into EventBridge Scheduler's `cron(m h dom mon dow *)`, or say why it
|
|
138
|
+
* can't be. The two dialects disagree exactly where silent translation would misfire:
|
|
139
|
+
* - EventBridge numbers day-of-week 1–7 (1 = Sunday); standard cron uses 0–6 (0/7 = Sunday) —
|
|
140
|
+
* numeric dow values and range endpoints are remapped ({@link mapDowField}); steps and names
|
|
141
|
+
* pass through; a range that wraps under renumbering is refused.
|
|
142
|
+
* - EventBridge requires `?` in dom or dow: a `*` on either side becomes `?`; BOTH restricted is
|
|
143
|
+
* standard cron's OR semantics, which EventBridge cannot express — refused, never approximated.
|
|
144
|
+
* - A 6-field (seconds) expression and L/# day-of-week forms are refused for the same reason.
|
|
145
|
+
*/
|
|
146
|
+
export function toEventBridgeCron(cron) {
|
|
147
|
+
const fields = cron.trim().split(/\s+/);
|
|
148
|
+
if (fields.length !== 5) {
|
|
149
|
+
return { error: `EventBridge supports 5-field cron only (got ${fields.length} fields)` };
|
|
150
|
+
}
|
|
151
|
+
const [min, hour, dom, mon, dow] = fields;
|
|
152
|
+
if (/[L#]/i.test(dow) || /[L#]/i.test(dom)) {
|
|
153
|
+
return { error: "L/# day forms don't translate to EventBridge numbering — set this schedule up manually" };
|
|
154
|
+
}
|
|
155
|
+
// `?` FIRST, and not as a synonym for `*`. Croner treats it as a day field that matches everything
|
|
156
|
+
// and — unlike `*` — does NOT trigger cron's "one field is unrestricted, so the other governs"
|
|
157
|
+
// special case. It therefore ORs with the other field to mean EVERY DAY, whatever that field says.
|
|
158
|
+
// Measured against croner: `0 9 ? * MON` and `0 9 1 * ?` both fire daily, while `0 9 * * MON`
|
|
159
|
+
// fires on Mondays. Translating `?` to `*` would deploy a rule that fires on a DIFFERENT set of
|
|
160
|
+
// days than the same file fires on locally — the silent divergence this whole target exists to
|
|
161
|
+
// avoid — so a `?` in either field becomes an explicitly daily EventBridge rule.
|
|
162
|
+
if (dom === "?" || dow === "?") {
|
|
163
|
+
return { expression: `cron(${min} ${hour} * ${mon} ? *)` };
|
|
164
|
+
}
|
|
165
|
+
if (dom !== "*" && dow !== "*") {
|
|
166
|
+
return {
|
|
167
|
+
error: "restricting BOTH day-of-month and day-of-week (cron OR semantics) is not expressible in EventBridge",
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
if (dow === "*") {
|
|
171
|
+
return { expression: `cron(${min} ${hour} ${dom} ${mon} ? *)` };
|
|
172
|
+
}
|
|
173
|
+
const mapped = mapDowField(dow);
|
|
174
|
+
if ("error" in mapped)
|
|
175
|
+
return mapped;
|
|
176
|
+
return { expression: `cron(${min} ${hour} ? ${mon} ${mapped.value} *)` };
|
|
177
|
+
}
|
|
178
|
+
/** CFN logical id fragment from a schedule name (alphanumeric only, capitalized). */
|
|
179
|
+
function logicalId(name) {
|
|
180
|
+
const slug = name.replace(/[^a-zA-Z0-9]+/g, "");
|
|
181
|
+
return slug.charAt(0).toUpperCase() + slug.slice(1) || "Schedule";
|
|
182
|
+
}
|
|
183
|
+
/** YAML single-quoted scalar (the one escape: `'` doubles). Used for values carrying user input. */
|
|
184
|
+
function yamlSingleQuote(value) {
|
|
185
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* The forwarder Lambda source — the ONLY string both the template's inline ZipFile and the readable
|
|
189
|
+
* `lambda/forwarder.js` artifact are generated from (one source, no drift). Zero-dependency: the
|
|
190
|
+
* Lambda Node runtime bundles AWS SDK v3. CommonJS ON PURPOSE: CloudFormation inline code always
|
|
191
|
+
* lands as `index.js`, where ESM `import` is a syntax error (found by the first real deploy). Two
|
|
192
|
+
* event shapes: a Function URL webhook (reconstructed verbatim into a `webhook` envelope; the
|
|
193
|
+
* channel's REAL response rides back inside the transport reply and is re-emitted byte-exact —
|
|
194
|
+
* Feishu's URL-verification challenge depends on it), and an EventBridge Scheduler fire
|
|
195
|
+
* (`{ scheduleFire }`, slot = the scheduled instant — the container's idempotency key). MUST stay
|
|
196
|
+
* under CloudFormation's 4096-byte inline-code cap.
|
|
197
|
+
*/
|
|
198
|
+
export function forwarderSource() {
|
|
199
|
+
return `// Generated by \`fastagent deploy agentcore\` — the deployment's only ingress.
|
|
200
|
+
// REGENERATED ON EVERY DEPLOY; edits here are overwritten and never deployed. \`--run\` builds the
|
|
201
|
+
// Lambda package from fastagent's own copy of this source, so this file is the readable MIRROR of
|
|
202
|
+
// what runs (and what the manual runbook zips) — not an input you can change.
|
|
203
|
+
// Webhooks (Function URL) and EventBridge Scheduler fires are forwarded as envelopes to the
|
|
204
|
+
// AgentCore Runtime over SigV4 InvokeAgentRuntime, all on ONE fixed ingress session (fastagent
|
|
205
|
+
// channel state is single-writer; one session = at most one microVM). With selfSchedule, this
|
|
206
|
+
// Lambda also OWNS the wake alarms: the container POSTs its pending wake-ups to /__fastagent/
|
|
207
|
+
// wake-alarm (shared secret) and each becomes a self-deleting one-shot EventBridge schedule that
|
|
208
|
+
// pokes this Lambda — which wakes the container, whose wake pump fires the due entry.
|
|
209
|
+
// CommonJS on purpose: the deployment package's entry lands as index.js, where ESM import is invalid.
|
|
210
|
+
"use strict";
|
|
211
|
+
const crypto = require("node:crypto");
|
|
212
|
+
const { BedrockAgentCoreClient, InvokeAgentRuntimeCommand } = require("@aws-sdk/client-bedrock-agentcore");
|
|
213
|
+
const client = new BedrockAgentCoreClient({});
|
|
214
|
+
let ownUrl; // self-resolved once per cold start; rides on every envelope for the wake-alarm callback
|
|
215
|
+
|
|
216
|
+
// Presigned S3 URLs for the container's state snapshot. AgentCore wipes the /mnt/state mount on
|
|
217
|
+
// every runtime version update (= every deploy), so the durable copy lives in S3 — but the
|
|
218
|
+
// container is given NO AWS credentials by the platform, so the only reachable form is a URL that
|
|
219
|
+
// carries its own authorization. SigV4 query signing, node:crypto only (no SDK, nothing to install).
|
|
220
|
+
const enc = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => \`%\${c.charCodeAt(0).toString(16).toUpperCase()}\`);
|
|
221
|
+
const hmac = (key, data) => crypto.createHmac("sha256", key).update(data).digest();
|
|
222
|
+
|
|
223
|
+
function presign(method, seconds) {
|
|
224
|
+
const bucket = process.env.STATE_BUCKET, key = process.env.STATE_KEY, region = process.env.AWS_REGION;
|
|
225
|
+
const host = \`\${bucket}.s3.\${region}.amazonaws.com\`;
|
|
226
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\\.\\d+/, "");
|
|
227
|
+
const scope = \`\${stamp.slice(0, 8)}/\${region}/s3/aws4_request\`;
|
|
228
|
+
const pairs = [
|
|
229
|
+
["X-Amz-Algorithm", "AWS4-HMAC-SHA256"],
|
|
230
|
+
["X-Amz-Credential", \`\${process.env.AWS_ACCESS_KEY_ID}/\${scope}\`],
|
|
231
|
+
["X-Amz-Date", stamp],
|
|
232
|
+
["X-Amz-Expires", String(seconds)],
|
|
233
|
+
["X-Amz-SignedHeaders", "host"],
|
|
234
|
+
];
|
|
235
|
+
if (process.env.AWS_SESSION_TOKEN) pairs.push(["X-Amz-Security-Token", process.env.AWS_SESSION_TOKEN]);
|
|
236
|
+
// The canonical query must be byte-identical to the one on the wire — build it ONCE, reuse below.
|
|
237
|
+
const query = pairs
|
|
238
|
+
.map(([k, v]) => [enc(k), enc(v)])
|
|
239
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
|
240
|
+
.map((p) => p.join("="))
|
|
241
|
+
.join("&");
|
|
242
|
+
const path = \`/\${key.split("/").map(enc).join("/")}\`;
|
|
243
|
+
const canonical = [method, path, query, \`host:\${host}\\n\`, "host", "UNSIGNED-PAYLOAD"].join("\\n");
|
|
244
|
+
const sts = ["AWS4-HMAC-SHA256", stamp, scope, crypto.createHash("sha256").update(canonical).digest("hex")].join("\\n");
|
|
245
|
+
let k = hmac(\`AWS4\${process.env.AWS_SECRET_ACCESS_KEY}\`, stamp.slice(0, 8));
|
|
246
|
+
for (const part of [region, "s3", "aws4_request"]) k = hmac(k, part);
|
|
247
|
+
return \`https://\${host}\${path}?\${query}&X-Amz-Signature=\${hmac(k, sts).toString("hex")}\`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function invoke(envelope) {
|
|
251
|
+
if ((process.env.WAKE_SECRET || process.env.STATE_REFRESH_SECRET) && !ownUrl) {
|
|
252
|
+
const { LambdaClient, GetFunctionUrlConfigCommand } = require("@aws-sdk/client-lambda");
|
|
253
|
+
ownUrl = (await new LambdaClient({}).send(
|
|
254
|
+
new GetFunctionUrlConfigCommand({ FunctionName: process.env.AWS_LAMBDA_FUNCTION_NAME }),
|
|
255
|
+
)).FunctionUrl;
|
|
256
|
+
}
|
|
257
|
+
if (ownUrl) envelope.wake = { url: ownUrl };
|
|
258
|
+
// Authenticates this envelope as coming from the forwarder (see the template's FastagentIngressSecret).
|
|
259
|
+
envelope.auth = process.env.INGRESS_SECRET;
|
|
260
|
+
// Keep each capability short-lived. Function-URL deployments also carry an authenticated refresh
|
|
261
|
+
// endpoint, so a background turn settling hours after its webhook never depends on the temporary
|
|
262
|
+
// Lambda credentials that signed the original pair still being alive.
|
|
263
|
+
if (process.env.STATE_BUCKET) envelope.state = {
|
|
264
|
+
getUrl: presign("GET", 3600),
|
|
265
|
+
putUrl: presign("PUT", 3600),
|
|
266
|
+
...(ownUrl && process.env.STATE_REFRESH_SECRET ? {
|
|
267
|
+
refresh: { url: ownUrl.replace(/\\/$/, "") + "/__fastagent/state-urls", auth: process.env.STATE_REFRESH_SECRET },
|
|
268
|
+
} : {}),
|
|
269
|
+
};
|
|
270
|
+
const res = await client.send(new InvokeAgentRuntimeCommand({
|
|
271
|
+
agentRuntimeArn: process.env.RUNTIME_ARN,
|
|
272
|
+
runtimeSessionId: process.env.INGRESS_SESSION_ID,
|
|
273
|
+
contentType: "application/json",
|
|
274
|
+
accept: "application/json",
|
|
275
|
+
payload: new TextEncoder().encode(JSON.stringify(envelope)),
|
|
276
|
+
}));
|
|
277
|
+
const body = Buffer.from(await res.response.transformToByteArray());
|
|
278
|
+
return { status: res.statusCode ?? 200, body };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Mirror the container's pending wake-ups into one-shot schedules: at(fireAt), poke me, delete
|
|
282
|
+
// after firing. Upsert (create → conflict → update). The container pre-filters DUE alarms (it is
|
|
283
|
+
// awake handling those), so every failure here is REAL — counted and propagated: a swallowed error
|
|
284
|
+
// would leave a pending wake with no alarm, exactly the reliability hole this mechanism closes.
|
|
285
|
+
// Cancelled wakes are NOT deleted here: their poke fires, finds nothing due, and the schedule
|
|
286
|
+
// self-deletes (lazy cleanup by design).
|
|
287
|
+
async function syncAlarms(alarms, ctx) {
|
|
288
|
+
const { SchedulerClient, CreateScheduleCommand, UpdateScheduleCommand } = require("@aws-sdk/client-scheduler");
|
|
289
|
+
const sch = new SchedulerClient({});
|
|
290
|
+
let failed = 0;
|
|
291
|
+
// Alarm name = a stable hash of the WHOLE wake id. A prefix of the id would collide (two wakes
|
|
292
|
+
// sharing 8 hex chars), and a collision is INDISTINGUISHABLE from the legitimate re-arm below:
|
|
293
|
+
// the second wake would "update" the first's alarm and silently steal its fire time.
|
|
294
|
+
const names = new Map();
|
|
295
|
+
for (const a of alarms) {
|
|
296
|
+
const name = process.env.WAKE_PREFIX + crypto.createHash("sha256").update(a.id).digest("hex").slice(0, 16);
|
|
297
|
+
if (names.has(name)) {
|
|
298
|
+
failed += 1;
|
|
299
|
+
console.log(\`alarm name collision \${name}: \${names.get(name)} vs \${a.id}\`);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
names.set(name, a.id);
|
|
303
|
+
const p = {
|
|
304
|
+
Name: name,
|
|
305
|
+
ScheduleExpression: \`at(\${a.at.slice(0, 19)})\`,
|
|
306
|
+
ScheduleExpressionTimezone: "UTC",
|
|
307
|
+
FlexibleTimeWindow: { Mode: "OFF" },
|
|
308
|
+
ActionAfterCompletion: "DELETE",
|
|
309
|
+
Target: { Arn: ctx.invokedFunctionArn, RoleArn: process.env.WAKE_ROLE_ARN, Input: '{"wakePoke":true}' },
|
|
310
|
+
};
|
|
311
|
+
try {
|
|
312
|
+
await sch.send(new CreateScheduleCommand(p));
|
|
313
|
+
} catch (e) {
|
|
314
|
+
try {
|
|
315
|
+
if (e.name !== "ConflictException") throw e;
|
|
316
|
+
await sch.send(new UpdateScheduleCommand(p));
|
|
317
|
+
} catch (u) {
|
|
318
|
+
failed += 1;
|
|
319
|
+
console.log(\`alarm \${p.Name}: \${u}\`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return failed;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
exports.handler = async (event, ctx) => {
|
|
327
|
+
// EventBridge wake-up poke: the invocation itself wakes the container; its pump does the rest.
|
|
328
|
+
if (event && event.wakePoke) {
|
|
329
|
+
const r = await invoke({ kind: "wake-poke" });
|
|
330
|
+
console.log(\`wake-poke: \${r.status}\`);
|
|
331
|
+
return { status: r.status };
|
|
332
|
+
}
|
|
333
|
+
// EventBridge Scheduler fire — throw on failure so the miss lands in CloudWatch, never silently.
|
|
334
|
+
if (event && event.scheduleFire) {
|
|
335
|
+
const { name, slot } = event.scheduleFire;
|
|
336
|
+
const r = await invoke({ kind: "schedule-fire", name, slot });
|
|
337
|
+
const out = r.body.toString();
|
|
338
|
+
console.log(\`schedule-fire \${name} (\${slot}): \${r.status} \${out}\`);
|
|
339
|
+
if (r.status >= 400) throw new Error(\`schedule-fire \${name} failed: \${r.status} \${out}\`);
|
|
340
|
+
return { status: r.status };
|
|
341
|
+
}
|
|
342
|
+
const http = event && event.requestContext && event.requestContext.http;
|
|
343
|
+
if (!http) throw new Error("unrecognized event shape");
|
|
344
|
+
// Refresh the snapshot capabilities with THIS Lambda invocation's current temporary credentials.
|
|
345
|
+
// The container may settle long after the webhook Lambda (and its credentials) expired.
|
|
346
|
+
if (event.rawPath === "/__fastagent/state-urls") {
|
|
347
|
+
const req = JSON.parse(event.isBase64Encoded ? Buffer.from(event.body, "base64").toString() : event.body || "{}");
|
|
348
|
+
const actual = Buffer.from(typeof req.auth === "string" ? req.auth : "");
|
|
349
|
+
const expected = Buffer.from(process.env.STATE_REFRESH_SECRET || "");
|
|
350
|
+
if (!expected.length || actual.length !== expected.length || !crypto.timingSafeEqual(actual, expected)) {
|
|
351
|
+
return { statusCode: 403, body: "forbidden\\n" };
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
statusCode: 200,
|
|
355
|
+
headers: { "content-type": "application/json" },
|
|
356
|
+
body: JSON.stringify({ getUrl: presign("GET", 3600), putUrl: presign("PUT", 3600) }),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
// The container's wake-alarm callback (reserved path, shared secret) — handled HERE, never forwarded.
|
|
360
|
+
if (event.rawPath === "/__fastagent/wake-alarm") {
|
|
361
|
+
const req = JSON.parse(event.isBase64Encoded ? Buffer.from(event.body, "base64").toString() : event.body || "{}");
|
|
362
|
+
if (!process.env.WAKE_SECRET || req.secret !== process.env.WAKE_SECRET) return { statusCode: 403, body: "forbidden\\n" };
|
|
363
|
+
const failed = await syncAlarms(req.alarms || [], ctx);
|
|
364
|
+
// Partial failure IS failure: the container retries the whole (idempotent) set until every
|
|
365
|
+
// pending wake really has its alarm.
|
|
366
|
+
if (failed > 0) return { statusCode: 500, body: \`\${failed} alarm(s) failed\\n\` };
|
|
367
|
+
return { statusCode: 200, body: "ok\\n" };
|
|
368
|
+
}
|
|
369
|
+
// Enforce the advertised ORIGINAL-body ceiling before base64 adds another 4/3 inside the runtime
|
|
370
|
+
// envelope. This also leaves deterministic room for headers/query/JSON under Lambda's 6 MB cap.
|
|
371
|
+
const webhookBytes = event.body === undefined ? 0
|
|
372
|
+
: event.isBase64Encoded ? Buffer.byteLength(event.body, "base64") : Buffer.byteLength(event.body);
|
|
373
|
+
if (webhookBytes > ${MAX_WEBHOOK_BODY_BYTES}) return { statusCode: 413, body: "payload too large\\n" };
|
|
374
|
+
// A schedule-only deployment has a Function URL solely for the authenticated refresh callback.
|
|
375
|
+
// Reject arbitrary public traffic BEFORE it can wake AgentCore (cost/DoS) or reach an inner route.
|
|
376
|
+
if (process.env.WEBHOOKS_ENABLED !== "1") return { statusCode: 404, body: "not found\\n" };
|
|
377
|
+
// Function URL webhook — forward the original request verbatim (signature material included).
|
|
378
|
+
const r = await invoke({
|
|
379
|
+
kind: "webhook",
|
|
380
|
+
method: http.method,
|
|
381
|
+
path: event.rawPath || "/",
|
|
382
|
+
query: event.rawQueryString || undefined,
|
|
383
|
+
headers: event.headers || {},
|
|
384
|
+
bodyB64: event.body === undefined ? undefined
|
|
385
|
+
: event.isBase64Encoded ? event.body : Buffer.from(event.body).toString("base64"),
|
|
386
|
+
});
|
|
387
|
+
if (r.status !== 200) {
|
|
388
|
+
console.log(\`transport error \${r.status}: \${r.body}\`);
|
|
389
|
+
return { statusCode: 502, body: "upstream error\\n" };
|
|
390
|
+
}
|
|
391
|
+
const reply = JSON.parse(r.body.toString()); // { status, headers, bodyB64 } from the adapter
|
|
392
|
+
for (const k of Object.keys(reply.headers)) {
|
|
393
|
+
if (/^(content-length|transfer-encoding|connection)$/i.test(k)) delete reply.headers[k];
|
|
394
|
+
}
|
|
395
|
+
return { statusCode: reply.status, headers: reply.headers, body: reply.bodyB64, isBase64Encoded: true };
|
|
396
|
+
};
|
|
397
|
+
`;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* The EventBridge physical name for a schedule. A schedule's local name is an arbitrary MODULE FILE
|
|
401
|
+
* NAME (`schedules/晨报.ts`, `schedules/deploy check.ts`), while AWS requires `[0-9A-Za-z-_.]+` within
|
|
402
|
+
* 64 chars — and the `fa-<agent>-` prefix already eats up to 44 of them. So: sanitize, bound the
|
|
403
|
+
* readable part, and end with a hash of the ORIGINAL name, which keeps distinct schedules distinct
|
|
404
|
+
* where sanitizing or truncation would have merged them (one rule silently firing for two).
|
|
405
|
+
*/
|
|
406
|
+
export function scheduleResourceName(agent, schedule) {
|
|
407
|
+
const prefix = `fa-${agent}-`;
|
|
408
|
+
const hash = createHash("sha256").update(schedule).digest("hex").slice(0, 8);
|
|
409
|
+
const safe = schedule.replace(/[^0-9A-Za-z\-_.]+/g, "-").replace(/^-+|-+$/g, "");
|
|
410
|
+
const room = Math.max(0, 64 - prefix.length - hash.length - 1);
|
|
411
|
+
return `${prefix}${safe.slice(0, room)}-${hash}`;
|
|
412
|
+
}
|
|
413
|
+
/** The CloudFormation template — the whole topology in one stack. */
|
|
414
|
+
function template(input, translated) {
|
|
415
|
+
const runtimeName = toRuntimeName(input.name);
|
|
416
|
+
// selfSchedule needs the forwarder too: it is both the wake-alarm registrar and the poke target.
|
|
417
|
+
// Every forwarder also gets a Function URL as the authenticated state-capability refresh channel:
|
|
418
|
+
// a schedule turn can outlive both its original presigned URL and the Lambda credentials that signed
|
|
419
|
+
// it. Schedule-only URLs reject every non-reserved HTTP path before invoking AgentCore, so they do
|
|
420
|
+
// not expose a webhook/data plane (and start never mounts the builtin /invoke under AgentCore).
|
|
421
|
+
const needsForwarder = input.routeChannels.length > 0 || translated.length > 0 || input.selfSchedule;
|
|
422
|
+
const needsFunctionUrl = needsForwarder;
|
|
423
|
+
const secrets = deploymentSecrets(input.modelAuth, input.channels, input.extraSecrets);
|
|
424
|
+
const forwarderFnArn = `!Sub arn:aws:lambda:\${AWS::Region}:\${AWS::AccountId}:function:fastagent-${input.name}-forwarder`;
|
|
425
|
+
// Secret env vars ride CFN NoEcho parameters. FASTAGENT_AUTH_SEED is always declared (Default "")
|
|
426
|
+
// so a `--run` OAuth carry has a slot; required secrets have NO default — `cloudformation deploy`
|
|
427
|
+
// fails loudly without a value instead of booting a half-configured box.
|
|
428
|
+
const params = [
|
|
429
|
+
` ImageUri:`,
|
|
430
|
+
` Type: String`,
|
|
431
|
+
` Description: ECR image URI (linux/arm64) — <account>.dkr.ecr.<region>.amazonaws.com/<repo>:<tag>`,
|
|
432
|
+
];
|
|
433
|
+
if (needsForwarder) {
|
|
434
|
+
params.push(` StateBucket:`, ` Type: String`, ` Description: S3 bucket holding the forwarder deployment package + the agent's state snapshot (created outside this stack)`, ` ForwarderS3Key:`, ` Type: String`, ` Description: key of the forwarder .zip in StateBucket — CONTENT-HASHED, so new code is a new value and CloudFormation rolls the function`);
|
|
435
|
+
}
|
|
436
|
+
const envLines = [
|
|
437
|
+
` PORT: "8080"`, // the Runtime service contract's fixed port (config.http.port does not apply here)
|
|
438
|
+
` FASTAGENT_AGENTCORE: "1"`, // serve mounts /invocations + /ping, arms no resident cron
|
|
439
|
+
` FASTAGENT_STATE_DIR: ${MOUNT}`,
|
|
440
|
+
];
|
|
441
|
+
// The auth seed is chunked (env values max 2048 chars — see AUTH_SEED_CHUNK_SIZE): N parameters,
|
|
442
|
+
// each riding its own env var; `start` reassembles them (collectAuthSeed). Empty defaults = unused.
|
|
443
|
+
for (let i = 1; i <= AUTH_SEED_MAX_CHUNKS; i++) {
|
|
444
|
+
const param = i === 1 ? "FastagentAuthSeed" : `FastagentAuthSeed${i}`;
|
|
445
|
+
const envName = i === 1 ? "FASTAGENT_AUTH_SEED" : `FASTAGENT_AUTH_SEED_${i}`;
|
|
446
|
+
params.push(` ${param}:`, ` Type: String`, ` Default: ""`, ` NoEcho: true`, ` Description: base64 auth.json carried by --run, chunk ${i}/${AUTH_SEED_MAX_CHUNKS} (env values cap at 2048 chars); empty = unused`);
|
|
447
|
+
envLines.push(` ${envName}: !Ref ${param}`);
|
|
448
|
+
}
|
|
449
|
+
for (const s of secrets) {
|
|
450
|
+
const p = cfnParamName(s.name);
|
|
451
|
+
params.push(` ${p}:`, ` Type: String`);
|
|
452
|
+
if (!s.required)
|
|
453
|
+
params.push(` Default: ""`);
|
|
454
|
+
params.push(` NoEcho: true`, ` Description: ${s.hint}`);
|
|
455
|
+
envLines.push(` ${s.name}: !Ref ${p}`);
|
|
456
|
+
}
|
|
457
|
+
if (needsForwarder) {
|
|
458
|
+
// The INGRESS secret authenticates forwarder→runtime envelopes. Without it the envelope union is
|
|
459
|
+
// an unauthenticated control plane: `InvokeAgentRuntime` is an ordinary IAM action, so any
|
|
460
|
+
// principal holding it could forge a `schedule-fire`, or ride `state`/`wake` on a public `invoke`
|
|
461
|
+
// to redirect the state snapshot (exfiltrating auth.json) or the wake-alarm callback (leaking the
|
|
462
|
+
// wake secret) to an address of their choosing. Public `invoke` stays unauthenticated by design —
|
|
463
|
+
// it is the programmatic data plane — but it may not carry internal fields.
|
|
464
|
+
params.push(` FastagentIngressSecret:`, ` Type: String`, ` NoEcho: true`, ` Description: shared secret authenticating forwarder→runtime envelopes (any random string; --run mints one)`);
|
|
465
|
+
envLines.push(` FASTAGENT_INGRESS_SECRET: !Ref FastagentIngressSecret`);
|
|
466
|
+
}
|
|
467
|
+
if (input.selfSchedule) {
|
|
468
|
+
// The wake-alarm shared secret: the container authenticates its alarm callbacks to the forwarder
|
|
469
|
+
// with it. Required (no default) — a selfSchedule deployment without it would silently degrade.
|
|
470
|
+
params.push(` FastagentWakeSecret:`, ` Type: String`, ` NoEcho: true`, ` Description: shared secret between the container and the forwarder's wake-alarm callback (any random string; --run mints one)`);
|
|
471
|
+
envLines.push(` FASTAGENT_WAKE_SECRET: !Ref FastagentWakeSecret`);
|
|
472
|
+
}
|
|
473
|
+
const lines = [
|
|
474
|
+
`${GENERATED_TEMPLATE_MARKER}. Edit freely — deploy then treats it as hand-written and never gates on drift.`,
|
|
475
|
+
`AWSTemplateFormatVersion: "2010-09-09"`,
|
|
476
|
+
`Description: fastagent agent "${input.name}" on AWS Bedrock AgentCore Runtime`,
|
|
477
|
+
``,
|
|
478
|
+
`Parameters:`,
|
|
479
|
+
...params,
|
|
480
|
+
``,
|
|
481
|
+
`Resources:`,
|
|
482
|
+
` ExecutionRole:`,
|
|
483
|
+
` Type: AWS::IAM::Role`,
|
|
484
|
+
` Properties:`,
|
|
485
|
+
` AssumeRolePolicyDocument:`,
|
|
486
|
+
` Version: "2012-10-17"`,
|
|
487
|
+
` Statement:`,
|
|
488
|
+
` - Effect: Allow`,
|
|
489
|
+
` Principal: { Service: bedrock-agentcore.amazonaws.com }`,
|
|
490
|
+
` Action: sts:AssumeRole`,
|
|
491
|
+
` Condition:`,
|
|
492
|
+
` StringEquals: { aws:SourceAccount: !Ref AWS::AccountId }`,
|
|
493
|
+
` Policies:`,
|
|
494
|
+
` - PolicyName: runtime`,
|
|
495
|
+
` PolicyDocument:`,
|
|
496
|
+
` Version: "2012-10-17"`,
|
|
497
|
+
` Statement:`,
|
|
498
|
+
` - Effect: Allow # pull the agent image`,
|
|
499
|
+
` Action: [ecr:GetAuthorizationToken]`,
|
|
500
|
+
` Resource: "*"`,
|
|
501
|
+
` - Effect: Allow`,
|
|
502
|
+
` Action: [ecr:BatchGetImage, ecr:GetDownloadUrlForLayer]`,
|
|
503
|
+
` Resource: !Sub arn:aws:ecr:\${AWS::Region}:\${AWS::AccountId}:repository/*`,
|
|
504
|
+
` - Effect: Allow # runtime logs + traces + metrics`,
|
|
505
|
+
` Action: [logs:CreateLogGroup, logs:CreateLogStream, logs:PutLogEvents, logs:DescribeLogGroups, logs:DescribeLogStreams]`,
|
|
506
|
+
` Resource: "*"`,
|
|
507
|
+
` - Effect: Allow`,
|
|
508
|
+
` Action: [xray:PutTraceSegments, xray:PutTelemetryRecords, cloudwatch:PutMetricData]`,
|
|
509
|
+
` Resource: "*"`,
|
|
510
|
+
` - Effect: Allow # AgentCore workload identity (the platform mints one per runtime)`,
|
|
511
|
+
` Action: [bedrock-agentcore:GetWorkloadAccessToken]`,
|
|
512
|
+
` Resource: "*"`,
|
|
513
|
+
``,
|
|
514
|
+
` Runtime:`,
|
|
515
|
+
` Type: AWS::BedrockAgentCore::Runtime`,
|
|
516
|
+
` Properties:`,
|
|
517
|
+
` AgentRuntimeName: ${runtimeName}`,
|
|
518
|
+
` Description: fastagent agent "${input.name}" (deploy agentcore)`,
|
|
519
|
+
` AgentRuntimeArtifact:`,
|
|
520
|
+
` ContainerConfiguration: { ContainerUri: !Ref ImageUri }`,
|
|
521
|
+
` RoleArn: !GetAtt ExecutionRole.Arn`,
|
|
522
|
+
` ProtocolConfiguration: HTTP`,
|
|
523
|
+
` NetworkConfiguration: { NetworkMode: PUBLIC }`,
|
|
524
|
+
` # SessionStorage is the agent's LOCAL disk: it survives compute stop/resume within a runtime`,
|
|
525
|
+
` # version, but AWS wipes it on every VERSION UPDATE (= every deploy) and after 14 idle days.`,
|
|
526
|
+
` # Durability therefore comes from the S3 snapshot the container pulls on its first`,
|
|
527
|
+
` # invocation and pushes when work settles (presigned by the forwarder — the container holds`,
|
|
528
|
+
` # no AWS credentials). A persistent MOUNT instead needs EfsAccessPoint + VPC mode, which`,
|
|
529
|
+
` # forces a NAT gateway for model/channel egress (~$33/mo) — deliberately not the default.`,
|
|
530
|
+
` FilesystemConfigurations:`,
|
|
531
|
+
` - SessionStorage: { MountPath: ${MOUNT} }`,
|
|
532
|
+
` # Idle ${IDLE_TIMEOUT_SECONDS}s (the ping's HealthyBusy keeps BUSY sessions alive regardless), max compute`,
|
|
533
|
+
` # lifetime ${MAX_LIFETIME_SECONDS}s — the platform ceiling; the session id stays valid, so the next invoke`,
|
|
534
|
+
` # just gets fresh compute with the same storage. Memory bills per second for the whole`,
|
|
535
|
+
` # session INCLUDING the idle tail, so a shorter tail is the main cost lever here.`,
|
|
536
|
+
` LifecycleConfiguration: { IdleRuntimeSessionTimeout: ${IDLE_TIMEOUT_SECONDS}, MaxLifetime: ${MAX_LIFETIME_SECONDS} }`,
|
|
537
|
+
` EnvironmentVariables:`,
|
|
538
|
+
...envLines,
|
|
539
|
+
];
|
|
540
|
+
if (needsForwarder) {
|
|
541
|
+
lines.push(``, ` ForwarderRole:`, ` Type: AWS::IAM::Role`, ` Properties:`, ` AssumeRolePolicyDocument:`, ` Version: "2012-10-17"`, ` Statement:`, ` - Effect: Allow`, ` Principal: { Service: lambda.amazonaws.com }`, ` Action: sts:AssumeRole`, ` ManagedPolicyArns: [arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]`, ` Policies:`, ` - PolicyName: invoke-runtime`, ` PolicyDocument:`, ` Version: "2012-10-17"`, ` Statement:`, ` - Effect: Allow`, ` Action: bedrock-agentcore:InvokeAgentRuntime`, ` Resource:`, ` - !GetAtt Runtime.AgentRuntimeArn`, ` - !Sub "\${Runtime.AgentRuntimeArn}/*"`, ` - Effect: Allow # mint the presigned URLs the container uses for its state snapshot`, ` Action: [s3:GetObject, s3:PutObject]`, ` Resource: !Sub arn:aws:s3:::\${StateBucket}/${STATE_KEY}`, ` # Without s3:ListBucket, S3 folds "key absent" into 403 (anti-enumeration), which is`, ` # indistinguishable from a broken signature — so the container's restore contract`, ` # (agentcore-state.ts: ONLY 404 means first deploy) would dead-end every first deploy.`, ` # Scoped to the snapshot prefix: this grants "may know whether the snapshot exists",`, ` # not a listing of the whole deployment bucket.`, ` - Effect: Allow`, ` Action: s3:ListBucket`, ` Resource: !Sub arn:aws:s3:::\${StateBucket}`, ` Condition:`, ` StringLike: { s3:prefix: state/* }`, ...(input.selfSchedule
|
|
542
|
+
? [
|
|
543
|
+
` - Effect: Allow # wake alarms: mirror pending wake-ups into one-shot schedules`,
|
|
544
|
+
` Action: [scheduler:CreateSchedule, scheduler:UpdateSchedule]`,
|
|
545
|
+
` Resource: !Sub arn:aws:scheduler:\${AWS::Region}:\${AWS::AccountId}:schedule/default/fa-${input.name}-wk-*`,
|
|
546
|
+
` - Effect: Allow # hand the poke schedules their invoke role`,
|
|
547
|
+
` Action: iam:PassRole`,
|
|
548
|
+
` Resource: !GetAtt WakeSchedulerRole.Arn`,
|
|
549
|
+
]
|
|
550
|
+
: []), ...(needsFunctionUrl
|
|
551
|
+
? [
|
|
552
|
+
` - Effect: Allow # self-resolve callback URL for state-URL refresh / wake alarms`,
|
|
553
|
+
` Action: lambda:GetFunctionUrlConfig`,
|
|
554
|
+
` Resource: ${forwarderFnArn}`,
|
|
555
|
+
]
|
|
556
|
+
: []), ``, ` Forwarder:`, ` Type: AWS::Lambda::Function`, ` Properties:`, ` FunctionName: fastagent-${input.name}-forwarder`, ` Runtime: nodejs22.x`, ` Handler: index.handler`, ` # Webhook ACKs are fast, but schedule-fire holds the connection for the WHOLE agent turn`, ` # (claim-before-run means a timeout never double-fires; the turn also continues and is`, ` # audited container-side). EventBridge→Lambda is async, so the long timeout costs nothing.`, ` Timeout: 900`, ` MemorySize: 256`, ` Role: !GetAtt ForwarderRole.Arn`, ` Environment:`, ` Variables:`, ` RUNTIME_ARN: !GetAtt Runtime.AgentRuntimeArn`, ` INGRESS_SESSION_ID: ${ingressSessionId(input.name)}`, ...(needsFunctionUrl ? [` STATE_REFRESH_SECRET: !Ref FastagentIngressSecret`] : []), ...(input.routeChannels.length > 0 ? [` WEBHOOKS_ENABLED: "1"`] : []), ...(input.selfSchedule
|
|
557
|
+
? [
|
|
558
|
+
` WAKE_SECRET: !Ref FastagentWakeSecret`,
|
|
559
|
+
` WAKE_ROLE_ARN: !GetAtt WakeSchedulerRole.Arn`,
|
|
560
|
+
` WAKE_PREFIX: fa-${input.name}-wk-`,
|
|
561
|
+
]
|
|
562
|
+
: []), ` INGRESS_SECRET: !Ref FastagentIngressSecret`, ` STATE_BUCKET: !Ref StateBucket`, ` STATE_KEY: ${STATE_KEY}`, ` # From S3, not inline: the forwarder mints SigV4-presigned URLs for the state snapshot and`, ` # no longer fits CloudFormation's 4096-byte inline cap. The key is content-hashed, so a`, ` # code change is a parameter change — CloudFormation cannot miss it.`, ` Code:`, ` S3Bucket: !Ref StateBucket`, ` S3Key: !Ref ForwarderS3Key`);
|
|
563
|
+
}
|
|
564
|
+
if (needsFunctionUrl) {
|
|
565
|
+
lines.push(``, ` ForwarderUrl:`, ` Type: AWS::Lambda::Url`, ` Properties:`, ` TargetFunctionArn: !GetAtt Forwarder.Arn`, ` # NONE is deliberate: webhook callers (Telegram/Feishu) cannot SigV4-sign. Authenticity is`, ` # verified downstream by each channel (secret token / signature), exactly as on every host.`, ` AuthType: NONE`, ``, ` ForwarderUrlPermission:`, ` Type: AWS::Lambda::Permission`, ` Properties:`, ` FunctionName: !Ref Forwarder`, ` Action: lambda:InvokeFunctionUrl`, ` Principal: "*"`, ` FunctionUrlAuthType: NONE`, ``, ` # Function URLs created after Oct 2025 require lambda:InvokeFunction IN ADDITION to`, ` # lambda:InvokeFunctionUrl for public (NONE) access — with only the first, every request 403s`, ` # (found by the first real deploy). InvokedViaFunctionUrl scopes it to URL traffic: without it`, ` # the bare * principal would also let any AWS principal call the Lambda API directly, bypassing`, ` # the Function URL event shape to forge internal events.`, ` ForwarderInvokePermission:`, ` Type: AWS::Lambda::Permission`, ` Properties:`, ` FunctionName: !Ref Forwarder`, ` Action: lambda:InvokeFunction`, ` Principal: "*"`, ` InvokedViaFunctionUrl: true`);
|
|
566
|
+
if (input.selfSchedule) {
|
|
567
|
+
lines.push(``, ` # The role the wake-poke schedules assume to invoke the forwarder. Its policy names the`, ` # function by CONSTRUCTED arn (not !Ref) — the forwarder's env references this role, so a`, ` # !Ref back would be a circular dependency.`, ` WakeSchedulerRole:`, ` Type: AWS::IAM::Role`, ` Properties:`, ` AssumeRolePolicyDocument:`, ` Version: "2012-10-17"`, ` Statement:`, ` - Effect: Allow`, ` Principal: { Service: scheduler.amazonaws.com }`, ` Action: sts:AssumeRole`, ` Condition:`, ` StringEquals: { aws:SourceAccount: !Ref AWS::AccountId }`, ` Policies:`, ` - PolicyName: poke-forwarder`, ` PolicyDocument:`, ` Version: "2012-10-17"`, ` Statement:`, ` - Effect: Allow`, ` Action: lambda:InvokeFunction`, ` Resource: ${forwarderFnArn}`);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
if (translated.length > 0) {
|
|
571
|
+
lines.push(``, ` SchedulerRole:`, ` Type: AWS::IAM::Role`, ` Properties:`, ` AssumeRolePolicyDocument:`, ` Version: "2012-10-17"`, ` Statement:`, ` - Effect: Allow`, ` Principal: { Service: scheduler.amazonaws.com }`, ` Action: sts:AssumeRole`, ` Condition:`, ` StringEquals: { aws:SourceAccount: !Ref AWS::AccountId }`, ` Policies:`, ` - PolicyName: fire-forwarder`, ` PolicyDocument:`, ` Version: "2012-10-17"`, ` Statement:`, ` - Effect: Allow`, ` Action: lambda:InvokeFunction`, ` Resource: !GetAtt Forwarder.Arn`);
|
|
572
|
+
for (const { fact, expression } of translated) {
|
|
573
|
+
lines.push(``, ` Schedule${logicalId(fact.name)}:`, ` Type: AWS::Scheduler::Schedule`, ` Properties:`, ` Name: ${scheduleResourceName(input.name, fact.name)}`, ` ScheduleExpression: ${expression}`, ` ScheduleExpressionTimezone: ${fact.tz ?? "Etc/UTC"}`, ` FlexibleTimeWindow: { Mode: "OFF" }`, ` Target:`, ` Arn: !GetAtt Forwarder.Arn`, ` RoleArn: !GetAtt SchedulerRole.Arn`, ` # <aws.scheduler.scheduled-time> = the slot instant — the container's idempotency key`, ` # (EventBridge delivery is at-least-once; a duplicate slot must not double-fire).`, ` Input: ${yamlSingleQuote(JSON.stringify({ scheduleFire: { name: fact.name, slot: "<aws.scheduler.scheduled-time>" } }))}`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
lines.push(``, `Outputs:`, ` RuntimeArn:`, ` Value: !GetAtt Runtime.AgentRuntimeArn`);
|
|
577
|
+
if (needsFunctionUrl) {
|
|
578
|
+
lines.push(` ForwarderUrl:`, ` Value: !GetAtt ForwarderUrl.FunctionUrl`);
|
|
579
|
+
}
|
|
580
|
+
return `${lines.join("\n")}\n`;
|
|
581
|
+
}
|
|
582
|
+
/** Compute the AgentCore deploy plan from the resolved definition. */
|
|
583
|
+
export function planAgentcoreDeploy(input) {
|
|
584
|
+
const { name, channels } = input;
|
|
585
|
+
const stack = `fastagent-${name}`;
|
|
586
|
+
const repo = `fastagent/${name}`;
|
|
587
|
+
const prefix = input.agentPrefix;
|
|
588
|
+
// Translate every schedule; the ones EventBridge cannot express become explicit runbook warnings —
|
|
589
|
+
// a schedule silently missing from the template would be the worst failure mode (nothing ever fires).
|
|
590
|
+
const translated = [];
|
|
591
|
+
const untranslatable = [];
|
|
592
|
+
for (const fact of input.schedules) {
|
|
593
|
+
const result = toEventBridgeCron(fact.cron);
|
|
594
|
+
if ("expression" in result)
|
|
595
|
+
translated.push({ fact, expression: result.expression });
|
|
596
|
+
else
|
|
597
|
+
untranslatable.push({ name: fact.name, reason: result.error });
|
|
598
|
+
}
|
|
599
|
+
// Identifier collisions: the author-side → AWS-side name mappings are lossy (logical ids strip
|
|
600
|
+
// punctuation; parameter names collapse underscores), so two DISTINCT legal inputs can land on one
|
|
601
|
+
// CloudFormation key — which would generate a silently wrong stack. Fail visibly at plan time; a
|
|
602
|
+
// rename is the fix (a hash-mangled allocator would trade readability for an edge case).
|
|
603
|
+
const logicalIds = new Map();
|
|
604
|
+
for (const { fact } of translated) {
|
|
605
|
+
const id = `Schedule${logicalId(fact.name)}`;
|
|
606
|
+
const clash = logicalIds.get(id);
|
|
607
|
+
if (clash !== undefined) {
|
|
608
|
+
throw new Error(`schedules "${clash}" and "${fact.name}" collapse to the same CloudFormation logical id (${id}) — rename one`);
|
|
609
|
+
}
|
|
610
|
+
logicalIds.set(id, fact.name);
|
|
611
|
+
}
|
|
612
|
+
const paramNames = new Map();
|
|
613
|
+
for (const s of deploymentSecrets(input.modelAuth, channels, input.extraSecrets)) {
|
|
614
|
+
const p = cfnParamName(s.name);
|
|
615
|
+
const clash = paramNames.get(p);
|
|
616
|
+
if (clash !== undefined && clash !== s.name) {
|
|
617
|
+
throw new Error(`secrets "${clash}" and "${s.name}" collapse to the same CloudFormation parameter (${p}) — rename one`);
|
|
618
|
+
}
|
|
619
|
+
paramNames.set(p, s.name);
|
|
620
|
+
}
|
|
621
|
+
// Every forwarder needs its authenticated Function URL to refresh S3 snapshot capabilities during
|
|
622
|
+
// a long turn. Without route channels, ordinary HTTP paths are rejected before AgentCore is invoked.
|
|
623
|
+
const needsForwarder = input.routeChannels.length > 0 || translated.length > 0 || input.selfSchedule;
|
|
624
|
+
const needsFunctionUrl = needsForwarder;
|
|
625
|
+
const artifacts = [
|
|
626
|
+
{ path: `${prefix}${TEMPLATE_FILE}`, content: template(input, translated) },
|
|
627
|
+
...(needsForwarder ? [{ path: `${prefix}${FORWARDER_FILE}`, content: forwarderSource() }] : []),
|
|
628
|
+
...containerArtifacts(input),
|
|
629
|
+
];
|
|
630
|
+
const secrets = deploymentSecrets(input.modelAuth, channels, input.extraSecrets);
|
|
631
|
+
const requiredSecrets = secrets.filter((s) => s.required);
|
|
632
|
+
const optionalSecrets = secrets.filter((s) => !s.required);
|
|
633
|
+
const paramHint = (list) => list.map((s) => `${cfnParamName(s.name)}=<value>`).join(" ");
|
|
634
|
+
const image = `<account-id>.dkr.ecr.<region>.amazonaws.com/${repo}:<tag>`;
|
|
635
|
+
const bucketHint = stateBucketName(name, "<account-id>");
|
|
636
|
+
const runbook = [
|
|
637
|
+
`# Deploy "${name}" to AWS Bedrock AgentCore. ${prefix}${TEMPLATE_FILE} / Dockerfile(.dockerignore) are generated above.`,
|
|
638
|
+
`# Prereqs: AWS CLI v2 with credentials + a region where AgentCore is available, and Docker with buildx`,
|
|
639
|
+
`# (the image MUST be linux/arm64 — the one host whose build runs on YOUR machine, not remotely).`,
|
|
640
|
+
``,
|
|
641
|
+
`# 1. ECR repository + the deployment bucket (one-time; skip what exists). The bucket lives OUTSIDE`,
|
|
642
|
+
`# the stack on purpose: it holds the agent's state snapshot, which must survive a delete-stack.`,
|
|
643
|
+
`aws ecr create-repository --repository-name ${repo}`,
|
|
644
|
+
`aws s3api create-bucket --bucket ${bucketHint} --region us-east-1 # us-east-1 ONLY`,
|
|
645
|
+
`aws s3api create-bucket --bucket ${bucketHint} --region <region> \\ # every OTHER region`,
|
|
646
|
+
` --create-bucket-configuration LocationConstraint=<region>`,
|
|
647
|
+
`aws s3api put-public-access-block --bucket ${bucketHint} \\`,
|
|
648
|
+
` --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true`,
|
|
649
|
+
``,
|
|
650
|
+
...(needsForwarder
|
|
651
|
+
? [
|
|
652
|
+
`# 1b. Package the forwarder and upload it. Name the object by its CONTENT (a hash/date):`,
|
|
653
|
+
`# CloudFormation rolls the function only when the ForwarderS3Key VALUE changes.`,
|
|
654
|
+
`(cd ${prefix}lambda && zip -q forwarder.zip index.js)`,
|
|
655
|
+
`aws s3 cp ${prefix}lambda/forwarder.zip s3://${bucketHint}/forwarder/<hash>.zip`,
|
|
656
|
+
``,
|
|
657
|
+
]
|
|
658
|
+
: []),
|
|
659
|
+
`# 2. Build (linux/arm64) + push. Use a UNIQUE tag per deploy (a git sha / date): CloudFormation only`,
|
|
660
|
+
`# rolls the runtime when the ImageUri VALUE changes — re-pushing the same tag deploys nothing.`,
|
|
661
|
+
`aws ecr get-login-password | docker login --username AWS --password-stdin <account-id>.dkr.ecr.<region>.amazonaws.com`,
|
|
662
|
+
prefix
|
|
663
|
+
? `docker buildx build --platform linux/arm64 -f ${prefix}Dockerfile -t ${image} --push .`
|
|
664
|
+
: `docker buildx build --platform linux/arm64 -t ${image} --push .`,
|
|
665
|
+
``,
|
|
666
|
+
`# 3. Deploy the stack (runtime + ingress + schedules in one template). Secrets ride NoEcho parameters:`,
|
|
667
|
+
];
|
|
668
|
+
if (requiredSecrets.length > 0) {
|
|
669
|
+
runbook.push(`# Required parameters:`, ...requiredSecrets.map((s) => `# ${cfnParamName(s.name)}: ${s.hint}`));
|
|
670
|
+
}
|
|
671
|
+
if (optionalSecrets.length > 0) {
|
|
672
|
+
runbook.push(`# Optional parameters (set only when the matching feature is configured):`, ...optionalSecrets.map((s) => `# ${cfnParamName(s.name)}: ${s.hint}`));
|
|
673
|
+
}
|
|
674
|
+
const wakeSecretHint = input.selfSchedule ? " FastagentWakeSecret=<any random string>" : "";
|
|
675
|
+
if (input.selfSchedule) {
|
|
676
|
+
runbook.push(`# FastagentWakeSecret: the wake-alarm shared secret — any random string (\`--run\` mints one)`);
|
|
677
|
+
}
|
|
678
|
+
runbook.push(`aws cloudformation deploy --stack-name ${stack} --template-file ${prefix}${TEMPLATE_FILE} \\`, ` --capabilities CAPABILITY_IAM \\`, ` --parameter-overrides ImageUri=${image}${needsForwarder ? ` StateBucket=${bucketHint} ForwarderS3Key=forwarder/<hash>.zip` : ""}${requiredSecrets.length > 0 ? ` ${paramHint(requiredSecrets)}` : ""}${wakeSecretHint}`, ``, needsFunctionUrl
|
|
679
|
+
? `# 4. Read the outputs (the runtime ARN + callback URL; it serves webhooks only when configured):`
|
|
680
|
+
: `# 4. Read the outputs (the runtime ARN — this topology has NO public URL: nothing outside AWS`, ...(needsFunctionUrl
|
|
681
|
+
? []
|
|
682
|
+
: [`# sends to it, so no Function URL is created and the agent is reachable only via SigV4).`]), `aws cloudformation describe-stacks --stack-name ${stack} --query "Stacks[0].Outputs"`);
|
|
683
|
+
// Model-auth guidance mirrors the other hosts: an env key became a parameter above; OAuth/stored
|
|
684
|
+
// can't be read at plan time — `--run` carries it as FastagentAuthSeed.
|
|
685
|
+
if (!isEnvKey(input.modelAuth)) {
|
|
686
|
+
runbook.push(``, input.modelAuth === undefined
|
|
687
|
+
? `# Model auth: none found at the local auth path — pass --auth-path <file>, or \`--run\` carries it`
|
|
688
|
+
: `# Model auth: your local auth is "${input.modelAuth}" — the plan can't read its value; \`--run\` carries it`, `# as the FastagentAuthSeed parameter (base64 of auth.json), materialized on first boot.`);
|
|
689
|
+
}
|
|
690
|
+
// Post-deploy webhook registration — same per-channel steps as every host, pointed at the
|
|
691
|
+
// forwarder's Function URL (read from the stack outputs).
|
|
692
|
+
const post = [];
|
|
693
|
+
if (channels.includes("telegram")) {
|
|
694
|
+
post.push(`# Register the Telegram webhook (default route POST /telegram; secret_token MUST equal TELEGRAM_SECRET_TOKEN):`, `curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/setWebhook" \\`, ` -d url=<ForwarderUrl>/telegram -d secret_token=<TELEGRAM_SECRET_TOKEN>`);
|
|
695
|
+
}
|
|
696
|
+
if (channels.includes("github")) {
|
|
697
|
+
post.push(`# Set the GitHub webhook (repo Settings → Webhooks): Payload URL = <ForwarderUrl>/webhook,`, `# content type application/json, secret = GITHUB_WEBHOOK_SECRET.`, `# NOTE: github turns are fire-and-forget with no replay — a compute reclaimed mid-review drops it`, `# (the ping's HealthyBusy holds the session while turns run, but the 8 h compute ceiling is hard).`);
|
|
698
|
+
}
|
|
699
|
+
if (channels.includes("slack")) {
|
|
700
|
+
post.push(`# Set Slack Event Subscriptions → Request URL = <ForwarderUrl>/slack (scopes per channels/slack.ts).`);
|
|
701
|
+
}
|
|
702
|
+
for (const kind of ["feishu", "lark"]) {
|
|
703
|
+
if (!channels.includes(kind))
|
|
704
|
+
continue;
|
|
705
|
+
post.push(`# Set the ${kind === "feishu" ? "Feishu" : "Lark"} event Request URL (developer console → Events & Callbacks):`, `# Request URL = <ForwarderUrl>/${kind} (the stack must be deployed when you save — the console`, `# sends a challenge, which rides through the forwarder to the channel and back verbatim).`);
|
|
706
|
+
}
|
|
707
|
+
if (post.length > 0)
|
|
708
|
+
runbook.push(``, ...post);
|
|
709
|
+
for (const u of untranslatable) {
|
|
710
|
+
runbook.push(``, `# WARNING: schedule "${u.name}" has NO EventBridge rule — ${u.reason}.`, `# It will NOT fire on this deployment until you create an equivalent trigger yourself.`);
|
|
711
|
+
}
|
|
712
|
+
if (input.selfSchedule) {
|
|
713
|
+
runbook.push(``, `# selfSchedule: the agent's wake-ups are EventBridge-backed — each pending wake-up is mirrored`, `# (via the forwarder, authenticated by FastagentWakeSecret) into a self-deleting one-shot`, `# schedule (fa-${name}-wk-*) that wakes the container at the right instant. Reliable even when`, `# the compute is reclaimed. Caveat: only for wake-ups set through the INGRESS surface (chat`, `# channels/schedules); a wake set inside a direct InvokeAgentRuntime session stays in that`, `# session's own storage and fires only while that session is awake.`);
|
|
714
|
+
}
|
|
715
|
+
runbook.push(``, `# Invoke the agent programmatically (any session id ≥ 33 chars; the response streams as SSE):`, `aws bedrock-agentcore invoke-agent-runtime --agent-runtime-arn <RuntimeArn> \\`, ` --runtime-session-id "my-conversation-000000000000000000" \\`, ` --payload '{"kind":"invoke","session":"cli","text":"hello"}' --cli-binary-format raw-in-base64-out /dev/stdout`);
|
|
716
|
+
if (needsForwarder) {
|
|
717
|
+
runbook.push(``, `# After a REDEPLOY, stop the ingress session so the new image serves immediately — a live session`, `# keeps its old compute (and the OLD image) until ${IDLE_TIMEOUT_SECONDS}s idle / the 8 h compute ceiling`, `# (\`--run\` does this automatically):`, `aws bedrock-agentcore stop-runtime-session --agent-runtime-arn <RuntimeArn> \\`, ` --runtime-session-id "${ingressSessionId(name)}"`);
|
|
718
|
+
}
|
|
719
|
+
runbook.push(``, `# Redeploy = step 1b (new forwarder key, if its code changed) + step 2 with a NEW tag + step 3.`, `# STATE: ${MOUNT} is a LOCAL disk — AWS wipes it on every runtime version update (i.e. every`, `# deploy) and after 14 idle days. What survives is the S3 snapshot under s3://${bucketHint}/${STATE_KEY}:`, `# the container restores it on its first invocation and pushes it whenever work settles. Keep that`, `# bucket and the agent keeps its sessions, channel state and pending wake-ups across deploys;`, `# delete it and the agent starts blank. (A persistent MOUNT would need EFS + VPC mode + a NAT`, `# gateway for model/channel egress — see the template comment.)`);
|
|
720
|
+
return { artifacts, runbook, untranslatableSchedules: untranslatable };
|
|
721
|
+
}
|