@fastagent-sh/fastagent 0.16.0 → 0.16.2
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 +1 -1
- package/dist/bind.d.ts +34 -0
- package/dist/bind.js +74 -0
- package/dist/channels/agentcore-state.js +21 -6
- package/dist/channels/control.js +3 -0
- package/dist/cli/commands/attach.d.ts +11 -1
- package/dist/cli/commands/attach.js +30 -2
- package/dist/cli/commands/deploy.d.ts +13 -0
- package/dist/cli/commands/deploy.js +18 -6
- package/dist/cli/commands/dev.d.ts +1 -0
- package/dist/cli/commands/dev.js +15 -4
- package/dist/cli/commands/info.js +1 -1
- package/dist/cli/commands/logs.d.ts +6 -0
- package/dist/cli/commands/logs.js +27 -0
- package/dist/cli/commands/start.d.ts +1 -0
- package/dist/cli/commands/start.js +11 -3
- package/dist/cli/commands/tool.js +10 -2
- package/dist/cli/program.js +35 -0
- package/dist/cli/serve.d.ts +26 -2
- package/dist/cli/serve.js +71 -17
- package/dist/cli/shared.d.ts +6 -0
- package/dist/cli/shared.js +14 -0
- package/dist/deploy/agentcore/logs.d.ts +30 -0
- package/dist/deploy/agentcore/logs.js +115 -0
- package/dist/deploy/agentcore/plan.d.ts +23 -0
- package/dist/deploy/agentcore/plan.js +41 -3
- package/dist/deploy/container.js +6 -3
- package/dist/deploy/preflight.js +18 -0
- package/dist/engines/pi/config.d.ts +7 -3
- package/dist/engines/pi/config.js +9 -3
- package/dist/engines/pi/create.d.ts +31 -18
- package/dist/engines/pi/create.js +46 -17
- package/dist/engines/pi/harness.d.ts +31 -33
- package/dist/engines/pi/harness.js +24 -76
- package/dist/engines/pi/open.d.ts +2 -2
- package/dist/engines/pi/open.js +2 -1
- package/dist/engines/pi/read-image.d.ts +4 -0
- package/dist/engines/pi/read-image.js +62 -0
- package/dist/engines/pi/search-tools.d.ts +6 -4
- package/dist/engines/pi/search-tools.js +3 -1
- package/dist/engines/pi/session-builder.js +7 -2
- package/dist/engines/pi/session-control.d.ts +12 -3
- package/dist/engines/pi/session-control.js +156 -27
- package/dist/engines/pi/session-settings.d.ts +51 -0
- package/dist/engines/pi/session-settings.js +73 -0
- package/dist/engines/pi/sessions.d.ts +21 -7
- package/dist/engines/pi/sessions.js +43 -0
- package/dist/engines/pi/tool.d.ts +13 -5
- package/dist/engines/pi/wake-tool.d.ts +3 -3
- package/dist/host/node.d.ts +2 -0
- package/dist/host/node.js +2 -1
- package/dist/pi.d.ts +1 -1
- package/dist/session.d.ts +34 -9
- package/package.json +4 -4
package/dist/cli/serve.d.ts
CHANGED
|
@@ -32,6 +32,7 @@ export declare function routesFor(agentDir: string, agent: Agent, stateRoot: str
|
|
|
32
32
|
export declare function mountSessionControl(routes: Routes, control: SessionControl | undefined, stateRoot: string, options?: {
|
|
33
33
|
tunnel?: boolean;
|
|
34
34
|
agent?: Agent;
|
|
35
|
+
host?: string;
|
|
35
36
|
}): {
|
|
36
37
|
routes: Routes;
|
|
37
38
|
announce: (boundPort: number) => void;
|
|
@@ -49,12 +50,35 @@ export declare function mountAgentcore(routes: Routes, options: {
|
|
|
49
50
|
schedules: LoadedSchedule[];
|
|
50
51
|
onStateReady?: () => void;
|
|
51
52
|
}): Routes;
|
|
53
|
+
/**
|
|
54
|
+
* Refuse `--tunnel` with a bind that cloudflared cannot reach: it dials the NAME `localhost:<port>`
|
|
55
|
+
* (the dev supervisor's tunnel too), so anything outside `127.0.0.1`/`::1`/wildcard — including a
|
|
56
|
+
* `127.0.0.2` bind, loopback though it is — would leave the tunnel up and 502ing every request.
|
|
57
|
+
* Checked BEFORE the bind (the flag alone pre-spawn in `dev`, again once config is loaded, since
|
|
58
|
+
* `http.host` can carry the address), so the failure is clean rather than a live-but-broken public URL.
|
|
59
|
+
* `source` decides the exit code, per fail.ts: a flag COMBINATION is a usage error (2), a value that
|
|
60
|
+
* came from config is broken runtime configuration (1).
|
|
61
|
+
*/
|
|
62
|
+
export declare function assertTunnelBindable(host: string | undefined, tunnel: boolean, source: "flag" | "config"): void;
|
|
63
|
+
/**
|
|
64
|
+
* The startup lines that name WHERE the serve is: the bind report, and the curl the reader copies.
|
|
65
|
+
* ONE function because they are one message — they were two, and `--bind` updated the first while the
|
|
66
|
+
* second went on dialing `localhost`, which is precisely what a non-wildcard bind stops answering. Now
|
|
67
|
+
* neither can be changed without the other in view, and the address has a single derivation.
|
|
68
|
+
*
|
|
69
|
+
* A wildcard bind is every interface, and naming one address there would understate it — but the curl
|
|
70
|
+
* still needs one to dial, which is what `clientHost` gives (loopback for a wildcard, itself otherwise).
|
|
71
|
+
*/
|
|
72
|
+
export declare function readyAddressLines(host: string | undefined, boundPort: number, builtinInvoke: boolean): string[];
|
|
52
73
|
/**
|
|
53
74
|
* Bind HTTP, open long-connection channels, and report ready only when both forms are usable. Each
|
|
54
75
|
* adapter owns reconnects; a terminal close rejects `closed` and fails the process visibly. Abort is
|
|
55
|
-
* the sole clean-shutdown command.
|
|
76
|
+
* the sole clean-shutdown command. `host` unset binds all interfaces.
|
|
56
77
|
*/
|
|
57
|
-
export declare function serve(surface: ServingSurface,
|
|
78
|
+
export declare function serve(surface: ServingSurface, bind: {
|
|
79
|
+
port: number;
|
|
80
|
+
host?: string;
|
|
81
|
+
}, onListening?: (boundPort: number) => void): void;
|
|
58
82
|
/** Start a Cloudflare tunnel for route channels only. */
|
|
59
83
|
export declare function maybeTunnel(agentDir: string, routeChannels: string[], boundPort: number, tunnel: boolean, stateRoot?: string): void;
|
|
60
84
|
/**
|
package/dist/cli/serve.js
CHANGED
|
@@ -12,13 +12,14 @@ import { INVOKE_EXAMPLE_BODY, createInvokeHandler } from "../channels/http.js";
|
|
|
12
12
|
import { text } from "../channels/respond.js";
|
|
13
13
|
import { loadChannels } from "../engines/pi/channel.js";
|
|
14
14
|
import { reportModuleLoadFailures } from "../engines/pi/report.js";
|
|
15
|
+
import { answersLocalhost, bindLabel, classifyBind, clientHost } from "../bind.js";
|
|
15
16
|
import { parseRouteKey, router, serveNode } from "../host/node.js";
|
|
16
17
|
import { log } from "../log.js";
|
|
17
18
|
import { openExternalUrl } from "../open-url.js";
|
|
18
19
|
import { loadSchedules } from "../schedule/discover.js";
|
|
19
20
|
import { createScheduler, fireScheduleOnce } from "../schedule/scheduler.js";
|
|
20
21
|
import { announceWebhooks, startCloudflareTunnel } from "../tunnel.js";
|
|
21
|
-
import { failStartup } from "./fail.js";
|
|
22
|
+
import { failStartup, failUsage } from "./fail.js";
|
|
22
23
|
/**
|
|
23
24
|
* The surface this deployment serves: default `GET /health` plus discovered channels, or the default
|
|
24
25
|
* POST `/invoke` only when neither a route nor a long-connection channel was declared.
|
|
@@ -90,15 +91,22 @@ export function mountSessionControl(routes, control, stateRoot, options = {}) {
|
|
|
90
91
|
// Atomic (tmp+rename, the state.ts pattern): attach re-reads this file exactly during the
|
|
91
92
|
// restart window — a torn read would be misdiagnosed as "serve gone".
|
|
92
93
|
const tmp = `${path}.tmp`;
|
|
93
|
-
writeFileSync(tmp, `${JSON.stringify({ url: `http
|
|
94
|
+
writeFileSync(tmp, `${JSON.stringify({ url: `http://${clientHost(options.host)}:${boundPort}`, token })}\n`, {
|
|
95
|
+
mode: 0o600,
|
|
96
|
+
});
|
|
94
97
|
chmodSync(tmp, 0o600); // an existing file keeps its old mode on rewrite — pin it
|
|
95
98
|
renameSync(tmp, path);
|
|
96
99
|
log.info(`[fastagent] session control on /control/* (token in ${path})`);
|
|
97
|
-
// The serve binds ALL interfaces (containers require it), so /control/* is
|
|
98
|
-
// with the bearer token as the only protection — the tunnel and deploy paths warn
|
|
99
|
-
// and the LAN path must not be the silent third way past the local trust story.
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
// The serve binds ALL interfaces by DEFAULT (containers require it), so /control/* is
|
|
101
|
+
// LAN-reachable with the bearer token as the only protection — the tunnel and deploy paths warn
|
|
102
|
+
// loudly, and the LAN path must not be the silent third way past the local trust story. A
|
|
103
|
+
// loopback bind closes exactly that reach, so it earns silence.
|
|
104
|
+
const bind = classifyBind(options.host);
|
|
105
|
+
if (bind !== "loopback") {
|
|
106
|
+
log.warn(`[fastagent] the port binds ${bind === "wildcard" ? "all interfaces" : `${options.host} (off this machine)`}: ` +
|
|
107
|
+
"/control/* is reachable on your LAN, protected only by the bearer token — bind loopback " +
|
|
108
|
+
"(--bind 127.0.0.1), firewall the port, or wrap it for real exposure (docs: design §14)");
|
|
109
|
+
}
|
|
102
110
|
if (options.tunnel) {
|
|
103
111
|
// Local trust = the token + its file permissions; --tunnel takes the whole port PUBLIC
|
|
104
112
|
// (beyond even the LAN reach the mount already warned about). The operator asked for the tunnel (webhooks), but must not DISCOVER the control
|
|
@@ -175,13 +183,55 @@ export function mountAgentcore(routes, options) {
|
|
|
175
183
|
}
|
|
176
184
|
return { ...routes, ...mounted };
|
|
177
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* Refuse `--tunnel` with a bind that cloudflared cannot reach: it dials the NAME `localhost:<port>`
|
|
188
|
+
* (the dev supervisor's tunnel too), so anything outside `127.0.0.1`/`::1`/wildcard — including a
|
|
189
|
+
* `127.0.0.2` bind, loopback though it is — would leave the tunnel up and 502ing every request.
|
|
190
|
+
* Checked BEFORE the bind (the flag alone pre-spawn in `dev`, again once config is loaded, since
|
|
191
|
+
* `http.host` can carry the address), so the failure is clean rather than a live-but-broken public URL.
|
|
192
|
+
* `source` decides the exit code, per fail.ts: a flag COMBINATION is a usage error (2), a value that
|
|
193
|
+
* came from config is broken runtime configuration (1).
|
|
194
|
+
*/
|
|
195
|
+
export function assertTunnelBindable(host, tunnel, source) {
|
|
196
|
+
if (!tunnel || answersLocalhost(host))
|
|
197
|
+
return;
|
|
198
|
+
// Name the source, not just the exit code: under `config` there is no `--bind` to change and no flag
|
|
199
|
+
// to drop, so flag-only wording would send the reader looking for something they never typed.
|
|
200
|
+
const fix = source === "flag"
|
|
201
|
+
? "bind 0.0.0.0 (or 127.0.0.1), or drop --tunnel"
|
|
202
|
+
: "set http.host to 0.0.0.0 (or 127.0.0.1) in fastagent.config.*, override it with --bind, or drop --tunnel";
|
|
203
|
+
const message = `--tunnel reaches the serve by dialing localhost, which the bind address ${host} does not answer — ${fix}`;
|
|
204
|
+
if (source === "flag")
|
|
205
|
+
failUsage(message);
|
|
206
|
+
failStartup(new Error(message));
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* The startup lines that name WHERE the serve is: the bind report, and the curl the reader copies.
|
|
210
|
+
* ONE function because they are one message — they were two, and `--bind` updated the first while the
|
|
211
|
+
* second went on dialing `localhost`, which is precisely what a non-wildcard bind stops answering. Now
|
|
212
|
+
* neither can be changed without the other in view, and the address has a single derivation.
|
|
213
|
+
*
|
|
214
|
+
* A wildcard bind is every interface, and naming one address there would understate it — but the curl
|
|
215
|
+
* still needs one to dial, which is what `clientHost` gives (loopback for a wildcard, itself otherwise).
|
|
216
|
+
*/
|
|
217
|
+
export function readyAddressLines(host, boundPort, builtinInvoke) {
|
|
218
|
+
const dial = `${clientHost(host)}:${boundPort}`;
|
|
219
|
+
const lines = [
|
|
220
|
+
`[fastagent] http host on ${classifyBind(host) === "wildcard" ? `:${boundPort} (all interfaces)` : bindLabel(host, boundPort)}`,
|
|
221
|
+
];
|
|
222
|
+
if (builtinInvoke) {
|
|
223
|
+
lines.push(`[fastagent] try it: curl -s ${dial}/invoke -X POST -H 'content-type: application/json' -d '${INVOKE_EXAMPLE_BODY}'`);
|
|
224
|
+
}
|
|
225
|
+
return lines;
|
|
226
|
+
}
|
|
178
227
|
/**
|
|
179
228
|
* Bind HTTP, open long-connection channels, and report ready only when both forms are usable. Each
|
|
180
229
|
* adapter owns reconnects; a terminal close rejects `closed` and fails the process visibly. Abort is
|
|
181
|
-
* the sole clean-shutdown command.
|
|
230
|
+
* the sole clean-shutdown command. `host` unset binds all interfaces.
|
|
182
231
|
*/
|
|
183
|
-
export function serve(surface,
|
|
184
|
-
const
|
|
232
|
+
export function serve(surface, bind, onListening) {
|
|
233
|
+
const { port, host } = bind;
|
|
234
|
+
const hosted = serveNode(router(surface.routes), { port, host });
|
|
185
235
|
const abort = new AbortController();
|
|
186
236
|
let stopping = false;
|
|
187
237
|
const stop = (exitCode) => {
|
|
@@ -236,14 +286,12 @@ export function serve(surface, port, onListening) {
|
|
|
236
286
|
port: boundPort,
|
|
237
287
|
routeChannels: surface.routeChannels,
|
|
238
288
|
});
|
|
239
|
-
|
|
289
|
+
for (const line of readyAddressLines(host, boundPort, surface.builtinInvoke))
|
|
290
|
+
log.info(line);
|
|
240
291
|
log.info(`[fastagent] routes: ${Object.keys(surface.routes).join(", ") || "(none)"}`);
|
|
241
292
|
if (surface.longConnections.length > 0) {
|
|
242
293
|
log.info(`[fastagent] long connections: ${surface.longConnections.map((connection) => connection.name).join(", ")}`);
|
|
243
294
|
}
|
|
244
|
-
if (surface.builtinInvoke) {
|
|
245
|
-
log.info(`[fastagent] try it: curl -s localhost:${boundPort}/invoke -X POST -H 'content-type: application/json' -d '${INVOKE_EXAMPLE_BODY}'`);
|
|
246
|
-
}
|
|
247
295
|
onListening?.(boundPort);
|
|
248
296
|
}
|
|
249
297
|
catch (error) {
|
|
@@ -254,9 +302,15 @@ export function serve(surface, port, onListening) {
|
|
|
254
302
|
failStartup(error);
|
|
255
303
|
}
|
|
256
304
|
}, (error) => {
|
|
257
|
-
if (error.code === "EADDRINUSE")
|
|
258
|
-
|
|
259
|
-
|
|
305
|
+
if (error.code === "EADDRINUSE") {
|
|
306
|
+
// With a bind address the port is only taken ON THAT interface, so moving the bind is as valid
|
|
307
|
+
// a fix as moving the port.
|
|
308
|
+
failStartup(new Error(`${bindLabel(host, port)} is already in use; choose another with ` +
|
|
309
|
+
`--port${classifyBind(host) === "wildcard" ? "" : " or --bind"}`));
|
|
310
|
+
}
|
|
311
|
+
// Through `bindLabel`, like every other message about a bind: hand-concatenating gives `:::8787`
|
|
312
|
+
// for an IPv6 bind and a bare `:8787` for the wildcard, which reads as an explicit bind of nothing.
|
|
313
|
+
failStartup(new Error(`cannot bind http channel on ${bindLabel(host, port)}: ${error.message}`));
|
|
260
314
|
});
|
|
261
315
|
}
|
|
262
316
|
/** Start a Cloudflare tunnel for route channels only. */
|
package/dist/cli/shared.d.ts
CHANGED
|
@@ -20,6 +20,12 @@ export declare function isInteractive(): boolean;
|
|
|
20
20
|
* bad `PORT` env is broken runtime configuration (1).
|
|
21
21
|
*/
|
|
22
22
|
export declare function parsePort(value: string | undefined, source: string, from: "flag" | "env"): number | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Parse a `--bind` address: empty/whitespace is "not set" → undefined (the `??` chain falls through to
|
|
25
|
+
* config, then all interfaces). An unbindable string is a USAGE error (2) — caught here rather than as
|
|
26
|
+
* a node bind failure, or worse, as a "the interface you bound" diagnostic downstream.
|
|
27
|
+
*/
|
|
28
|
+
export declare function parseBind(value: string | undefined): string | undefined;
|
|
23
29
|
/** Report which source provides the model's credentials, surfacing a remediation hint at startup. Non-blocking. */
|
|
24
30
|
export declare function reportAuth(modelSpec: string, authPath: string): Promise<void>;
|
|
25
31
|
/**
|
package/dist/cli/shared.js
CHANGED
|
@@ -14,6 +14,7 @@ import { createPiModels, probeApiKey, probeAuthSource, providerAuthStatuses } fr
|
|
|
14
14
|
import { formatAuthReport } from "./auth-view.js";
|
|
15
15
|
import { log } from "../log.js";
|
|
16
16
|
import { openExternalUrl } from "../open-url.js";
|
|
17
|
+
import { bindAddress, isBindAddress } from "../bind.js";
|
|
17
18
|
import { failStartup, failUsage } from "./fail.js";
|
|
18
19
|
/**
|
|
19
20
|
* The padded label writer for the STARTUP report (`dev`/`start`, stderr via the log level). Hand-spaced
|
|
@@ -56,6 +57,19 @@ export function parsePort(value, source, from) {
|
|
|
56
57
|
}
|
|
57
58
|
return Number(trimmed);
|
|
58
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Parse a `--bind` address: empty/whitespace is "not set" → undefined (the `??` chain falls through to
|
|
62
|
+
* config, then all interfaces). An unbindable string is a USAGE error (2) — caught here rather than as
|
|
63
|
+
* a node bind failure, or worse, as a "the interface you bound" diagnostic downstream.
|
|
64
|
+
*/
|
|
65
|
+
export function parseBind(value) {
|
|
66
|
+
const trimmed = value?.trim();
|
|
67
|
+
if (!trimmed)
|
|
68
|
+
return undefined;
|
|
69
|
+
if (!isBindAddress(trimmed))
|
|
70
|
+
failUsage(`invalid --bind "${value}": must be an IP address or "localhost"`);
|
|
71
|
+
return bindAddress(trimmed); // a name never travels past this point — see bind.ts
|
|
72
|
+
}
|
|
59
73
|
/** Report which source provides the model's credentials, surfacing a remediation hint at startup. Non-blocking. */
|
|
60
74
|
export async function reportAuth(modelSpec, authPath) {
|
|
61
75
|
const provider = providerOf(modelSpec);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentCore log discovery + tailing. AWS puts the container's stdout/stderr and its OTEL telemetry
|
|
3
|
+
* in the same per-endpoint CloudWatch log group, while the forwarder Lambda has a separate group.
|
|
4
|
+
* This operator surface resolves the stack's RuntimeArn, discovers the endpoint group by prefix, and
|
|
5
|
+
* tails ONLY `[runtime-logs]` for the runtime source — the same application lines shown locally,
|
|
6
|
+
* without mixing in spans/otel-rt-logs.
|
|
7
|
+
*/
|
|
8
|
+
import type { CliRunner } from "../runner.ts";
|
|
9
|
+
export type AgentcoreLogSource = "runtime" | "forwarder";
|
|
10
|
+
export interface AgentcoreLogsPlan {
|
|
11
|
+
/** Deployment base name — stack `fastagent-<name>`, forwarder `fastagent-<name>-forwarder`. */
|
|
12
|
+
name: string;
|
|
13
|
+
source: AgentcoreLogSource;
|
|
14
|
+
/** AWS CLI relative/ISO-8601 window (`10m`, `2h`, ...). Defaults to the CLI's own 10 minutes. */
|
|
15
|
+
since?: string;
|
|
16
|
+
follow: boolean;
|
|
17
|
+
}
|
|
18
|
+
export type AgentcoreLogsOutcome = {
|
|
19
|
+
ok: true;
|
|
20
|
+
logGroup: string;
|
|
21
|
+
} | {
|
|
22
|
+
ok: false;
|
|
23
|
+
gate: string;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Find and tail one AgentCore log source. Discovery is dynamic rather than spelling `-DEFAULT`:
|
|
27
|
+
* endpoint naming belongs to AWS, and an edited stack may use a different endpoint. Runtime tailing
|
|
28
|
+
* filters the log STREAM prefix so OTEL/spans in the same group never pollute the application log.
|
|
29
|
+
*/
|
|
30
|
+
export declare function tailAgentcoreLogs(plan: AgentcoreLogsPlan, aws: CliRunner, announce?: (message: string) => void): Promise<AgentcoreLogsOutcome>;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { parseStackOutputs } from "./run.js";
|
|
2
|
+
/** Runtime id from `arn:...:runtime/<id>` — the id prefixes AgentCore's per-endpoint log group. */
|
|
3
|
+
function runtimeIdFromArn(arn) {
|
|
4
|
+
const marker = ":runtime/";
|
|
5
|
+
const at = arn.lastIndexOf(marker);
|
|
6
|
+
const id = at === -1 ? "" : arn.slice(at + marker.length);
|
|
7
|
+
return id && !id.includes("/") ? id : undefined;
|
|
8
|
+
}
|
|
9
|
+
function parseLogGroupNames(stdout) {
|
|
10
|
+
try {
|
|
11
|
+
const parsed = JSON.parse(stdout);
|
|
12
|
+
return Array.isArray(parsed) && parsed.every((v) => typeof v === "string") ? parsed : undefined;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Find and tail one AgentCore log source. Discovery is dynamic rather than spelling `-DEFAULT`:
|
|
20
|
+
* endpoint naming belongs to AWS, and an edited stack may use a different endpoint. Runtime tailing
|
|
21
|
+
* filters the log STREAM prefix so OTEL/spans in the same group never pollute the application log.
|
|
22
|
+
*/
|
|
23
|
+
export async function tailAgentcoreLogs(plan, aws, announce = () => { }) {
|
|
24
|
+
const stack = `fastagent-${plan.name}`;
|
|
25
|
+
const outputsResult = await aws(["cloudformation", "describe-stacks", "--stack-name", stack, "--query", "Stacks[0].Outputs", "--output", "json"], { capture: true });
|
|
26
|
+
if (outputsResult.code === 127) {
|
|
27
|
+
return { ok: false, gate: "aws CLI not found — install AWS CLI v2: https://docs.aws.amazon.com/cli/" };
|
|
28
|
+
}
|
|
29
|
+
if (outputsResult.code !== 0) {
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
gate: `could not read AgentCore stack ${stack} — deploy it first, or fix the AWS account/region shown above`,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const outputs = parseStackOutputs(outputsResult.stdout);
|
|
36
|
+
let prefix;
|
|
37
|
+
let exact;
|
|
38
|
+
if (plan.source === "runtime") {
|
|
39
|
+
const runtimeArn = outputs.RuntimeArn;
|
|
40
|
+
const runtimeId = runtimeArn && runtimeIdFromArn(runtimeArn);
|
|
41
|
+
if (!runtimeId) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
gate: `stack ${stack} has no valid RuntimeArn output — regenerate/deploy the AgentCore stack`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
prefix = `/aws/bedrock-agentcore/runtimes/${runtimeId}-`;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
// `ForwarderUrl` is the stack's INGRESS URL, NOT proof that a forwarder Lambda exists: plan.ts
|
|
51
|
+
// keeps needsForwarder and needsFunctionUrl as two variables on purpose (a schedules-only topology
|
|
52
|
+
// may keep the forwarder and drop the public URL). So DISCOVERY decides existence below, and the
|
|
53
|
+
// URL output only picks which not-found sentence is true.
|
|
54
|
+
exact = `/aws/lambda/fastagent-${plan.name}-forwarder`;
|
|
55
|
+
prefix = exact;
|
|
56
|
+
}
|
|
57
|
+
const groupsResult = await aws([
|
|
58
|
+
"logs",
|
|
59
|
+
"describe-log-groups",
|
|
60
|
+
"--log-group-name-prefix",
|
|
61
|
+
prefix,
|
|
62
|
+
"--query",
|
|
63
|
+
"logGroups[].logGroupName",
|
|
64
|
+
"--output",
|
|
65
|
+
"json",
|
|
66
|
+
], { capture: true });
|
|
67
|
+
if (groupsResult.code !== 0) {
|
|
68
|
+
return { ok: false, gate: "could not discover the CloudWatch log group — see the AWS error above" };
|
|
69
|
+
}
|
|
70
|
+
const groups = parseLogGroupNames(groupsResult.stdout);
|
|
71
|
+
if (!groups) {
|
|
72
|
+
return { ok: false, gate: "AWS returned an invalid CloudWatch log-group response" };
|
|
73
|
+
}
|
|
74
|
+
const matches = groups.filter((group) => (exact ? group === exact : group.startsWith(prefix))).sort();
|
|
75
|
+
if (matches.length === 0) {
|
|
76
|
+
// Absent group = never used, EXCEPT when the stack has no forwarder at all — an invoke-only
|
|
77
|
+
// deployment would otherwise be told to deliver a webhook it can never receive. Both facts agree
|
|
78
|
+
// there (no ingress URL output either), so the message can name the topology instead of a trigger.
|
|
79
|
+
if (plan.source === "forwarder" && !outputs.ForwarderUrl) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
gate: `stack ${stack} has neither a forwarder log group nor an ingress URL — this looks like an invoke-only deployment, which has Runtime logs only`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const trigger = plan.source === "runtime" ? "invoke the Runtime once" : "deliver one webhook or schedule fire";
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
gate: `no ${plan.source} log group exists yet — ${trigger}, then retry (AWS creates it on first use)`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
// A generated stack has one Runtime endpoint. If an operator's edited stack has several, choosing
|
|
92
|
+
// one silently would show a valid but potentially WRONG agent log — list them and make the choice explicit.
|
|
93
|
+
if (matches.length > 1) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
gate: `several Runtime log groups match this stack: ${matches.join(", ")} — tail the intended one with the ` +
|
|
97
|
+
`same stream filter this command applies: aws logs tail <group> --log-stream-name-prefix '[runtime-logs]' ` +
|
|
98
|
+
`(quote the prefix — [...] is a shell glob)`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const logGroup = matches[0];
|
|
102
|
+
announce(`${plan.source} → ${logGroup}`);
|
|
103
|
+
const tailArgs = ["logs", "tail", logGroup, "--format", "short"];
|
|
104
|
+
if (plan.since)
|
|
105
|
+
tailArgs.push("--since", plan.since);
|
|
106
|
+
if (plan.source === "runtime")
|
|
107
|
+
tailArgs.push("--log-stream-name-prefix", "[runtime-logs]");
|
|
108
|
+
if (plan.follow)
|
|
109
|
+
tailArgs.push("--follow");
|
|
110
|
+
const tailed = await aws(tailArgs);
|
|
111
|
+
if (tailed.code !== 0) {
|
|
112
|
+
return { ok: false, gate: `aws logs tail failed for ${logGroup} — see the AWS error above` };
|
|
113
|
+
}
|
|
114
|
+
return { ok: true, logGroup };
|
|
115
|
+
}
|
|
@@ -37,6 +37,27 @@ export interface AgentcorePlan {
|
|
|
37
37
|
* a fast LOCAL disk only: the platform wipes it on every runtime version update (= every deploy).
|
|
38
38
|
* Durability across deploys comes from the S3 snapshot (channels/agentcore-state.ts). */
|
|
39
39
|
export declare const MOUNT = "/mnt/state";
|
|
40
|
+
/**
|
|
41
|
+
* FASTAGENT_SECRETS_DIR — the seeded-then-ROTATED auth.json, deliberately INSIDE the state root
|
|
42
|
+
* rather than beside it.
|
|
43
|
+
*
|
|
44
|
+
* Every other host mounts a real volume and puts the two machinery dirs side by side (`/data/.state`
|
|
45
|
+
* + `/data/.secrets`), because there the persistence boundary is the MOUNT POINT: anything under it
|
|
46
|
+
* survives. AgentCore has no volume. Its persistence boundary is `packStateRoot(stateRoot)` — the one
|
|
47
|
+
* directory tree the S3 snapshot copies out and back (channels/agentcore-state.ts) — while {@link MOUNT}
|
|
48
|
+
* itself is wiped on every runtime version update, i.e. on every deploy.
|
|
49
|
+
*
|
|
50
|
+
* So the sibling layout would put credentials INSIDE the mount but OUTSIDE the snapshot: nothing
|
|
51
|
+
* copies them out, the platform wipes them, and the next microVM re-seeds the deploy-time copy. With
|
|
52
|
+
* single-use OAuth refresh tokens that is a slow-motion outage — the box works until the seeded token
|
|
53
|
+
* is rotated away, then loses model access with only a redeploy to restore it.
|
|
54
|
+
*
|
|
55
|
+
* Nesting is what makes agentcore-state.ts's stated contract ("restores VERBATIM — including
|
|
56
|
+
* auth.json") reachable at all; `packStateRoot` walks the whole tree, so no snapshot code knows about
|
|
57
|
+
* this. Tests assert the containment, not just the two names — the sibling spelling looks tidier and
|
|
58
|
+
* reintroduces the outage silently.
|
|
59
|
+
*/
|
|
60
|
+
export declare const SECRETS_DIR = "/mnt/state/.secrets";
|
|
40
61
|
/**
|
|
41
62
|
* How long an idle session keeps its microVM. Memory is billed per second across the WHOLE session
|
|
42
63
|
* — idle included, at the peak level reached — so this tail is the standing cost of every burst of
|
|
@@ -70,6 +91,8 @@ export declare const TEMPLATE_FILE = "agentcore.template.yaml";
|
|
|
70
91
|
export declare const GENERATED_TEMPLATE_MARKER = "# Generated by `fastagent deploy agentcore`";
|
|
71
92
|
/** Whether an on-disk template is fastagent-generated (vs hand-written — kept, never gated). */
|
|
72
93
|
export declare function isGeneratedAgentcoreTemplate(content: string): boolean;
|
|
94
|
+
/** Deployment base name from the workspace basename — the ONE mapping used to find its stack later. */
|
|
95
|
+
export declare function agentcoreName(workspaceBasename: string): string;
|
|
73
96
|
/** Runtime name (`[a-zA-Z][a-zA-Z0-9_]{0,47}`) from a dir basename. */
|
|
74
97
|
export declare function toRuntimeName(basename: string): string;
|
|
75
98
|
/** The ONE fixed ingress session id (webhooks + schedule fires) — ≥ 33 chars (the API minimum),
|
|
@@ -29,12 +29,34 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import { createHash } from "node:crypto";
|
|
31
31
|
import { MAX_WEBHOOK_BODY_BYTES } from "../../channels/agentcore-limits.js";
|
|
32
|
+
import { SECRETS_DIRNAME } from "../../paths.js";
|
|
32
33
|
import { containerArtifacts } from "../container.js";
|
|
33
34
|
import { deploymentSecrets, isEnvKey } from "../secrets.js";
|
|
34
35
|
/** SessionStorage mount = FASTAGENT_STATE_DIR (AgentCore requires exactly `/mnt/<one-level>`). It is
|
|
35
36
|
* a fast LOCAL disk only: the platform wipes it on every runtime version update (= every deploy).
|
|
36
37
|
* Durability across deploys comes from the S3 snapshot (channels/agentcore-state.ts). */
|
|
37
38
|
export const MOUNT = "/mnt/state";
|
|
39
|
+
/**
|
|
40
|
+
* FASTAGENT_SECRETS_DIR — the seeded-then-ROTATED auth.json, deliberately INSIDE the state root
|
|
41
|
+
* rather than beside it.
|
|
42
|
+
*
|
|
43
|
+
* Every other host mounts a real volume and puts the two machinery dirs side by side (`/data/.state`
|
|
44
|
+
* + `/data/.secrets`), because there the persistence boundary is the MOUNT POINT: anything under it
|
|
45
|
+
* survives. AgentCore has no volume. Its persistence boundary is `packStateRoot(stateRoot)` — the one
|
|
46
|
+
* directory tree the S3 snapshot copies out and back (channels/agentcore-state.ts) — while {@link MOUNT}
|
|
47
|
+
* itself is wiped on every runtime version update, i.e. on every deploy.
|
|
48
|
+
*
|
|
49
|
+
* So the sibling layout would put credentials INSIDE the mount but OUTSIDE the snapshot: nothing
|
|
50
|
+
* copies them out, the platform wipes them, and the next microVM re-seeds the deploy-time copy. With
|
|
51
|
+
* single-use OAuth refresh tokens that is a slow-motion outage — the box works until the seeded token
|
|
52
|
+
* is rotated away, then loses model access with only a redeploy to restore it.
|
|
53
|
+
*
|
|
54
|
+
* Nesting is what makes agentcore-state.ts's stated contract ("restores VERBATIM — including
|
|
55
|
+
* auth.json") reachable at all; `packStateRoot` walks the whole tree, so no snapshot code knows about
|
|
56
|
+
* this. Tests assert the containment, not just the two names — the sibling spelling looks tidier and
|
|
57
|
+
* reintroduces the outage silently.
|
|
58
|
+
*/
|
|
59
|
+
export const SECRETS_DIR = `${MOUNT}/${SECRETS_DIRNAME}`;
|
|
38
60
|
/**
|
|
39
61
|
* How long an idle session keeps its microVM. Memory is billed per second across the WHOLE session
|
|
40
62
|
* — idle included, at the peak level reached — so this tail is the standing cost of every burst of
|
|
@@ -72,6 +94,13 @@ export const GENERATED_TEMPLATE_MARKER = "# Generated by `fastagent deploy agent
|
|
|
72
94
|
export function isGeneratedAgentcoreTemplate(content) {
|
|
73
95
|
return content.startsWith(GENERATED_TEMPLATE_MARKER);
|
|
74
96
|
}
|
|
97
|
+
/** Deployment base name from the workspace basename — the ONE mapping used to find its stack later. */
|
|
98
|
+
export function agentcoreName(workspaceBasename) {
|
|
99
|
+
return (workspaceBasename
|
|
100
|
+
.toLowerCase()
|
|
101
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
102
|
+
.replace(/^-+|-+$/g, "") || "agent");
|
|
103
|
+
}
|
|
75
104
|
/** Runtime name (`[a-zA-Z][a-zA-Z0-9_]{0,47}`) from a dir basename. */
|
|
76
105
|
export function toRuntimeName(basename) {
|
|
77
106
|
const slug = basename.replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
@@ -437,6 +466,10 @@ function template(input, translated) {
|
|
|
437
466
|
` PORT: "8080"`, // the Runtime service contract's fixed port (config.http.port does not apply here)
|
|
438
467
|
` FASTAGENT_AGENTCORE: "1"`, // serve mounts /invocations + /ping, arms no resident cron
|
|
439
468
|
` FASTAGENT_STATE_DIR: ${MOUNT}`,
|
|
469
|
+
// Inside the state root on purpose — the snapshot is this host's only durable store, and it copies
|
|
470
|
+
// exactly one tree. See {@link SECRETS_DIR}: the sibling layout every other host uses would leave a
|
|
471
|
+
// rotated OAuth credential outside it, i.e. discarded with the microVM.
|
|
472
|
+
` FASTAGENT_SECRETS_DIR: ${SECRETS_DIR}`,
|
|
440
473
|
];
|
|
441
474
|
// The auth seed is chunked (env values max 2048 chars — see AUTH_SEED_CHUNK_SIZE): N parameters,
|
|
442
475
|
// each riding its own env var; `start` reassembles them (collectAuthSeed). Empty defaults = unused.
|
|
@@ -538,7 +571,7 @@ function template(input, translated) {
|
|
|
538
571
|
...envLines,
|
|
539
572
|
];
|
|
540
573
|
if (needsForwarder) {
|
|
541
|
-
lines.push(``, ` ForwarderRole:`, ` Type: AWS::IAM::Role`, ` Properties:`, ` AssumeRolePolicyDocument:`, ` Version: "2012-10-17"`, ` Statement:`, ` - Effect: Allow`, ` Principal: { Service: lambda.amazonaws.com }`, ` Action: sts:AssumeRole`, ` ManagedPolicyArns: [arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]`, ` Policies:`, ` - PolicyName: invoke-runtime`, ` PolicyDocument:`, ` Version: "2012-10-17"`, ` Statement:`, ` - Effect: Allow`, ` Action: bedrock-agentcore:InvokeAgentRuntime`, ` Resource:`, ` - !GetAtt Runtime.AgentRuntimeArn`, ` - !Sub "\${Runtime.AgentRuntimeArn}/*"`, ` - Effect: Allow # mint the presigned URLs the container uses for its state snapshot`, ` Action: [s3:GetObject, s3:PutObject]`, ` Resource: !Sub arn:aws:s3:::\${StateBucket}/${STATE_KEY}`, ...(input.selfSchedule
|
|
574
|
+
lines.push(``, ` ForwarderRole:`, ` Type: AWS::IAM::Role`, ` Properties:`, ` AssumeRolePolicyDocument:`, ` Version: "2012-10-17"`, ` Statement:`, ` - Effect: Allow`, ` Principal: { Service: lambda.amazonaws.com }`, ` Action: sts:AssumeRole`, ` ManagedPolicyArns: [arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]`, ` Policies:`, ` - PolicyName: invoke-runtime`, ` PolicyDocument:`, ` Version: "2012-10-17"`, ` Statement:`, ` - Effect: Allow`, ` Action: bedrock-agentcore:InvokeAgentRuntime`, ` Resource:`, ` - !GetAtt Runtime.AgentRuntimeArn`, ` - !Sub "\${Runtime.AgentRuntimeArn}/*"`, ` - Effect: Allow # mint the presigned URLs the container uses for its state snapshot`, ` Action: [s3:GetObject, s3:PutObject]`, ` Resource: !Sub arn:aws:s3:::\${StateBucket}/${STATE_KEY}`, ` # Without s3:ListBucket, S3 folds "key absent" into 403 (anti-enumeration), which is`, ` # indistinguishable from a broken signature — so the container's restore contract`, ` # (agentcore-state.ts: ONLY 404 means first deploy) would dead-end every first deploy.`, ` # Scoped to the snapshot prefix: this grants "may know whether the snapshot exists",`, ` # not a listing of the whole deployment bucket.`, ` - Effect: Allow`, ` Action: s3:ListBucket`, ` Resource: !Sub arn:aws:s3:::\${StateBucket}`, ` Condition:`, ` StringLike: { s3:prefix: state/* }`, ...(input.selfSchedule
|
|
542
575
|
? [
|
|
543
576
|
` - Effect: Allow # wake alarms: mirror pending wake-ups into one-shot schedules`,
|
|
544
577
|
` Action: [scheduler:CreateSchedule, scheduler:UpdateSchedule]`,
|
|
@@ -679,7 +712,12 @@ export function planAgentcoreDeploy(input) {
|
|
|
679
712
|
? `# 4. Read the outputs (the runtime ARN + callback URL; it serves webhooks only when configured):`
|
|
680
713
|
: `# 4. Read the outputs (the runtime ARN — this topology has NO public URL: nothing outside AWS`, ...(needsFunctionUrl
|
|
681
714
|
? []
|
|
682
|
-
: [`# sends to it, so no Function URL is created and the agent is reachable only via SigV4).`]), `aws cloudformation describe-stacks --stack-name ${stack} --query "Stacks[0].Outputs"`
|
|
715
|
+
: [`# 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 filters to [runtime-logs], excluding OTEL/spans AWS stores in the same log group:`, `fastagent logs agentcore --follow`, ...(needsForwarder
|
|
716
|
+
? [
|
|
717
|
+
`# The ingress transport is a separate Lambda and therefore a separate log source:`,
|
|
718
|
+
`fastagent logs agentcore --source forwarder --follow`,
|
|
719
|
+
]
|
|
720
|
+
: []));
|
|
683
721
|
// Model-auth guidance mirrors the other hosts: an env key became a parameter above; OAuth/stored
|
|
684
722
|
// can't be read at plan time — `--run` carries it as FastagentAuthSeed.
|
|
685
723
|
if (!isEnvKey(input.modelAuth)) {
|
|
@@ -716,6 +754,6 @@ export function planAgentcoreDeploy(input) {
|
|
|
716
754
|
if (needsForwarder) {
|
|
717
755
|
runbook.push(``, `# After a REDEPLOY, stop the ingress session so the new image serves immediately — a live session`, `# keeps its old compute (and the OLD image) until ${IDLE_TIMEOUT_SECONDS}s idle / the 8 h compute ceiling`, `# (\`--run\` does this automatically):`, `aws bedrock-agentcore stop-runtime-session --agent-runtime-arn <RuntimeArn> \\`, ` --runtime-session-id "${ingressSessionId(name)}"`);
|
|
718
756
|
}
|
|
719
|
-
runbook.push(``, `# Redeploy = step 1b (new forwarder key, if its code changed) + step 2 with a NEW tag + step 3.`, `# STATE: ${MOUNT} is a LOCAL disk — AWS wipes it on every runtime version update (i.e. every`, `# deploy) and after 14 idle days. What survives is the S3 snapshot under s3://${bucketHint}/${STATE_KEY}:`, `# the container restores it on its first invocation and pushes it whenever work settles. Keep that`, `# bucket and the agent keeps its sessions, channel state and pending wake-ups across deploys;`, `# delete it and the agent starts blank. (A persistent MOUNT would need EFS + VPC mode + a NAT`, `# gateway for model/channel egress — see the template comment.)
|
|
757
|
+
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.`);
|
|
720
758
|
return { artifacts, runbook, untranslatableSchedules: untranslatable };
|
|
721
759
|
}
|
package/dist/deploy/container.js
CHANGED
|
@@ -128,8 +128,10 @@ CMD ["./${into("node_modules/.bin/fastagent")}", "start", "/app"]
|
|
|
128
128
|
/** Patterns are RECURSIVE (`**/`) on purpose — dockerignore patterns are root-anchored (unlike
|
|
129
129
|
* .gitignore), and a baked workspace can hold nested projects: a bare `node_modules` would upload
|
|
130
130
|
* their build-machine deps (macOS binaries!) and a bare `.env` would bake their secrets into the
|
|
131
|
-
* image. `.secrets`/`.state` are fastagent machinery —
|
|
132
|
-
* store
|
|
131
|
+
* image. `.secrets`/`.state` are fastagent machinery — credential contents travel through the host's
|
|
132
|
+
* secret store and state lives on the volume, so neither may enter an image. The two tracked secrets
|
|
133
|
+
* scaffolds (`.env.example` + `.gitignore`) are the narrow exception: they carry no values and must stay
|
|
134
|
+
* beside the shipped `.git`, or the image starts with tracked deletions. `.cache` is generic hygiene
|
|
133
135
|
* (a baked project's own build cache), not a fastagent directory.
|
|
134
136
|
* `.git` is deliberately SHIPPED: the deployed agent's write-back (pull/commit/push) needs the
|
|
135
137
|
* repo's history+remote — the WYSIWYG bake's freshness/durability loop runs through git, driven by
|
|
@@ -145,12 +147,13 @@ const dockerignore = (input) => DOCKERIGNORE_BASE +
|
|
|
145
147
|
.join("");
|
|
146
148
|
const DOCKERIGNORE_BASE = `${GENERATED_DOCKERIGNORE_MARKER}. Delete this line to take ownership (deploy then keeps your file).
|
|
147
149
|
**/node_modules
|
|
148
|
-
**/${SECRETS_DIRNAME}
|
|
150
|
+
**/${SECRETS_DIRNAME}/**
|
|
149
151
|
**/${STATE_DIRNAME}
|
|
150
152
|
**/.cache
|
|
151
153
|
**/.env
|
|
152
154
|
**/.env.*
|
|
153
155
|
!**/.env.example
|
|
156
|
+
!**/${SECRETS_DIRNAME}/.gitignore
|
|
154
157
|
**/*.log
|
|
155
158
|
# .git is deliberately shipped: the agent can pull to freshen content and push its work back
|
|
156
159
|
# (the generated image installs the git binary when this directory ships a .git; otherwise
|
package/dist/deploy/preflight.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { readdir, readFile } from "node:fs/promises";
|
|
13
13
|
import { basename, isAbsolute, join, relative, sep } from "node:path";
|
|
14
14
|
import ignore from "ignore";
|
|
15
|
+
import { classifyBind } from "../bind.js";
|
|
15
16
|
import { resolveAuthPath } from "../engines/pi/config.js";
|
|
16
17
|
import { resolveSecretsDir, resolveStateRoot } from "../paths.js";
|
|
17
18
|
import { inspectChannels } from "../engines/pi/channel.js";
|
|
@@ -341,6 +342,23 @@ export async function preflightDeploy(input) {
|
|
|
341
342
|
shipsGit,
|
|
342
343
|
};
|
|
343
344
|
const port = config.http?.port ?? 8787;
|
|
345
|
+
// `http.host` travels in the artifact (config is what deploy ships), and any non-wildcard value that
|
|
346
|
+
// is right on a laptop is wrong in a container: the wildcard bind is what makes the published port,
|
|
347
|
+
// the health check and webhook ingress reachable at all. `--bind` is the local-only knob; config is not.
|
|
348
|
+
const configBind = classifyBind(config.http?.host);
|
|
349
|
+
if (configBind !== "wildcard") {
|
|
350
|
+
const issue = `fastagent.config.ts sets http.host: "${config.http?.host}" — it travels into the image, where ` +
|
|
351
|
+
(configBind === "loopback"
|
|
352
|
+
? `nothing outside the container can reach the serve (published port, health check, webhooks).`
|
|
353
|
+
: `that address does not exist, so the container fails to bind at start.`) +
|
|
354
|
+
` Drop it and use \`--bind ${config.http?.host}\` locally instead.`;
|
|
355
|
+
// Same disposition as the model-travel issue: warn when producing artifacts (the operator may be
|
|
356
|
+
// deploying somewhere that fronts the port), gate `--run` — which would otherwise ship a box that
|
|
357
|
+
// answers nothing, or crash-loops on a bind that cannot resolve inside the container.
|
|
358
|
+
if (run)
|
|
359
|
+
return { ok: false, gate: issue };
|
|
360
|
+
messages.push({ level: "warn", text: issue });
|
|
361
|
+
}
|
|
344
362
|
// What the agent declared it needs on the box (fastagent.config deploy.secrets) — carried like channel
|
|
345
363
|
// secrets: listed in the runbook, set from the local env under --run, gated if a value is missing.
|
|
346
364
|
const extraSecrets = config.deploy?.secrets ?? [];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
2
2
|
import type { FastagentTool } from "./tool.ts";
|
|
3
3
|
import type { Models } from "@earendil-works/pi-ai";
|
|
4
|
-
import {
|
|
4
|
+
import type { AnyModel } from "./harness.ts";
|
|
5
5
|
export interface FastagentConfig {
|
|
6
6
|
/** "provider/modelId". Precedence: CLI --model > FASTAGENT_MODEL > config. */
|
|
7
7
|
model?: string;
|
|
@@ -12,8 +12,11 @@ export interface FastagentConfig {
|
|
|
12
12
|
/** Extra custom tools, appended after pi defaults — never replaces them. `FastagentTool` = AgentTool
|
|
13
13
|
* plus the optional `deferred` marker (see defineTool). */
|
|
14
14
|
tools?: FastagentTool[];
|
|
15
|
+
/** `host` is the bind address: unset (or `0.0.0.0`) binds all interfaces — what containers need;
|
|
16
|
+
* `127.0.0.1` keeps the serve (including `/control/*`) off the LAN. Precedence: `--bind` > this. */
|
|
15
17
|
http?: {
|
|
16
18
|
port?: number;
|
|
19
|
+
host?: string;
|
|
17
20
|
};
|
|
18
21
|
/** Mount the built-in `wake` tool so the agent can schedule its OWN follow-up turns (self-scheduling).
|
|
19
22
|
* Off by default — self-scheduling is an autonomy capability, opt in when you want it. Only takes
|
|
@@ -24,8 +27,9 @@ export interface FastagentConfig {
|
|
|
24
27
|
* steer/abort/compact/set_model…) for remote consumers: a Web panel, a desktop app, `fastagent
|
|
25
28
|
* attach`. Default off (it is a remote-control surface). When on, `dev`/`start` generate a
|
|
26
29
|
* per-boot bearer token and write `<stateRoot>/control.json` for local discovery. The serve
|
|
27
|
-
* binds all interfaces, so the routes are LAN-reachable with the token as the only
|
|
28
|
-
*
|
|
30
|
+
* binds all interfaces by default, so the routes are LAN-reachable with the token as the only
|
|
31
|
+
* protection — bind loopback (`--bind 127.0.0.1`; not `http.host`, which travels into a deployed
|
|
32
|
+
* image), firewall the port, or wrap it for real exposure (design §14).
|
|
29
33
|
*/
|
|
30
34
|
sessionControl?: boolean;
|
|
31
35
|
/** Deploy-time declarations for what the agent needs on the box, so real agents don't hand-write a
|
|
@@ -20,7 +20,8 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
20
20
|
import { existsSync, statSync } from "node:fs";
|
|
21
21
|
import { basename, join } from "node:path";
|
|
22
22
|
import { pathToFileURL } from "node:url";
|
|
23
|
-
import { THINKING_LEVELS } from "./
|
|
23
|
+
import { THINKING_LEVELS } from "./session-settings.js";
|
|
24
|
+
import { isBindAddress } from "../../bind.js";
|
|
24
25
|
import { moduleLoadHint } from "../../loader.js";
|
|
25
26
|
import { AGENT_CONFIG_NAMES, resolveOverridePath, resolveSecretsDir } from "../../paths.js";
|
|
26
27
|
/** Identity function for typing and IDE completion (vite/next-style). */
|
|
@@ -116,13 +117,18 @@ export async function loadConfig(dir) {
|
|
|
116
117
|
throw new Error(`${path}: "http" must be an object`);
|
|
117
118
|
}
|
|
118
119
|
for (const key of Object.keys(c.http ?? {})) {
|
|
119
|
-
if (key !== "port") {
|
|
120
|
-
throw new Error(`${path}: unknown key "http.${key}" (valid keys: port)`);
|
|
120
|
+
if (key !== "port" && key !== "host") {
|
|
121
|
+
throw new Error(`${path}: unknown key "http.${key}" (valid keys: port, host)`);
|
|
121
122
|
}
|
|
122
123
|
}
|
|
123
124
|
if (c.http?.port !== undefined && (typeof c.http.port !== "number" || !isValidPort(c.http.port))) {
|
|
124
125
|
throw new Error(`${path}: "http.port" must be an integer 0-65535`);
|
|
125
126
|
}
|
|
127
|
+
// Validated as strictly as http.port: an unbindable string ("banana") must fail HERE, not surface
|
|
128
|
+
// later as a topology diagnostic about "the interface you bound".
|
|
129
|
+
if (c.http?.host !== undefined && (typeof c.http.host !== "string" || !isBindAddress(c.http.host))) {
|
|
130
|
+
throw new Error(`${path}: "http.host" must be an IP address or "localhost" (e.g. "127.0.0.1", "0.0.0.0")`);
|
|
131
|
+
}
|
|
126
132
|
if (c.deploy !== undefined && (typeof c.deploy !== "object" || c.deploy === null)) {
|
|
127
133
|
throw new Error(`${path}: "deploy" must be an object`);
|
|
128
134
|
}
|