@fastagent-sh/fastagent 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/dist/channels/agentcore-protocol.d.ts +2 -1
  2. package/dist/channels/agentcore-service.js +10 -18
  3. package/dist/channels/agentcore.js +6 -3
  4. package/dist/channels/control.js +23 -0
  5. package/dist/channels/slack/config-api.d.ts +2 -2
  6. package/dist/channels/slack/config-api.js +2 -2
  7. package/dist/channels/slack/onboard.js +2 -2
  8. package/dist/channels/slack/register-webhook.js +2 -2
  9. package/dist/channels/slack/slack-api.js +4 -4
  10. package/dist/cli/add-feishu.js +4 -2
  11. package/dist/cli/auth-view.d.ts +15 -6
  12. package/dist/cli/auth-view.js +24 -8
  13. package/dist/cli/box-login.d.ts +33 -0
  14. package/dist/cli/box-login.js +162 -0
  15. package/dist/cli/commands/deploy/agentcore.js +48 -11
  16. package/dist/cli/commands/deploy/docker.js +24 -13
  17. package/dist/cli/commands/deploy/fly.js +30 -7
  18. package/dist/cli/commands/deploy/railway.js +21 -7
  19. package/dist/cli/commands/deploy/shared.d.ts +18 -17
  20. package/dist/cli/commands/deploy/shared.js +20 -21
  21. package/dist/cli/commands/destroy.js +2 -2
  22. package/dist/cli/commands/info.js +9 -6
  23. package/dist/cli/commands/invoke.js +2 -2
  24. package/dist/cli/commands/login.d.ts +7 -1
  25. package/dist/cli/commands/login.js +123 -10
  26. package/dist/cli/commands/logs.js +2 -2
  27. package/dist/cli/commands/models.d.ts +4 -1
  28. package/dist/cli/commands/models.js +37 -4
  29. package/dist/cli/commands/routine-run.js +2 -2
  30. package/dist/cli/commands/start.d.ts +0 -5
  31. package/dist/cli/commands/start.js +3 -13
  32. package/dist/cli/kernel.d.ts +2 -0
  33. package/dist/cli/kernel.js +1 -1
  34. package/dist/cli/login-relay.d.ts +38 -0
  35. package/dist/cli/login-relay.js +121 -0
  36. package/dist/cli/program.js +46 -8
  37. package/dist/cli/shared.d.ts +4 -4
  38. package/dist/cli/shared.js +24 -30
  39. package/dist/deploy/agentcore/plan.d.ts +10 -11
  40. package/dist/deploy/agentcore/plan.js +8 -14
  41. package/dist/deploy/agentcore/run.d.ts +8 -1
  42. package/dist/deploy/agentcore/run.js +20 -14
  43. package/dist/deploy/agentcore/shell.d.ts +25 -0
  44. package/dist/deploy/agentcore/shell.js +202 -0
  45. package/dist/deploy/box-shell.d.ts +43 -0
  46. package/dist/deploy/box-shell.js +28 -0
  47. package/dist/deploy/build-context.js +16 -7
  48. package/dist/deploy/channel-ingress.d.ts +20 -1
  49. package/dist/deploy/channel-ingress.js +34 -7
  50. package/dist/deploy/container.d.ts +14 -0
  51. package/dist/deploy/container.js +45 -1
  52. package/dist/deploy/docker/plan.d.ts +7 -3
  53. package/dist/deploy/docker/plan.js +9 -14
  54. package/dist/deploy/docker/run.d.ts +14 -7
  55. package/dist/deploy/docker/run.js +58 -35
  56. package/dist/deploy/fly/plan.d.ts +6 -3
  57. package/dist/deploy/fly/plan.js +6 -8
  58. package/dist/deploy/fly/run.d.ts +8 -2
  59. package/dist/deploy/fly/run.js +15 -25
  60. package/dist/deploy/preflight.d.ts +9 -7
  61. package/dist/deploy/preflight.js +74 -20
  62. package/dist/deploy/railway/plan.d.ts +6 -3
  63. package/dist/deploy/railway/plan.js +7 -9
  64. package/dist/deploy/railway/run.d.ts +10 -6
  65. package/dist/deploy/railway/run.js +29 -13
  66. package/dist/deploy/runner.d.ts +16 -0
  67. package/dist/deploy/runner.js +31 -0
  68. package/dist/deploy/secrets.d.ts +4 -13
  69. package/dist/deploy/secrets.js +8 -27
  70. package/dist/dev-supervisor.js +5 -2
  71. package/dist/engines/pi/agent-models.d.ts +70 -0
  72. package/dist/engines/pi/agent-models.js +93 -0
  73. package/dist/engines/pi/auth.d.ts +56 -3
  74. package/dist/engines/pi/auth.js +63 -16
  75. package/dist/engines/pi/config.d.ts +0 -25
  76. package/dist/engines/pi/config.js +3 -37
  77. package/dist/engines/pi/create.d.ts +22 -16
  78. package/dist/engines/pi/create.js +28 -28
  79. package/dist/engines/pi/entry-images.d.ts +14 -0
  80. package/dist/engines/pi/entry-images.js +23 -0
  81. package/dist/engines/pi/invoke-session.js +26 -0
  82. package/dist/engines/pi/login.d.ts +20 -13
  83. package/dist/engines/pi/login.js +19 -12
  84. package/dist/engines/pi/models.d.ts +115 -36
  85. package/dist/engines/pi/models.js +218 -48
  86. package/dist/engines/pi/open.d.ts +42 -11
  87. package/dist/engines/pi/open.js +53 -21
  88. package/dist/engines/pi/service.d.ts +2 -2
  89. package/dist/engines/pi/service.js +1 -0
  90. package/dist/engines/pi/session-builder.d.ts +3 -2
  91. package/dist/engines/pi/session-builder.js +28 -58
  92. package/dist/engines/pi/session-control.js +14 -3
  93. package/dist/engines/pi/turn-kit.js +11 -5
  94. package/dist/open-url.d.ts +15 -1
  95. package/dist/open-url.js +35 -3
  96. package/dist/paths.d.ts +4 -2
  97. package/dist/paths.js +6 -2
  98. package/dist/pi.d.ts +5 -3
  99. package/dist/pi.js +3 -2
  100. package/dist/service.d.ts +5 -8
  101. package/dist/session-remote.js +21 -0
  102. package/dist/session.d.ts +39 -6
  103. package/package.json +2 -1
