@go-to-k/cdkd 0.286.2 → 0.286.3

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.
@@ -1,9 +1,9 @@
1
1
  import { _ as withStackName, d as applyDefaultNameForFallback, f as generateResourceName, h as looksLikeCdkdGeneratedName, m as getCurrentStackName, n as getLogger, p as generateResourceNameWithFallback, r as isStdoutReservedForPayload, s as getLiveRenderer } from "./logger-Dw-3y48G.js";
2
2
  import { t as awsClientDefaults } from "./aws-client-defaults-D-iYiIJ6.js";
3
- import { t as getCdkdVersion } from "./version-Crg_SdRh.js";
3
+ import { t as getCdkdVersion } from "./version-D3md0Lde.js";
4
4
  import { AsyncLocalStorage } from "node:async_hooks";
5
5
  import { randomUUID } from "node:crypto";
6
- import { CreateBucketCommand, DeleteObjectCommand, DeleteObjectsCommand, GetBucketLocationCommand, GetObjectCommand, HeadBucketCommand, HeadObjectCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchKey, PutBucketEncryptionCommand, PutBucketPolicyCommand, PutObjectCommand, PutPublicAccessBlockCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
6
+ import { CreateBucketCommand, DeleteObjectCommand, DeleteObjectsCommand, GetBucketLocationCommand, GetBucketReplicationCommand, GetObjectCommand, HeadBucketCommand, HeadObjectCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchKey, PutBucketEncryptionCommand, PutBucketPolicyCommand, PutObjectCommand, PutPublicAccessBlockCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
7
7
  import { CloudControlClient, CreateResourceCommand, DeleteResourceCommand, GetResourceCommand, GetResourceRequestStatusCommand, ListResourcesCommand, UpdateResourceCommand } from "@aws-sdk/client-cloudcontrol";
8
8
  import { AttachRolePolicyCommand, CreateRoleCommand, DeleteRoleCommand, DeleteRolePermissionsBoundaryCommand, DeleteRolePolicyCommand, DetachRolePolicyCommand, GetRoleCommand, GetRolePolicyCommand, IAMClient, ListAttachedRolePoliciesCommand, ListInstanceProfilesForRoleCommand, ListRolePoliciesCommand, ListRoleTagsCommand, NoSuchEntityException, PutRolePermissionsBoundaryCommand, PutRolePolicyCommand, RemoveRoleFromInstanceProfileCommand, TagRoleCommand, UntagRoleCommand, UpdateAssumeRolePolicyCommand, UpdateRoleCommand } from "@aws-sdk/client-iam";
9
9
  import { SQSClient } from "@aws-sdk/client-sqs";
@@ -35,6 +35,7 @@ import { KMSClient, ListAliasesCommand } from "@aws-sdk/client-kms";
35
35
  import { readFile } from "fs/promises";
36
36
  import { join as join$1 } from "path";
37
37
  import { CreateRepositoryCommand, DescribeImagesCommand as DescribeImagesCommand$1, DescribeRepositoriesCommand, ECRClient, GetAuthorizationTokenCommand, PutImageTagMutabilityCommand } from "@aws-sdk/client-ecr";
38
+ import { inspect } from "node:util";
38
39
  import { hostname } from "os";
39
40
  import graphlib from "graphlib";
40
41
  import { DescribeDBClustersCommand, DescribeDBInstancesCommand, RDSClient } from "@aws-sdk/client-rds";
@@ -4027,16 +4028,402 @@ function displaySafe(value, opts) {
4027
4028
  }
4028
4029
 
4029
4030
  //#endregion
4030
- //#region src/state/s3-noncurrent-version-purge.ts
4031
+ //#region src/state/s3-replication-purge-gap.ts
4032
+ /**
4033
+ * Tell the user when S3 REPLICATION has silently kept the bodies that
4034
+ * {@link ../state/s3-noncurrent-version-purge.js | the noncurrent-version
4035
+ * purge} just removed (issue
4036
+ * [#2447](https://github.com/go-to-k/cdkd/issues/2447)).
4037
+ *
4038
+ * ## The hole this closes
4039
+ *
4040
+ * `purgeNoncurrentKeyVersions` exists so that "cdkd deleted the object" means
4041
+ * the body is no longer retrievable. It reaches that by issuing
4042
+ * `DeleteObjects` entries that name a specific `VersionId`.
4043
+ *
4044
+ * **S3 never replicates a version-id delete.** Replication propagates PUTs
4045
+ * and — only when `DeleteMarkerReplication` is enabled — delete markers; a
4046
+ * delete naming a version id is deliberately not replicated, so that a delete
4047
+ * on the source cannot destroy data on the destination. On a state bucket
4048
+ * with Cross-Region (CRR) or Same-Region (SRR) replication the purge is
4049
+ * therefore SOURCE-ONLY: the replica keeps its own copy of every version,
4050
+ * `GetObject` with a `VersionId` still returns the secret, and cdkd reports
4051
+ * the purge as complete because on the only bucket it knows about, it was.
4052
+ *
4053
+ * That is exactly the shape the purge was built to remove — an operation that
4054
+ * reports success while the value stays readable — reproduced one bucket over
4055
+ * and invisible from every signal cdkd emitted. The user is not merely
4056
+ * unprotected, they are REASSURED, which is the direction this repo treats as
4057
+ * a defect rather than a gap.
4058
+ *
4059
+ * ## Why a probe and not only a doc line
4060
+ *
4061
+ * Documentation reaches the reader who goes looking. This reaches the one who
4062
+ * does not, at the moment the false belief would be formed. The whole cost is
4063
+ * one bucket-metadata call per process (see the cache below) and one IAM
4064
+ * action the caller may well not hold.
4065
+ *
4066
+ * ## Degrading is the DEFAULT, not the failure
4067
+ *
4068
+ * The purge's contract is that a missing permission produces a warning and
4069
+ * never an abort, and a detector bolted onto it must not be able to break
4070
+ * that. So this module NEVER THROWS, and treats every non-answer as "we do
4071
+ * not know" rather than as either polarity:
4072
+ *
4073
+ * - `ReplicationConfigurationNotFoundError` — the answer "no replication
4074
+ * configuration exists". S3 reports the ABSENCE of a configuration as an
4075
+ * ERROR, not as an empty response body, so this arm is the ordinary case
4076
+ * for almost every bucket and must not read as a failure.
4077
+ * `ReplicationConfiguration` ABSENT from a 200 response is treated the same
4078
+ * way: the SDK models the field as optional, and a modelled field is not a
4079
+ * promise that the API populates it.
4080
+ * - `AccessDenied` and friends — the caller lacks
4081
+ * `s3:GetReplicationConfiguration`. Logged at DEBUG and never at `warn`:
4082
+ * the overwhelming majority of buckets are not replicated, so warning here
4083
+ * would tell almost every user to grant a permission in order to be told
4084
+ * nothing. The doc names the action for the user who wants the check.
4085
+ * - anything else (throttle, 5xx, network) — DEBUG, and deliberately NOT
4086
+ * cached, so the next purge in the same process retries.
4087
+ *
4088
+ * ## What is cached, what is deduped, and why they are different keys
4089
+ *
4090
+ * The PROBE is cached per (bucket, asserted owner) for the process lifetime, as
4091
+ * the sibling `src/provisioning/create-only-properties.ts` caches its
4092
+ * `DescribeType` lookups and for the same reason: the answer cannot change
4093
+ * mid-run, the value stored is the in-flight PROMISE so concurrent purges share
4094
+ * one call, and only non-transient outcomes are kept. Without it a stack with
4095
+ * thirty custom resources would issue thirty identical `GetBucketReplication`
4096
+ * calls.
4097
+ *
4098
+ * The WARNING is deduped too, but on a DIFFERENT and deliberately wider key —
4099
+ * (bucket, object description, destinations) — with the repeats sent to
4100
+ * `debug`. The two keys answer two questions and must not be merged:
4101
+ *
4102
+ * - the bucket alone is not enough, because `lock-manager.ts` routes this
4103
+ * module's `warn` sink to `debug` on the ordinary release path (it purges a
4104
+ * heartbeat record on every command and must not warn about it). Keyed on the
4105
+ * bucket, that demoted line would consume the one warning the rollback
4106
+ * journal or the response sidecar needed;
4107
+ * - adding the description is still not enough, because two rules covering
4108
+ * different prefixes can name DIFFERENT destinations. Purging a `cdkd/dev/…`
4109
+ * key can match only the first rule; a later `cdkd/prod/…` purge under the
4110
+ * same description matches both, and without the destinations in the key the
4111
+ * user would clean the first replica and never learn the second one holds the
4112
+ * production journal.
4113
+ *
4114
+ * Deduping at all is a departure from the sibling purge module, which
4115
+ * deliberately repeats its own failure warning. The difference is that a
4116
+ * failure RECURS — each one is another object cdkd could not remove — while
4117
+ * this is a fact about a BUCKET, identical every time it is re-derived.
4118
+ * Repeating it thirty times in a deploy adds nothing and erodes the failure
4119
+ * warning standing next to it.
4120
+ */
4031
4121
  /**
4032
4122
  * Parenthetical used when a caller names nothing.
4033
4123
  *
4034
- * True of ANY object this function is pointed at, which is the bar for a
4035
- * default here: a caller that forgets to describe its object must still emit a
4036
- * warning that is correct, just less specific. It deliberately does not guess
4037
- * at content.
4124
+ * True of ANY object the purge is pointed at, which is the bar for a default
4125
+ * here: a caller that forgets to describe its object must still emit a warning
4126
+ * that is correct, just less specific. It deliberately does not guess at
4127
+ * content.
4128
+ *
4129
+ * Lives HERE rather than in `s3-noncurrent-version-purge.ts` only to keep the
4130
+ * import one-directional — the purge imports this module, so a shared constant
4131
+ * defined there and read here would be a cycle. It is the same sentence both
4132
+ * warnings fall back to, and one definition is what stops the two drifting.
4133
+ */
4134
+ const DEFAULT_PURGED_OBJECT_DESCRIPTION = "the body of an object cdkd has just reported as removed";
4135
+ /**
4136
+ * Cache of the replication probe, holding the in-flight promise.
4137
+ *
4138
+ * Keyed on the bucket AND the `ExpectedBucketOwner` the caller asserts. The
4139
+ * SUCCESS answer does not depend on the owner assertion — two callers
4140
+ * differing only in whether they assert it read the same configuration — but
4141
+ * the FAILURE does: a mismatched-owner 403 is an answer about that caller's
4142
+ * request, and caching it under the bare bucket name silenced the detector for
4143
+ * every correctly-scoped caller in the process. cdkd has two shapes today (the
4144
+ * provider probes without an owner, the state backend and lock manager with
4145
+ * one), so the two are distinguishable in practice.
4146
+ */
4147
+ const replicationProbeCache = /* @__PURE__ */ new Map();
4148
+ /**
4149
+ * (bucket, object description, destinations) triples already warned about.
4150
+ *
4151
+ * The one thing the warning says that VARIES is `objectDescription`; the rest
4152
+ * is a fact about the bucket, identical every time. So a stack with thirty
4153
+ * custom resources repeated a ~600-character warning thirty times while adding
4154
+ * no information after the first — and warning fatigue erodes the purge's own
4155
+ * failure warning standing next to it.
4156
+ *
4157
+ * Keyed on all three precisely so this cannot become a silence hole; the
4158
+ * module doc above gives the two failures a narrower key produces. Entries are
4159
+ * added only after the warning is actually emitted, so a throwing sink does
4160
+ * not burn a slot.
4161
+ *
4162
+ * RESIDUAL, bounded and recorded: cdkd cannot see whether the caller's sink
4163
+ * DEMOTED the line, so a `lock-manager.ts` release (which routes `warn` to
4164
+ * `debug`) claims the lock's slot and a later reap of a stale lock — which
4165
+ * would have warned — is suppressed. It is bounded to the LOCK description,
4166
+ * whose object the docs describe as carrying no secret, and to one process.
4167
+ */
4168
+ const warnedWarnings = /* @__PURE__ */ new Set();
4169
+ /** How many destinations the warning names before it truncates. */
4170
+ const MAX_NAMED_DESTINATIONS = 3;
4171
+ /** Shown when a rule carries no `Destination.Bucket` for us to name. */
4172
+ const UNNAMED_DESTINATION = "<destination not named by the replication rule>";
4173
+ /**
4174
+ * Error codes meaning "this principal will never get an answer here".
4175
+ *
4176
+ * Cached like a real answer, because unlike a throttle they do not clear
4177
+ * within the process: retrying on every purge would spend one denied API call
4178
+ * per custom resource for a result that cannot change.
4179
+ */
4180
+ const PERMANENT_DENIALS = /* @__PURE__ */ new Set([
4181
+ "AccessDenied",
4182
+ "AccessDeniedException",
4183
+ "AllAccessDisabled",
4184
+ "MethodNotAllowed",
4185
+ "NotImplemented"
4186
+ ]);
4187
+ /**
4188
+ * Credential-shaped failures that ARRIVE AS 403 and are nonetheless transient.
4189
+ *
4190
+ * Checked BEFORE the blanket 403 rule, which is the only way they can win:
4191
+ * `InvalidAccessKeyId` is the classic blip while an IAM principal or an
4192
+ * assumed-role credential propagates — this repo retries that whole class over
4193
+ * a 47.75 s schedule elsewhere — and an expired or skewed token clears on
4194
+ * refresh. Cached as permanent, ONE such blip on the first purge of a long
4195
+ * deploy would silence the detector for the rest of the process, which is the
4196
+ * "false silence leaves them believing a secret is gone" direction this module
4197
+ * forbids.
4198
+ */
4199
+ const TRANSIENT_CREDENTIAL_CODES = /* @__PURE__ */ new Set([
4200
+ "InvalidAccessKeyId",
4201
+ "ExpiredToken",
4202
+ "ExpiredTokenException",
4203
+ "InvalidToken",
4204
+ "TokenRefreshRequired",
4205
+ "RequestTimeTooSkewed",
4206
+ "RequestExpired"
4207
+ ]);
4208
+ const describe$2 = (error) => error instanceof Error ? error.message : String(error);
4209
+ /**
4210
+ * Every wire error code the value offers, `name` and `Code` alike.
4211
+ *
4212
+ * NO PRECEDENCE, deliberately. An earlier revision collapsed the two to one
4213
+ * string and preferred a specific `name`, which had two failure modes in
4214
+ * opposite directions: preferring `name` unconditionally made the `Code`
4215
+ * fallback dead (`{ name: 'Error', Code: 'AccessDenied' }` classified as
4216
+ * retryable, so a denied principal re-probed on every purge), while
4217
+ * preferring a SPECIFIC `name` let a wrapper's own name hide
4218
+ * `Code: 'InvalidAccessKeyId'` and cache a credential blip as a permanent
4219
+ * denial. Membership is tested against every code the error carries, so
4220
+ * neither field can mask the other. (Verified against real S3: a missing
4221
+ * replication configuration arrives with that string on BOTH fields.)
4222
+ *
4223
+ * Null-safe because a `send` that rejects with `null` used to throw a
4224
+ * TypeError INSIDE the probe's own catch, leaving a REJECTED promise in the
4225
+ * cache forever: every later purge on that bucket then fell into the outer
4226
+ * `catch {}` with no warning and no debug line — a silently dead detector,
4227
+ * which is the worst possible failure for this module.
4228
+ *
4229
+ * This guard and `probe`'s rejection arm both serve one invariant — **no
4230
+ * rejection ever reaches the cache** — and the honest statement of their
4231
+ * relationship, measured one mutation at a time on 2026-09-05, is:
4232
+ *
4233
+ * - removing the ARM alone REDS a case (a rejection whose own `toString`
4234
+ * throws: the guards pass that value through and `describe()` is what
4235
+ * raises), so the arm is independently fenced;
4236
+ * - removing these GUARDS alone leaves the CACHING behaviour identical:
4237
+ * `errorCodes` runs inside `runProbe`'s own catch, so a TypeError here is
4238
+ * caught by the arm and produces the same `unknown` / `retry: true` /
4239
+ * evicted outcome. What DOES change is the debug line's reason — guarded it
4240
+ * reports the rejection (`: null`), unguarded it reports the classifier's
4241
+ * own crash — and that difference is what the `throw null` case asserts, so
4242
+ * the guards are fenced after all.
4243
+ *
4244
+ * They also earn their place structurally: classification is inside the catch
4245
+ * TODAY. An edit that hoists it out — or that adds a caller of `errorCodes`
4246
+ * outside a try — reinstates the original defect, in which a `null` rejection
4247
+ * crashed the classifier and left a REJECTED promise cached for the process,
4248
+ * so every later purge on that bucket fell into the outer `catch {}` with no
4249
+ * warning and no debug line: a silently dead detector.
4250
+ */
4251
+ const errorCodes = (error) => {
4252
+ if (error === null || typeof error !== "object" && typeof error !== "function") return [];
4253
+ const e = error;
4254
+ const codes = [];
4255
+ if (typeof e.name === "string" && e.name.length > 0) codes.push(e.name);
4256
+ if (typeof e.Code === "string" && e.Code.length > 0) codes.push(e.Code);
4257
+ return codes;
4258
+ };
4259
+ /** True when ANY code the error carries is in `set`. */
4260
+ const hasCode = (error, set) => errorCodes(error).some((code) => set.has(code));
4261
+ const httpStatus = (error) => {
4262
+ if (error === null || typeof error !== "object" && typeof error !== "function") return void 0;
4263
+ const meta = error.$metadata;
4264
+ return typeof meta?.httpStatusCode === "number" ? meta.httpStatusCode : void 0;
4265
+ };
4266
+ /**
4267
+ * Strip the ARN wrapper off `Destination.Bucket`, which S3 returns as
4268
+ * `arn:<partition>:s3:::<name>`.
4269
+ *
4270
+ * Partition-agnostic on purpose: `aws-cn` and `aws-us-gov` produce a
4271
+ * different second segment, and a hard-coded `arn:aws:s3:::` would leave the
4272
+ * whole ARN in the message there rather than mis-parse it — survivable, but
4273
+ * the regex costs nothing. A value that is not ARN-shaped is passed through
4274
+ * unchanged rather than rejected; the field is only ever displayed.
4275
+ */
4276
+ function destinationName(bucketArn, account) {
4277
+ if (bucketArn === void 0 || bucketArn === "") return UNNAMED_DESTINATION;
4278
+ const name = bucketArn.replace(/^arn:[^:]*:s3:::/, "");
4279
+ return account !== void 0 && account !== "" ? `${name} (account ${account})` : name;
4280
+ }
4281
+ /**
4282
+ * Reduce a `ReplicationConfiguration` to the ENABLED rules and their effective
4283
+ * prefixes.
4284
+ *
4285
+ * Exported for its own unit tests: the mapping from the four filter shapes S3
4286
+ * accepts onto one prefix string is the part with real cases in it, and
4287
+ * pinning it through a stubbed client would test the plumbing instead.
4288
+ *
4289
+ * Over-approximates DELIBERATELY, in the direction of warning:
4290
+ *
4291
+ * - a rule filtering on a TAG (`Filter.Tag`, or `Filter.And.Tags` with no
4292
+ * `Prefix`) cannot be evaluated without reading each object's tag set, so
4293
+ * its prefix reduces to `''` and it covers everything. A false warning
4294
+ * sends the reader to look at a replica; a false silence leaves them
4295
+ * believing a secret is gone.
4296
+ * - a `Disabled` rule is KEPT, flagged rather than dropped. Disabling a rule
4297
+ * stops FUTURE replication; it does not remove what the rule already
4298
+ * copied, so a bucket whose rule covered `cdkd/` last month and is disabled
4299
+ * today still holds every body ever purged. Dropping it was the first cut
4300
+ * and is the reassured-user failure this module exists to remove, arriving
4301
+ * inside the module that removes it. A rule with no `Status` at all is kept
4302
+ * as ENABLED: `Status` is required by the model, so an absent one means the
4303
+ * response was not what we assumed — the same reason the sibling purge
4304
+ * treats an absent `IsLatest` as "assume the unsafe thing is true".
4305
+ *
4306
+ * Pure: it reads only its argument, so no ambient locale, clock or
4307
+ * environment can change the answer.
4308
+ */
4309
+ function normalizeReplicationRules(config) {
4310
+ const targets = [];
4311
+ for (const rule of config.Rules ?? []) {
4312
+ const prefix = rule.Filter !== void 0 ? rule.Filter.Prefix ?? rule.Filter.And?.Prefix ?? "" : rule.Prefix ?? "";
4313
+ targets.push({
4314
+ prefix,
4315
+ destination: destinationName(rule.Destination?.Bucket, rule.Destination?.Account),
4316
+ enabled: rule.Status !== "Disabled"
4317
+ });
4318
+ }
4319
+ return targets;
4320
+ }
4321
+ async function runProbe(s3Client, bucket, requestFields) {
4322
+ try {
4323
+ const config = (await s3Client.send(new GetBucketReplicationCommand({
4324
+ Bucket: bucket,
4325
+ ...requestFields
4326
+ }))).ReplicationConfiguration;
4327
+ if (config === void 0) return { kind: "none" };
4328
+ const rules = normalizeReplicationRules(config);
4329
+ return rules.length === 0 ? { kind: "none" } : {
4330
+ kind: "rules",
4331
+ rules
4332
+ };
4333
+ } catch (error) {
4334
+ if (errorCodes(error).includes("ReplicationConfigurationNotFoundError")) return { kind: "none" };
4335
+ if (hasCode(error, TRANSIENT_CREDENTIAL_CODES)) return {
4336
+ kind: "unknown",
4337
+ reason: describe$2(error),
4338
+ retry: true
4339
+ };
4340
+ const permanent = hasCode(error, PERMANENT_DENIALS) || httpStatus(error) === 403;
4341
+ return {
4342
+ kind: "unknown",
4343
+ reason: describe$2(error),
4344
+ retry: !permanent
4345
+ };
4346
+ }
4347
+ }
4348
+ function probe(s3Client, bucket, requestFields) {
4349
+ const key = `${bucket}\u0000${requestFields.ExpectedBucketOwner ?? ""}`;
4350
+ const cached = replicationProbeCache.get(key);
4351
+ if (cached) return cached;
4352
+ const pending = runProbe(s3Client, bucket, requestFields).then((result) => {
4353
+ if (result.kind === "unknown" && result.retry) evictIfOwn(key, pending);
4354
+ return result;
4355
+ }, (error) => {
4356
+ evictIfOwn(key, pending);
4357
+ return {
4358
+ kind: "unknown",
4359
+ reason: describe$2(error),
4360
+ retry: true
4361
+ };
4362
+ });
4363
+ replicationProbeCache.set(key, pending);
4364
+ return pending;
4365
+ }
4366
+ /**
4367
+ * Remove a cache entry only when it is still the one we put there.
4368
+ *
4369
+ * The identity check is UNFENCED by a test, deliberately and stated rather
4370
+ * than left to be discovered: the only way a stale probe can settle against a
4371
+ * newer entry under the same key is for something to have cleared the cache
4372
+ * mid-flight, and the sole clearer is `clearReplicationProbeCache()`, a
4373
+ * test-only helper. Writing a test for it would be writing a test for the test
4374
+ * helper. It is kept because the alternative is an unconditional `delete` that
4375
+ * is wrong the moment a non-test clearer appears.
4376
+ */
4377
+ function evictIfOwn(key, own) {
4378
+ if (replicationProbeCache.get(key) === own) replicationProbeCache.delete(key);
4379
+ }
4380
+ /**
4381
+ * Warn if replication on `bucket` covers any of `keys`.
4382
+ *
4383
+ * `keys` are the keys whose bodies were actually removed (or could not be
4384
+ * settled) — NOT everything the caller asked about. The purge scopes them; see
4385
+ * its call site for why announcing a survival that never happened is worse
4386
+ * than saying nothing.
4387
+ *
4388
+ * NEVER THROWS and never rejects: the caller runs on a cleanup path that must
4389
+ * not abort, and this is a diagnostic bolted onto it.
4038
4390
  */
4039
- const DEFAULT_OBJECT_DESCRIPTION = "the body of an object cdkd has just reported as removed";
4391
+ async function warnIfPurgeIsReplicated(s3Client, bucket, keys, options = {}) {
4392
+ if (keys.length === 0) return;
4393
+ const emitDebug = (message) => {
4394
+ const sink = options.logger;
4395
+ if (sink?.debug) sink.debug(message);
4396
+ else getLogger().child("s3-replication-check").debug(message);
4397
+ };
4398
+ try {
4399
+ const safeBucket = displaySafe(bucket, { asciiOnly: true });
4400
+ const result = await probe(s3Client, bucket, options.requestFields ?? {});
4401
+ if (result.kind === "none") return;
4402
+ if (result.kind === "unknown") {
4403
+ emitDebug(`Could not determine whether s3://${safeBucket} is replicated (grant s3:GetReplicationConfiguration to enable the check): ${displaySafe(result.reason)}`);
4404
+ return;
4405
+ }
4406
+ const covered = result.rules.filter((rule) => keys.some((key) => key.startsWith(rule.prefix)));
4407
+ if (covered.length === 0) return;
4408
+ const destinations = [...new Set(covered.map((rule) => rule.enabled ? rule.destination : `${rule.destination} (rule currently Disabled -- it stops FUTURE replication, not what it already copied)`))].sort();
4409
+ const warnKey = [
4410
+ bucket,
4411
+ options.objectDescription ?? "",
4412
+ ...destinations
4413
+ ].join("\0");
4414
+ if (warnedWarnings.has(warnKey)) {
4415
+ emitDebug(`S3 replication on s3://${safeBucket} still covers purged keys (${options.objectDescription ?? "the body of an object cdkd has just reported as removed"}); already reported once this run for ${destinations.map((d) => displaySafe(d, { asciiOnly: true })).join(", ")}.`);
4416
+ return;
4417
+ }
4418
+ const named = destinations.slice(0, MAX_NAMED_DESTINATIONS).map((d) => displaySafe(d, { asciiOnly: true }));
4419
+ const elided = destinations.length - named.length;
4420
+ (options.logger ?? getLogger().child("s3-replication-check")).warn(`S3 replication is enabled on s3://${safeBucket} and covers the key(s) cdkd just purged. S3 NEVER replicates a version-id delete, so the purge removed those versions from THIS bucket only — the copies in the destination bucket survive and remain readable there via GetObject with a VersionId (${options.objectDescription ?? "the body of an object cdkd has just reported as removed"}). cdkd cannot delete them. Remove them in the destination bucket yourself (aws s3api list-object-versions, then delete-object --version-id), or narrow the replication rule so it excludes the prefixes cdkd purges under. Destination(s): ${named.join(", ")}` + (elided > 0 ? ` (and ${elided} more; aws s3api get-bucket-replication --bucket ${safeBucket} lists them all)` : ""));
4421
+ warnedWarnings.add(warnKey);
4422
+ } catch {}
4423
+ }
4424
+
4425
+ //#endregion
4426
+ //#region src/state/s3-noncurrent-version-purge.ts
4040
4427
  /**
4041
4428
  * `objectDescription` for the custom-resource response sidecar.
4042
4429
  *
@@ -4110,17 +4497,27 @@ async function purgeNoncurrentKeyVersions(s3Client, bucket, keys, options = {})
4110
4497
  const prefixes = options.listPrefix !== void 0 ? [options.listPrefix] : keys;
4111
4498
  const failed = /* @__PURE__ */ new Map();
4112
4499
  const unknown = { n: 0 };
4500
+ const purged = /* @__PURE__ */ new Set();
4501
+ const unsettledBodies = /* @__PURE__ */ new Set();
4113
4502
  for (const prefix of prefixes) try {
4114
- await purgeUnderPrefix(s3Client, bucket, prefix, wanted, requestFields, failed, unknown);
4503
+ await purgeUnderPrefix(s3Client, bucket, prefix, wanted, requestFields, failed, unknown, purged, unsettledBodies);
4115
4504
  } catch (error) {
4116
4505
  const affected = options.listPrefix !== void 0 ? keys : [prefix];
4117
- for (const key of affected) recordFailure(failed, key, describe$1(error));
4506
+ for (const key of affected) {
4507
+ recordFailure(failed, key, describe$1(error));
4508
+ unsettledBodies.add(key);
4509
+ }
4118
4510
  }
4119
4511
  if (failed.size > 0) {
4120
4512
  const named = [...failed.entries()].slice(0, MAX_NAMED_FAILURES).map(([key, reasons]) => displaySafe(`${key} (${reasons.join("; ")})`));
4121
4513
  const elided = failed.size - named.length;
4122
- logger.warn(`Could not purge noncurrent versions of ${failed.size} key(s) in s3://${bucket}. Their previous versions survive and remain readable via GetObject with a VersionId (${options.objectDescription ?? DEFAULT_OBJECT_DESCRIPTION}). Grant s3:ListBucketVersions and s3:DeleteObjectVersion on the state bucket, or purge the key(s) by hand. Failures: ${named.join(", ")}` + (elided > 0 ? ` (and ${elided} more)` : ""));
4514
+ logger.warn(`Could not purge noncurrent versions of ${failed.size} key(s) in s3://${displaySafe(bucket, { asciiOnly: true })}. Their previous versions survive and remain readable via GetObject with a VersionId (${options.objectDescription ?? "the body of an object cdkd has just reported as removed"}). Grant s3:ListBucketVersions and s3:DeleteObjectVersion on the state bucket, or purge the key(s) by hand. Failures: ${named.join(", ")}` + (elided > 0 ? ` (and ${elided} more)` : ""));
4123
4515
  }
4516
+ await warnIfPurgeIsReplicated(s3Client, bucket, [.../* @__PURE__ */ new Set([...purged, ...unsettledBodies])], {
4517
+ requestFields,
4518
+ logger,
4519
+ ...options.objectDescription !== void 0 && { objectDescription: options.objectDescription }
4520
+ });
4124
4521
  }
4125
4522
  /**
4126
4523
  * Paginate `ListObjectVersions` under one prefix and delete every returned
@@ -4135,7 +4532,7 @@ async function purgeNoncurrentKeyVersions(s3Client, bucket, keys, options = {})
4135
4532
  * id is NOT filtered out on its own, because a bucket whose versioning was
4136
4533
  * SUSPENDED can carry a genuine noncurrent `'null'` version holding the body.
4137
4534
  */
4138
- async function purgeUnderPrefix(s3Client, bucket, prefix, wanted, requestFields, failed, unknown) {
4535
+ async function purgeUnderPrefix(s3Client, bucket, prefix, wanted, requestFields, failed, unknown, purged, unsettledBodies) {
4139
4536
  let keyMarker;
4140
4537
  let versionIdMarker;
4141
4538
  do {
@@ -4147,15 +4544,30 @@ async function purgeUnderPrefix(s3Client, bucket, prefix, wanted, requestFields,
4147
4544
  ...versionIdMarker !== void 0 && { VersionIdMarker: versionIdMarker }
4148
4545
  }));
4149
4546
  const stale = [];
4150
- for (const entry of [...resp.Versions ?? [], ...resp.DeleteMarkers ?? []]) {
4547
+ const entries = [...(resp.Versions ?? []).map((entry) => ({
4548
+ entry,
4549
+ hasBody: true
4550
+ })), ...(resp.DeleteMarkers ?? []).map((entry) => ({
4551
+ entry,
4552
+ hasBody: false
4553
+ }))];
4554
+ for (const { entry, hasBody } of entries) {
4151
4555
  if (entry.Key === void 0 || !wanted.has(entry.Key)) continue;
4152
- if (entry.IsLatest === void 0) recordFailure(failed, entry.Key, `version ${entry.VersionId ?? "<unknown>"}: listing omitted IsLatest, so the entry was left alone rather than risk deleting a current version`);
4556
+ if (entry.IsLatest === void 0) {
4557
+ recordFailure(failed, entry.Key, `version ${entry.VersionId ?? "<unknown>"}: listing omitted IsLatest, so the entry was left alone rather than risk deleting a current version`);
4558
+ if (hasBody) unsettledBodies.add(entry.Key);
4559
+ }
4153
4560
  if (entry.IsLatest !== false) continue;
4154
- if (!entry.VersionId) continue;
4561
+ if (!entry.VersionId) {
4562
+ recordFailure(failed, entry.Key, `listing returned a noncurrent entry with no VersionId, so it could not be deleted`);
4563
+ if (hasBody) unsettledBodies.add(entry.Key);
4564
+ continue;
4565
+ }
4155
4566
  stale.push({
4156
4567
  Key: entry.Key,
4157
4568
  VersionId: entry.VersionId
4158
4569
  });
4570
+ if (hasBody) purged.add(entry.Key);
4159
4571
  }
4160
4572
  for (let i = 0; i < stale.length; i += DELETE_BATCH_SIZE) {
4161
4573
  const batch = stale.slice(i, i + DELETE_BATCH_SIZE);
@@ -4182,7 +4594,10 @@ async function purgeUnderPrefix(s3Client, bucket, prefix, wanted, requestFields,
4182
4594
  }
4183
4595
  }
4184
4596
  if (resp.IsTruncated === true && resp.NextKeyMarker === void 0) {
4185
- for (const key of wanted) if (key.startsWith(prefix)) recordFailure(failed, key, TRUNCATED_NO_MARKER);
4597
+ for (const key of wanted) if (key.startsWith(prefix)) {
4598
+ recordFailure(failed, key, TRUNCATED_NO_MARKER);
4599
+ unsettledBodies.add(key);
4600
+ }
4186
4601
  }
4187
4602
  keyMarker = resp.IsTruncated === true ? resp.NextKeyMarker : void 0;
4188
4603
  versionIdMarker = keyMarker !== void 0 ? resp.NextVersionIdMarker : void 0;
@@ -6073,6 +6488,374 @@ function mergeEnv(overrides) {
6073
6488
  else merged[k] = v;
6074
6489
  return merged;
6075
6490
  }
6491
+ /** Replaces a redacted argv VALUE wherever this module masks one. */
6492
+ const REDACTED_ARGV_VALUE = "***";
6493
+ /**
6494
+ * `docker` argv flags whose NEXT token is a `KEY=VALUE` pair built from
6495
+ * user-supplied template data, and whose VALUE therefore must not reach a
6496
+ * user-visible error string:
6497
+ *
6498
+ * - `-e` / `--env` — a container's `Environment.Variables` (Lambda) or
6499
+ * `ContainerDefinition.Environment` (ECS), plus `--env-vars` overrides.
6500
+ * Values that cdkd classifies as sensitive never get here at all
6501
+ * ({@link partitionSensitiveEnv} emits those as a value-less `-e KEY`),
6502
+ * but everything else does: connection strings, endpoints, API base URLs.
6503
+ * - `--opt` — `DockerVolumeConfiguration.DriverOpts`, which for the `local`
6504
+ * driver carries mount options (`o=addr=…,username=…,password=…`).
6505
+ * - `--label` — `DockerVolumeConfiguration.Labels`, user-authored metadata.
6506
+ *
6507
+ * A flag NOT in this list keeps its value. For most of the argv that is
6508
+ * because the value is cdkd-authored or infrastructure-shaped — a container
6509
+ * id, an image ref, a `--subnet` CIDR, a `--format` template — and is the
6510
+ * diagnostic.
6511
+ *
6512
+ * That is NOT true of the whole remainder, and the residual is recorded here
6513
+ * rather than quietly implied: `--health-cmd`, `--ulimit`, `--link`,
6514
+ * `--entrypoint`, `--workdir` and the positional image + container command are
6515
+ * all template-supplied and still echoed. They stay unmasked deliberately —
6516
+ * each is a COMMAND or a structural knob whose text IS what an operator reads
6517
+ * the failure for, and none is a documented place to put a secret, unlike
6518
+ * `Environment` / `Secrets` / `DriverOpts`. Revisit per flag if a real leak is
6519
+ * found through one; do not widen the set on suspicion, since every addition
6520
+ * trades away diagnostic text.
6521
+ */
6522
+ const ARGV_VALUE_BEARING_FLAGS = /* @__PURE__ */ new Set([
6523
+ "-e",
6524
+ "--env",
6525
+ "--opt",
6526
+ "--label"
6527
+ ]);
6528
+ /**
6529
+ * Structural, whitespace-delimited form of {@link ARGV_VALUE_BEARING_FLAGS}
6530
+ * for scanning a STRING that embeds a space-joined argv. Keyed on the FLAG's
6531
+ * position — never on the secret's value, so an unrelated literal that merely
6532
+ * coincides with a value is left alone.
6533
+ *
6534
+ * What each piece actually buys, since one of them was mis-credited in review:
6535
+ * the `(^|\s)` lead is what stops `-e` matching inside `--env` (there is no
6536
+ * `m` flag, so `^` is start-of-INPUT), NOT the alternation order — reordering
6537
+ * the branches leaves every test green. The order is kept as belt and braces
6538
+ * only. The mandatory `\s+` after the flag IS load-bearing: it is what stops
6539
+ * `-easy` / `--environment` matching. The KEY is `[^\s=]*` rather than `+` so
6540
+ * an EMPTY key (`-e =value`) is masked too.
6541
+ */
6542
+ const ARGV_VALUE_TOKEN_RE = /(^|\s)(--env|--label|--opt|-e)(\s+)([^\s=]*)=\S*/g;
6543
+ /**
6544
+ * Return a copy of `args` with the VALUE of every
6545
+ * {@link ARGV_VALUE_BEARING_FLAGS} pair replaced by `***`. The KEY survives —
6546
+ * "which variable" is the diagnostic, "what it was set to" is the disclosure.
6547
+ *
6548
+ * A value-less `-e KEY` (the form {@link partitionSensitiveEnv} emits for a
6549
+ * sensitive key) has nothing to mask and is returned unchanged. `args` is
6550
+ * never mutated: the redacted copy is for DISPLAY only, never for `spawn`.
6551
+ */
6552
+ function redactDockerArgvValues(args) {
6553
+ const out = [];
6554
+ for (let i = 0; i < args.length; i++) {
6555
+ const cur = args[i];
6556
+ const next = args[i + 1];
6557
+ if (ARGV_VALUE_BEARING_FLAGS.has(cur) && typeof next === "string") {
6558
+ const eqIdx = next.indexOf("=");
6559
+ if (eqIdx >= 0) {
6560
+ out.push(cur, `${next.substring(0, eqIdx)}=${REDACTED_ARGV_VALUE}`);
6561
+ i++;
6562
+ continue;
6563
+ }
6564
+ }
6565
+ out.push(cur);
6566
+ }
6567
+ return out;
6568
+ }
6569
+ /**
6570
+ * Redact argv-borne values inside an error TEXT before it reaches the user.
6571
+ *
6572
+ * **Wrap EVERY `execFile`-derived docker failure text in this**, including one
6573
+ * whose argv carries no user data today — `execFile` puts the WHOLE command
6574
+ * line into `err.message` (`Command failed: <file> <args joined by ' '>\n<stderr>`,
6575
+ * measured on Node 24), so any argv that later gains a `-e` pair starts leaking
6576
+ * with no edit to the error site. That silent-gap shape is exactly what
6577
+ * [#2440](https://github.com/go-to-k/cdkd/issues/2440) reported: the debug log
6578
+ * one screen earlier redacted the same argv and the error path did not.
6579
+ *
6580
+ * Two passes, both structural (positional), never value-based — matching on a
6581
+ * secret's VALUE would also blank an unrelated string that happens to equal it:
6582
+ *
6583
+ * 1. **Exact command-line substitution** (only when `args` is given). The raw
6584
+ * `args.join(' ')` is replaced by {@link redactDockerArgvValues}' rendering
6585
+ * of the same array. This is the pass that matters, because it is the only
6586
+ * one that can mask a value CONTAINING WHITESPACE (`-e CFG={"a": 1}`), which
6587
+ * no token scan of a space-joined string can delimit.
6588
+ * 2. **Token scan** ({@link ARGV_VALUE_TOKEN_RE}) over the result. Catches an
6589
+ * argv echoed in a shape pass 1 cannot see — a future Node message format,
6590
+ * a docker stderr quoting the flag back, or a call site with no `args` to
6591
+ * hand. Deliberately fail-loud rather than fail-open: a `-e X=y` occurring
6592
+ * in unrelated stderr prose loses its value and keeps its key.
6593
+ *
6594
+ * Idempotent (`-e KEY=***` re-masks to itself), so double-wrapping is safe.
6595
+ *
6596
+ * **Call sites should use a composer** ({@link describeDockerFailure} and its
6597
+ * siblings) rather than this function: the composers take a REQUIRED `args`,
6598
+ * which is what makes the redaction impossible to forget. This is the
6599
+ * primitive they are built from, exported so the four passes can be tested
6600
+ * against Node's real message shapes directly — it has no other caller in
6601
+ * `src/`, and that is deliberate rather than an oversight.
6602
+ */
6603
+ function redactDockerArgvInText(text, args) {
6604
+ let out = text;
6605
+ if (args && args.length > 0) {
6606
+ const maskedArgs = redactDockerArgvValues(args);
6607
+ const raw = args.join(" ");
6608
+ const masked = maskedArgs.join(" ");
6609
+ if (masked !== raw) out = out.split(raw).join(masked);
6610
+ for (let i = 0; i < args.length; i++) {
6611
+ const rawArg = args[i];
6612
+ const maskedArg = maskedArgs[i];
6613
+ if (maskedArg === rawArg) continue;
6614
+ if (!isSubstitutableToken(rawArg)) continue;
6615
+ out = out.split(rawArg).join(maskedArg);
6616
+ const escapedRaw = nodeQuotedRendering(rawArg);
6617
+ const escapedMasked = nodeQuotedRendering(maskedArg);
6618
+ if (escapedRaw !== void 0 && escapedMasked !== void 0 && escapedRaw !== rawArg) out = out.split(escapedRaw).join(escapedMasked);
6619
+ }
6620
+ out = repairSpawnRefusal(out, args, maskedArgs);
6621
+ }
6622
+ return out.replace(ARGV_VALUE_TOKEN_RE, (_match, lead, flag, gap, key) => `${lead}${flag}${gap}${key}=${REDACTED_ARGV_VALUE}`);
6623
+ }
6624
+ /**
6625
+ * Shortest VALUE that pass 1b will substitute as a bare token.
6626
+ *
6627
+ * Pass 1b's needle is the whole `KEY=VALUE` element, and it is replaced
6628
+ * EVERYWHERE in the text — so a tiny needle is a liability rather than a
6629
+ * protection. The empty-key case makes that concrete: a non-sensitive
6630
+ * `{ Name: '', Value: '1' }` produces the two-character token `=1`, and
6631
+ * substituting that rewrites every `=1` in the message (`--cpus=1`,
6632
+ * `status=1`, a path segment). Below this floor the value is not worth
6633
+ * protecting by substring match, and passes 1 and 3 still cover it in the
6634
+ * message shapes that carry a `-e ` prefix or the joined command line.
6635
+ */
6636
+ const MIN_SUBSTITUTABLE_VALUE_LENGTH = 4;
6637
+ /** Is this `KEY=VALUE` element long enough to substitute as a bare token? */
6638
+ function isSubstitutableToken(arg) {
6639
+ const eqIdx = arg.indexOf("=");
6640
+ if (eqIdx < 0) return false;
6641
+ return arg.length - eqIdx - 1 >= MIN_SUBSTITUTABLE_VALUE_LENGTH;
6642
+ }
6643
+ /**
6644
+ * Render `value` the way Node renders an argv element it quotes back at you —
6645
+ * i.e. with the SAME function Node used, `util.inspect`, minus the quote
6646
+ * characters it chose.
6647
+ *
6648
+ * This started as a hand-rolled `\xNN` escaper and it was WRONG for 16 of the
6649
+ * first 128 code points. Measured against real `execFile` rejections on Node
6650
+ * 24.19.0 (this repo, 2026-09-05): hand-rolled matched 4 of 13 cases,
6651
+ * `inspect` matched 13 of 13.
6652
+ *
6653
+ * input Node emits hand-rolled built
6654
+ * LF `K=a\nb\x00S` `K=a\x0ab\x00S` (Node uses a short escape)
6655
+ * DEL `K=a\x7Fb\x00S` `K=a\x7fb\x00S` (Node uses UPPERCASE hex)
6656
+ * backslash `K=a\\b\x00S` `K=a\b\x00S` (not escaped at all)
6657
+ *
6658
+ * Every miss printed the whole secret. The trigger for this path is a NUL in
6659
+ * a value — binary-ish data, which almost always carries a second control
6660
+ * byte or a backslash — so the table missed the REALISTIC case and hit only
6661
+ * the synthetic NUL-alone one its own fixture happened to use.
6662
+ *
6663
+ * `slice(1, -1)` is safe across the quote Node picks: `inspect` switches
6664
+ * between `'`, `"` and a backtick depending on content and escapes whichever
6665
+ * it chose; because this is the same call Node made, the rendering matches
6666
+ * whatever it picked (verified for a value containing a single quote, a
6667
+ * double quote, both, and a backtick).
6668
+ *
6669
+ * The general lesson, and why this is no longer a table: do not re-implement
6670
+ * another program's formatter — call it.
6671
+ */
6672
+ function nodeQuotedRendering(value) {
6673
+ const rendered = inspect(value);
6674
+ const quote = rendered[0];
6675
+ if (quote === void 0 || !`'"\``.includes(quote)) return void 0;
6676
+ if (rendered.length < 2 || !rendered.endsWith(quote)) return void 0;
6677
+ const inner = rendered.slice(1, -1);
6678
+ return inner.includes(`${quote} +`) ? void 0 : inner;
6679
+ }
6680
+ /**
6681
+ * Node's refusal to spawn, which QUOTES ONE argv element and names its INDEX:
6682
+ *
6683
+ * The argument 'args[2]' must be a string without null bytes. Received '…'
6684
+ *
6685
+ * The one message shape where the index tells us exactly which of OUR args is
6686
+ * being echoed — so the clause can be REWRITTEN from cdkd's own copy of the
6687
+ * element rather than searched for. That matters because Node does not print
6688
+ * the element whole: it truncates near 200 characters and renders a
6689
+ * newline-bearing value as concatenated chunks, so no needle can match it.
6690
+ *
6691
+ * Global, and scanned to exhaustion rather than bailing on the first match.
6692
+ * Anchoring at `^` was an earlier attempt at forgery resistance and it was
6693
+ * WORSE: it made a refusal anywhere but position 0 unrepairable, which is a
6694
+ * LEAK, and a leak beats a truncated message every time. Pinning Node's
6695
+ * literal prefix keeps the forgery bar high without that trade.
6696
+ *
6697
+ * Pinning the wording has its own cost: if Node rewords the message the repair
6698
+ * silently stops firing. That is why `tests/unit/utils/docker-cmd.test.ts`
6699
+ * drives this path from a REAL rejection — a reword turns CI red instead of
6700
+ * turning the redaction off.
6701
+ */
6702
+ const SPAWN_REFUSAL_RE = /The argument 'args\[(\d+)\]'[^']*?Received /g;
6703
+ /**
6704
+ * The quoted element Node prints after `Received `.
6705
+ *
6706
+ * Three shapes, and the third is the one that matters. `inspect` renders a
6707
+ * newline-bearing value as chunks joined by ` +`, and Node then SLICES the
6708
+ * whole rendering at 128 characters and appends `...` — so the last chunk is
6709
+ * usually UNTERMINATED:
6710
+ *
6711
+ * Received 'DB_URL=AAA…\n' +
6712
+ * 'hunter2SECRETCCC...
6713
+ *
6714
+ * A pattern that only accepts complete chunks stops after the second `'` and
6715
+ * leaves that tail in place. Measured on a real rejection: 272 of 288 probe
6716
+ * shapes leaked the secret verbatim, and the end-of-string version this
6717
+ * replaced did not — the bound was a regression, not a hardening, until the
6718
+ * trailing open chunk was admitted.
6719
+ *
6720
+ * The separator is Node's own (` +\n `) with HORIZONTAL space only, and an
6721
+ * open chunk runs to end of LINE. Both are deliberate: `\s*\+\s*` and an
6722
+ * unbounded tail let a crafted value swallow the diagnostic lines that follow
6723
+ * the clause.
6724
+ */
6725
+ const QUOTED_CHUNK = String.raw`'(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"|\`(?:[^\`\\\n]|\\.)*\``;
6726
+ /** An opening quote whose closing one was truncated away; bounded to its line. */
6727
+ const OPEN_CHUNK = String.raw`['"\`](?:[^\\\n]|\\.)*`;
6728
+ /** Node's chunk join: horizontal space, `+`, at most one newline, horizontal space. */
6729
+ const CHUNK_SEPARATOR = String.raw`[^\S\n]*\+[^\S\n]*\n?[^\S\n]*`;
6730
+ const QUOTED_ELEMENT_RE = new RegExp(`^(?:(?:${QUOTED_CHUNK})(?:${CHUNK_SEPARATOR}(?:${QUOTED_CHUNK}))*(?:${CHUNK_SEPARATOR}(?:${OPEN_CHUNK}))?|(?:${OPEN_CHUNK}))`);
6731
+ /**
6732
+ * Replace the quoted argv element in a spawn-refusal message with the masked
6733
+ * rendering of the arg its OWN index names.
6734
+ *
6735
+ * Known cosmetic effect: the replacement prints the FULL key where Node had
6736
+ * truncated, so a pathologically long env-var NAME makes the message longer
6737
+ * than Node's ~200 characters. Keys are not secret, and the width is not worth
6738
+ * a second truncation rule with its own edge cases.
6739
+ */
6740
+ function repairSpawnRefusal(text, args, maskedArgs) {
6741
+ const scanner = new RegExp(SPAWN_REFUSAL_RE.source, "g");
6742
+ let out = "";
6743
+ let cursor = 0;
6744
+ for (let match = scanner.exec(text); match !== null; match = scanner.exec(text)) {
6745
+ if (match.index < cursor) continue;
6746
+ const index = Number(match[1]);
6747
+ const rawArg = args[index];
6748
+ const maskedArg = maskedArgs[index];
6749
+ if (rawArg !== void 0 && maskedArg === rawArg) continue;
6750
+ const clauseEnd = match.index + match[0].length;
6751
+ const quoted = QUOTED_ELEMENT_RE.exec(text.slice(clauseEnd));
6752
+ if (quoted === null) continue;
6753
+ const replacement = maskedArg === void 0 ? `'${REDACTED_ARGV_VALUE}'` : inspect(maskedArg);
6754
+ out += text.slice(cursor, clauseEnd) + replacement;
6755
+ cursor = clauseEnd + quoted[0].length;
6756
+ scanner.lastIndex = cursor;
6757
+ }
6758
+ return out + text.slice(cursor);
6759
+ }
6760
+ /**
6761
+ * Compose a user-visible description of a `child_process` rejection from a
6762
+ * docker call, ALREADY REDACTED against that call's own argv.
6763
+ *
6764
+ * Moved here from `src/local/invoke-agentcore-watch-loop.ts` in issue #2440's
6765
+ * review round 3, and the move is the point: `args` is REQUIRED, so a call
6766
+ * site cannot obtain the text without handing over the argv to redact it
6767
+ * with. That is a stronger guarantee than any text fence over the call sites
6768
+ * — which is what the round-2 reviewers demonstrated, by writing four
6769
+ * spellings of an unredacted read that the fence could not see.
6770
+ *
6771
+ * Shape differs from the `stderr || message` composition the other sites use,
6772
+ * and deliberately so: `err.stderr` is where docker writes its actionable
6773
+ * diagnostics, while `err.message` carries the exit status, so the AgentCore
6774
+ * soft-reload path APPENDS rather than prefers — without stderr the wrapped
6775
+ * error would only say "Command failed with exit code N".
6776
+ */
6777
+ function describeDockerExecFailure(error, args) {
6778
+ const message = thrownMessageText(error) || safeStringify(error);
6779
+ const stderrText = capturedStreamText(error, "stderr");
6780
+ return redactDockerArgvInText(stderrText ? `${message}\n${stderrText}` : message, args);
6781
+ }
6782
+ /**
6783
+ * A captured stream of a `child_process` rejection as trimmed text, or `''`.
6784
+ *
6785
+ * Takes `unknown`, not `Error`, and duck-types the field. Every composer here
6786
+ * is called from a `catch`, where the value is whatever was thrown — and the
6787
+ * shapes the call sites actually see are plain objects
6788
+ * (`{ stderr, message }`), `SpawnError`, and cross-realm `Error`s that
6789
+ * `instanceof` misses. An earlier revision narrowed this to `Error` and the
6790
+ * standard composer then wrapped non-Errors in a FRESH `Error`, which has no
6791
+ * `.stderr` at all — so the whole diagnostic silently became
6792
+ * `'[object Object]'` for exactly the shape most of the call sites throw.
6793
+ *
6794
+ * `execFile` hands back a string under the default encoding and a `Buffer`
6795
+ * under `encoding: 'buffer'`; `ArrayBuffer.isView` covers a plain
6796
+ * `Uint8Array` too.
6797
+ */
6798
+ function capturedStreamText(error, field) {
6799
+ try {
6800
+ const raw = error?.[field];
6801
+ if (typeof raw === "string") return raw.trim();
6802
+ if (ArrayBuffer.isView(raw)) return Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength).toString("utf8").trim();
6803
+ } catch {}
6804
+ return "";
6805
+ }
6806
+ /** A thrown value's `message` as text, or `''`. Duck-typed, for the same reason. */
6807
+ function thrownMessageText(error) {
6808
+ try {
6809
+ const raw = error?.["message"];
6810
+ return typeof raw === "string" ? raw : "";
6811
+ } catch {
6812
+ return "";
6813
+ }
6814
+ }
6815
+ /**
6816
+ * `String(value)` that cannot itself throw. A rejection with a null prototype
6817
+ * (or a `toString` that throws) would otherwise blow up INSIDE a `catch` — and
6818
+ * two of these call sites are in `cleanupEcsRun`, where an exception aborts
6819
+ * the remaining volume / network teardown and leaks real Docker resources.
6820
+ */
6821
+ function safeStringify(error) {
6822
+ try {
6823
+ return String(error);
6824
+ } catch {
6825
+ return "[unstringifiable rejection]";
6826
+ }
6827
+ }
6828
+ /**
6829
+ * The STANDARD composer for a docker failure text: the captured stderr if
6830
+ * there is any, else the error's own message, else its string form —
6831
+ * ALREADY REDACTED against that call's argv.
6832
+ *
6833
+ * Use this at every site that wraps a docker `execFile` / spawn rejection.
6834
+ * `args` is REQUIRED, which is the whole point: a call site cannot obtain the
6835
+ * text without handing over the argv to redact it with, so the guarantee is
6836
+ * type-checked rather than fenced. Issue #2440's review spent three rounds
6837
+ * showing that a text fence over hand-composed sites is evadable — an
6838
+ * intermediate variable, an inline cast, a destructure, a computed member, a
6839
+ * concat — while this shape has nothing to evade.
6840
+ *
6841
+ * Prefer stderr over message because docker writes its actionable diagnostic
6842
+ * there, and `execFile`'s message is mostly the command line plus an exit
6843
+ * status. {@link describeDockerExecFailure} keeps BOTH, for a caller whose
6844
+ * wrapper text needs the status as well.
6845
+ */
6846
+ function describeDockerFailure(error, args) {
6847
+ return redactDockerArgvInText(capturedStreamText(error, "stderr") || thrownMessageText(error) || safeStringify(error), args);
6848
+ }
6849
+ /**
6850
+ * Composer for a captured-output failure where the diagnostic may be on
6851
+ * STDOUT rather than stderr (`runDockerStreaming`'s non-zero-exit path, whose
6852
+ * `SpawnError` carries both). `fallback` is used when neither stream said
6853
+ * anything. Redacted, and `args` is required, for the same reason as
6854
+ * {@link describeDockerFailure}.
6855
+ */
6856
+ function describeDockerCapturedOutput(error, args, fallback) {
6857
+ return redactDockerArgvInText(capturedStreamText(error, "stderr") || capturedStreamText(error, "stdout") || fallback, args);
6858
+ }
6076
6859
 
6077
6860
  //#endregion
6078
6861
  //#region src/assets/docker-build.ts
@@ -22525,7 +23308,7 @@ var CloudControlProvider = class {
22525
23308
  const indeterminateGuard = await this.confirmDeleteTargetIdentity(logicalId, resourceType, physicalId, context);
22526
23309
  if (context?.removeProtection === true && resourceType === "AWS::AutoScaling::AutoScalingGroup") {
22527
23310
  this.logger.debug(`Delegating protected AutoScalingGroup ${logicalId} delete to the SDK ASGProvider (Cloud Control cannot force-delete a protected ASG)`);
22528
- const { ASGProvider } = await import("./asg-provider-CSEPihOt.js").then((n) => n.n);
23311
+ const { ASGProvider } = await import("./asg-provider-uaOnRUMe.js").then((n) => n.n);
22529
23312
  return withIndeterminateGuard(await new ASGProvider().delete(logicalId, physicalId, resourceType, _properties, context), indeterminateGuard);
22530
23313
  }
22531
23314
  const isProtectedEc2Instance = context?.removeProtection === true && resourceType === "AWS::EC2::Instance";
@@ -29614,7 +30397,7 @@ function isStatefulRecreateTargetForReplace(resourceType, recordedProperties) {
29614
30397
  function renderStatefulReason(reason) {
29615
30398
  switch (reason) {
29616
30399
  case "always": return "destroy loses all data in the resource";
29617
- case "has-objects": return "S3 bucket is non-empty";
30400
+ case "has-objects": return "S3 bucket is not provably empty";
29618
30401
  case "has-retention": return "log group retains data (RetentionInDays > 0)";
29619
30402
  case "has-log-events": return "log group is not provably empty";
29620
30403
  case null: return "(not stateful)";
@@ -34818,5 +35601,5 @@ var DeployEngine = class {
34818
35601
  };
34819
35602
 
34820
35603
  //#endregion
34821
- export { beginCommandInterruptScope as $, resolveAutoAssetStorage as $n, isTransientServerError as $r, maskSecretsInError as $t, formatResourceLine as A, ensureAssetStorage as An, DependencyError as Ar, requireConfigString as At, isExportAliasCollision as B, dockerSpawnEnvWithSensitive as Bn, ResourceTimeoutError as Br, INTRINSIC_KEYS as Bt, unsupportedFinalSnapshotError as C, loadPublishableAssetManifest as Cn, getAwsClients as Cr, coerceCfnBoolean as Ct, isStatefulRecreateTargetForReplace as D, AssetModeResolver as Dn, CdkdError as Dr, replayWarn as Dt, MULTI_REGION_RECREATE_BLOCKED_TYPES as E, stripControlChars as En, AssetError as Er, readConfigString as Et, red as F, validateAssetBucketName as Fn, LocalStartServiceError as Fr, s3BucketDualStackDomainName as Ft, collectInlinePolicyNamesManagedBySiblings as G, runDockerStreaming as Gn, SynthesisError as Gr, STATE_SOURCED_CROSS_GENERATION_RULES as Gt, stateKeySecretExposure as H, getDockerCmd as Hn, StackHasActiveImportsError as Hr, withRetry as Ht, yellow as I, validateContainerRepoName as In, LockError as Ir, s3BucketRegionalDomainName as It, findActionableSilentDrops as J, Synthesizer as Jn, normalizeAwsError as Jr, carriesSecretMask as Jt, clearOnUpdateRemoval as K, AssetManifestLoader as Kn, formatError as Kr, STATE_SOURCED_READBACK_RULES as Kt, collectDeclaredOutputNames as L, buildDenyExternalAccessPolicy as Ln, NestedStackChildDirectDestroyError as Lr, s3BucketWebsiteUrl as Lt, cyan as M, isCrossRegionRedirect as Mn, DynamicReferenceRegionAmbiguousError as Mr, producerRegionsFromState as Mt, gray as N, parseBootstrapMarker as Nn, IntrinsicResolutionRefusalError as Nr, s3BucketArn as Nt, isStatefulRecreateTargetSync as O, BOOTSTRAP_MARKER_PREFIX as On, ConfigError as Or, requireConfigArray as Ot, green as P, readBootstrapMarkerBody as Pn, LocalInvokeBuildError as Pr, s3BucketDomainName as Pt, maskerOrIdentity as Q, resolveApp as Qn, isThrottlingError as Qr, isSingleDynamicReferenceToken as Qt, collectPublishedOutputNames as R, describeAwsFailure as Rn, PartialFailureError as Rr, applyRoleArnIfSet as Rt, refusesFinalSnapshot as S, createAssetRedirectResolver as Sn, AwsClients as Sr, assertRegionMatch as St, extractDeploymentEventError as T, escapeRegExp$1 as Tn, setAwsClients as Tr, configStringRefusal as Tt, getCurrentResourceSecrets as U, partitionSensitiveEnv as Un, StackTerminationProtectionError as Ur, DagBuilder as Ut, secretBearingStateKeyWarning as V, formatDockerLoginError as Vn, ResourceUpdateNotSupportedError as Vr, describeTypeWithThrottleRetry as Vt, IAMRoleProvider as W, runDockerForeground as Wn, StateError as Wr, TemplateParser as Wt, createMaskedRetryLogger as X, getDefaultStateBucketName as Xn, isMarkedNonRetryable as Xr, dynamicReferenceTokens as Xt, findSilentDropProperties as Y, synthesisStatusMessage as Yn, withErrorHandling as Yr, createSecretMasker as Yt, maskDeep as Z, getLegacyStateBucketName as Zn, isRetryableTransientError as Zr, errorCauseChain as Zt, PRE_DELETE_SNAPSHOT_TYPES as _, shouldRetainResource as _n, derivePartitionAndUrlSuffix as _r, parameterTypeMayLoseSecretIdentity as _t, DeploymentEventsStore as a, LockManager as an, stateBucketExistenceConfirmed as ar, slowCcOperationTimeoutMs as at, createPreDeleteFinalSnapshot as b, WorkGraph as bn, clearBucketRegionCache as br, normalizeAwsTagsToCfn as bt, replayFailedOperations as c, UNRENDERABLE as cn, CFN_TEMPLATE_URL_LIMIT as cr, deleteSkipReason as ct, updatePartialReason as d, forceQuitRecoveryClause as dn, uploadCfnTemplate as dr, IntrinsicFunctionResolver as dt, markNonRetryable as ei, maskSecretsInText as en, resolveCaptureObservedState as er, endCommandInterruptScope as et, withResourceDeadline as f, CUSTOM_RESOURCE_RESPONSE_PREFIX as fn, CUSTOM_RESOURCE_RESPONSE_OBJECT_DESCRIPTION as fr, carriesDynamicReference as ft, ATOMIC_FINAL_SNAPSHOT_TYPES as g, importableOutputs as gn, canonicalizeRegion as gr, isUnboundTemplateParameter as gt, computeImplicitDeleteEdges as h, importableOutputKeys as hn, PARTITION_TABLE as hr, getAccountInfo as ht, DeploymentEventsReader as i, scrubResourceRecord as in, resolveUseCdkBootstrapAssets as ir, CloudControlProvider as it, bold as j, getBootstrapMarkerKey as jn, DeployCancelledError as jr, classifyReplaySecretRegion as jt, renderStatefulReason as k, assertAssetBucketRegion as kn, CrossAccountSecretRefusalError as kr, requireConfigObject as kt, replayRollback as l, buildForceUnlockCommand as ln, MIGRATE_TMP_PREFIX as lr, disableInstanceApiTermination as lt, IMPLICIT_DELETE_DEPENDENCIES as m, exportNamesCarriedFrom as mn, expectedOwnerParam as mr, coerceParameterTypedValue as mt, DEFAULT_RESOURCE_WARN_AFTER_MS as n, retryClassificationText as ni, recoverMaskedOutput as nn, resolveStateBucketWithDefault as nr, isInterruptedWaitError as nt, planFailedOps as o, S3StateBackend as on, warnDeprecatedNoPrefixCliFlag as or, UNSPECIFIED_SKIP_REASON as ot, maskingRetryLogger as p, DEFAULT_STATE_PREFIX as pn, displaySafe as pr, cfnRefValueFromPhysicalId as pt, ProviderRegistry as q, getDockerImageBySourceHash as qn, isCdkdError as qr, TEMPLATE_SOURCED_RULES as qt, DeployEngine as r, __exportAll as ri, redactSecretsForState as rn, resolveStateBucketWithDefaultAndSource as rr, startInterruptWatch as rt, planRollback as s, rebuildClientForBucketRegion as sn, CFN_TEMPLATE_BODY_LIMIT as sr, deleteIndeterminateGuards as st, DEFAULT_RESOURCE_TIMEOUT_MS as t, markRedactedCause as ti, recordMaskOnlyValue as tn, resolveSkipPrefix as tr, interruptWatchListenerCount as tt, updatePartialMessage as u, buildLockContentionMessage as un, findLargeInlineResources as ur, isTerminationProtectionPropagationError as ut, buildFinalSnapshotIdentifier as v, AssetPublisher as vn, AssemblyReader as vr, refStateLookupFromResource as vt, makeCanonicalizePropertiesFn as w, rewriteTemplateAssetReferences as wn, resetAwsClients as wr, configBooleanRefusal as wt, isFinalSnapshotError as x, buildAssetRedirectMap as xn, resolveBucketRegion as xr, resolveExplicitPhysicalId as xt, ccRoutedFinalSnapshotError as y, stringifyValue as yn, processStackMessages as yr, WAFv2WebACLProvider as yt, exportAliasCollisionScrubWarning as z, buildDockerImage as zn, ProvisioningError as zr, DiffCalculator as zt };
34822
- //# sourceMappingURL=deploy-engine-BoSlW08T.js.map
35604
+ export { beginCommandInterruptScope as $, synthesisStatusMessage as $n, withErrorHandling as $r, maskSecretsInError as $t, formatResourceLine as A, ensureAssetStorage as An, AssetError as Ar, requireConfigString as At, isExportAliasCollision as B, describeDockerCapturedOutput as Bn, LockError as Br, INTRINSIC_KEYS as Bt, unsupportedFinalSnapshotError as C, loadPublishableAssetManifest as Cn, processStackMessages as Cr, coerceCfnBoolean as Ct, isStatefulRecreateTargetForReplace as D, AssetModeResolver as Dn, getAwsClients as Dr, replayWarn as Dt, MULTI_REGION_RECREATE_BLOCKED_TYPES as E, stripControlChars as En, AwsClients as Er, readConfigString as Et, red as F, validateAssetBucketName as Fn, DeployCancelledError as Fr, s3BucketDualStackDomainName as Ft, collectInlinePolicyNamesManagedBySiblings as G, getDockerCmd as Gn, ResourceUpdateNotSupportedError as Gr, STATE_SOURCED_CROSS_GENERATION_RULES as Gt, stateKeySecretExposure as H, describeDockerFailure as Hn, PartialFailureError as Hr, withRetry as Ht, yellow as I, validateContainerRepoName as In, DynamicReferenceRegionAmbiguousError as Ir, s3BucketRegionalDomainName as It, findActionableSilentDrops as J, runDockerForeground as Jn, StateError as Jr, carriesSecretMask as Jt, clearOnUpdateRemoval as K, partitionSensitiveEnv as Kn, StackHasActiveImportsError as Kr, STATE_SOURCED_READBACK_RULES as Kt, collectDeclaredOutputNames as L, buildDenyExternalAccessPolicy as Ln, IntrinsicResolutionRefusalError as Lr, s3BucketWebsiteUrl as Lt, cyan as M, isCrossRegionRedirect as Mn, ConfigError as Mr, producerRegionsFromState as Mt, gray as N, parseBootstrapMarker as Nn, CrossAccountSecretRefusalError as Nr, s3BucketArn as Nt, isStatefulRecreateTargetSync as O, BOOTSTRAP_MARKER_PREFIX as On, resetAwsClients as Or, requireConfigArray as Ot, green as P, readBootstrapMarkerBody as Pn, DependencyError as Pr, s3BucketDomainName as Pt, maskerOrIdentity as Q, Synthesizer as Qn, normalizeAwsError as Qr, isSingleDynamicReferenceToken as Qt, collectPublishedOutputNames as R, describeAwsFailure as Rn, LocalInvokeBuildError as Rr, applyRoleArnIfSet as Rt, refusesFinalSnapshot as S, createAssetRedirectResolver as Sn, AssemblyReader as Sr, assertRegionMatch as St, extractDeploymentEventError as T, escapeRegExp$1 as Tn, resolveBucketRegion as Tr, configStringRefusal as Tt, getCurrentResourceSecrets as U, dockerSpawnEnvWithSensitive as Un, ProvisioningError as Ur, DagBuilder as Ut, secretBearingStateKeyWarning as V, describeDockerExecFailure as Vn, NestedStackChildDirectDestroyError as Vr, describeTypeWithThrottleRetry as Vt, IAMRoleProvider as W, formatDockerLoginError as Wn, ResourceTimeoutError as Wr, TemplateParser as Wt, createMaskedRetryLogger as X, AssetManifestLoader as Xn, formatError as Xr, dynamicReferenceTokens as Xt, findSilentDropProperties as Y, runDockerStreaming as Yn, SynthesisError as Yr, createSecretMasker as Yt, maskDeep as Z, getDockerImageBySourceHash as Zn, isCdkdError as Zr, errorCauseChain as Zt, PRE_DELETE_SNAPSHOT_TYPES as _, shouldRetainResource as _n, displaySafe as _r, parameterTypeMayLoseSecretIdentity as _t, DeploymentEventsStore as a, markRedactedCause as ai, LockManager as an, resolveSkipPrefix as ar, slowCcOperationTimeoutMs as at, createPreDeleteFinalSnapshot as b, WorkGraph as bn, canonicalizeRegion as br, normalizeAwsTagsToCfn as bt, replayFailedOperations as c, UNRENDERABLE as cn, resolveUseCdkBootstrapAssets as cr, deleteSkipReason as ct, updatePartialReason as d, forceQuitRecoveryClause as dn, CFN_TEMPLATE_BODY_LIMIT as dr, IntrinsicFunctionResolver as dt, isMarkedNonRetryable as ei, maskSecretsInText as en, getDefaultStateBucketName as er, endCommandInterruptScope as et, withResourceDeadline as f, CUSTOM_RESOURCE_RESPONSE_PREFIX as fn, CFN_TEMPLATE_URL_LIMIT as fr, carriesDynamicReference as ft, ATOMIC_FINAL_SNAPSHOT_TYPES as g, importableOutputs as gn, CUSTOM_RESOURCE_RESPONSE_OBJECT_DESCRIPTION as gr, isUnboundTemplateParameter as gt, computeImplicitDeleteEdges as h, importableOutputKeys as hn, uploadCfnTemplate as hr, getAccountInfo as ht, DeploymentEventsReader as i, markNonRetryable as ii, scrubResourceRecord as in, resolveCaptureObservedState as ir, CloudControlProvider as it, bold as j, getBootstrapMarkerKey as jn, CdkdError as jr, classifyReplaySecretRegion as jt, renderStatefulReason as k, assertAssetBucketRegion as kn, setAwsClients as kr, requireConfigObject as kt, replayRollback as l, buildForceUnlockCommand as ln, stateBucketExistenceConfirmed as lr, disableInstanceApiTermination as lt, IMPLICIT_DELETE_DEPENDENCIES as m, exportNamesCarriedFrom as mn, findLargeInlineResources as mr, coerceParameterTypedValue as mt, DEFAULT_RESOURCE_WARN_AFTER_MS as n, isThrottlingError as ni, recoverMaskedOutput as nn, resolveApp as nr, isInterruptedWaitError as nt, planFailedOps as o, retryClassificationText as oi, S3StateBackend as on, resolveStateBucketWithDefault as or, UNSPECIFIED_SKIP_REASON as ot, maskingRetryLogger as p, DEFAULT_STATE_PREFIX as pn, MIGRATE_TMP_PREFIX as pr, cfnRefValueFromPhysicalId as pt, ProviderRegistry as q, redactDockerArgvValues as qn, StackTerminationProtectionError as qr, TEMPLATE_SOURCED_RULES as qt, DeployEngine as r, isTransientServerError as ri, redactSecretsForState as rn, resolveAutoAssetStorage as rr, startInterruptWatch as rt, planRollback as s, __exportAll as si, rebuildClientForBucketRegion as sn, resolveStateBucketWithDefaultAndSource as sr, deleteIndeterminateGuards as st, DEFAULT_RESOURCE_TIMEOUT_MS as t, isRetryableTransientError as ti, recordMaskOnlyValue as tn, getLegacyStateBucketName as tr, interruptWatchListenerCount as tt, updatePartialMessage as u, buildLockContentionMessage as un, warnDeprecatedNoPrefixCliFlag as ur, isTerminationProtectionPropagationError as ut, buildFinalSnapshotIdentifier as v, AssetPublisher as vn, expectedOwnerParam as vr, refStateLookupFromResource as vt, makeCanonicalizePropertiesFn as w, rewriteTemplateAssetReferences as wn, clearBucketRegionCache as wr, configBooleanRefusal as wt, isFinalSnapshotError as x, buildAssetRedirectMap as xn, derivePartitionAndUrlSuffix as xr, resolveExplicitPhysicalId as xt, ccRoutedFinalSnapshotError as y, stringifyValue as yn, PARTITION_TABLE as yr, WAFv2WebACLProvider as yt, exportAliasCollisionScrubWarning as z, buildDockerImage as zn, LocalStartServiceError as zr, DiffCalculator as zt };
35605
+ //# sourceMappingURL=deploy-engine--rkIGhow.js.map