fix(vpc): correct create-harness VPC validation, dry-run persistence, and TUI review display (follow-up to #1671)#1682
Conversation
Package TarballHow to installgh release download pr-1682-tarball --repo aws/agentcore-cli --pattern "*.tgz" --dir /tmp/pr-tarball
npm install -g /tmp/pr-tarball/aws-agentcore-0.22.0.tgz |
|
Claude Security Review: no high-confidence findings. (run) |
agentcore-cli-automation
left a comment
There was a problem hiding this comment.
Thanks for the follow-up. The three fixes are on-target and the new integration tests exercise the command.tsx → validator seam that the original unit tests bypassed — nice catch on that gap.
I do have one correctness concern with the BUG-2 fix that I'd like addressed before merge; details inline.
| // Plan mode: stop after synth and checks, don't deploy. Revert any vpcId the backfill wrote to | ||
| // disk for synth — a preview must not dirty the working tree. | ||
| if (options.plan) { | ||
| await backfill.restore(); |
There was a problem hiding this comment.
backfill.restore() is only called on the two happy-path returns (plan at L485, diff at L504), but between the backfill call at L419 and those returns there are several ways the flow can bail out after agentcore.json/harness.json have already been rewritten to disk:
synthesizeCdkat L427 can throw → caught by the outercatchat L982, no restore runs.- Stack selection early-return at L436–L444 (still inside the same
try), no restore. - Bootstrap-required early-return at L456–L463, no restore.
- Deployability check early-return at L471–L479, no restore.
- In diff mode,
runDiffat L501 can throw → outer catch, no restore.
So a --dry-run or --diff that fails for any reason after the backfill will leave the working tree dirty — which is exactly the "preview must not dirty the working tree" contract this PR's BUG-2 fix is trying to establish. The new dry-run (persist=false) restore test only covers the happy path.
A few ways to fix:
- Register
restore()for thefinallyblock, e.g. capture it into apreviewRestore: (() => Promise<void>) | undefineddeclared before thetryand invoke it in the existingfinally(L986–L991) when set. Cheapest change and covers every exit path. - Move the two current
await backfill.restore()calls out of the happy branches entirely and rely solely on option 1. - Wrap the backfill + synth + preview span in its own
try/finallythat always callsrestore()in preview mode.
Any of these will do; option 1 is the smallest diff. Please also add a regression test that forces a throw between backfillContainerVpcIds and the plan/diff short-circuit (e.g. by making synthesizeCdk reject) and asserts the on-disk files are still reverted.
agentcore-cli-automation
left a comment
There was a problem hiding this comment.
Nice targeted fix. The BUG-1 dispatch wiring is clearly right and the new integration tests exercise the seam the unit tests missed. The BUG-3 guard is a one-liner and the schema-mapper guard is consistent with it.
One concern on the BUG-2 fix — the preview-mode backfill.restore() calls are placed inline (not in a finally), so several failure paths between the backfill and the explicit restore will still leave agentcore.json / harness.json dirty on a --dry-run / --diff. That's the exact contract the fix is meant to uphold, so I think it's worth handling before merging. Details inline.
| // Plan mode: stop after synth and checks, don't deploy. Revert any vpcId the backfill wrote to | ||
| // disk for synth — a preview must not dirty the working tree. | ||
| if (options.plan) { | ||
| await backfill.restore(); |
There was a problem hiding this comment.
BUG-2's contract is "a preview must not dirty the working tree" — but the restore is only called on the happy-path exits of the plan/diff blocks (L485, L504). Between the backfill at L419 and those calls there are several failure paths where the file stays dirty on a --dry-run / --diff:
synthesizeCdkat L427 throws → caught by the outer catch at L982,restore()never runs.selectTargetStackfails at L436 → earlyreturn, no restore.- Bootstrap check fails at L451-L464 → early
return, no restore. - Deployability check fails at L471-L479 → early
return, no restore. - In
--diffmode,runDiffat L501 throws → outer catch, no restore.
Since restore() is a no-op when persist=true (the originals map is empty), it's safe to call unconditionally. A couple of options:
- Move the restore into the
finallyblock at L986 — hoistlet backfill: BackfillVpcIdResult | null = null;above thetry, and addif (backfill) await backfill.restore();infinally. Cleanest and covers every failure path uniformly. - Wrap the preview flow in its own
try/finally— keeps the scope narrower but is more invasive since synth/deployability/etc. are shared with the real deploy path. - At minimum,
try { … } finally { await backfill.restore(); }around synth + the diff run whenisPreview— narrower still but easy to get subtly wrong later.
Option 1 seems most robust and matches the pattern already used for restoreEnv / toolkitWrapper.dispose() in the same finally.
Coverage Report
|
…kfill paths A full bug-bash (14 isolated scenarios, adversarially verified) found three defects in the review-fix changes — one a blocker regression introduced by the prior fix commit (59bbde0): - BLOCKER: `agentcore create --network-mode VPC` (harness path) always failed with a misleading "--subnets is required" even when supplied. Root cause: 59bbde0 added a validateVpcOptions call inside validateCreateHarnessOptions, but the two call sites in command.tsx (dry-run + main) never forwarded container/subnets/ securityGroups/vpcId into it, so those were always undefined. The entire VPC create-harness path was dead and the intended friendly errors unreachable. Fix: forward the four fields at both call sites. - MEDIUM: `deploy --dry-run` persisted the backfilled vpcId to agentcore.json/ harness.json, dirtying the working tree — violating the "preview without deploying" contract. Fix: backfillContainerVpcIds takes a `persist` flag; in preview mode it still writes (the CDK synth subprocess reads from disk) but returns restore() which reverts the files after synth. Plan + diff modes call it. - LOW (TUI): a stale "VPC ID" line rendered on the CodeZip review screen after a Container→CodeZip back-nav, and the stale vpcId could leak into agentcore.json. Fix: guard the review render and the schema-mapper write on buildType==='Container'. The blocker slipped past unit tests because they called validateCreateHarnessOptions directly with a full options object, bypassing the broken command.tsx caller. Added integration tests that drive the real CLI dispatch via runCLI --dry-run (would have caught it), plus dry-run restore tests for the backfill. Full unit suite green (6120).
9871ffd to
6d836d2
Compare
|
Claude Security Review: no high-confidence findings. (run) |
…ot just happy path Addresses review on #1682: the preview-mode backfill restore() was only called on the plan/diff happy-path returns. If any step between the backfill and those returns bailed out — synthesizeCdk throwing, the stack-selection / bootstrap / deployability early-returns, or runDiff throwing — the resolved vpcId written to disk for synth was left behind, dirtying the working tree on a failed --dry-run / --diff. Move the restore into the deploy handler's finally block (hoisted previewRestore callback, mirroring the existing restoreEnv / toolkitWrapper.dispose() pattern) so it runs on every exit path — success, early return, or throw. restore() is a no-op on a real deploy. Test: a --dry-run on a pre-existing vpcId-less Container+VPC config (which fails at synth/bootstrap without creds) now leaves agentcore.json byte-for-byte unchanged.
|
Claude Security Review: no high-confidence findings. (run) |
agentcore-cli-automation
left a comment
There was a problem hiding this comment.
The follow-up commit (f8a1656) addresses the BUG-2 concern raised in both prior reviews: previewRestore is now hoisted before the outer try and invoked in the finally at L991-998, so every exit path (synth throw, stack-selection / bootstrap / deployability early returns, runDiff throw, or happy path) reverts the on-disk backfill. The new deploy.test.ts case at L62 also exercises this via a real deploy --dry-run without AWS creds — the run fails somewhere after the backfill and the test asserts agentcore.json is byte-for-byte unchanged, which is exactly the regression coverage the earlier review requested.
The other two fixes (create-harness VPC dispatch wiring in command.tsx, and the buildType === 'Container' guards in the review screen + schema-mapper) look correct, and the mocking in the new tests is confined to true I/O boundaries (EC2's resolveVpcIdFromSubnets, and ConfigIO — with real temp dirs for the on-disk assertions). No new serious issues from my pass.
tejaskash
left a comment
There was a problem hiding this comment.
Code review (high-effort, recall-biased). 8 findings; 5 posted inline below, 3 land in files this PR doesn't touch so they're summarized here.
Correctness
- [#2 — HarnessPrimitive.ts:354, not in diff] The CLI create/add-harness write path has no
buildType === 'Container'guard on the vpcId write — the same stale-vpcId leak defect #3 fixes in schema-mapper.ts and GenerateWizardUI.tsx, left unfixed on this third path.agentcore create --name X --network-mode VPC --subnets ... --security-groups ... --vpc-id ...with no--container(a CodeZip harness) setsharnessBuildType=undefined, sovalidateVpcOptionsnever rejects the stray--vpc-id, and...(options.vpcId && { vpcId: options.vpcId })at HarnessPrimitive.ts:354 leaks a Container-only field into a CodeZip harness.json. Add the same guard here.
Altitude
- [#5 — useGenerateWizard.ts:148, not in diff]
setBuildTypeclearsdockerfilebut notvpcIdon a Container→CodeZip switch; this PR instead guards two read sites. That's symptom-patching stale state.setNetworkMode→PUBLIC already clearsvpcId(line 269), proving the source-clear pattern exists. There are 4+ read sites ofconfig.vpcId; clearing it insetBuildTypewhen leaving Container (one line) makes all reads correct by default instead of requiring the buildType guard at every present and future consumer. - [#7 — vpc-utils.ts:104, not in diff]
validateVpcOptionsgained an optionalbuildType?param, and the vpcId requirement fires only when'Container'is passed. A caller that omitsbuildTypegets zero vpcId validation and no error — the same forwarding-failure class this PR is fixing (59bbde0 forgot to forward fields). MakebuildTyperequired so the type system forces every caller to supply it.
Notable clears: snapshot() timing is correct (captures old on-disk bytes before the in-memory spec is written); the persist=true path is a genuine no-op; the redundant ec2:DescribeSubnets-per-resource calls are pre-existing (feature commit), not introduced here; no AGENTS.md rule violations in the new code.
| } | ||
|
|
||
| return { backfilled }; | ||
| const restore = async (): Promise<void> => { |
There was a problem hiding this comment.
Correctness — a throw inside this function after a partial write leaves the working tree dirty, defeating PATCH 2's "restore on every exit path" guarantee.
restore is only handed back on the successful return { backfilled, restore }. On deploy --dry-run with a Container+VPC runtime and a Container+VPC harness both missing vpcId (or two such harnesses): the runtime resolves and agentcore.json is snapshotted+rewritten with the vpcId, then the harness loop's resolveVpcIdFromSubnets (line 76) throws (subnets span multiple VPCs, DescribeSubnets throttled/denied). The function rejects before returning, so in actions.ts const backfill = await ... (423) throws, previewRestore = backfill.restore (426) never runs, and the finally-block restore is skipped. agentcore.json is left dirtied.
The added tests only exercise a single-resource fixture, so they never hit a post-write throw. Root-cause fix: own the rollback in the caller, or wrap the loops in try/finally here, so a partial write is always revertible — not just on a clean return.
| return { backfilled }; | ||
| const restore = async (): Promise<void> => { | ||
| for (const [path, content] of originals) { | ||
| await writeFile(path, content, 'utf-8'); |
There was a problem hiding this comment.
Robustness — restore() is non-atomic. The writes run sequentially with no per-file try/catch; if the first writeFile rejects (file concurrently removed, transient EPERM), the remaining files are never reverted (partial restore) and the rejection propagates out of handleDeploy's finally, also skipping restoreEnv?.(). Make it best-effort/total: attempt every path and aggregate errors.
|
|
||
| // Snapshot a file's current bytes before we rewrite it — but only in preview mode. Computes the | ||
| // path lazily so a real deploy never calls the path getter (keeps the persist=true path minimal). | ||
| const snapshot = async (getPath: () => string): Promise<void> => { |
There was a problem hiding this comment.
Simplification. The lazy getPath: () => string closure buys nothing: the first statement is already if (persist) return before getPath() is ever called, and the getters are trivial string returns. The comment "so a real deploy never calls the path getter" implies a cost that the guard already eliminates. Simplify to snapshot(path: string), called as await snapshot(configIO.getAgentConfigPath()).
| await toolkitWrapper.dispose(); | ||
| } | ||
| // Revert the preview-mode vpcId backfill on every exit path (success, early return, or throw). | ||
| if (previewRestore) { |
There was a problem hiding this comment.
Robustness — restore ordering in finally. previewRestore() runs after toolkitWrapper.dispose() (line 993). If dispose() throws — common after a synth/bootstrap failure on a creds-less preview, the exact path this PR targets — the finally block aborts before if (previewRestore) await previewRestore(), so agentcore.json stays dirty and restoreEnv?.() (999) never runs. Wrap dispose() in try/catch, or run previewRestore before dispose.
| modelProvider: options.modelProvider, | ||
| modelId: options.modelId, | ||
| apiKeyArn: options.apiKeyArn, | ||
| container: options.container, |
There was a problem hiding this comment.
Simplification — this duplication is the shape that caused this PR's blocker. This 12-field options object is byte-identical to the telemetry-wrapped call site at line 223. The original bug (59bbde0) was forwarding VPC fields at one path but not the other; two verbatim copies guarantee the next field-add can be applied to one and forgotten on the other, reopening the same "validation sees undefined" blocker. Hoist one const harnessValidationInput = { name, projectName, ... } above the if (options.dryRun) branch and pass it to both validateCreateHarnessOptions calls.
…w follow-up) Addresses five review comments on #1682: - Roll back partial writes on a mid-run failure: if a later resource's resolveVpcIdFromSubnets throws after an earlier file was already rewritten in a preview, the function now reverts the written files before propagating, so a failed dry-run/diff never leaves the working tree dirty (previously `restore` was only handed back on the success path, so the caller's finally couldn't run). - Make restore() best-effort and total: each file is reverted independently and any failures are aggregated, so one bad write no longer skips the rest. - Run each deploy `finally` cleanup step in its own try/catch so a throwing toolkitWrapper.dispose() (common after a creds-less preview) can't skip the vpcId rollback or the env restore. - Simplify snapshot() to take a path directly (the persist guard already runs first); stub the ConfigIO path getters in the backfill test mock to match the real interface. - Hoist the duplicated create-harness validation input to a single object shared by both call sites, removing the drift that caused the original blocker. Adds a multi-resource partial-write rollback regression test. Full unit suite green (6145).
|
Thanks @tejaskash — all five addressed in
Full unit suite green (6145). |
|
Claude Security Review: no high-confidence findings. (run) |
agentcore-cli-automation
left a comment
There was a problem hiding this comment.
LGTM. The three targeted fixes are on-point:
- BUG-1 (create-harness VPC dispatch): the shared
harnessValidationInputobject addresses both the immediate wiring gap and the drift-vector that caused it. Integration tests throughrunCLI --dry-runcorrectly cover the seam the direct-validator unit tests bypassed. - BUG-2 (preview dry-run/diff persistence): the
persistflag +restore()design, plus the hoistedpreviewRestoreinhandleDeploy'sfinallywith per-step try/catch isolation, correctly enforces the "preview must not dirty the working tree" contract on every exit path (early returns, throws, dispose failures). Internal try/catch insidebackfillContainerVpcIdsalso handles the mid-loop partial-write case that a caller-owned restore can't reach. - BUG-3 (stale VPC ID in TUI review + schema-mapper): the
buildType === 'Container'guards are consistent between the two read sites.
All the correctness concerns I would have raised are already flagged in existing comments — most notably the parallel guard needed at HarnessPrimitive.ts:354, the setBuildType source-clear for vpcId in useGenerateWizard.ts, and making buildType required on validateVpcOptions to prevent the exact same forwarding-failure class this PR is fixing. Those are all out-of-diff and can be addressed in a follow-up. No new blocking issues from my pass.
Description
Follow-up to #1671. Three issues were found in the container-build VPC support after that PR merged; this PR corrects them.
1.
agentcore create --network-mode VPCfor a harness project (#1691)validateCreateHarnessOptionsvalidates the VPC options (--subnets,--security-groups,--vpc-id, and the build type derived from--container), but the two call sites incommand.tsx(the dry-run path and the main path) were not passing those fields into it. As a result the validator saw them as undefined and returned--subnets is required when network mode is VPCeven when the flags were supplied, so a VPC-mode harness could not be created throughagentcore create, and the intended errors (missing--vpc-id, malformed--vpc-id, security-group limit) were never reached. The same VPC configuration works through the agent path ofcreateand throughadd harness, so this path was simply unreachable.Fix: forward
container,subnets,securityGroups, andvpcIdintovalidateCreateHarnessOptionsat both call sites, bringing it to parity withadd harnessand the agent path.The existing unit tests called
validateCreateHarnessOptionsdirectly with a fully-populated object, so they did not cover the wiring incommand.tsx. Added integration tests that run the command throughrunCLI --dry-runto exercise that path.2.
deploy --dry-runwriting to config filesThe VPC-ID backfill on deploy resolves a missing
networkConfig.vpcIdfrom the subnets and writes it toagentcore.json/harness.jsonso the CDK synth subprocess (which reads config from disk) can use it. This write also happened under--dry-runand--diff, which are meant to preview without changing anything, so a preview left the config files modified.Fix:
backfillContainerVpcIdsnow takes apersistflag. In preview modes it still writes the resolved value to disk for synth, then returns arestore()callback that reverts the files to their original contents afterward, so the working tree is unchanged. The plan and diff paths callrestore().3. TUI review screen showing a stale VPC ID
In the interactive
agentcore createwizard, selecting Container + VPC, entering a VPC ID, then going back and switching the build to CodeZip left the VPC ID set. The review screen then displayed a VPC ID line for a CodeZip agent, and the value could be written intoagentcore.json.Fix: guard the review-screen render and the schema-mapper write on
buildType === 'Container', so a VPC ID only appears and persists for container builds.Related Issue
Closes #1691 (issue #1 above). Issues #2 and #3 were found during follow-up testing of #1671 and do not have separate tracking issues.
Type of Change
Testing
npm run test:unit— full unit suite passing (6120 tests)npm run typechecknpm run lintagentcore createthrough the CLI dispatch for the VPC harness path, plus tests for the dry-run restore behaviorVerified end-to-end on a build of this branch (rebased onto latest
main):create --container ./Dockerfile --network-mode VPC --subnets ... --security-groups ... --vpc-id ...now succeeds and persistsnetworkConfig.vpcIdtoharness.json(previously exited 1 with the misleading--subnets is required).--vpc-idnow returns--vpc-id is required for Container builds with --network-mode VPC.--vpc-idreturnsInvalid VPC ID format.New/updated tests:
create.test.ts:harness VPC dispatch (--dry-run)— accepts a full set of VPC flags, returns the expected error when--vpc-idis missing, and rejects a malformed--vpc-id.backfill-vpc-id.test.ts:dry-run (persist=false) restore— writes the resolved VPC ID for synth and then reverts the file; confirms a real deploy leaves the value in place.Checklist