@fastagent-sh/fastagent 0.20.0 → 0.21.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 +5 -1
- package/dist/channels/agentcore-protocol.d.ts +112 -0
- package/dist/channels/agentcore-protocol.js +22 -0
- package/dist/channels/agentcore-service.d.ts +0 -4
- package/dist/channels/agentcore-service.js +1 -6
- package/dist/channels/agentcore-state.d.ts +5 -11
- package/dist/channels/agentcore-state.js +4 -1
- package/dist/channels/agentcore.d.ts +1 -67
- package/dist/channels/agentcore.js +90 -89
- package/dist/channels/control.d.ts +1 -1
- package/dist/channels/control.js +13 -62
- package/dist/channels/discover.d.ts +0 -1
- package/dist/channels/discover.js +1 -3
- 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 +2 -4
- package/dist/channels/feishu/feishu.js +56 -115
- 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/turn-runner.d.ts +59 -0
- package/dist/channels/kit/turn-runner.js +84 -0
- 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/manifest.js +5 -1
- package/dist/channels/slack/onboard.d.ts +5 -5
- package/dist/channels/slack/onboard.js +41 -20
- package/dist/channels/slack/register-webhook.d.ts +3 -2
- package/dist/channels/slack/register-webhook.js +34 -14
- package/dist/channels/slack/scaffold/channel.ts +3 -10
- package/dist/channels/slack/scaffold/slack-send.ts +18 -126
- 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 +81 -22
- package/dist/channels/slack/slack.d.ts +0 -10
- package/dist/channels/slack/slack.js +60 -99
- package/dist/channels/sse.d.ts +4 -0
- package/dist/channels/sse.js +66 -0
- package/dist/channels/telegram/register-webhook.d.ts +6 -9
- package/dist/channels/telegram/register-webhook.js +44 -42
- package/dist/channels/telegram/telegram.js +47 -142
- package/dist/channels/wait-health.js +7 -4
- package/dist/cli/add-feishu.js +3 -10
- package/dist/cli/add-slack.js +7 -20
- package/dist/cli/commands/add.d.ts +0 -1
- package/dist/cli/commands/add.js +25 -37
- 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 +17 -49
- package/dist/cli/commands/deploy.js +39 -618
- package/dist/cli/commands/dev.js +17 -46
- package/dist/cli/commands/fire.js +6 -12
- package/dist/cli/commands/info.js +2 -1
- package/dist/cli/commands/invoke.js +4 -11
- package/dist/cli/commands/schedule.js +1 -1
- package/dist/cli/commands/start.js +17 -46
- 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 +36 -19
- package/dist/cli/serve.js +91 -7
- package/dist/cli/shared.d.ts +13 -18
- package/dist/cli/shared.js +20 -4
- package/dist/deploy/agentcore/forwarder.js +250 -0
- package/dist/deploy/agentcore/plan.d.ts +23 -2
- package/dist/deploy/agentcore/plan.js +29 -244
- package/dist/deploy/agentcore/run.d.ts +4 -20
- package/dist/deploy/agentcore/run.js +22 -7
- package/dist/deploy/channel-ingress.js +1 -1
- package/dist/deploy/docker/run.d.ts +17 -3
- package/dist/deploy/docker/run.js +30 -8
- package/dist/deploy/fly/plan.js +7 -0
- package/dist/deploy/fly/run.d.ts +27 -0
- package/dist/deploy/fly/run.js +102 -20
- package/dist/deploy/hosts.d.ts +5 -0
- package/dist/deploy/hosts.js +4 -0
- package/dist/deploy/preflight.js +3 -3
- package/dist/deploy/railway/plan.d.ts +5 -0
- package/dist/deploy/railway/plan.js +7 -0
- package/dist/deploy/railway/run.d.ts +2 -1
- package/dist/deploy/railway/run.js +5 -4
- package/dist/deploy/secrets.js +0 -17
- package/dist/engines/pi/agent-session-factory.d.ts +37 -17
- package/dist/engines/pi/agent-session-factory.js +109 -85
- package/dist/engines/pi/config.d.ts +1 -1
- package/dist/engines/pi/create.d.ts +22 -39
- package/dist/engines/pi/create.js +58 -73
- package/dist/engines/pi/definition.js +8 -8
- package/dist/engines/pi/invoke-session.js +13 -30
- package/dist/engines/pi/open.d.ts +2 -2
- package/dist/engines/pi/open.js +31 -41
- package/dist/engines/pi/retry-event.d.ts +6 -0
- package/dist/engines/pi/retry-event.js +15 -0
- package/dist/engines/pi/session-builder.js +26 -113
- package/dist/engines/pi/session-control.d.ts +10 -18
- package/dist/engines/pi/session-control.js +47 -81
- package/dist/engines/pi/session-settings.d.ts +1 -1
- package/dist/engines/pi/session-settings.js +1 -1
- package/dist/engines/pi/session-store.d.ts +12 -17
- package/dist/engines/pi/session-store.js +18 -40
- package/dist/engines/pi/tool-context.d.ts +3 -3
- package/dist/engines/pi/tool.d.ts +6 -9
- package/dist/engines/pi/tool.js +1 -0
- 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 +2 -0
- package/dist/loader.js +5 -0
- package/dist/log.d.ts +9 -17
- package/dist/log.js +25 -30
- package/dist/paths.d.ts +7 -3
- package/dist/paths.js +17 -4
- package/dist/scaffold/add-channel.d.ts +6 -1
- package/dist/scaffold/add-channel.js +48 -65
- package/dist/schedule/wake-alarm.d.ts +1 -12
- package/dist/schedule/wake-alarm.js +2 -3
- package/dist/service.d.ts +23 -22
- package/dist/service.js +7 -73
- package/dist/session-remote.d.ts +5 -5
- package/dist/session-remote.js +25 -28
- package/dist/session.d.ts +1 -1
- package/dist/slack.d.ts +2 -0
- package/dist/slack.js +1 -0
- package/dist/tunnel.d.ts +16 -6
- package/dist/tunnel.js +53 -12
- package/package.json +5 -4
- package/dist/channels/slack/bot-auth.d.ts +0 -15
- package/dist/channels/slack/bot-auth.js +0 -135
|
@@ -28,11 +28,22 @@
|
|
|
28
28
|
* host where the build runs on the operator's machine (docker buildx) instead of remotely.
|
|
29
29
|
*/
|
|
30
30
|
import { createHash } from "node:crypto";
|
|
31
|
+
import { readFileSync } from "node:fs";
|
|
31
32
|
import { MAX_WEBHOOK_BODY_BYTES } from "../../channels/agentcore-limits.js";
|
|
32
33
|
import { SECRETS_DIRNAME } from "../../paths.js";
|
|
33
34
|
import { webhookKinds, webhookRunbook } from "../channel-ingress.js";
|
|
34
35
|
import { containerArtifacts } from "../container.js";
|
|
35
36
|
import { deploymentSecrets, isEnvKey } from "../secrets.js";
|
|
37
|
+
function agentcoreTopology(input,
|
|
38
|
+
/** Schedules EventBridge CAN express — the ones that become rules. */
|
|
39
|
+
translatedSchedules) {
|
|
40
|
+
const webhooks = input.channels.some((channel) => channel.ingress === "webhook");
|
|
41
|
+
return {
|
|
42
|
+
webhooks,
|
|
43
|
+
forwarder: webhooks || translatedSchedules > 0 || input.selfSchedule,
|
|
44
|
+
wakeAlarms: input.selfSchedule,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
36
47
|
/** SessionStorage mount = FASTAGENT_STATE_DIR (AgentCore requires exactly `/mnt/<one-level>`). It is
|
|
37
48
|
* a fast LOCAL disk only: the platform wipes it on every runtime version update (= every deploy).
|
|
38
49
|
* Durability across deploys comes from the S3 snapshot (channels/agentcore-state.ts). */
|
|
@@ -216,8 +227,10 @@ function yamlSingleQuote(value) {
|
|
|
216
227
|
return `'${value.replace(/'/g, "''")}'`;
|
|
217
228
|
}
|
|
218
229
|
/**
|
|
219
|
-
* The forwarder Lambda source —
|
|
220
|
-
* `lambda/
|
|
230
|
+
* The forwarder Lambda source — `forwarder.js` beside this file, the ONE text both the deployment
|
|
231
|
+
* package and the readable `lambda/index.js` artifact are generated from. A real file, not a template
|
|
232
|
+
* literal: it is linted, and it needs no interpolation — the one value it once took from here
|
|
233
|
+
* (`MAX_WEBHOOK_BODY_BYTES`) rides in as a Lambda environment variable. Zero-dependency: the
|
|
221
234
|
* Lambda Node runtime bundles AWS SDK v3. CommonJS ON PURPOSE: the package's entry lands as
|
|
222
235
|
* `index.js` ({@link zipSingleFile}), where ESM `import` is a syntax error. Two
|
|
223
236
|
* event shapes: a Function URL webhook (reconstructed verbatim into a `webhook` envelope; the
|
|
@@ -230,227 +243,7 @@ function yamlSingleQuote(value) {
|
|
|
230
243
|
* of stale rule that stops the next person making a correct change.
|
|
231
244
|
*/
|
|
232
245
|
export function forwarderSource() {
|
|
233
|
-
return
|
|
234
|
-
// REGENERATED ON EVERY DEPLOY; edits here are overwritten and never deployed. \`--run\` builds the
|
|
235
|
-
// Lambda package from fastagent's own copy of this source, so this file is the readable MIRROR of
|
|
236
|
-
// what runs (and what the manual runbook zips) — not an input you can change.
|
|
237
|
-
// Webhooks (Function URL) and EventBridge Scheduler fires are forwarded as envelopes to the
|
|
238
|
-
// AgentCore Runtime over SigV4 InvokeAgentRuntime, all on ONE fixed ingress session (fastagent
|
|
239
|
-
// channel state is single-writer; one session = at most one microVM). With selfSchedule, this
|
|
240
|
-
// Lambda also OWNS the wake alarms: the container POSTs its pending wake-ups to /__fastagent/
|
|
241
|
-
// wake-alarm (shared secret) and each becomes a self-deleting one-shot EventBridge schedule that
|
|
242
|
-
// pokes this Lambda — which wakes the container, whose wake pump fires the due entry.
|
|
243
|
-
// CommonJS on purpose: the deployment package's entry lands as index.js, where ESM import is invalid.
|
|
244
|
-
"use strict";
|
|
245
|
-
const crypto = require("node:crypto");
|
|
246
|
-
const { BedrockAgentCoreClient, InvokeAgentRuntimeCommand } = require("@aws-sdk/client-bedrock-agentcore");
|
|
247
|
-
const client = new BedrockAgentCoreClient({});
|
|
248
|
-
let ownUrl; // self-resolved once per cold start; rides on every envelope for the wake-alarm callback
|
|
249
|
-
|
|
250
|
-
// Presigned S3 URLs for the container's state snapshot. AgentCore wipes the /mnt/state mount on
|
|
251
|
-
// every runtime version update (= every deploy), so the durable copy lives in S3 — but the
|
|
252
|
-
// container is given NO AWS credentials by the platform, so the only reachable form is a URL that
|
|
253
|
-
// carries its own authorization. SigV4 query signing, node:crypto only (no SDK, nothing to install).
|
|
254
|
-
const enc = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => \`%\${c.charCodeAt(0).toString(16).toUpperCase()}\`);
|
|
255
|
-
const hmac = (key, data) => crypto.createHmac("sha256", key).update(data).digest();
|
|
256
|
-
|
|
257
|
-
// Every shared-secret gate on this public URL goes through here. Constant-time, and an unset
|
|
258
|
-
// expected secret NEVER matches — otherwise a topology that did not configure one would accept a
|
|
259
|
-
// request that sent none. Non-string input is coerced to "" first: Buffer.from(8) allocates eight
|
|
260
|
-
// bytes rather than throwing, which would compare a number against a secret by length alone.
|
|
261
|
-
const secretEq = (given, expected) => {
|
|
262
|
-
const a = Buffer.from(typeof given === "string" ? given : ""), b = Buffer.from(expected || "");
|
|
263
|
-
return b.length > 0 && a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
264
|
-
};
|
|
265
|
-
|
|
266
|
-
function presign(method, seconds) {
|
|
267
|
-
const bucket = process.env.STATE_BUCKET, key = process.env.STATE_KEY, region = process.env.AWS_REGION;
|
|
268
|
-
const host = \`\${bucket}.s3.\${region}.amazonaws.com\`;
|
|
269
|
-
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\\.\\d+/, "");
|
|
270
|
-
const scope = \`\${stamp.slice(0, 8)}/\${region}/s3/aws4_request\`;
|
|
271
|
-
const pairs = [
|
|
272
|
-
["X-Amz-Algorithm", "AWS4-HMAC-SHA256"],
|
|
273
|
-
["X-Amz-Credential", \`\${process.env.AWS_ACCESS_KEY_ID}/\${scope}\`],
|
|
274
|
-
["X-Amz-Date", stamp],
|
|
275
|
-
["X-Amz-Expires", String(seconds)],
|
|
276
|
-
["X-Amz-SignedHeaders", "host"],
|
|
277
|
-
];
|
|
278
|
-
if (process.env.AWS_SESSION_TOKEN) pairs.push(["X-Amz-Security-Token", process.env.AWS_SESSION_TOKEN]);
|
|
279
|
-
// The canonical query must be byte-identical to the one on the wire — build it ONCE, reuse below.
|
|
280
|
-
const query = pairs
|
|
281
|
-
.map(([k, v]) => [enc(k), enc(v)])
|
|
282
|
-
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
|
283
|
-
.map((p) => p.join("="))
|
|
284
|
-
.join("&");
|
|
285
|
-
const path = \`/\${key.split("/").map(enc).join("/")}\`;
|
|
286
|
-
const canonical = [method, path, query, \`host:\${host}\\n\`, "host", "UNSIGNED-PAYLOAD"].join("\\n");
|
|
287
|
-
const sts = ["AWS4-HMAC-SHA256", stamp, scope, crypto.createHash("sha256").update(canonical).digest("hex")].join("\\n");
|
|
288
|
-
let k = hmac(\`AWS4\${process.env.AWS_SECRET_ACCESS_KEY}\`, stamp.slice(0, 8));
|
|
289
|
-
for (const part of [region, "s3", "aws4_request"]) k = hmac(k, part);
|
|
290
|
-
return \`https://\${host}\${path}?\${query}&X-Amz-Signature=\${hmac(k, sts).toString("hex")}\`;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
async function invoke(envelope) {
|
|
294
|
-
if ((process.env.WAKE_SECRET || process.env.STATE_REFRESH_SECRET) && !ownUrl) {
|
|
295
|
-
const { LambdaClient, GetFunctionUrlConfigCommand } = require("@aws-sdk/client-lambda");
|
|
296
|
-
ownUrl = (await new LambdaClient({}).send(
|
|
297
|
-
new GetFunctionUrlConfigCommand({ FunctionName: process.env.AWS_LAMBDA_FUNCTION_NAME }),
|
|
298
|
-
)).FunctionUrl;
|
|
299
|
-
}
|
|
300
|
-
if (ownUrl) envelope.wake = { url: ownUrl };
|
|
301
|
-
// Authenticates this envelope as coming from the forwarder (see the template's FastagentIngressSecret).
|
|
302
|
-
envelope.auth = process.env.INGRESS_SECRET;
|
|
303
|
-
// Keep each capability short-lived. Function-URL deployments also carry an authenticated refresh
|
|
304
|
-
// endpoint, so a background turn settling hours after its webhook never depends on the temporary
|
|
305
|
-
// Lambda credentials that signed the original pair still being alive.
|
|
306
|
-
if (process.env.STATE_BUCKET) envelope.state = {
|
|
307
|
-
getUrl: presign("GET", 3600),
|
|
308
|
-
putUrl: presign("PUT", 3600),
|
|
309
|
-
...(ownUrl && process.env.STATE_REFRESH_SECRET ? {
|
|
310
|
-
refresh: { url: ownUrl.replace(/\\/$/, "") + "/__fastagent/state-urls", auth: process.env.STATE_REFRESH_SECRET },
|
|
311
|
-
} : {}),
|
|
312
|
-
};
|
|
313
|
-
const res = await client.send(new InvokeAgentRuntimeCommand({
|
|
314
|
-
agentRuntimeArn: process.env.RUNTIME_ARN,
|
|
315
|
-
runtimeSessionId: process.env.INGRESS_SESSION_ID,
|
|
316
|
-
contentType: "application/json",
|
|
317
|
-
accept: "application/json",
|
|
318
|
-
payload: new TextEncoder().encode(JSON.stringify(envelope)),
|
|
319
|
-
}));
|
|
320
|
-
const body = Buffer.from(await res.response.transformToByteArray());
|
|
321
|
-
return { status: res.statusCode ?? 200, body };
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// Mirror the container's pending wake-ups into one-shot schedules: at(fireAt), poke me, delete
|
|
325
|
-
// after firing. Upsert (create → conflict → update). The container pre-filters DUE alarms (it is
|
|
326
|
-
// awake handling those), so every failure here is REAL — counted and propagated: a swallowed error
|
|
327
|
-
// would leave a pending wake with no alarm, exactly the reliability hole this mechanism closes.
|
|
328
|
-
// Cancelled wakes are NOT deleted here: their poke fires, finds nothing due, and the schedule
|
|
329
|
-
// self-deletes (lazy cleanup by design).
|
|
330
|
-
async function syncAlarms(alarms, ctx) {
|
|
331
|
-
const { SchedulerClient, CreateScheduleCommand, UpdateScheduleCommand } = require("@aws-sdk/client-scheduler");
|
|
332
|
-
const sch = new SchedulerClient({});
|
|
333
|
-
let failed = 0;
|
|
334
|
-
// Alarm name = a stable hash of the WHOLE wake id. A prefix of the id would collide (two wakes
|
|
335
|
-
// sharing 8 hex chars), and a collision is INDISTINGUISHABLE from the legitimate re-arm below:
|
|
336
|
-
// the second wake would "update" the first's alarm and silently steal its fire time.
|
|
337
|
-
const names = new Map();
|
|
338
|
-
for (const a of alarms) {
|
|
339
|
-
const name = process.env.WAKE_PREFIX + crypto.createHash("sha256").update(a.id).digest("hex").slice(0, 16);
|
|
340
|
-
if (names.has(name)) {
|
|
341
|
-
failed += 1;
|
|
342
|
-
console.log(\`alarm name collision \${name}: \${names.get(name)} vs \${a.id}\`);
|
|
343
|
-
continue;
|
|
344
|
-
}
|
|
345
|
-
names.set(name, a.id);
|
|
346
|
-
const p = {
|
|
347
|
-
Name: name,
|
|
348
|
-
ScheduleExpression: \`at(\${a.at.slice(0, 19)})\`,
|
|
349
|
-
ScheduleExpressionTimezone: "UTC",
|
|
350
|
-
FlexibleTimeWindow: { Mode: "OFF" },
|
|
351
|
-
ActionAfterCompletion: "DELETE",
|
|
352
|
-
Target: { Arn: ctx.invokedFunctionArn, RoleArn: process.env.WAKE_ROLE_ARN, Input: '{"wakePoke":true}' },
|
|
353
|
-
};
|
|
354
|
-
try {
|
|
355
|
-
await sch.send(new CreateScheduleCommand(p));
|
|
356
|
-
} catch (e) {
|
|
357
|
-
try {
|
|
358
|
-
if (e.name !== "ConflictException") throw e;
|
|
359
|
-
await sch.send(new UpdateScheduleCommand(p));
|
|
360
|
-
} catch (u) {
|
|
361
|
-
failed += 1;
|
|
362
|
-
console.log(\`alarm \${p.Name}: \${u}\`);
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
return failed;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
exports.handler = async (event, ctx) => {
|
|
370
|
-
// EventBridge wake-up poke: the invocation itself wakes the container; its pump does the rest.
|
|
371
|
-
if (event && event.wakePoke) {
|
|
372
|
-
const r = await invoke({ kind: "wake-poke" });
|
|
373
|
-
console.log(\`wake-poke: \${r.status}\`);
|
|
374
|
-
return { status: r.status };
|
|
375
|
-
}
|
|
376
|
-
// EventBridge Scheduler fire — throw on failure so the miss lands in CloudWatch, never silently.
|
|
377
|
-
if (event && event.scheduleFire) {
|
|
378
|
-
const { name, slot } = event.scheduleFire;
|
|
379
|
-
const r = await invoke({ kind: "schedule-fire", name, slot });
|
|
380
|
-
const out = r.body.toString();
|
|
381
|
-
console.log(\`schedule-fire \${name} (\${slot}): \${r.status} \${out}\`);
|
|
382
|
-
if (r.status >= 400) throw new Error(\`schedule-fire \${name} failed: \${r.status} \${out}\`);
|
|
383
|
-
return { status: r.status };
|
|
384
|
-
}
|
|
385
|
-
const http = event && event.requestContext && event.requestContext.http;
|
|
386
|
-
if (!http) throw new Error("unrecognized event shape");
|
|
387
|
-
// Refresh the snapshot capabilities with THIS Lambda invocation's current temporary credentials.
|
|
388
|
-
// The container may settle long after the webhook Lambda (and its credentials) expired.
|
|
389
|
-
if (event.rawPath === "/__fastagent/state-urls") {
|
|
390
|
-
const req = JSON.parse(event.isBase64Encoded ? Buffer.from(event.body, "base64").toString() : event.body || "{}");
|
|
391
|
-
if (!secretEq(req.auth, process.env.STATE_REFRESH_SECRET)) return { statusCode: 403, body: "forbidden\\n" };
|
|
392
|
-
return {
|
|
393
|
-
statusCode: 200,
|
|
394
|
-
headers: { "content-type": "application/json" },
|
|
395
|
-
body: JSON.stringify({ getUrl: presign("GET", 3600), putUrl: presign("PUT", 3600) }),
|
|
396
|
-
};
|
|
397
|
-
}
|
|
398
|
-
// The container's wake-alarm callback (reserved path, shared secret) — handled HERE, never forwarded.
|
|
399
|
-
if (event.rawPath === "/__fastagent/wake-alarm") {
|
|
400
|
-
const req = JSON.parse(event.isBase64Encoded ? Buffer.from(event.body, "base64").toString() : event.body || "{}");
|
|
401
|
-
if (!secretEq(req.secret, process.env.WAKE_SECRET)) return { statusCode: 403, body: "forbidden\\n" };
|
|
402
|
-
const failed = await syncAlarms(req.alarms || [], ctx);
|
|
403
|
-
// Partial failure IS failure: the container retries the whole (idempotent) set until every
|
|
404
|
-
// pending wake really has its alarm.
|
|
405
|
-
if (failed > 0) return { statusCode: 500, body: \`\${failed} alarm(s) failed\\n\` };
|
|
406
|
-
return { statusCode: 200, body: "ok\\n" };
|
|
407
|
-
}
|
|
408
|
-
// The deploy driver's probe (reserved path, ingress secret): wake the runtime through the SAME
|
|
409
|
-
// trusted envelope pipeline (state URLs included — a direct InvokeAgentRuntime call could not mint
|
|
410
|
-
// them, and would make the runtime construct against a pre-restore mount) and pass its structured
|
|
411
|
-
// transport-200 verdict back VERBATIM. The ordinary webhook path below folds a non-200 transport
|
|
412
|
-
// into an opaque 502, which would strip exactly the diagnostics the probe exists to carry — and it
|
|
413
|
-
// sits BEFORE the WEBHOOKS_ENABLED gate so schedule-only topologies (whose URLs refuse ordinary
|
|
414
|
-
// public traffic) are probeable too.
|
|
415
|
-
if (event.rawPath === "/__fastagent/probe") {
|
|
416
|
-
const req = JSON.parse(event.isBase64Encoded ? Buffer.from(event.body, "base64").toString() : event.body || "{}");
|
|
417
|
-
if (!secretEq(req.auth, process.env.INGRESS_SECRET)) return { statusCode: 403, body: "forbidden\\n" };
|
|
418
|
-
const r = await invoke({ kind: "probe" });
|
|
419
|
-
if (r.status !== 200) {
|
|
420
|
-
console.log(\`probe transport error \${r.status}: \${r.body}\`);
|
|
421
|
-
return { statusCode: 502, body: "upstream error\\n" };
|
|
422
|
-
}
|
|
423
|
-
return { statusCode: 200, headers: { "content-type": "application/json" }, body: r.body.toString() };
|
|
424
|
-
}
|
|
425
|
-
// Enforce the advertised ORIGINAL-body ceiling before base64 adds another 4/3 inside the runtime
|
|
426
|
-
// envelope. This also leaves deterministic room for headers/query/JSON under Lambda's 6 MB cap.
|
|
427
|
-
const webhookBytes = event.body === undefined ? 0
|
|
428
|
-
: event.isBase64Encoded ? Buffer.byteLength(event.body, "base64") : Buffer.byteLength(event.body);
|
|
429
|
-
if (webhookBytes > ${MAX_WEBHOOK_BODY_BYTES}) return { statusCode: 413, body: "payload too large\\n" };
|
|
430
|
-
// A schedule-only deployment has a Function URL solely for the authenticated refresh callback.
|
|
431
|
-
// Reject arbitrary public traffic BEFORE it can wake AgentCore (cost/DoS) or reach an inner route.
|
|
432
|
-
if (process.env.WEBHOOKS_ENABLED !== "1") return { statusCode: 404, body: "not found\\n" };
|
|
433
|
-
// Function URL webhook — forward the original request verbatim (signature material included).
|
|
434
|
-
const r = await invoke({
|
|
435
|
-
kind: "webhook",
|
|
436
|
-
method: http.method,
|
|
437
|
-
path: event.rawPath || "/",
|
|
438
|
-
query: event.rawQueryString || undefined,
|
|
439
|
-
headers: event.headers || {},
|
|
440
|
-
bodyB64: event.body === undefined ? undefined
|
|
441
|
-
: event.isBase64Encoded ? event.body : Buffer.from(event.body).toString("base64"),
|
|
442
|
-
});
|
|
443
|
-
if (r.status !== 200) {
|
|
444
|
-
console.log(\`transport error \${r.status}: \${r.body}\`);
|
|
445
|
-
return { statusCode: 502, body: "upstream error\\n" };
|
|
446
|
-
}
|
|
447
|
-
const reply = JSON.parse(r.body.toString()); // { status, headers, bodyB64 } from the adapter
|
|
448
|
-
for (const k of Object.keys(reply.headers)) {
|
|
449
|
-
if (/^(content-length|transfer-encoding|connection)$/i.test(k)) delete reply.headers[k];
|
|
450
|
-
}
|
|
451
|
-
return { statusCode: reply.status, headers: reply.headers, body: reply.bodyB64, isBase64Encoded: true };
|
|
452
|
-
};
|
|
453
|
-
`;
|
|
246
|
+
return readFileSync(new URL("./forwarder.js", import.meta.url), "utf8");
|
|
454
247
|
}
|
|
455
248
|
/**
|
|
456
249
|
* The EventBridge physical name for a schedule. A schedule's local name is an arbitrary MODULE FILE
|
|
@@ -467,15 +260,9 @@ export function scheduleResourceName(agent, schedule) {
|
|
|
467
260
|
return `${prefix}${safe.slice(0, room)}-${hash}`;
|
|
468
261
|
}
|
|
469
262
|
/** The CloudFormation template — the whole topology in one stack. */
|
|
470
|
-
function template(input, translated) {
|
|
263
|
+
function template(input, translated, topology) {
|
|
471
264
|
const runtimeName = toRuntimeName(input.name);
|
|
472
|
-
|
|
473
|
-
// Every forwarder also gets a Function URL as the authenticated state-capability refresh channel:
|
|
474
|
-
// a schedule turn can outlive both its original presigned URL and the Lambda credentials that signed
|
|
475
|
-
// it. Schedule-only URLs reject every non-reserved HTTP path before invoking AgentCore, so they do
|
|
476
|
-
// not expose a webhook/data plane (and start never mounts the builtin /invoke under AgentCore).
|
|
477
|
-
const needsForwarder = input.channels.some((channel) => channel.ingress === "webhook") || translated.length > 0 || input.selfSchedule;
|
|
478
|
-
const needsFunctionUrl = needsForwarder;
|
|
265
|
+
const needsForwarder = topology.forwarder;
|
|
479
266
|
const secrets = deploymentSecrets(input.modelAuth, input.channels, input.extraSecrets);
|
|
480
267
|
const forwarderFnArn = `!Sub arn:aws:lambda:\${AWS::Region}:\${AWS::AccountId}:function:fastagent-${input.name}-forwarder`;
|
|
481
268
|
// Secret env vars ride CFN NoEcho parameters. FASTAGENT_AUTH_SEED is always declared (Default "")
|
|
@@ -607,21 +394,21 @@ function template(input, translated) {
|
|
|
607
394
|
` Action: iam:PassRole`,
|
|
608
395
|
` Resource: !GetAtt WakeSchedulerRole.Arn`,
|
|
609
396
|
]
|
|
610
|
-
: []), ...(
|
|
397
|
+
: []), ...(needsForwarder
|
|
611
398
|
? [
|
|
612
399
|
` - Effect: Allow # self-resolve callback URL for state-URL refresh / wake alarms`,
|
|
613
400
|
` Action: lambda:GetFunctionUrlConfig`,
|
|
614
401
|
` Resource: ${forwarderFnArn}`,
|
|
615
402
|
]
|
|
616
|
-
: []), ``, ` 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)}`, ...(
|
|
403
|
+
: []), ``, ` 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)}`, ...(needsForwarder ? [` STATE_REFRESH_SECRET: !Ref FastagentIngressSecret`] : []), ...(topology.webhooks ? [` WEBHOOKS_ENABLED: "1"`] : []), ...(input.selfSchedule
|
|
617
404
|
? [
|
|
618
405
|
` WAKE_SECRET: !Ref FastagentWakeSecret`,
|
|
619
406
|
` WAKE_ROLE_ARN: !GetAtt WakeSchedulerRole.Arn`,
|
|
620
407
|
` WAKE_PREFIX: fa-${input.name}-wk-`,
|
|
621
408
|
]
|
|
622
|
-
: []), ` 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`);
|
|
409
|
+
: []), ` INGRESS_SECRET: !Ref FastagentIngressSecret`, ` STATE_BUCKET: !Ref StateBucket`, ` STATE_KEY: ${STATE_KEY}`, ` MAX_WEBHOOK_BODY_BYTES: "${MAX_WEBHOOK_BODY_BYTES}"`, ` # 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`);
|
|
623
410
|
}
|
|
624
|
-
if (
|
|
411
|
+
if (needsForwarder) {
|
|
625
412
|
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`);
|
|
626
413
|
if (input.selfSchedule) {
|
|
627
414
|
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}`);
|
|
@@ -634,7 +421,7 @@ function template(input, translated) {
|
|
|
634
421
|
}
|
|
635
422
|
}
|
|
636
423
|
lines.push(``, `Outputs:`, ` RuntimeArn:`, ` Value: !GetAtt Runtime.AgentRuntimeArn`);
|
|
637
|
-
if (
|
|
424
|
+
if (needsForwarder) {
|
|
638
425
|
lines.push(` ForwarderUrl:`, ` Value: !GetAtt ForwarderUrl.FunctionUrl`);
|
|
639
426
|
}
|
|
640
427
|
return `${lines.join("\n")}\n`;
|
|
@@ -678,12 +465,10 @@ export function planAgentcoreDeploy(input) {
|
|
|
678
465
|
}
|
|
679
466
|
paramNames.set(p, s.name);
|
|
680
467
|
}
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
const needsForwarder = input.channels.some((channel) => channel.ingress === "webhook") || translated.length > 0 || input.selfSchedule;
|
|
684
|
-
const needsFunctionUrl = needsForwarder;
|
|
468
|
+
const topology = agentcoreTopology(input, translated.length);
|
|
469
|
+
const needsForwarder = topology.forwarder;
|
|
685
470
|
const artifacts = [
|
|
686
|
-
{ path: `${prefix}${TEMPLATE_FILE}`, content: template(input, translated) },
|
|
471
|
+
{ path: `${prefix}${TEMPLATE_FILE}`, content: template(input, translated, topology) },
|
|
687
472
|
...(needsForwarder ? [{ path: `${prefix}${FORWARDER_FILE}`, content: forwarderSource() }] : []),
|
|
688
473
|
...containerArtifacts(input),
|
|
689
474
|
];
|
|
@@ -735,9 +520,9 @@ export function planAgentcoreDeploy(input) {
|
|
|
735
520
|
if (input.selfSchedule) {
|
|
736
521
|
runbook.push(`# FastagentWakeSecret: the wake-alarm shared secret — any random string (\`--run\` mints one)`);
|
|
737
522
|
}
|
|
738
|
-
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}`, ``,
|
|
523
|
+
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}`, ``, needsForwarder
|
|
739
524
|
? `# 4. Read the outputs (the runtime ARN + callback URL; it serves webhooks only when configured):`
|
|
740
|
-
: `# 4. Read the outputs (the runtime ARN — this topology has NO public URL: nothing outside AWS`, ...(
|
|
525
|
+
: `# 4. Read the outputs (the runtime ARN — this topology has NO public URL: nothing outside AWS`, ...(needsForwarder
|
|
741
526
|
? []
|
|
742
527
|
: [`# 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"`, ``, `# 5. Tail the Runtime's application stdout/stderr (same fastagent messages + log level as locally).`, `# Discovery resolves the per-endpoint log group from the stack's RuntimeArn:`, `fastagent logs agentcore --follow`, ...(needsForwarder
|
|
743
528
|
? [
|
|
@@ -781,5 +566,5 @@ export function planAgentcoreDeploy(input) {
|
|
|
781
566
|
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)}"`);
|
|
782
567
|
}
|
|
783
568
|
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.)`, `# CREDENTIALS RIDE THAT SNAPSHOT TOO: FASTAGENT_SECRETS_DIR is ${SECRETS_DIR}, inside the state`, `# root, so an OAuth auth.json ROTATED on the box persists (a refresh token is single-use — without`, `# this the next microVM would re-seed the deploy-time copy and eventually fail to authenticate).`, `# The bucket is therefore credential storage: it is created with public access blocked and`, `# versioning on, and deleting it costs model access until the next deploy re-seeds.`);
|
|
784
|
-
return { artifacts, runbook, untranslatableSchedules: untranslatable };
|
|
569
|
+
return { artifacts, runbook, untranslatableSchedules: untranslatable, topology };
|
|
785
570
|
}
|
|
@@ -1,22 +1,7 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `fastagent deploy agentcore --run` — drive the AWS CLI + Docker to completion. The middle of the
|
|
3
|
-
* deploy the plain runbook hands to the operator; `--run` executes it so a coding agent runs ONE
|
|
4
|
-
* command. Idempotent (ECR check-then-act; `cloudformation deploy` converges the stack) and
|
|
5
|
-
* resumable: it STOPS at a human gate with one actionable line and a non-zero exit.
|
|
6
|
-
*
|
|
7
|
-
* TWO runners, one seam ({@link CliRunner}): `aws` (identity, ECR, CloudFormation) and `docker`
|
|
8
|
-
* (buildx). AgentCore is the ONE host whose image builds on the operator's machine — the platform
|
|
9
|
-
* requires linux/arm64 in the account's ECR and has no remote builder — so a missing Docker/buildx
|
|
10
|
-
* is a first-class gate, not an incidental failure.
|
|
11
|
-
*
|
|
12
|
-
* Secrets ride CloudFormation NoEcho parameters. `--parameter-overrides` on argv would put the
|
|
13
|
-
* values in the process listing (the same reason Fly imports secrets over stdin), so they go through
|
|
14
|
-
* a caller-provided temp parameters file (`file://…`, mode 0600, deleted by the caller) — the write
|
|
15
|
-
* is injected to keep this module pure and the security-sensitive wiring testable.
|
|
16
|
-
*/
|
|
17
1
|
import type { DeclaredChannel } from "../../channels/discover.ts";
|
|
18
2
|
import { type Registrars } from "../channel-ingress.ts";
|
|
19
3
|
import type { CliRunner } from "../runner.ts";
|
|
4
|
+
import { type AgentcoreTopology } from "./plan.ts";
|
|
20
5
|
export interface AgentcoreRunPlan {
|
|
21
6
|
/** The base name — stack `fastagent-<name>`, ECR repo `fastagent/<name>`. */
|
|
22
7
|
name: string;
|
|
@@ -37,10 +22,9 @@ export interface AgentcoreRunPlan {
|
|
|
37
22
|
missingSecrets: string[];
|
|
38
23
|
/** Every declared channel and its ingress — the driver asks which of them have a webhook. */
|
|
39
24
|
channels: readonly DeclaredChannel[];
|
|
40
|
-
/**
|
|
41
|
-
*
|
|
42
|
-
|
|
43
|
-
needsForwarder: boolean;
|
|
25
|
+
/** What the stack contains — the plan's own reading, so this driver cannot disagree with the
|
|
26
|
+
* template about whether a forwarder (and with it the state bucket and its parameters) exists. */
|
|
27
|
+
topology: AgentcoreTopology;
|
|
44
28
|
}
|
|
45
29
|
export type AgentcoreRunOutcome = {
|
|
46
30
|
ok: true;
|
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fastagent deploy agentcore --run` — drive the AWS CLI + Docker to completion. The middle of the
|
|
3
|
+
* deploy the plain runbook hands to the operator; `--run` executes it so a coding agent runs ONE
|
|
4
|
+
* command. Idempotent (ECR check-then-act; `cloudformation deploy` converges the stack) and
|
|
5
|
+
* resumable: it STOPS at a human gate with one actionable line and a non-zero exit.
|
|
6
|
+
*
|
|
7
|
+
* TWO runners, one seam ({@link CliRunner}): `aws` (identity, ECR, CloudFormation) and `docker`
|
|
8
|
+
* (buildx). AgentCore is the ONE host whose image builds on the operator's machine — the platform
|
|
9
|
+
* requires linux/arm64 in the account's ECR and has no remote builder — so a missing Docker/buildx
|
|
10
|
+
* is a first-class gate, not an incidental failure.
|
|
11
|
+
*
|
|
12
|
+
* Secrets ride CloudFormation NoEcho parameters. `--parameter-overrides` on argv would put the
|
|
13
|
+
* values in the process listing (the same reason Fly imports secrets over stdin), so they go through
|
|
14
|
+
* a caller-provided temp parameters file (`file://…`, mode 0600, deleted by the caller) — the write
|
|
15
|
+
* is injected to keep this module pure and the security-sensitive wiring testable.
|
|
16
|
+
*/
|
|
17
|
+
import { RESERVED_PATHS } from "../../channels/agentcore-protocol.js";
|
|
1
18
|
import { registerWebhooks } from "../channel-ingress.js";
|
|
2
19
|
import { createHash } from "node:crypto";
|
|
3
20
|
import { Buffer } from "node:buffer";
|
|
@@ -8,7 +25,7 @@ import { zipSingleFile } from "./zip.js";
|
|
|
8
25
|
const PROBE_TIMEOUT_MS = 120_000;
|
|
9
26
|
const PROBE_INTERVAL_MS = 3_000;
|
|
10
27
|
/**
|
|
11
|
-
* Drive the forwarder's reserved
|
|
28
|
+
* Drive the forwarder's reserved probe path until it answers, and read the runtime's
|
|
12
29
|
* STRUCTURED verdict. The path answers on every forwarder topology (a schedule-only URL refuses
|
|
13
30
|
* ordinary public traffic, so a plain `GET /health` would 404 there), and the verdict rides a
|
|
14
31
|
* transport-200 JSON body `{ ok, error? }` — the ordinary webhook relay folds a non-200 transport
|
|
@@ -208,7 +225,7 @@ probe = {}) {
|
|
|
208
225
|
// out of CloudFormation means a `delete-stack` — or a rolled-back create — cannot take the
|
|
209
226
|
// agent's sessions, channel state and pending wake-ups with it.
|
|
210
227
|
let forwarderParams;
|
|
211
|
-
if (plan.
|
|
228
|
+
if (plan.topology.forwarder) {
|
|
212
229
|
const bucket = stateBucketName(plan.name, account);
|
|
213
230
|
if ((await aws(["s3api", "head-bucket", "--bucket", bucket], { capture: true })).code !== 0) {
|
|
214
231
|
log(`creating deployment bucket ${bucket}…`);
|
|
@@ -360,7 +377,7 @@ probe = {}) {
|
|
|
360
377
|
// Keyed on the FORWARDER: every current forwarder has a callback URL for state-capability refresh,
|
|
361
378
|
// and every forwarder topology has an ingress session whose next event would otherwise land on compute still
|
|
362
379
|
// running the previous image.
|
|
363
|
-
if (plan.
|
|
380
|
+
if (plan.topology.forwarder) {
|
|
364
381
|
// CHECKPOINT FIRST. The stop cuts whatever turn is running, and that turn's durable intent was
|
|
365
382
|
// written to a mount the version update erases — so without this flush "replay re-runs it" would
|
|
366
383
|
// be false: the intent never reaches S3 and the message is simply gone. Best-effort: a session
|
|
@@ -437,9 +454,7 @@ probe = {}) {
|
|
|
437
454
|
// check (there is no boot-time failStartup on this host). A missing output means an edited
|
|
438
455
|
// template; skipping the probe silently would let such a deploy report success unverified.
|
|
439
456
|
// Only a pure-invoke deployment (no forwarder) legitimately has no URL and nothing to probe.
|
|
440
|
-
|
|
441
|
-
// list, but this gate must not silently trust that invariant across callers.
|
|
442
|
-
if ((plan.needsForwarder || plan.channels.length > 0) && !url) {
|
|
457
|
+
if (plan.topology.forwarder && !url) {
|
|
443
458
|
return gate("this deployment needs the forwarder but the stack has no ForwarderUrl output — regenerate the " +
|
|
444
459
|
"template with --force");
|
|
445
460
|
}
|
|
@@ -450,7 +465,7 @@ probe = {}) {
|
|
|
450
465
|
// unrestorable snapshot surfaces AT DEPLOY TIME with the runtime's own error text.
|
|
451
466
|
if (url) {
|
|
452
467
|
log("probing the deployed runtime (state restore + channel construction)…");
|
|
453
|
-
const verdict = await probeRuntime(`${url}
|
|
468
|
+
const verdict = await probeRuntime(`${url}${RESERVED_PATHS.probe}`, plan.secrets.FASTAGENT_INGRESS_SECRET ?? "", probe.fetchImpl ?? fetch, probe.timeoutMs, probe.intervalMs);
|
|
454
469
|
if (!verdict.ok)
|
|
455
470
|
return gate(verdict.gate);
|
|
456
471
|
log("runtime verified (state restored, channels constructed)");
|
|
@@ -39,7 +39,7 @@ const INGRESS = {
|
|
|
39
39
|
manual: (baseUrl) => `slack: set Event Subscriptions → Request URL → ${baseUrl}/slack`,
|
|
40
40
|
runbook: (baseUrl) => [
|
|
41
41
|
`# Set Slack Event Subscriptions → Request URL (default route POST /slack; the running service`,
|
|
42
|
-
`# answers Slack's challenge), and match scopes/subscriptions to
|
|
42
|
+
`# answers Slack's challenge), and match scopes/subscriptions to the app \`add slack --group-behavior\` created:`,
|
|
43
43
|
`# Request URL = ${baseUrl}/slack`,
|
|
44
44
|
],
|
|
45
45
|
},
|
|
@@ -38,15 +38,29 @@ type DockerAnnounce = (baseUrl: string) => Promise<{
|
|
|
38
38
|
outcome: RegistrationOutcome;
|
|
39
39
|
}[]>;
|
|
40
40
|
export type DockerHealthProbe = (healthUrl: string) => Promise<boolean>;
|
|
41
|
-
|
|
41
|
+
/** A published Quick Tunnel URL, and whether its tunnel ever reported an edge connection. Both, because
|
|
42
|
+
* a URL that never connected still gets served (retrying meets the same network) and the operator has
|
|
43
|
+
* to be told which of the two they are looking at. */
|
|
44
|
+
export interface ComposeTunnel {
|
|
45
|
+
url: string;
|
|
46
|
+
connected: boolean;
|
|
47
|
+
}
|
|
48
|
+
export type DockerTunnelUrlProbe = (docker: CliRunner, composeFile: string, env: NodeJS.ProcessEnv) => Promise<ComposeTunnel | undefined>;
|
|
42
49
|
/** Resolve Docker Compose's `host:port` output to a loopback URL (safe for 0.0.0.0/[::] bindings too). */
|
|
43
50
|
export declare function localUrlFromComposePort(stdout: string): string | undefined;
|
|
44
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* Poll the detached cloudflared service's logs until its Quick Tunnel URL is assigned AND the tunnel
|
|
53
|
+
* reports an edge connection. Waiting for the second is the same requirement `startCloudflareTunnel`
|
|
54
|
+
* has and for the same reason — the hostname does not exist until then (#435), and this driver hands
|
|
55
|
+
* the URL straight to `announce`. Returns a URL that never connected rather than nothing: the
|
|
56
|
+
* registrars downstream report their own outcome, and a gate saying "no URL" would misname it — but it
|
|
57
|
+
* says WHICH it is returning, because the caller owes the operator that sentence.
|
|
58
|
+
*/
|
|
45
59
|
export declare function waitForComposeTunnelUrl(docker: CliRunner, composeFile: string, env: NodeJS.ProcessEnv, options?: {
|
|
46
60
|
attempts?: number;
|
|
47
61
|
intervalMs?: number;
|
|
48
62
|
sleep?: (ms: number) => Promise<void>;
|
|
49
|
-
}): Promise<
|
|
63
|
+
}): Promise<ComposeTunnel | undefined>;
|
|
50
64
|
/**
|
|
51
65
|
* Drive Docker Compose. A custom Compose file remains authoritative: the driver invokes it as-is and
|
|
52
66
|
* only assumes the generated service contract (`agent`, config's container port) for optional URL/
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* carry through the child environment (never argv), and a readiness check on the published loopback port.
|
|
5
5
|
*/
|
|
6
6
|
import { waitForHealth } from "../../channels/wait-health.js";
|
|
7
|
-
import { parseTunnelUrl } from "../../tunnel.js";
|
|
7
|
+
import { TUNNEL_DNS_LAG_MS, hasTunnelConnection, parseTunnelUrl } from "../../tunnel.js";
|
|
8
8
|
import { registrationGate } from "../registration-gate.js";
|
|
9
9
|
import { MIN_DOCKER_COMPOSE_VERSION } from "./plan.js";
|
|
10
10
|
/** Resolve Docker Compose's `host:port` output to a loopback URL (safe for 0.0.0.0/[::] bindings too). */
|
|
@@ -18,19 +18,31 @@ export function localUrlFromComposePort(stdout) {
|
|
|
18
18
|
}
|
|
19
19
|
const defaultHealthProbe = (healthUrl) => waitForHealth(healthUrl, 30_000, 500);
|
|
20
20
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Poll the detached cloudflared service's logs until its Quick Tunnel URL is assigned AND the tunnel
|
|
23
|
+
* reports an edge connection. Waiting for the second is the same requirement `startCloudflareTunnel`
|
|
24
|
+
* has and for the same reason — the hostname does not exist until then (#435), and this driver hands
|
|
25
|
+
* the URL straight to `announce`. Returns a URL that never connected rather than nothing: the
|
|
26
|
+
* registrars downstream report their own outcome, and a gate saying "no URL" would misname it — but it
|
|
27
|
+
* says WHICH it is returning, because the caller owes the operator that sentence.
|
|
28
|
+
*/
|
|
22
29
|
export async function waitForComposeTunnelUrl(docker, composeFile, env, options = {}) {
|
|
23
30
|
const compose = ["compose", "-f", composeFile];
|
|
24
31
|
const attempts = options.attempts ?? 60;
|
|
32
|
+
let assigned;
|
|
25
33
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
26
34
|
const logs = await docker([...compose, "logs", "--no-color", "tunnel"], { capture: true, env });
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
if (logs.code === 0) {
|
|
36
|
+
assigned ??= parseTunnelUrl(logs.stdout);
|
|
37
|
+
if (assigned && hasTunnelConnection(logs.stdout)) {
|
|
38
|
+
await (options.sleep ?? sleep)(TUNNEL_DNS_LAG_MS);
|
|
39
|
+
return { url: assigned, connected: true };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
30
42
|
if (attempt + 1 < attempts)
|
|
31
43
|
await (options.sleep ?? sleep)(options.intervalMs ?? 500);
|
|
32
44
|
}
|
|
33
|
-
return undefined;
|
|
45
|
+
return assigned ? { url: assigned, connected: false } : undefined;
|
|
34
46
|
}
|
|
35
47
|
const defaultTunnelUrlProbe = (docker, composeFile, env) => waitForComposeTunnelUrl(docker, composeFile, env);
|
|
36
48
|
/**
|
|
@@ -119,10 +131,20 @@ export async function deployDockerRun(plan, docker, log, healthProbe = defaultHe
|
|
|
119
131
|
if (!hasTunnel)
|
|
120
132
|
return { ok: true, url };
|
|
121
133
|
log("waiting for the Compose tunnel service to publish its Quick Tunnel URL…");
|
|
122
|
-
const
|
|
123
|
-
if (!
|
|
134
|
+
const tunnel = await tunnelUrlProbe(docker, plan.composeFile, env);
|
|
135
|
+
if (!tunnel) {
|
|
124
136
|
return gate(`tunnel did not publish a Quick Tunnel URL — inspect \`docker compose -f ${plan.composeFile} logs tunnel\``);
|
|
125
137
|
}
|
|
138
|
+
// The same sentence `startCloudflareTunnel` prints for the same state, and needed MORE here: this
|
|
139
|
+
// cloudflared runs in a container, so the author cannot see the logs that would explain the
|
|
140
|
+
// registration failures about to follow. Silence sends them to debug the platform for what is a
|
|
141
|
+
// local tunnel that never came up.
|
|
142
|
+
if (!tunnel.connected) {
|
|
143
|
+
log(`warn: the tunnel service never reported an edge connection for ${tunnel.url} — announcing it anyway. ` +
|
|
144
|
+
`Nothing reaches a tunnel that has not connected, so a webhook registration that cannot resolve the ` +
|
|
145
|
+
`host is this, not the platform. Inspect \`docker compose -f ${plan.composeFile} logs tunnel\`.`);
|
|
146
|
+
}
|
|
147
|
+
const tunnelUrl = tunnel.url;
|
|
126
148
|
// Registration lives HERE, like every other host's driver, and not at the CLI: this is the layer
|
|
127
149
|
// that owns the outcome, so it is the layer that can gate on one. While it sat above, docker was
|
|
128
150
|
// the one target whose `--run` could exit 0 with a webhook that never registered.
|
package/dist/deploy/fly/plan.js
CHANGED
|
@@ -87,6 +87,13 @@ export function planFlyDeploy(input) {
|
|
|
87
87
|
`# <region> MUST equal primary_region in fly.toml (a volume in another region can't mount) — fly.toml`,
|
|
88
88
|
`# is the single source for the region; skip this if the volume exists (fly volumes list --app ${appName}):`,
|
|
89
89
|
`fly volumes create data --app ${appName} --region <region> --size 1`,
|
|
90
|
+
``,
|
|
91
|
+
`# An address to be reached ON: [http_service] declares a service, it does not allocate an IP.`,
|
|
92
|
+
`# \`fly deploy\` allocates one on a FIRST deploy only, and merely WARNS when that fails — which`,
|
|
93
|
+
`# leaves the machine serving and https://${appName}.fly.dev with no DNS record at all. Both are`,
|
|
94
|
+
`# free; skip if \`fly ips list --app ${appName}\` already shows a v4/v6/shared_v4 address:`,
|
|
95
|
+
`fly ips allocate-v4 --shared --app ${appName}`,
|
|
96
|
+
`fly ips allocate-v6 --app ${appName}`,
|
|
90
97
|
];
|
|
91
98
|
if (requiredSecrets.length > 0) {
|
|
92
99
|
runbook.push(``, `# Required secrets (replace each <value>):`, ...requiredSecrets.map((s) => `# ${s.name}: ${s.hint}`), `fly secrets set --app ${appName} ${requiredSecrets.map((s) => `${s.name}=<value>`).join(" ")}`);
|
package/dist/deploy/fly/run.d.ts
CHANGED
|
@@ -43,6 +43,33 @@ export type FlyRunOutcome = {
|
|
|
43
43
|
ok: false;
|
|
44
44
|
gate: string;
|
|
45
45
|
};
|
|
46
|
+
/**
|
|
47
|
+
* Which PUBLIC ingress families `fly ips list --json` shows — asked per family, because "has an
|
|
48
|
+
* ingress address" is not the question the two allocate commands answer. An app holding only a `v6`
|
|
49
|
+
* passes the coarse test and still resolves `<app>.fly.dev` to an AAAA record alone, so an IPv4-only
|
|
50
|
+
* webhook sender (Telegram, GitHub) reproduces #425 against it.
|
|
51
|
+
*
|
|
52
|
+
* The list is EVERY assignment the app holds, and a Flycast or egress address carries a non-empty
|
|
53
|
+
* `Address` too — reading one as routable pre-empts flyctl's own first-deploy fallback, which returns
|
|
54
|
+
* early once any assignment exists.
|
|
55
|
+
*
|
|
56
|
+
* Output that is not a JSON array THROWS rather than reading as "no address": the caller turns that
|
|
57
|
+
* into a gate, because a list we cannot read is not a state we can act on.
|
|
58
|
+
*/
|
|
59
|
+
export declare function ingressAddresses(stdout: string): {
|
|
60
|
+
v4: boolean;
|
|
61
|
+
v6: boolean;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Whether a `fly … list --json` array contains an object named `name` (Fly capitalizes `Name`; accept
|
|
65
|
+
* both). Exported for the same reason railway's parsers are: what it encodes is an assumption about
|
|
66
|
+
* another tool's output, and the live probe checks that assumption against the real `flyctl`.
|
|
67
|
+
*
|
|
68
|
+
* THROWS on output that is not a JSON array, for the same reason {@link ingressAddresses} does —
|
|
69
|
+
* `false` here means "the host does not have it", and answering that for a list nobody could read
|
|
70
|
+
* creates a SECOND volume, or in teardown destroys nothing at all.
|
|
71
|
+
*/
|
|
72
|
+
export declare function listHasName(stdout: string, name: string): boolean;
|
|
46
73
|
/**
|
|
47
74
|
* Run the deploy through `fly`. `log` reports progress; the injected {@link Registrars} perform the
|
|
48
75
|
* post-deploy webhook steps from the builder machine (Slack's control credential never travels to the
|