@@ -5,7 +5,7 @@ import { MAX_WEBHOOK_BODY_BYTES } from "../../channels/agentcore-limits.js";
5
5
  import { SECRETS_DIRNAME } from "../../paths.js";
6
6
  import { webhookKinds, webhookRunbook } from "../channel-ingress.js";
7
7
  import { containerArtifacts } from "../container.js";
8
- import { isEnvKey } from "../secrets.js";
8
+ import { deploymentLoginCommand } from "../box-shell.js";
9
9
  function agentcoreTopology(input) {
10
10
  return { webhooks: input.channels.some((channel) => channel.ingress === "webhook") };
11
11
  }
@@ -33,15 +33,13 @@ export function deploymentBucketName(name, account) {
33
33
  return `fa-${name}-${account}`;
34
34
  }
35
35
  /**
36
- * AgentCore env values max 2048 chars — a real OAuth auth.json's base64 exceeds it, so each carrier is CHUNKED across
37
- * `<NAME>` + `_2`… (collectChunked reassembles at boot): FASTAGENT_AUTH_SEED for the credential, FASTAGENT_ENV for the
38
- * value file's variables.
36
+ * AgentCore env values max 2048 chars — a value file's variables easily exceed it, so the carrier is CHUNKED across
37
+ * `<NAME>` + `_2`… (collectChunked reassembles at boot).
39
38
  */
40
39
  export const CARRIER_CHUNK_SIZE = 2000;
41
40
  export const CARRIER_MAX_CHUNKS = 4;
42
- /** The template's two chunked carriers: CloudFormation parameter prefix → container env-var name. */
41
+ /** The template's chunked carrier: CloudFormation parameter prefix → container env-var name. */
43
42
  export const CARRIERS = [
44
- { param: "FastagentAuthSeed", env: "FASTAGENT_AUTH_SEED", what: "base64 auth.json" },
45
43
  { param: "FastagentEnv", env: "FASTAGENT_ENV", what: "base64 JSON of the value file's variables" },
46
44
  ];
47
45
  /** Chunk `i` (0-based) of a carrier: its parameter and env-var names. */
@@ -216,7 +214,7 @@ function template(input, translated, topology) {
216
214
  ` FASTAGENT_STATE_DIR: ${MOUNT}/.state`,
217
215
  ` FASTAGENT_SECRETS_DIR: ${SECRETS_DIR}`,
218
216
  ];
219
- // Both carriers are chunked (env values max 2048 chars — see CARRIER_CHUNK_SIZE): N parameters, each riding its
217
+ // The carrier is chunked (env values max 2048 chars — see CARRIER_CHUNK_SIZE): N parameters, each riding its
220
218
  // own env var; `start` reassembles them. ONE parameter set for every variable, so the template does not change
221
219
  // when the value file gains a name.
