cdk-local 0.147.13 → 0.147.15

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.
@@ -322,13 +322,15 @@ function buildStsClientConfig(args) {
322
322
  *
323
323
  * # What this does NOT close
324
324
  *
325
- * A single-LINE forged string still reaches readers other than a human.
326
- * `cdkl studio` matches every serve-child stdout line against an unanchored
327
- * ready-line regex and proxies to the URL it captures, so a message
328
- * containing `Server listening on http://...` can redirect the studio proxy.
329
- * That is not fixed here — the sanitizer bounds the line, it does not stop a
330
- * machine from reading it and it predates this change (an ordinary handler
331
- * log line trips the same matcher). Tracked in issue #578.
325
+ * A single-LINE forged string still reaches readers other than a human. It no
326
+ * longer redirects the studio capture proxy: issue #578 anchored every
327
+ * `cdkl studio` ready pattern to the start of the line, made the serve manager
328
+ * skip a line carrying cdk-local's own `WARN: ` / `ERROR: ` decoration
329
+ * outright, and bounded the resolved upstream to loopback so a message
330
+ * containing `Server listening on http://...` is neither read as a banner nor
331
+ * usable as a destination. What remains is the reader this sanitizer was
332
+ * always aimed at: a HUMAN scanning the log, for whom a forged-looking line is
333
+ * still misleading text, which is why the flattening + capping stay.
332
334
  */
333
335
  /**
334
336
  * Longest service-exception message relayed into a `warn` line, in
@@ -5160,16 +5162,25 @@ async function fetchAllExports(client) {
5160
5162
  * S3-bucket-specific (it rewrites the synthetic `Unknown`/`UnknownError`
5161
5163
  * with bucket / region context), so the CFn provider extracts the
5162
5164
  * pieces directly here.
5165
+ *
5166
+ * Issue #578 — the result is FLATTENED TO ONE LINE via
5167
+ * {@link flattenToOneLine}, the same helper `credential-error` applies to
5168
+ * every other wire-derived value that lands on a log line, rather than a
5169
+ * second spelling of it. Both `err.name` and `err.message` come off the wire,
5170
+ * and this warn is relayed onto a `cdkl studio` serve child's stdout, where
5171
+ * `studio-serve-manager` splits on `\n`: an embedded newline puts line 2 on
5172
+ * the stream with no `WARN: ` prefix, so it clears the diagnostic bound and
5173
+ * matches a ready pattern at `^`. One line in, one line out.
5163
5174
  */
5164
5175
  function formatAwsErrorForWarn(err) {
5165
- if (!(err instanceof Error)) return String(err);
5176
+ if (!(err instanceof Error)) return flattenToOneLine(String(err));
5166
5177
  const name = err.name && err.name !== "Error" ? err.name : void 0;
5167
5178
  const status = err.$metadata?.httpStatusCode;
5168
5179
  const prefixParts = [];
5169
5180
  if (name !== void 0) prefixParts.push(name);
5170
5181
  if (status !== void 0) prefixParts.push(`HTTP ${status}`);
5171
- if (prefixParts.length === 0) return err.message;
5172
- return `${prefixParts.join(" ")}: ${err.message}`;
5182
+ if (prefixParts.length === 0) return flattenToOneLine(err.message);
5183
+ return flattenToOneLine(`${prefixParts.join(" ")}: ${err.message}`);
5173
5184
  }
5174
5185
 
5175
5186
  //#endregion
@@ -36990,6 +37001,154 @@ async function relayServeRequest(input, fetchFn = fetch, clock = Date.now) {
36990
37001
  //#region src/local/studio-proxy.ts
36991
37002
  let proxyIdCounter = 0;
36992
37003
  /**
37004
+ * True when `hostname` names this machine's loopback interface: `localhost`,
37005
+ * an IPv4 address in `127.0.0.0/8`, IPv6 `::1`, or an IPv4-mapped loopback
37006
+ * (`::ffff:127.0.0.1`, which `URL.hostname` renders in hex as
37007
+ * `[::ffff:7f00:1]`). Surrounding brackets, as `URL.hostname` keeps them for
37008
+ * an IPv6 literal, are tolerated. The match is EXACT — `localhost.example.com`
37009
+ * and `127.0.0.1.example.com` are ordinary DNS names that resolve wherever
37010
+ * their owner points them, and are NOT loopback.
37011
+ *
37012
+ * A `cdkl` serve child always listens on this machine, so this is the bound
37013
+ * studio puts on where it will ever forward a composer request (issue #578).
37014
+ * A destination that is not loopback did not come from a genuine serve, and
37015
+ * forwarding to it would carry the developer's request — headers and body —
37016
+ * off-box.
37017
+ */
37018
+ function isLoopbackHostname(hostname) {
37019
+ const h = hostname.trim().replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
37020
+ if (h === "localhost") return true;
37021
+ if (h === "::1" || h === "0:0:0:0:0:0:0:1") return true;
37022
+ const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(h);
37023
+ if (mapped) return parseInt(mapped[1], 16) >> 8 === 127;
37024
+ const dotted = /^(?:::ffff:)?(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h);
37025
+ if (!dotted) return false;
37026
+ const octets = dotted.slice(1).map(Number);
37027
+ if (octets.some((o) => o > 255)) return false;
37028
+ return octets[0] === 127;
37029
+ }
37030
+ /**
37031
+ * Drop the brackets `URL.hostname` keeps around an IPv6 literal (`[::1]`), so
37032
+ * the host can be handed to `node:http` / `node:net`.
37033
+ *
37034
+ * Those two disagree with `URL` about the spelling: `http.request({ host:
37035
+ * '[::1]' })` fails with `ENOTFOUND` (it is looked up as a NAME), while `'::1'`
37036
+ * connects. {@link isLoopbackHostname} already tolerates the bracketed form —
37037
+ * so `--host ::1` on a serve child yields an upstream this proxy ACCEPTS and
37038
+ * then 502s every request through, which is the worst of the three outcomes.
37039
+ * Bracket-stripping is safe for every other spelling: a dotted IPv4, a name
37040
+ * and a bare hextet form carry no brackets to remove.
37041
+ */
37042
+ function stripHostBrackets(hostname) {
37043
+ return hostname.replace(/^\[/, "").replace(/\]$/, "");
37044
+ }
37045
+ /**
37046
+ * True when `hostname` is the UNSPECIFIED (wildcard) address: IPv4 `0.0.0.0`,
37047
+ * IPv6 `::` (`URL` normalises `0:0:0:0:0:0:0:0` to it), or the IPv4-mapped
37048
+ * `::ffff:0.0.0.0` (which `URL.hostname` renders as `[::ffff:0:0]`).
37049
+ *
37050
+ * A wildcard is a BIND address, not a destination — a server bound to it IS
37051
+ * reachable on loopback, and `--container-host 0.0.0.0` is an ordinary value
37052
+ * for `start-service` / `run-task` (default `127.0.0.1`) that `cdkl studio`
37053
+ * auto-renders in its "All options" section. So a wildcard is normalised to
37054
+ * loopback rather than refused; the security property is untouched, because a
37055
+ * line naming a real foreign host still names a real foreign host, and no
37056
+ * spelling of the wildcard reaches anywhere but this machine.
37057
+ */
37058
+ function isWildcardHostname(hostname) {
37059
+ const h = hostname.trim().replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
37060
+ return h === "0.0.0.0" || h === "::" || h === "0:0:0:0:0:0:0:0" || h === "::ffff:0.0.0.0" || h === "::ffff:0:0";
37061
+ }
37062
+ /**
37063
+ * Rewrite the HOST TOKEN of a wildcard-bound URL to `127.0.0.1`, leaving
37064
+ * scheme, userinfo, port, path, query and fragment byte-for-byte (rather than
37065
+ * re-serialising the `URL`, which would append a `/` the child never printed).
37066
+ *
37067
+ * This is string surgery over an authority the WHATWG parser has ALREADY
37068
+ * accepted, and the two parsers do not agree on where the authority ends —
37069
+ * `\` terminates it for the parser and not for the `[/?#]` scan here, and any
37070
+ * future terminator will diverge the same way. So this is deliberately NOT the
37071
+ * decision-maker: {@link normalizeLocalUpstream} re-parses whatever comes back
37072
+ * and refuses it unless the RESULT is loopback. Returning a wrong string here
37073
+ * costs a refusal, never a foreign destination.
37074
+ */
37075
+ function rewriteWildcardHostToken(upstream) {
37076
+ const schemeEnd = upstream.indexOf("://");
37077
+ if (schemeEnd === -1) return void 0;
37078
+ const head = upstream.slice(0, schemeEnd + 3);
37079
+ const rest = upstream.slice(schemeEnd + 3);
37080
+ const cut = rest.search(/[/?#]/);
37081
+ const authority = cut === -1 ? rest : rest.slice(0, cut);
37082
+ const tail = cut === -1 ? "" : rest.slice(cut);
37083
+ const at = authority.lastIndexOf("@");
37084
+ const userinfo = at === -1 ? "" : authority.slice(0, at + 1);
37085
+ const hostPort = at === -1 ? authority : authority.slice(at + 1);
37086
+ const portAt = hostPort.startsWith("[") ? hostPort.indexOf("]") + 1 : hostPort.indexOf(":");
37087
+ return `${head}${userinfo}127.0.0.1${portAt > 0 ? hostPort.slice(portAt) : ""}${tail}`;
37088
+ }
37089
+ /**
37090
+ * Resolve an upstream URL a serve child named into the URL studio may
37091
+ * actually use, or `undefined` when it must be refused (issue #578).
37092
+ *
37093
+ * A wildcard host is REWRITTEN to `127.0.0.1` (see
37094
+ * {@link isWildcardHostname}) and the rewritten URL is what gets adopted as
37095
+ * the endpoint and handed to the proxy, so the destination really is
37096
+ * loopback rather than merely tolerated. Everything else must already be
37097
+ * loopback; a foreign host or an unparseable URL resolves to `undefined`.
37098
+ *
37099
+ * # The answer is CHECKED, not asserted
37100
+ *
37101
+ * Whatever string this is about to return — the input verbatim, or the
37102
+ * host-token rewrite of {@link rewriteWildcardHostToken} — is re-parsed with
37103
+ * `new URL` and its `hostname` must satisfy {@link isLoopbackHostname}. That
37104
+ * single post-condition is the whole guarantee, and it is stated over the
37105
+ * VALUE HANDED BACK rather than over the value inspected on the way in, so it
37106
+ * cannot be outrun by a spelling nobody enumerated.
37107
+ *
37108
+ * It has to be, because the string surgery and the URL parser disagree about
37109
+ * where an authority ends. `http://0.0.0.0\@attacker.example/` parses to host
37110
+ * `0.0.0.0` (WHATWG treats `\` as an authority terminator) but the `[/?#]`
37111
+ * scan reads `0.0.0.0\@attacker.example` as one authority, so the rewrite
37112
+ * lands on the wrong token and produces `http://0.0.0.0\@127.0.0.1/` — which
37113
+ * re-parses to host `0.0.0.0`, NOT loopback, and is refused here. Adding `\`
37114
+ * to the cut set would close exactly that spelling and leave the next one
37115
+ * open; the post-condition closes the shape.
37116
+ *
37117
+ * Userinfo rides through byte-for-byte on the accepted path
37118
+ * (`http://tok@0.0.0.0:51234/` -> `http://tok@127.0.0.1:51234/`) — verified,
37119
+ * not assumed, since the post-condition now proves the destination that
37120
+ * string actually names.
37121
+ */
37122
+ function normalizeLocalUpstream(upstream) {
37123
+ let parsed;
37124
+ try {
37125
+ parsed = new URL(upstream);
37126
+ } catch {
37127
+ return;
37128
+ }
37129
+ const candidate = isWildcardHostname(parsed.hostname) ? rewriteWildcardHostToken(upstream) : upstream;
37130
+ if (candidate === void 0) return void 0;
37131
+ let confirmed;
37132
+ try {
37133
+ confirmed = new URL(candidate);
37134
+ } catch {
37135
+ return;
37136
+ }
37137
+ return isLoopbackHostname(confirmed.hostname) ? candidate : void 0;
37138
+ }
37139
+ /**
37140
+ * Render an UNTRUSTED endpoint string for a human-readable message. The
37141
+ * string reached studio from a child's stdout, so it can carry control
37142
+ * characters (ANSI escapes, a bare CR) that would forge extra output, and it
37143
+ * can be arbitrarily long. Non-printable-ASCII bytes become `?` and the result
37144
+ * is length-capped, the same shape `credential-error` applies to a relayed
37145
+ * service message.
37146
+ */
37147
+ function describeEndpointForMessage(value) {
37148
+ const flat = value.replace(/[^\x20-\x7e]/g, "?");
37149
+ return flat.length > 120 ? `${flat.slice(0, 120)}...` : flat;
37150
+ }
37151
+ /**
36993
37152
  * Start a capturing reverse proxy in front of a studio serve target
36994
37153
  * (decision D4a: because every request to the served port flows through
36995
37154
  * `cdkl studio`, the timeline observes them regardless of source —
@@ -37006,6 +37165,12 @@ let proxyIdCounter = 0;
37006
37165
  * Studio is a control plane over the CLI, so this proxy sits in front of
37007
37166
  * the long-running `cdkl start-api` child the serve manager spawned; it
37008
37167
  * does NOT re-implement any routing — it forwards verbatim.
37168
+ *
37169
+ * Throws SYNCHRONOUSLY (rather than rejecting) when `upstream` is unparseable
37170
+ * or names a non-loopback host (issue #578) — a refusal to attempt the proxy
37171
+ * at all, rather than a runtime failure of one. A wildcard bind address
37172
+ * (`0.0.0.0` / `::`) is not a refusal: it is rewritten to `127.0.0.1` and
37173
+ * forwarded there ({@link normalizeLocalUpstream}).
37009
37174
  */
37010
37175
  function startStudioProxy(config) {
37011
37176
  const host = config.host ?? "127.0.0.1";
@@ -37015,8 +37180,10 @@ function startStudioProxy(config) {
37015
37180
  proxyIdCounter += 1;
37016
37181
  return `req-${clock()}-${proxyIdCounter}`;
37017
37182
  });
37018
- const upstreamUrl = new URL(config.upstream);
37019
- const upstreamHost = upstreamUrl.hostname;
37183
+ const resolvedUpstream = normalizeLocalUpstream(config.upstream);
37184
+ if (resolvedUpstream === void 0) throw new Error(`studio proxy refuses the non-loopback upstream '${describeEndpointForMessage(config.upstream)}': a serve child always listens on this machine, so a request forwarded there would leave it.`);
37185
+ const upstreamUrl = new URL(resolvedUpstream);
37186
+ const upstreamHost = stripHostBrackets(upstreamUrl.hostname);
37020
37187
  const upstreamPort = Number(upstreamUrl.port) || 80;
37021
37188
  const server = createServer$1((clientReq, clientRes) => {
37022
37189
  const id = idFactory();
@@ -37198,25 +37365,27 @@ const SERVE_SPECS = {
37198
37365
  "--host",
37199
37366
  "127.0.0.1"
37200
37367
  ],
37201
- readyRe: /Server listening on (\S+)/,
37202
- capturesHttp: true
37368
+ readyRe: /^Server listening on (\S+)/,
37369
+ capturesHttp: true,
37370
+ readyRepeats: true
37203
37371
  },
37204
37372
  alb: {
37205
37373
  command: "start-alb",
37206
37374
  portArgs: [],
37207
- readyRe: /ALB front-door: (https?:\/\/\S+)/,
37208
- capturesHttp: true
37375
+ readyRe: /^ALB front-door: (https?:\/\/\S+)/,
37376
+ capturesHttp: true,
37377
+ readyRepeats: true
37209
37378
  },
37210
37379
  ecs: {
37211
37380
  command: "start-service",
37212
37381
  portArgs: [],
37213
- readyRe: /Service\(s\) running:/,
37382
+ readyRe: /^Service\(s\) running:/,
37214
37383
  capturesHttp: false
37215
37384
  },
37216
37385
  "ecs-task": {
37217
37386
  command: "run-task",
37218
37387
  portArgs: [],
37219
- readyRe: /Task running \(family=/,
37388
+ readyRe: /^Task running \(family=/,
37220
37389
  capturesHttp: false
37221
37390
  },
37222
37391
  cloudfront: {
@@ -37227,7 +37396,7 @@ const SERVE_SPECS = {
37227
37396
  "--host",
37228
37397
  "127.0.0.1"
37229
37398
  ],
37230
- readyRe: /CloudFront distribution serving on (https?:\/\/\S+)/,
37399
+ readyRe: /^CloudFront distribution serving on (https?:\/\/\S+)/,
37231
37400
  capturesHttp: true
37232
37401
  },
37233
37402
  "agentcore-ws": {
@@ -37238,27 +37407,119 @@ const SERVE_SPECS = {
37238
37407
  "--host",
37239
37408
  "127.0.0.1"
37240
37409
  ],
37241
- readyRe: /Server listening on (\S+)/,
37410
+ readyRe: /^Server listening on (\S+)/,
37242
37411
  capturesHttp: true,
37243
- extraEndpointRe: /HTTP contract served on (https?:\/\/\S+)/
37412
+ extraEndpointRe: /^HTTP contract served on (https?:\/\/\S+)/
37244
37413
  }
37245
37414
  };
37246
37415
  /**
37247
37416
  * Parse an auto-published replica host endpoint from an `ecs` serve child's
37248
37417
  * stdout (issue #392). `start-service` publishes each replica's declared
37249
37418
  * container port on the host — auto-remapping a privileged port (< 1024) to a
37250
- * free high port (issue #357) — and logs a line like
37251
- * `... container port 80 published on 127.0.0.1:54321. Reach it at ...`. studio
37252
- * surfaces the FIRST such endpoint as the serve's `hostUrl` so the in-workspace
37253
- * request composer can target it even when the user passed no explicit
37254
- * `--host-port`. Returns `http://<ip>:<port>` or `undefined` when the line does
37255
- * not carry a published endpoint. Exported for unit testing.
37419
+ * free high port (issue #357) — and logs the WHOLE line
37420
+ * `Container 'web' container port 80 published on 127.0.0.1:54321. Reach it at
37421
+ * 127.0.0.1:54321.` (`ecs-task-runner`). studio surfaces the FIRST such
37422
+ * endpoint as the serve's `hostUrl` so the in-workspace request composer can
37423
+ * target it even when the user passed no explicit `--host-port`. Returns
37424
+ * `http://<ip>:<port>` or `undefined` when the line is not that banner.
37425
+ * Exported for unit testing.
37426
+ *
37427
+ * ANCHORED to the whole banner (issue #578), not just the `published on`
37428
+ * phrase: `hostUrl` is a destination the request composer posts to DIRECTLY,
37429
+ * with no proxy in between, so a mid-message occurrence of the phrase — in a
37430
+ * relayed error, or in a replica's own application output — must not be able
37431
+ * to name it. Pass the decoration-stripped line ({@link classifyChildLine}).
37432
+ * The loopback bound is applied by the CALLER (a parsed endpoint is still only
37433
+ * adopted when {@link normalizeLocalUpstream} accepts it), so this stays a pure
37434
+ * reader of the banner.
37256
37435
  */
37257
37436
  function parsePublishedHostEndpoint(line) {
37258
- const m = /published on (\d{1,3}(?:\.\d{1,3}){3}:\d+)/.exec(line);
37437
+ const m = /^Container '[^']*' container port \d+ published on (\d{1,3}(?:\.\d{1,3}){3}:\d+)/.exec(line);
37259
37438
  return m ? `http://${m[1]}` : void 0;
37260
37439
  }
37261
37440
  /**
37441
+ * The one wording for "studio will not send requests to that destination"
37442
+ * (issue #578), shared by every site that resolves a relay target: the ready
37443
+ * line, the agentcore extra-endpoint line, the ecs replica publish banner, and
37444
+ * the `--host-port` mapping. `endpoint` is untrusted text (child stdout, or a
37445
+ * user-supplied flag value), so it is flattened + length-capped before being
37446
+ * quoted back.
37447
+ */
37448
+ function describeForeignEndpointRefusal(targetId, endpoint, what) {
37449
+ const quoted = `'${describeEndpointForMessage(endpoint)}'`;
37450
+ const outcome = "No endpoint was adopted and no capture proxy was started.";
37451
+ if (!parsesAsUrl(endpoint)) return `refused an unparseable ${what} ${quoted} from '${targetId}': it is not a URL, so studio cannot tell where a request to it would go. ${outcome}`;
37452
+ return `refused a non-loopback ${what} ${quoted} from '${targetId}': a serve child always listens on this machine, so studio will not send requests there. ${outcome}`;
37453
+ }
37454
+ /** True when `value` is a URL the WHATWG parser accepts. */
37455
+ function parsesAsUrl(value) {
37456
+ try {
37457
+ new URL(value);
37458
+ return true;
37459
+ } catch {
37460
+ return false;
37461
+ }
37462
+ }
37463
+ /** ANSI SGR colour escapes, as the logger wraps a warn / error line with. */
37464
+ const ANSI_SGR_RE = /\u001b\[[0-9;]*m/g;
37465
+ /** The verbose-mode preamble `<iso-timestamp> <LEVEL> ` (`utils/logger`). */
37466
+ const VERBOSE_PREAMBLE_RE = /^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+(DEBUG|INFO|WARN|ERROR)\s+/;
37467
+ /**
37468
+ * A child logger's `[module] ` tag (`utils/logger`'s `ChildLogger`).
37469
+ *
37470
+ * Stripped ONLY after a verbose preamble, and ONLY for the ecs publish banner
37471
+ * ({@link ClassifiedChildLine.untagged}) — never for the ready-banner match.
37472
+ * `ChildLogger` emits the tag exclusively at `debug` level (`logger.ts`
37473
+ * `info` / `warn` / `error` prefix only when `getLevel() === 'debug'`), which
37474
+ * is also the level that renders the `<timestamp> <LEVEL>` preamble, so a BARE
37475
+ * `[tag] ` at column 0 is never cdk-local's. It IS how relayed third-party
37476
+ * container output arrives — `[<container>] ` (`ecs-task-runner`) and
37477
+ * `[svc=... r=... c=...] ` (`ecs-service-runner`) — so stripping it
37478
+ * unconditionally re-anchored an application's own log line to `^` and let it
37479
+ * impersonate a ready banner (issue #578).
37480
+ */
37481
+ const MODULE_TAG_RE = /^\[[^\]]*\]\s+/;
37482
+ /** The compact-mode level prefix `WARN: ` / `ERROR: ` (`utils/logger`). */
37483
+ const COMPACT_LEVEL_RE = /^(WARN|ERROR):\s/;
37484
+ /**
37485
+ * Read the log decoration off one child stdout line (issue #578).
37486
+ *
37487
+ * `cdkl studio` runs its serve children with `CDKL_LOG_STREAM=stdout` (issue
37488
+ * #403), so cdk-local's own warn / error lines share the stream the ready
37489
+ * banners arrive on. Those lines relay wire-derived text (an AWS SDK error's
37490
+ * `message`), which is exactly the text that must never be able to name the
37491
+ * endpoint studio proxies to. The compact renderer prefixes them `WARN: ` /
37492
+ * `ERROR: ` (the same signal `studio-ui`'s `logLineClass` colours off) and the
37493
+ * verbose renderer (`--verbose` / `CDKL_LOG_LEVEL=debug`, both reachable in a
37494
+ * studio-spawned child) writes `<timestamp> <LEVEL> [module] `. Both shapes
37495
+ * are recognised here, so the caller can skip a diagnostic line AND anchor its
37496
+ * patterns against `text` without the mode deciding whether the anchor holds.
37497
+ */
37498
+ function classifyChildLine(line) {
37499
+ let text = line.replace(ANSI_SGR_RE, "");
37500
+ let diagnostic = false;
37501
+ const verbose = VERBOSE_PREAMBLE_RE.exec(text);
37502
+ if (verbose) {
37503
+ if (verbose[1] === "WARN" || verbose[1] === "ERROR") diagnostic = true;
37504
+ text = text.slice(verbose[0].length);
37505
+ }
37506
+ const level = COMPACT_LEVEL_RE.exec(text);
37507
+ if (level) {
37508
+ diagnostic = true;
37509
+ text = text.slice(level[0].length);
37510
+ }
37511
+ let untagged = text;
37512
+ if (verbose) {
37513
+ const tag = MODULE_TAG_RE.exec(text);
37514
+ if (tag) untagged = text.slice(tag[0].length);
37515
+ }
37516
+ return {
37517
+ diagnostic,
37518
+ text,
37519
+ untagged
37520
+ };
37521
+ }
37522
+ /**
37262
37523
  * Build the studio serve manager. Slice C1 drives a long-running
37263
37524
  * `cdkl start-api <target>` child — studio is a control plane over the
37264
37525
  * CLI (the same pattern as the single-shot invoke dispatcher), so it
@@ -37301,8 +37562,9 @@ function createStudioServeManager(config) {
37301
37562
  * leaks when a serve stops / errors / times out.
37302
37563
  */
37303
37564
  async function closeProxies(e) {
37304
- const proxies = e.proxies.splice(0);
37305
- await Promise.all(proxies.map((p) => p.close().catch(() => void 0)));
37565
+ const proxies = [...e.proxies.values()];
37566
+ e.proxies.clear();
37567
+ await Promise.all(proxies.map((p) => p.then((x) => x.close()).catch(() => void 0)));
37306
37568
  if (e.envDir) {
37307
37569
  try {
37308
37570
  rmSync(e.envDir, {
@@ -37392,7 +37654,7 @@ function createStudioServeManager(config) {
37392
37654
  endpoints: [],
37393
37655
  startedAt,
37394
37656
  child,
37395
- proxies: []
37657
+ proxies: /* @__PURE__ */ new Map()
37396
37658
  };
37397
37659
  if (child.pid !== void 0) entry.pid = child.pid;
37398
37660
  if (envDir) entry.envDir = envDir;
@@ -37400,7 +37662,15 @@ function createStudioServeManager(config) {
37400
37662
  const hp = req.options?.["--host-port"];
37401
37663
  if (Array.isArray(hp)) {
37402
37664
  const first = hp.find((r) => r && typeof r === "object" && typeof r.right === "string" && r.right.trim() !== "");
37403
- if (first) entry.hostUrl = "http://127.0.0.1:" + first.right.trim();
37665
+ if (first) {
37666
+ const candidate = "http://127.0.0.1:" + first.right.trim();
37667
+ const local = normalizeLocalUpstream(candidate);
37668
+ if (local === void 0) {
37669
+ const msg = describeForeignEndpointRefusal(req.targetId, candidate, "--host-port mapping");
37670
+ emitLog(config.bus, clock, req.targetId, `WARN: ${msg}`, "stderr");
37671
+ getLogger().warn(msg);
37672
+ } else entry.hostUrl = local;
37673
+ }
37404
37674
  }
37405
37675
  }
37406
37676
  entries.set(req.targetId, entry);
@@ -37410,13 +37680,13 @@ function createStudioServeManager(config) {
37410
37680
  child.stdout.setEncoding("utf8");
37411
37681
  child.stderr.setEncoding("utf8");
37412
37682
  const timer = setTimeoutFn(() => {
37413
- if (settled) return;
37683
+ if (settled || entry.stopping) return;
37414
37684
  settled = true;
37415
37685
  entry.status = "error";
37416
37686
  emitServe(entry, `Timed out after ${readyTimeoutMs}ms waiting for the serve to be ready.`);
37417
37687
  stopChild(child, stopGraceMs, setTimeoutFn, clearTimeoutFn);
37418
37688
  closeProxies(entry);
37419
- entries.delete(req.targetId);
37689
+ if (entries.get(req.targetId) === entry) entries.delete(req.targetId);
37420
37690
  reject(/* @__PURE__ */ new Error(`'${req.targetId}' did not start within ${readyTimeoutMs}ms.`));
37421
37691
  }, readyTimeoutMs);
37422
37692
  timer.unref?.();
@@ -37428,19 +37698,87 @@ function createStudioServeManager(config) {
37428
37698
  emitServe(entry);
37429
37699
  resolve(publicState(entry));
37430
37700
  };
37431
- const onReady = async (childUrl) => {
37701
+ /**
37702
+ * Refuse a destination a child's stdout named that is not on this
37703
+ * machine (issue #578), and say so loudly: on the bus, so the studio
37704
+ * LOGS panel shows it (rendered with the same `WARN: ` prefix the
37705
+ * compact logger emits, which `studio-ui` colours), and on the studio
37706
+ * process's own logger, for a user watching the terminal. The endpoint
37707
+ * is untrusted text, so it is flattened + length-capped before being
37708
+ * quoted back.
37709
+ */
37710
+ const refuseForeignEndpoint = (endpoint, what) => {
37711
+ const msg = describeForeignEndpointRefusal(req.targetId, endpoint, what);
37712
+ emitLog(config.bus, clock, req.targetId, `WARN: ${msg}`, "stderr");
37713
+ getLogger().warn(msg);
37714
+ return msg;
37715
+ };
37716
+ /**
37717
+ * Fail the serve NOW with `reason`, instead of leaving it `starting`
37718
+ * until `readyTimeoutMs` expires (issue #578 review).
37719
+ *
37720
+ * A refused ready line is terminal: the child named the only endpoint it
37721
+ * is going to name, and studio will not use it. Waiting out the default
37722
+ * 120 s and then reporting `did not start within 120000ms` buries the
37723
+ * real cause in the LOGS panel, and it is a live configuration rather
37724
+ * than an attack that lands here — `cdkl start-alb --container-host
37725
+ * <non-loopback>` makes the front-door banner the refused line. Mirrors
37726
+ * the timeout path exactly (graceful SIGTERM so the child tears its own
37727
+ * containers down, proxies closed, entry dropped) but carries the
37728
+ * refusal text as the failure reason, so the UI error banner says why.
37729
+ *
37730
+ * The trade: a serve that would have recovered — a foreign-looking line
37731
+ * arriving BEFORE the genuine banner — now dies instead of waiting. That
37732
+ * line has to clear bound 1 (not a WARN / ERROR), bound 2 (column 0, and
37733
+ * as of this change not tag-stripped) and still name a non-loopback,
37734
+ * non-wildcard host, which is the misconfiguration case far more than
37735
+ * the coincidence case; and the old outcome for it was a 120 s wait
37736
+ * ending in the same failure with a worse message.
37737
+ */
37738
+ const failServe = (reason) => {
37739
+ if (settled || entry.stopping) return;
37740
+ settled = true;
37741
+ clearTimeoutFn(timer);
37742
+ entry.status = "error";
37743
+ emitServe(entry, reason);
37744
+ stopChild(child, stopGraceMs, setTimeoutFn, clearTimeoutFn);
37745
+ closeProxies(entry);
37746
+ entries.delete(req.targetId);
37747
+ reject(new Error(reason));
37748
+ };
37749
+ const onReady = async (readyUrl) => {
37750
+ let childUrl = readyUrl;
37751
+ if (readyUrl !== void 0) {
37752
+ const local = normalizeLocalUpstream(readyUrl);
37753
+ if (local === void 0) {
37754
+ const msg = refuseForeignEndpoint(readyUrl, "ready line");
37755
+ failServe(msg);
37756
+ return;
37757
+ }
37758
+ childUrl = local;
37759
+ }
37432
37760
  let endpoint = childUrl;
37433
37761
  if (childUrl && spec.capturesHttp && captureRequests && /^https?:/i.test(childUrl)) try {
37434
- const proxy = await proxyFactory({
37435
- bus: config.bus,
37436
- target: req.targetId,
37437
- kind: req.kind,
37438
- upstream: childUrl
37439
- });
37440
- entry.proxies.push(proxy);
37441
- endpoint = proxy.url;
37762
+ let pending = entry.proxies.get(childUrl);
37763
+ if (pending === void 0) {
37764
+ pending = proxyFactory({
37765
+ bus: config.bus,
37766
+ target: req.targetId,
37767
+ kind: req.kind,
37768
+ upstream: childUrl
37769
+ });
37770
+ entry.proxies.set(childUrl, pending);
37771
+ }
37772
+ endpoint = (await pending).url;
37442
37773
  } catch {
37443
- endpoint = childUrl;
37774
+ entry.proxies.delete(childUrl);
37775
+ const direct = normalizeLocalUpstream(childUrl);
37776
+ if (direct === void 0) {
37777
+ const msg = refuseForeignEndpoint(childUrl, "ready line");
37778
+ failServe(msg);
37779
+ return;
37780
+ }
37781
+ endpoint = direct;
37444
37782
  }
37445
37783
  if (entry.stopping || settled && !entries.has(req.targetId)) {
37446
37784
  await closeProxies(entry);
@@ -37451,17 +37789,24 @@ function createStudioServeManager(config) {
37451
37789
  else becomeRunning();
37452
37790
  };
37453
37791
  streamLines(child.stdout, (line) => {
37454
- const m = spec.readyRe.exec(line);
37455
- if (m) onReady(m[1]);
37456
- if (spec.extraEndpointRe) {
37457
- const me = spec.extraEndpointRe.exec(line);
37458
- if (me) onReady(me[1]);
37459
- }
37460
- if (req.kind === "ecs" && entry.hostUrl === void 0) {
37461
- const endpoint = parsePublishedHostEndpoint(line);
37462
- if (endpoint) {
37463
- entry.hostUrl = endpoint;
37464
- if (entry.status === "running") emitServe(entry);
37792
+ const parsed = classifyChildLine(line);
37793
+ if (!parsed.diagnostic) {
37794
+ const m = spec.readyRepeats || !settled ? spec.readyRe.exec(parsed.text) : null;
37795
+ if (m) onReady(m[1]);
37796
+ if (spec.extraEndpointRe) {
37797
+ const me = spec.extraEndpointRe.exec(parsed.text);
37798
+ if (me) onReady(me[1]);
37799
+ }
37800
+ if (req.kind === "ecs" && entry.hostUrl === void 0) {
37801
+ const endpoint = parsePublishedHostEndpoint(parsed.untagged);
37802
+ if (endpoint) {
37803
+ const local = normalizeLocalUpstream(endpoint);
37804
+ if (local === void 0) refuseForeignEndpoint(endpoint, "published replica endpoint");
37805
+ else {
37806
+ entry.hostUrl = local;
37807
+ if (entry.status === "running") emitServe(entry);
37808
+ }
37809
+ }
37465
37810
  }
37466
37811
  }
37467
37812
  emitLog(config.bus, clock, req.targetId, line, "stdout");
@@ -38491,4 +38836,4 @@ function addStudioSpecificOptions(cmd) {
38491
38836
 
38492
38837
  //#endregion
38493
38838
  export { applyEdgeResponseResult as $, buildJwksUrlFromIssuer as $n, resolveCfnStackName as $r, buildCloudMapIndex as $t, startAgentCoreHttpServer as A, describeCredentialLoadFailure as Ai, classifySourceChange as An, ConnectionRegistry as Ar, addRunTaskSpecificOptions as At, idFromArn as B, buildStageMap as Bn, resolveRuntimeFileExtension as Br, resolveEcsAssumeRoleOption as Bt, addListSpecificOptions as C, resolveAgentCoreTarget as Ci, waitForAgentCorePing as Cn, tryParseStatus as Cr, parseLbPortOverrides as Ct, createLocalStartAgentCoreCommand as D, tryResolveImageFnJoin as Di, computeCodeImageTag as Dn, probeHostGatewaySupport as Dr, addStartServiceSpecificOptions as Dt, addStartAgentCoreSpecificOptions as E, substituteImagePlaceholders as Ei, buildAgentCoreCodeImage as En, HOST_GATEWAY_MIN_VERSION as Er, resolveAlbFrontDoor as Et, createLocalStartCloudFrontCommand as F, createWatchPredicates as Fn, buildDisconnectEvent as Fr, addImageOverrideOptions as Ft, classifyS3Error as G, filterRoutesByApiIdentifiers as Gn, substituteEnvVarsFromState as Gr, enforceImageOverrideOrphans as Gt, createDeployedKvsDataSource as H, resolveEnvVars$1 as Hn, EcsTaskResolutionError as Hr, runEcsServiceEmulator as Ht, normalizeKvsFileKeys as I, resolveApiTargetSubset as In, buildMessageEvent as Ir, buildEcsImageResolutionContext$1 as It, startCloudFrontServer as J, startApiServer as Jn, createLocalStateProvider as Jr, resolveImageOverrides as Jt, createS3OriginReader as K, groupRoutesByServer as Kn, substituteEnvVarsFromStateAsync as Kr, mergeForService as Kt, parseKvsFileOverrides as L, createAuthorizerCache as Ln, architectureToPlatform as Lr, ecsClusterOption as Lt, startAgentCoreWsBridge as M, resolveProfileCredentials as Mi, createLocalInvokeCommand as Mn, handleConnectionsRequest as Mr, MAX_TASKS_SUBNET_RANGE_CAP as Mt, LocalStartCloudFrontError as N, addStartApiSpecificOptions as Nn, parseConnectionsPath as Nr, addCommonEcsServiceOptions as Nt, buildAgentCoreServeAuthCheck as O, LocalInvokeBuildError as Oi, renderCodeDockerfile as On, resolveHostGatewayExtraHosts as Or, createLocalStartServiceCommand as Ot, addStartCloudFrontSpecificOptions as P, createLocalStartApiCommand as Pn, buildConnectEvent as Pr, addEcsAssumeRoleOptions as Pt, applyEdgeRequestResult as Q, buildCognitoJwksUrl as Qn, resolveCfnRegion as Qr, listPinnedTargets as Qt, parseOriginOverrides as R, createFileWatcher as Rn, buildContainerImage as Rr, parseMaxTasks as Rt, StudioEventBus as S, pickAgentCoreCandidateStack as Si, waitForAgentCoreHttpReady as Sn, selectIntegrationResponse as Sr, createLocalStartAlbCommand as St, formatTargetListing as T, formatStateRemedy as Ti, SUPPORTED_CODE_RUNTIMES as Tn, HOST_DOCKER_INTERNAL_GATEWAY as Tr, isApplicationLoadBalancer as Tt, resolveDeployedKvsArnByName as U, availableApiIdentifiers as Un, substituteAgainstState as Ur, ImageOverrideError as Ut, resolveKvsModulesForDistribution as V, materializeLayerFromArn as Vn, resolveRuntimeImage as Vr, resolveSharedSidecarCredentials as Vt, resolveDeployedOriginBucket as W, filterRoutesByApiIdentifier as Wn, substituteAgainstStateAsync as Wr, buildImageOverrideTag as Wt, serveFromStaticOrigin as X, resolveServiceIntegrationParameters as Xn, rejectExplicitCfnStackWithMultipleStacks as Xr, describePinnedImageUri as Xt, resolveErrorResponseCandidates as Y, resolveSelectionExpression as Yn, isCfnFlagPresent as Yr, runImageOverrideBuilds as Yt, serveLambdaUrlOrigin as Z, defaultCredentialsLoader as Zn, resolveCfnFallbackRegion as Zr, isLocalCdkAssetImage as Zt, filterStudioTargetGroups as _, AGENTCORE_AGUI_PROTOCOL as _i, parseSseForJsonRpc as _n, applyAuthorizerOverlay as _r, createCloudFrontModule as _t, createLocalStudioCommand as a, countTargets as ai, attachContainerLogStreamer as an, computeRequestIdentityHash as ar, describeS3OriginDomain as at, renderStudioHtml as b, AGENTCORE_RUNTIME_TYPE as bi, AGENTCORE_SESSION_ID_HEADER as bn, evaluateResponseParameters as br, addAlbSpecificOptions as bt, startStudioProxy as c, discoverWebSocketApis as ci, bridgeAgentCoreWs as cn, invokeTokenAuthorizer as cr, pickFunctionUrlLogicalIdFromOrigin as ct, createStudioDispatcher as d, parseSelectionExpressionPath as di, A2A_PATH as dn, buildCorsConfigByApiId as dr, pickTargetFunctionLogicalId as dt, CfnLocalStateProvider as ei, CloudMapRegistry as en, createJwksCache as er, buildEdgeRequestEvent as et, filterStudioCustomResources as f, webSocketApiMatchesIdentifier as fi, a2aInvokeOnce as fn, buildCorsConfigFromCloudFrontChain as fr, resolveCloudFrontDistribution as ft, annotatePinnedEcsTargets as g, AGENTCORE_A2A_PROTOCOL as gi, mcpInvokeOnce as gn, translateLambdaResponse as gr, stripCloudFrontImport as gt, annotateEcsTaskPinnedTargets as h, resolveLambdaArnIntrinsic as hi, MCP_PROTOCOL_VERSION as hn, matchRoute as hr, runViewerResponse as ht, coerceStopRequest as i, resolveSingleTarget as ii, getContainerNetworkIp as in, buildMethodArn as ir, CLOUDFRONT_DISTRIBUTION_TYPE as it, attachAgentCoreWsBridge as j, buildStsClientConfig as ji, addInvokeSpecificOptions as jn, buildMgmtEndpointEnvUrl as jr, createLocalRunTaskCommand as jt, selectServeInboundAuth as k, describeAwsFailureForWarn as ki, toCmdArgv as kn, bufferToBody as kr, serviceStrategy as kt, relayServeRequest as l, discoverWebSocketApisOrThrow as li, invokeAgentCoreWs as ln, attachAuthorizers as lr, pickKvsLogicalIdFromArn as lt, annotateAlbPinnedBackingServices as m, pickRefLogicalId as mi, MCP_PATH as mn, matchPreflight as mr, runViewerRequest as mt, coerceRunRequest as n, resolveSsmParameters as ni, SOFT_RELOAD_COMPLETION_LOG_SUFFIX as nn, verifyJwtAuthorizer as nr, edgeHeadersToHttp as nt, resolveServeBaseUrl as o, listTargets as oi, addInvokeAgentCoreSpecificOptions as on, evaluateCachedLambdaPolicy as or, extractKvsAssociations as ot, isCustomResourceLambdaTarget as p, discoverRoutes as pi, MCP_CONTAINER_PORT as pn, isFunctionUrlOacFronted as pr, compileCloudFrontFunction as pt, matchBehavior as q, readMtlsMaterialsFromDisk as qn, LocalStateSourceError as qr, parseImageOverrideFlags as qt, coerceServeRequest as r, resolveWatchConfig as ri, setShadowReadyTimeoutMs as rn, verifyJwtViaDiscovery as rr, httpHeadersToEdge as rt, createStudioServeManager as s, availableWebSocketApiIdentifiers as si, createLocalInvokeAgentCoreCommand as sn, invokeRequestAuthorizer as sr, isCloudFrontDistribution as st, addStudioSpecificOptions as t, collectSsmParameterRefs as ti, DEFAULT_SHADOW_READY_TIMEOUT_MS as tn, verifyCognitoJwt as tr, buildEdgeResponseEvent as tt, reinvoke as u, filterWebSocketApisByIdentifiers as ui, A2A_CONTAINER_PORT as un, applyCorsResponseHeaders as ur, pickLambdaEdgeFunctionLogicalId as ut, startStudioServer as v, AGENTCORE_HTTP_PROTOCOL as vi, AGENTCORE_SIGV4_SERVICE as vn, buildHttpApiV2Event as vr, createLocalFileKvsDataSource as vt, createLocalListCommand as w, derivePseudoParametersFromRegion as wi, downloadAndExtractS3Bundle as wn, VtlEvaluationError as wr, resolveAlbTarget as wt, createStudioStore as x, AgentCoreResolutionError as xi, invokeAgentCore as xn, pickResponseTemplate as xr, albStrategy as xt, toStudioTargetGroups as y, AGENTCORE_MCP_PROTOCOL as yi, signAgentCoreInvocation as yn, buildRestV1Event as yr, createUnboundCloudFrontModule as yt, resolveCloudFrontTarget as z, attachStageContext as zn, resolveRuntimeCodeMountPath as zr, parseRestartPolicy as zt };
38494
- //# sourceMappingURL=local-studio-5iIuMtjd.js.map
38839
+ //# sourceMappingURL=local-studio-BJxBmC75.js.map