222
220
  for (const carrier of CARRIERS) {
@@ -381,12 +379,8 @@ export function planAgentcoreDeploy(input) {
381
379
  const wakeSecretHint = " FastagentWakeSecret=<any random string>";
382
380
  runbook.push(`# FastagentWakeSecret: the wake-alarm shared secret — any random string (\`--run\` mints one)`);
383
381
  runbook.push(`aws cloudformation deploy --stack-name ${stack} --template-file ${prefix}${TEMPLATE_FILE} \\`, ` --capabilities CAPABILITY_IAM \\`, ` --parameter-overrides ImageUri=${image} ForwarderBucket=${bucketHint} ForwarderS3Key=forwarder/<hash>.zip${secrets.length > 0 ? " FastagentEnv=<base64 JSON>" : ""}${wakeSecretHint}`, ``, `# 4. Read the outputs (the runtime ARN + callback URL; it serves webhooks only when configured):`, `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`, `# The ingress transport is a separate Lambda and therefore a separate log source:`, `fastagent logs agentcore --source forwarder --follow`, ``, `# 6. Set a log retention period on EVERY group this stack writes to. Each is created by the service that`, `# writes it, not by this template, and CloudWatch keeps log data indefinitely by default — which is`, `# forever-billed storage, and the logs are where WHY a scheduled turn failed is recorded. Logs are the`, `# ONE state path this host does not reclaim on its own.`, `aws logs put-retention-policy --log-group-name <the group \`fastagent logs agentcore\` resolves> --retention-in-days 14`, `# The forwarder is a separate Lambda, so its group is separate and defaults the same way:`, `aws logs put-retention-policy --log-group-name ${forwarderLogGroup(input.name)} --retention-in-days 14`);
384
- // Model-auth guidance mirrors the other hosts: an env key became a parameter above; OAuth/stored can't be read at
385
- // plan time — `--run` carries it as FastagentAuthSeed.
386
- if (!isEnvKey(input.modelAuth)) {
387
- runbook.push(``, input.modelAuth === undefined
388
- ? `# Model auth: none found at the local auth path — set FASTAGENT_AUTH_PATH, or \`--run\` carries it`
389
- : `# 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.`);
382
+ if (input.boxLogin) {
383
+ runbook.push(``, `# Model auth: after EVERY deploy and after 14 idle days (each storage reset wipes the last login),`, `# log the runtime in to ${input.boxLogin} from this workspace — before pointing any webhook at it:`, `${deploymentLoginCommand("agentcore", input.boxLogin)}`);
390
384
  }
391
385
  // Post-deploy webhook registration — the shared channel-ingress steps, pointed at the forwarder's Function URL
392
386
  // (read from the stack outputs).
@@ -403,6 +397,6 @@ export function planAgentcoreDeploy(input) {
403
397
  }
404
398
  runbook.push(``, `# Wake-ups: the agent's own follow-up turns (the wake tool) 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. The forwarder supplies the callback URL; use the deployment's`, `# fixed runtime session id for programmatic calls too.`);
405
399
  runbook.push(``, `# Invoke the agent programmatically (reuse the deployment session; conversations remain separate):`, `aws bedrock-agentcore invoke-agent-runtime --agent-runtime-arn <RuntimeArn> \\`, ` --runtime-session-id "${ingressSessionId(name)}" \\`, ` --payload '{"kind":"invoke","session":"cli","text":"hello"}' --cli-binary-format raw-in-base64-out /dev/stdout`);
406
- runbook.push(``, `# Stop the fixed runtime session after redeploy so the next call uses the new image.`, `# \`--run\` does this automatically. In-flight turns are interrupted.`, `aws bedrock-agentcore stop-runtime-session --agent-runtime-arn <RuntimeArn> \\`, ` --runtime-session-id "${ingressSessionId(name)}"`, ``, `# Redeploy = \`fastagent deploy agentcore\` (a new definition needs a fresh release manifest) + step 1b`, `# (new forwarder key, if its code changed) + step 2 with a NEW tag + step 3.`, `# STATE: ${MOUNT} is managed SessionStorage. It keeps base/ (including unfinished work), .state/`, `# and .secrets/ across compute stop/resume — an idle-reclaimed agent resumes with its memory. AWS`, `# RESETS it on every runtime version update (i.e. every deploy) and after 14 idle days, so a deploy`, `# replaces the state along with the image: sessions, channel state and pending wake-ups start blank,`, `# and the model credential is re-seeded from FASTAGENT_AUTH_SEED. Deploying IS re-authenticating.`, `# An OAuth refresh token is single-use and shared with your machine, so the box can lose model`, `# access between deploys — deploy again to refresh it, or use a provider API key.`, `# Cross-deploy memory needs EFS or S3 Files, which are VPC-only (a NAT gateway for model/channel`, `# egress, ~$33/mo standing): use \`deploy fly\` or \`deploy railway\` for a real volume instead.`, `# Keep one runtime writer per workspace; use the fixed runtime session id printed above for every entry point.`);
400
+ runbook.push(``, `# Stop the fixed runtime session after redeploy so the next call uses the new image.`, `# \`--run\` does this automatically. In-flight turns are interrupted.`, `aws bedrock-agentcore stop-runtime-session --agent-runtime-arn <RuntimeArn> \\`, ` --runtime-session-id "${ingressSessionId(name)}"`, ``, `# Redeploy = \`fastagent deploy agentcore\` (a new definition needs a fresh release manifest) + step 1b`, `# (new forwarder key, if its code changed) + step 2 with a NEW tag + step 3.`, `# STATE: ${MOUNT} is managed SessionStorage. It keeps base/ (including unfinished work), .state/`, `# and .secrets/ across compute stop/resume — an idle-reclaimed agent resumes with its memory. AWS`, `# RESETS it on every runtime version update (i.e. every deploy) and after 14 idle days, so a deploy`, `# replaces the state along with the image: sessions, channel state and pending wake-ups start blank.`, `# A provider API key in the value file travels with every deploy; a login on the runtime survives neither reset.`, `# Cross-deploy memory needs EFS or S3 Files, which are VPC-only (a NAT gateway for model/channel`, `# egress, ~$33/mo standing): use \`deploy fly\` or \`deploy railway\` for a real volume instead.`, `# Keep one runtime writer per workspace; use the fixed runtime session id printed above for every entry point.`);
407
401
  return { artifacts, runbook, untranslatableSchedules: untranslatable, topology };
408
402
  }
@@ -1,3 +1,5 @@
1
+ /** `fastagent deploy agentcore --run` — drive the AWS CLI + Docker to completion. */
2
+ import type { BoxLoginStep } from "../box-shell.ts";
1
3
  import type { DeclaredChannel } from "../../channels/discover.ts";
2
4
  import { type Registrars } from "../channel-ingress.ts";
3
5
  import type { CliRunner } from "../runner.ts";
@@ -16,7 +18,7 @@ export interface AgentcoreRunPlan {
16
18
  * region`.
17
19
  */
18
20
  region?: string;
19
- /** Env-var name → value: the value file's carried variables, plus FASTAGENT_AUTH_SEED for a file credential. */
21
+ /** Env-var name → value: the value file's carried variables. */
20
22
  secrets: Record<string, string>;
21
23
  /** Declared names the value file supplies no value for — the run gates on these before any side effect. */
22
24
  missingSecrets: string[];
@@ -29,6 +31,11 @@ export interface AgentcoreRunPlan {
29
31
  * forwarder (and its artifact bucket parameters) exists.
30
32
  */
31
33
  topology: AgentcoreTopology;
34
+ /**
35
+ * Log the runtime in (`fastagent login --deployment`) once the probe verified it and before any webhook points at
36
+ * it. Resolves a gate line, or undefined.
37
+ */
38
+ boxLogin?: BoxLoginStep<[runtimeArn: string]>;
32
39
  }
33
40
  export type AgentcoreRunOutcome = {
34
41
  ok: true;
@@ -1,6 +1,5 @@
1
- /** `fastagent deploy agentcore --run` — drive the AWS CLI + Docker to completion. */
2
1
  import { RESERVED_PATHS } from "../../channels/agentcore-protocol.js";
3
- import { registerWebhooks } from "../channel-ingress.js";
2
+ import { loginGate, registerWebhooks } from "../channel-ingress.js";
4
3
  import { awsCli, awsJson } from "./aws-cli.js";
5
4
  import { createHash } from "node:crypto";
6
5
  import { Buffer } from "node:buffer";
@@ -97,11 +96,10 @@ const MINTED_PARAMS = {
97
96
  FASTAGENT_INGRESS_SECRET: "FastagentIngressSecret",
98
97
  FASTAGENT_WAKE_SECRET: "FastagentWakeSecret",
99
98
  };
100
- /** What each carrier holds for this deploy: the credential seed, and every other variable as one encoded object. */
99
+ /** What each carrier holds for this deploy: every variable but the minted ones, as one encoded object. */
101
100
  function carrierValues(secrets) {
102
- const { FASTAGENT_AUTH_SEED: seed = "", ...rest } = secrets;
103
- const carried = Object.fromEntries(Object.entries(rest).filter(([name]) => !(name in MINTED_PARAMS)));
104
- return { FASTAGENT_AUTH_SEED: seed, FASTAGENT_ENV: encodeCarriedEnv(carried) };
101
+ const carried = Object.fromEntries(Object.entries(secrets).filter(([name]) => !(name in MINTED_PARAMS)));
102
+ return { FASTAGENT_ENV: encodeCarriedEnv(carried) };
105
103
  }
106
104
  /** The `--parameter-overrides file://` payload: a JSON array of "Key=Value" strings. */
107
105
  export function paramsFileContent(imageUri, secrets, forwarder) {
@@ -170,10 +168,6 @@ probe = {}) {
170
168
  // 3b.
171
169
  const capacity = CARRIER_CHUNK_SIZE * CARRIER_MAX_CHUNKS;
172
170
  const carried = carrierValues(plan.secrets);
173
- if (carried.FASTAGENT_AUTH_SEED.length > capacity) {
174
- return gate(`your auth.json is too large to carry (${carried.FASTAGENT_AUTH_SEED.length} chars base64 > ${capacity}) — ` +
175
- `slim it (keep only the model's credential), or set a provider API key in .env instead`);
176
- }
177
171
  if (carried.FASTAGENT_ENV.length > capacity) {
178
172
  return gate(`the variables in ${plan.valueFile} are too large to carry (${carried.FASTAGENT_ENV.length} chars encoded > ` +
179
173
  `${capacity}) — move what the deployment does not need out of that file`);
@@ -224,6 +218,8 @@ probe = {}) {
224
218
  ], (stdout) => stdout.trim() || undefined);
225
219
  const stackStatus = await readStackStatus();
226
220
  const status = "ok" in stackStatus ? stackStatus.ok : "";
221
+ // A stack that held state: an earlier deploy served from it, and may have registered webhooks at its forwarder.
222
+ const redeploy = status !== "" && !EMPTY_STACK_STATUSES.has(status);
227
223
  // The only answers the build can invalidate: one still in flight (a first create rolling back is exactly what step 7
228
224
  // exists for, and minutes of arm64 build are long enough for it to settle), and one we never got.
229
225
  const settling = status.endsWith("_IN_PROGRESS") || "unreadable" in stackStatus;
@@ -233,11 +229,9 @@ probe = {}) {
233
229
  log(`warn: could not read stack ${stack} (${stackStatus.unreadable}) — if it exists, this deploy resets its ` +
234
230
  `managed SessionStorage (${MOUNT}) and a failed first create will not be cleared`);
235
231
  }
236
- else if (status !== "" && !EMPTY_STACK_STATUSES.has(status)) {
232
+ else if (redeploy) {
237
233
  log(`warn: this is a REDEPLOY and AWS resets managed SessionStorage (${MOUNT}) on every runtime version update — ` +
238
- `sessions, channel state and pending wake-ups start blank` +
239
- // Only the carried auth.json is re-seeded; a provider API key deployment has no such step to blame.
240
- `${carried.FASTAGENT_AUTH_SEED ? ", and the model credential is re-seeded from FASTAGENT_AUTH_SEED" : ""}. ` +
234
+ `sessions, channel state and pending wake-ups start blank. ` +
241
235
  `Cross-deploy memory needs a real volume: \`deploy fly\` or \`deploy railway\`.`);
242
236
  }
243
237
  // 4.
@@ -394,6 +388,18 @@ probe = {}) {
394
388
  return gate(verdict.gate);
395
389
  log("runtime verified (workspace ready, channels constructed)");
396
390
  }
391
+ const notLoggedIn = await plan.boxLogin?.run(runtimeArn);
392
+ if (notLoggedIn) {
393
+ return gate(loginGate({
394
+ notLoggedIn,
395
+ channels: plan.channels,
396
+ log,
397
+ // The forwarder URL outlives a redeploy, so the webhooks an earlier deploy registered already point at it.
398
+ ...(redeploy
399
+ ? { afterLogin: "they work again: the forwarder URL is unchanged, so nothing needs pointing" }
400
+ : { baseUrl: url, afterLogin: "point the channels by hand (a redeploy would wipe that login again)" }),
401
+ }));
402
+ }
397
403
  // 9.
398
404
  const registrationGateMsg = url
399
405
  ? await registerWebhooks({
@@ -0,0 +1,25 @@
1
+ import type { BoxChannel, BoxShell } from "../box-shell.ts";
2
+ import type { CliRunner } from "../runner.ts";
3
+ export interface AwsCredentials {
4
+ accessKeyId: string;
5
+ secretAccessKey: string;
6
+ sessionToken?: string;
7
+ }
8
+ /** A presigned `wss://` URL for one shell on one runtime session: the SDK's `connectShellPresigned`. */
9
+ export declare function presignShellUrl(input: {
10
+ runtimeArn: string;
11
+ sessionId: string;
12
+ shellId: string;
13
+ credentials: AwsCredentials;
14
+ now?: Date;
15
+ }): Promise<string>;
16
+ /** What a status frame says: `undefined` for the connection confirmation, else how the shell exited. */
17
+ export declare function shellExit(status: string): string | undefined;
18
+ /** Open `command` in a new shell on `url`: stdin/stdout as streams, the other channels folded in. */
19
+ export declare function openShellChannel(url: string, command: string): BoxChannel;
20
+ /**
21
+ * The shell `login --deployment` opens on an AgentCore runtime, on the session its server runs in. `wake` first sends
22
+ * that session an IAM `probe`: the runtime prepares its workspace only on an invocation, and after a deploy or an idle
23
+ * reset nothing may have invoked it yet, which would leave the login no agent directory to run in.
24
+ */
25
+ export declare function agentcoreShell(runtimeArn: string, sessionId: string, aws: CliRunner): BoxShell;
@@ -0,0 +1,202 @@
1
+ /**
2
+ * A shell into the AgentCore runtime, for `fastagent login --deployment agentcore`: `InvokeAgentRuntimeCommandShell`,
3
+ * a WebSocket to an interactive shell inside the running session. Opened on the fixed ingress session, so the shell
4
+ * sees the same `/mnt/data` the server does.
5
+ *
6
+ * Authenticated with the same AWS credentials `deploy agentcore` uses, read from the AWS CLI
7
+ * (`aws configure export-credentials`), so profiles, SSO and `aws login` all work with nothing else installed. The
8
+ * URL is presigned (SigV4 in the query string, the SDK's `connectShellPresigned`), which needs no custom upgrade
9
+ * header.
10
+ *
11
+ * The wire (the TypeScript SDK's `shell/protocol.ts`, the Kubernetes v5 channel protocol): every binary frame is one
12
+ * channel byte then payload — 0 stdin, 1 stdout, 2 stderr, 3 status JSON, 5 heartbeat, 0xFF close. The shell is a
13
+ * terminal, so the command switches it to raw, no echo (answers are not echoed back, and a long line is not cut by
14
+ * the terminal's line editor) before handing it to `exec`.
15
+ */
16
+ import { createHash, createHmac, randomUUID } from "node:crypto";
17
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { PassThrough, Writable } from "node:stream";
21
+ import { SignatureV4 } from "@smithy/signature-v4";
22
+ import { WebSocket, ping } from "undici";
23
+ import { awsCli, awsJson } from "./aws-cli.js";
24
+ import { MOUNT } from "./plan.js";
25
+ const STDIN = 0x00;
26
+ const STDOUT = 0x01;
27
+ const STDERR = 0x02;
28
+ const STATUS = 0x03;
29
+ const HEARTBEAT = 0x05;
30
+ /** The service closes a connection whose frame is larger (code 1009). */
31
+ const MAX_PAYLOAD = 64 * 1024 - 1;
32
+ /** The SDK's interval; a connection with no ping for ~60s is dropped by the service. */
33
+ const KEEPALIVE_MS = 30_000;
34
+ /** The longest a presigned shell URL may live (the SDK refuses more). */
35
+ const PRESIGN_SECONDS = 300;
36
+ /** The AWS CLI's own credentials, the same ones every `deploy agentcore` call runs with. */
37
+ async function awsCliCredentials(aws) {
38
+ const read = await awsCli(aws).read(["configure", "export-credentials", "--format", "process"], awsJson((parsed) => {
39
+ const p = parsed;
40
+ if (typeof p?.AccessKeyId !== "string" || typeof p.SecretAccessKey !== "string")
41
+ return undefined;
42
+ return {
43
+ accessKeyId: p.AccessKeyId,
44
+ secretAccessKey: p.SecretAccessKey,
45
+ ...(typeof p.SessionToken === "string" ? { sessionToken: p.SessionToken } : {}),
46
+ };
47
+ }));
48
+ if ("ok" in read)
49
+ return read.ok;
50
+ throw new Error(`could not read AWS credentials from the AWS CLI (${"absent" in read ? "none configured" : read.unreadable})`);
51
+ }
52
+ /** SHA-256 / HMAC-SHA-256 over node:crypto, in the shape SigV4's signer takes. */
53
+ class Sha256 {
54
+ hash;
55
+ constructor(secret) {
56
+ const key = secret instanceof ArrayBuffer ? Buffer.from(secret) : secret;
57
+ this.hash = key === undefined ? createHash("sha256") : createHmac("sha256", key);
58
+ }
59
+ update(data) {
60
+ this.hash.update(data instanceof ArrayBuffer ? Buffer.from(data) : data);
61
+ }
62
+ async digest() {
63
+ return new Uint8Array(this.hash.digest());
64
+ }
65
+ }
66
+ /** The runtime's region, from its ARN (`arn:<partition>:bedrock-agentcore:<region>:<account>:runtime/<id>`). */
67
+ function regionOf(runtimeArn) {
68
+ const region = runtimeArn.split(":")[3];
69
+ if (!region)
70
+ throw new Error(`not an AgentCore runtime ARN: ${runtimeArn}`);
71
+ return region;
72
+ }
73
+ /** A presigned `wss://` URL for one shell on one runtime session: the SDK's `connectShellPresigned`. */
74
+ export async function presignShellUrl(input) {
75
+ const region = regionOf(input.runtimeArn);
76
+ const hostname = `bedrock-agentcore.${region}.amazonaws.com${region.startsWith("cn-") ? ".cn" : ""}`;
77
+ const url = new URL(`https://${hostname}/runtimes/${encodeURIComponent(input.runtimeArn)}/ws/shells`);
78
+ url.searchParams.set("shellId", input.shellId);
79
+ url.searchParams.set("X-Amzn-Bedrock-AgentCore-Runtime-Session-Id", input.sessionId);
80
+ const signed = await new SignatureV4({
81
+ credentials: input.credentials,
82
+ region,
83
+ service: "bedrock-agentcore",
84
+ sha256: Sha256,
85
+ }).presign({
86
+ method: "GET",
87
+ protocol: "https:",
88
+ hostname,
89
+ path: url.pathname,
90
+ query: Object.fromEntries(url.searchParams),
91
+ headers: { host: hostname },
92
+ }, { expiresIn: PRESIGN_SECONDS, ...(input.now ? { signingDate: input.now } : {}) });
93
+ const query = Object.entries(signed.query ?? {})
94
+ .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`)
95
+ .join("&");
96
+ return `wss://${hostname}${signed.path}?${query}`;
97
+ }
98
+ /** What a status frame says: `undefined` for the connection confirmation, else how the shell exited. */
99
+ export function shellExit(status) {
100
+ const s = JSON.parse(status);
101
+ if (s.metadata?.shellId)
102
+ return undefined;
103
+ return s.status === "Success" ? "exit 0" : `exit ${s.details?.causes?.[0]?.message ?? "(unknown)"}`;
104
+ }
105
+ /** Open `command` in a new shell on `url`: stdin/stdout as streams, the other channels folded in. */
106
+ export function openShellChannel(url, command) {
107
+ const output = new PassThrough();
108
+ const ws = new WebSocket(url);
109
+ ws.binaryType = "arraybuffer";
110
+ const frame = (channel, payload = Buffer.alloc(0)) => ws.send(Buffer.concat([Buffer.from([channel]), payload]));
111
+ const stdin = (bytes) => {
112
+ for (let at = 0; at < bytes.length; at += MAX_PAYLOAD)
113
+ frame(STDIN, bytes.subarray(at, at + MAX_PAYLOAD));
114
+ };
115
+ let exit;
116
+ let failure;
117
+ let keepalive;
118
+ const opened = new Promise((resolve) => {
119
+ ws.addEventListener("open", () => resolve(true), { once: true });
120
+ ws.addEventListener("close", () => resolve(false), { once: true });
121
+ });
122
+ ws.addEventListener("open", () => {
123
+ stdin(Buffer.from(`stty raw -echo 2>/dev/null; exec sh -c '${command}'\n`));
124
+ keepalive = setInterval(() => {
125
+ ping(ws);
126
+ frame(HEARTBEAT);
127
+ }, KEEPALIVE_MS);
128
+ });
129
+ ws.addEventListener("message", (event) => {
130
+ const data = Buffer.from(event.data);
131
+ const payload = data.subarray(1);
132
+ if (data[0] === STDOUT)
133
+ output.write(payload);
134
+ else if (data[0] === STDERR)
135
+ process.stderr.write(payload);
136
+ else if (data[0] === STATUS)
137
+ exit = shellExit(payload.toString("utf8")) ?? exit;
138
+ });
139
+ ws.addEventListener("error", (event) => {
140
+ failure = event.message || "connection failed";
141
+ });
142
+ const closed = new Promise((resolve) => {
143
+ ws.addEventListener("close", async (event) => {
144
+ clearInterval(keepalive);
145
+ output.end();
146
+ if (!(await opened)) {
147
+ resolve({
148
+ opened: false,
149
+ error: `could not open a shell on the AgentCore runtime (${failure ?? `closed ${event.code}`}) — it needs ` +
150
+ `bedrock-agentcore:InvokeAgentRuntimeCommandShell, and a runtime created or redeployed after 2026-06-05`,
151
+ });
152
+ return;
153
+ }
154
+ resolve({ opened: true, how: exit ?? `WebSocket closed ${event.code}${event.reason ? ` ${event.reason}` : ""}` });
155
+ });
156
+ });
157
+ const input = new Writable({
158
+ write(chunk, _encoding, done) {
159
+ void opened.then((open) => {
160
+ if (open && ws.readyState === WebSocket.OPEN)
161
+ stdin(chunk);
162
+ done();
163
+ });
164
+ },
165
+ });
166
+ return { output, input, closed };
167
+ }
168
+ /**
169
+ * The shell `login --deployment` opens on an AgentCore runtime, on the session its server runs in. `wake` first sends
170
+ * that session an IAM `probe`: the runtime prepares its workspace only on an invocation, and after a deploy or an idle
171
+ * reset nothing may have invoked it yet, which would leave the login no agent directory to run in.
172
+ */
173
+ export function agentcoreShell(runtimeArn, sessionId, aws) {
174
+ return {
175
+ storage: MOUNT,
176
+ async wake() {
177
+ const dir = await mkdtemp(join(tmpdir(), "fastagent-agentcore-wake-"));
178
+ try {
179
+ const reply = join(dir, "reply.json");
180
+ const args = ["bedrock-agentcore", "invoke-agent-runtime", "--agent-runtime-arn", runtimeArn];
181
+ args.push("--runtime-session-id", sessionId, "--payload", '{"kind":"probe"}');
182
+ args.push("--cli-binary-format", "raw-in-base64-out", reply);
183
+ const sent = await awsCli(aws).present(args);
184
+ if (!("ok" in sent)) {
185
+ const why = "absent" in sent ? "the runtime is not there" : sent.unreadable;
186
+ throw new Error(`could not open the AgentCore runtime's workspace (${why})`);
187
+ }
188
+ const answer = await readFile(reply, "utf8");
189
+ if (!/"ok"\s*:\s*true/.test(answer)) {
190
+ throw new Error(`the AgentCore runtime did not open its workspace: ${answer.trim().slice(0, 300)}`);
191
+ }
192
+ }
193
+ finally {
194
+ await rm(dir, { recursive: true, force: true });
195
+ }
196
+ },
197
+ async open(command) {
198
+ const credentials = await awsCliCredentials(aws);
199
+ return openShellChannel(await presignShellUrl({ runtimeArn, sessionId, shellId: randomUUID(), credentials }), command);
200
+ },
201
+ };
202
+ }
@@ -0,0 +1,43 @@
1
+ import type { Readable, Writable } from "node:stream";
2
+ /** One open session on the box, running one command. */
3
+ export interface BoxChannel {
4
+ /** What the command writes to its stdout. Ends when the session does. */
5
+ output: Readable;
6
+ /** The command's stdin. */
7
+ input: Writable;
8
+ /** How the session ended, or why it never opened. */
9
+ closed: Promise<{
10
+ opened: true;
11
+ how: string;
12
+ } | {
13
+ opened: false;
14
+ error: string;
15
+ }>;
16
+ }
17
+ export interface BoxShell {
18
+ /** Run `command` (one POSIX shell line) on the box. Its stderr goes to this process's stderr. */
19
+ open(command: string): Promise<BoxChannel>;
20
+ /** Make sure there is a running machine to open the shell on (a host that suspends idle machines). */
21
+ wake?(): Promise<void>;
22
+ /**
23
+ * The box's storage root, for a shell that does not carry the server's environment (AgentCore's). Unset: the
24
+ * shell sees `FASTAGENT_STORAGE_DIR` as the server does, or the image's default.
25
+ */
26
+ storage?: string;
27
+ }
28
+ /** A host CLI that runs a command on the box itself: `bin ...args(command)`, run from `cwd`. */
29
+ export declare function processShell(bin: string, args: (command: string) => string[], cwd: string): BoxShell;
30
+ /**
31
+ * The command that logs a deployment in, as every message and runbook names it: with the provider when it is known,
32
+ * because the model needs THAT one, and a menu invites another.
33
+ */
34
+ export declare function deploymentLoginCommand(host: string, provider?: string): string;
35
+ /**
36
+ * The login a host driver runs on the box it just deployed, before any webhook points at it. `run` resolves a gate line,
37
+ * or undefined once the box authenticates; `command` is how a person runs the same login, for the manual route.
38
+ * ONE value, so a driver cannot have the step without its command.
39
+ */
40
+ export interface BoxLoginStep<Args extends unknown[] = []> {
41
+ command: string;
42
+ run: (...args: Args) => Promise<string | undefined>;
43
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * A running box's owner-authenticated shell, as a byte channel: what `fastagent login --deployment` speaks through.
3
+ * Host-neutral: Docker, Fly and Railway open it by running their own CLI (`processShell`); AgentCore opens a
4
+ * WebSocket (`agentcore/shell.ts`). Whoever opens one only writes bytes in and reads bytes out.
5
+ */
6
+ import { spawn } from "node:child_process";
7
+ /** A host CLI that runs a command on the box itself: `bin ...args(command)`, run from `cwd`. */
8
+ export function processShell(bin, args, cwd) {
9
+ return {
10
+ async open(command) {
11
+ const child = spawn(bin, args(command), { cwd, stdio: ["pipe", "pipe", "inherit"] });
12
+ // The box may exit before reading an answer; its result (or its absence) is what gets reported, not the EPIPE.
13
+ child.stdin.on("error", () => { });
14
+ const closed = new Promise((resolve) => {
15
+ child.on("error", (error) => resolve({ opened: false, error: `could not run ${bin}: ${error.message}` }));
16
+ child.on("close", (code, signal) => resolve({ opened: true, how: signal ? `signal ${signal}` : `exit ${code}` }));
17
+ });
18
+ return { output: child.stdout, input: child.stdin, closed };
19
+ },
20
+ };
21
+ }
22
+ /**
23
+ * The command that logs a deployment in, as every message and runbook names it: with the provider when it is known,
24
+ * because the model needs THAT one, and a menu invites another.
25
+ */
26
+ export function deploymentLoginCommand(host, provider) {
27
+ return `fastagent login ${provider ? `${provider} ` : ""}--deployment ${host}`;
28
+ }
@@ -1,8 +1,3 @@
1
- /**
2
- * What a deploy's BUILD CONTEXT (the workspace) holds that must not ship, and whether an ignore file the author kept
3
- * still keeps it out. A kept workspace-root `.dockerignore` silently replaces the generated one's protections, so the
4
- * pre-flight asks it the same questions the generated one answers by construction.
5
- */
6
1
  import { readdir } from "node:fs/promises";
7
2
  import { basename, isAbsolute, join, relative, sep } from "node:path";
8
3
  import ignore from "ignore";
@@ -29,6 +24,20 @@ function dockerignoreMatcher(text) {
29
24
  const matcher = ignore({ ignorecase: false }).add(anchored);
30
25
  return (path) => matcher.ignores(path);
31
26
  }
27
+ /**
28
+ * A directory's entries, or none when it does not exist. Any other failure throws: the leak scan would otherwise pass
29
+ * on a directory it never read, and a credential inside it would ship without a word.
30
+ */
31
+ async function entriesIfExists(path) {
32
+ try {
33
+ return await readdir(path, { withFileTypes: true });
34
+ }
35
+ catch (error) {
36
+ if (error.code === "ENOENT")
37
+ return [];
38
+ throw new Error(`cannot read ${path} to check what the image would ship: ${error.message}`);
39
+ }
40
+ }
32
41
  export async function buildContextPaths(workspace, agentDir, agentPrefix, authPath) {
33
42
  const inContext = (p) => {
34
43
  const rel = relative(workspace, p);
@@ -55,7 +64,7 @@ export async function buildContextPaths(workspace, agentDir, agentPrefix, authPa
55
64
  const stateShips = stateRel !== undefined && (await exists(join(workspace, stateRel))) ? stateRel : undefined;
56
65
  // The `.env` family at the two levels fastagent is RESPONSIBLE for: the agent dir and the workspace root.
57
66
  const dotEnvFiles = async (relDir) => {
58
- const names = await readdir(join(workspace, relDir || ".")).catch(() => []);
67
+ const names = (await entriesIfExists(join(workspace, relDir || "."))).map((entry) => entry.name);
59
68
  // POSIX separators, like every other context-relative path here (`inContext`).
60
69
  return names
61
70
  .filter((n) => (n === ".env" || n.startsWith(".env.")) && n !== ".env.example")
@@ -65,7 +74,7 @@ export async function buildContextPaths(workspace, agentDir, agentPrefix, authPa
65
74
  // Everything ACTUALLY inside the secrets dir, minus the two tracked scaffolds the image ships on purpose (they
66
75
  // carry no values; the generated ignore re-includes them by name).
67
76
  const secretDirFiles = async (dirRel) => {
68
- const entries = await readdir(join(workspace, dirRel), { withFileTypes: true }).catch(() => []);
77
+ const entries = await entriesIfExists(join(workspace, dirRel));
69
78
  const files = [];
70
79
  for (const entry of entries) {
71
80
  if (entry.name === ".gitignore" || entry.name === ".env.example")
@@ -37,7 +37,8 @@ export type PublicHealthProbe = (healthUrl: string) => Promise<boolean>;
37
37
  * report success. The channels that DO verify (Slack/Feishu challenge) would fail here too, but as "registration
38
38
  * failed" — a diagnosis that hides the actual cause.
39
39
  *
40
- * Asked ONLY when this deployment actually has a webhook to point. A definition with none (schedules only, the
40
+ * Asked ONLY when this deployment actually has a webhook to point, or a login on the box follows (the box must
41
+ * have prepared its workspace and installed the CLI the login runs). A definition with neither (schedules only, the
41
42
  * built-in `POST /invoke`, or long-connection channels) needs no inbound reachability at all, and demanding it would
42
43
  * invent a failure for a deploy run from a network that cannot reach the platform's edge.
43
44
  */
@@ -48,7 +49,25 @@ export declare function publicHealthGate(input: {
48
49
  /** How THIS host is inspected and re-run — the only per-host words in the gate. */
49
50
  inspectHint: string;
50
51
  probe?: PublicHealthProbe;
52
+ /** The login that follows on the box (`fastagent login --deployment <host>`), when one does. */
53
+ login?: string;
51
54
  }): Promise<string | undefined>;
55
+ /**
56
+ * The gate when the login on the box did not happen. It stops the run BEFORE registration (a channel pointed at a box
57
+ * with no model credential fails every message), so the login it asks for does not register anything by itself: with
58
+ * a webhook to point, the refusal says what comes after the login, and prints the lines to do it by hand when the
59
+ * URL is known.
60
+ */
61
+ export declare function loginGate(input: {
62
+ /** What the login reported, ending with the command to run. */
63
+ notLoggedIn: string;
64
+ channels: readonly DeclaredChannel[];
65
+ log: (msg: string) => void;
66
+ /** What THIS host does about its webhooks once the box is logged in — the only per-host words in the gate. */
67
+ afterLogin: string;
68
+ /** Where the channels point; unset when the host has not minted it yet (Docker's tunnel is read after). */
69
+ baseUrl?: string;
70
+ }): string;
52
71
  /** {@link pointChannelsAt} plus the shared gate policy, for a command that EXITS. */
53
72
  export declare function registerWebhooks(input: {
54
73
  baseUrl: string;
@@ -85,24 +85,51 @@ const WAIT_NOTICE_MS = 15_000;
85
85
  * report success. The channels that DO verify (Slack/Feishu challenge) would fail here too, but as "registration
86
86
  * failed" — a diagnosis that hides the actual cause.
87
87
  *
88
- * Asked ONLY when this deployment actually has a webhook to point. A definition with none (schedules only, the
88
+ * Asked ONLY when this deployment actually has a webhook to point, or a login on the box follows (the box must
89
+ * have prepared its workspace and installed the CLI the login runs). A definition with neither (schedules only, the
89
90
  * built-in `POST /invoke`, or long-connection channels) needs no inbound reachability at all, and demanding it would
90
91
  * invent a failure for a deploy run from a network that cannot reach the platform's edge.
91
92
  */
92
93
  export async function publicHealthGate(input) {
93
- if (webhookKinds(input.channels).length === 0)
94
+ if (webhookKinds(input.channels).length === 0 && input.login === undefined)
94
95
  return undefined;
95
96
  const healthUrl = `${input.baseUrl}/health`;
96
97
  input.log(`waiting for ${healthUrl} (up to ${PUBLIC_HEALTH_TIMEOUT_MS / 1000}s)…`);
97
98
  const probe = input.probe ?? ((url) => waitForHealth(url, PUBLIC_HEALTH_TIMEOUT_MS, 500, announce(input.log)));
98
99
  if (await probe(healthUrl))
99
100
  return undefined;
100
- // The infrastructure is up and only the webhook is missing, so hand over the manual route rather than leaving
101
- // `re-run` as the only way out (a Fly re-run repeats the remote build).
102
- for (const line of webhookRunbook(input.baseUrl, input.channels))
101
+ // The infrastructure is up and only activation is missing, so hand over the manual route rather than leaving
102
+ // `re-run` as the only way out (a Fly re-run repeats the remote build). The login comes first: a channel pointed at
103
+ // a box with no model credential fails every message.
104
+ const steps = [
105
+ ...(input.login !== undefined ? [`# First, log the box in: ${input.login}`] : []),
106
+ ...webhookRunbook(input.baseUrl, input.channels),
107
+ ];
108
+ for (const line of steps)
109
+ input.log(line);
110
+ const byHand = webhookKinds(input.channels).length > 0
111
+ ? `To finish by hand once it answers, use the lines above.`
112
+ : `Once it answers, log it in: ${input.login}.`;
113
+ return (`the deployed agent did not become healthy at ${healthUrl}, so it was not activated — ${input.inspectHint}. ` +
114
+ byHand);
115
+ }
116
+ /**
117
+ * The gate when the login on the box did not happen. It stops the run BEFORE registration (a channel pointed at a box
118
+ * with no model credential fails every message), so the login it asks for does not register anything by itself: with
119
+ * a webhook to point, the refusal says what comes after the login, and prints the lines to do it by hand when the
120
+ * URL is known.
121
+ */
122
+ export function loginGate(input) {
123
+ if (webhookKinds(input.channels).length === 0)
124
+ return input.notLoggedIn;
125
+ const byHand = input.baseUrl === undefined ? [] : webhookRunbook(input.baseUrl, input.channels);
126
+ for (const line of byHand)
103
127
  input.log(line);
104
- return (`the deployed agent did not become healthy at ${healthUrl}, so no webhook was registered — ${input.inspectHint}. ` +
105
- `To point the channels by hand instead, use the lines above.`);
128
+ // "No webhook was registered" would be about THIS run only: one an earlier deploy registered still points here.
129
+ return (`${input.notLoggedIn}. This deploy registered no webhook, and logging in registers none; any an earlier deploy ` +
130
+ `registered still points here, and every message it brings fails until the box is logged in. Once it is, ` +
131
+ `${input.afterLogin}` +
132
+ (byHand.length > 0 ? `. The lines above point the channels by hand.` : ``));
106
133
  }
107
134
  /**
108
135
  * Keep the wait visible. This occupies `waitForHealth`'s liveness slot without answering it: neither Fly nor Railway
@@ -2,6 +2,20 @@ export interface Artifact {
2
2
  path: string;
3
3
  content: string;
4
4
  }
5
+ /**
6
+ * The shell command that runs `fastagent login --stdio <args>` inside a running generated image, as the server there
7
+ * would see it: in the deployed agent directory, with the storage and credential paths resolved by the same defaults
8
+ * `prepareStartWorkspace` applies. A host's own shell does not reliably hand those over — its session may not start
9
+ * in the server's directory, and the platform's variables are the only ones it inherits. `storage` names the root
10
+ * outright for a shell that inherits none of them. The CLI is the one the server runs: the agent's own install when
11
+ * it has one (under Bun when the image is Bun's), else the pinned global.
12
+ *
13
+ * That own install may predate `login --stdio` (an agent pinned to an older release, deployed by a newer CLI). It then
14
+ * refuses the flag as a usage error, exit 2, which a `login --stdio` that ran never gives (it answers 0 or 1 after its
15
+ * result line), so the command answers for it: one result line naming the mismatch, instead of a shell that ended
16
+ * with nothing to say. No quote or backtick in it: hosts wrap this whole line in `sh -c '…'`.
17
+ */
18
+ export declare function boxLoginCommand(agent: string, args: readonly string[], storage?: string): string;
5
19
  /** Fixed first-line prefix of a fastagent-generated Dockerfile. */
6
20
  export declare const GENERATED_DOCKERFILE_MARKER = "# Generated by `fastagent deploy`";
7
21
  /** The ONE predicate for "did fastagent generate this Dockerfile?". */