Skip to content

Commit fc328ef

Browse files
committed
fix(ecs): address #596 re-review nits — stale parity comments + WARN noise + max_turns coercion
Scott's fresh re-review (head 86ab249) is Approve-with-nits: B1 (over-privilege) and B2 (governance) resolved; remaining asks are doc-accuracy + WARN-channel hygiene. - N1: rewrite the artifactsBucket JSDoc (ecs-agent-cluster.ts) that still claimed a read/write task-role grant the B1 fix deliberately removed — now states env-only wiring with delivery through the task_id-scoped SessionRole (parity with the AgentCore runtime role, which has no artifacts grant). The grant block ~30 lines below was already corrected in the B1 commit; this was its residual half. - N2: same inverted-comment fix at the agent.ts wiring site — '(read+write grant in the construct)' → env-only + SessionRole delivery. - N3: drop github_token_secret_arn from _KNOWN_ORCHESTRATOR_KEYS. It is ALWAYS present and ALWAYS resolved via the GITHUB_TOKEN_SECRET_ARN env, so listing it fired the known-key WARN on 100% of ECS boots — pure noise diluting the channel meant for genuine future contract gaps. Now falls through as a quiet foreign-key drop; comment records why it must not be re-added. +test asserting no WARN. - N4: max_turns int() coercion accepted a bool (int(True)==1) and truncated a non-integral float (int(3.9)==3), and the comment falsely claimed it 'matches how every other field is handled' (it is the one non-str coercion). Now rejects bools + non-integral floats with a breadcrumb; valid int / int-string / int-float still pass. +test. Reachable only via a corrupt payload (orchestrator emits a real int), so cosmetic — bundled since cheap. - N5: no-op — the Dockerfile corepack line already has no 2>/dev/null. Gates: cdk 2355 tests + eslint + synth green; agent 1268 tests + ruff + ty green. Non-blocking #608 test-seam gaps (finalize deleteEcsPayload, boot-uri parse, entrypoint re-export, zero-ECS-synth assert) remain tracked, not in scope here.
1 parent 86ab249 commit fc328ef

4 files changed

Lines changed: 57 additions & 11 deletions

File tree

agent/src/pipeline.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1328,7 +1328,13 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None:
13281328
"lint_command",
13291329
"merge_branches",
13301330
"base_branch",
1331-
"github_token_secret_arn",
1331+
# NB: ``github_token_secret_arn`` is deliberately NOT listed (N3). It is
1332+
# ALWAYS in the payload and ALWAYS dropped here (resolved via the
1333+
# ``GITHUB_TOKEN_SECRET_ARN`` env in build_config, never a run_task
1334+
# param), so listing it would fire the known-key WARN on 100% of ECS
1335+
# boots — pure noise that dilutes the channel meant to surface genuine
1336+
# future "wired one side, forgot the other" gaps. It falls through as a
1337+
# quiet foreign-key drop instead.
13321338
# AgentCore's server.py exports task_started_at as TASK_STARTED_AT, which
13331339
# hooks._remaining_maxlifetime_s() uses to clip the Cedar HITL approval-gate
13341340
# maxLifetime. The ECS boot path bypasses server.py and does not (yet) set
@@ -1383,14 +1389,25 @@ def run_task_from_payload(payload: dict) -> dict:
13831389
if target in _PAYLOAD_STR_KEYS:
13841390
value = str(value)
13851391
elif target == "max_turns":
1386-
# Defensive, matching how every other field is handled: a malformed
1387-
# max_turns must not crash the whole boot — drop it and let run_task's
1388-
# default apply (with a breadcrumb) rather than raise a ValueError.
1392+
# Defensive: a malformed max_turns must not crash the whole boot —
1393+
# drop it and let run_task's default apply (with a breadcrumb) rather
1394+
# than raise. Unlike the str keys above, this is the one field with a
1395+
# non-str coercion, so it also guards the surprising int() cases the
1396+
# orchestrator never emits but a hand-edited payload might: a bool
1397+
# (``int(True) == 1``) and a non-integral float (``int(3.9) == 3``)
1398+
# would both silently become a bogus turn count (N4).
1399+
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
1400+
log("WARN", f"run_task_from_payload: ignoring non-integer max_turns {value!r}")
1401+
continue
13891402
try:
1390-
value = int(value)
1403+
coerced = int(value)
13911404
except (TypeError, ValueError):
13921405
log("WARN", f"run_task_from_payload: ignoring non-integer max_turns {value!r}")
13931406
continue
1407+
if isinstance(value, float) and coerced != value:
1408+
log("WARN", f"run_task_from_payload: ignoring non-integral max_turns {value!r}")
1409+
continue
1410+
value = coerced
13941411
kwargs[target] = value
13951412

13961413
kwargs.setdefault("aws_region", os.environ.get("AWS_REGION", ""))

agent/tests/test_run_task_from_payload.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,18 @@ def test_ignores_unknown_payload_keys(self):
102102
assert "github_token_secret_arn" not in seen
103103
assert "sources" not in seen
104104

105+
def test_github_token_secret_arn_dropped_quietly(self):
106+
# N3: github_token_secret_arn is ALWAYS present and ALWAYS resolved via
107+
# the GITHUB_TOKEN_SECRET_ARN env (never a run_task param), so its drop is
108+
# 100% expected and must NOT fire the known-key WARN — that channel is for
109+
# genuine future contract gaps, not this always-dropped key.
110+
logs: list[tuple[str, str]] = []
111+
with patch("pipeline.log", side_effect=lambda level, msg, **kw: logs.append((level, msg))):
112+
_capture({"github_token_secret_arn": "arn:aws:secretsmanager:...", "repo_url": "r"})
113+
assert not [m for level, m in logs if "github_token_secret_arn" in m], (
114+
"github_token_secret_arn must drop quietly (N3) — it is always resolved via env"
115+
)
116+
105117
def test_drops_none_values_so_run_task_defaults_apply(self):
106118
seen = _capture({"repo_url": "org/repo", "base_branch": None, "channel_metadata": None})
107119
assert "base_branch" not in seen
@@ -143,6 +155,18 @@ def test_every_forwarded_key_is_a_real_run_task_param(self):
143155
)
144156
assert set(seen).issubset(accepted)
145157

158+
def test_max_turns_rejects_surprising_inputs(self):
159+
# N4: int() accepts a bool (int(True)==1) and truncates a float
160+
# (int(3.9)==3). The orchestrator always emits a real int, but a corrupt
161+
# / hand-edited payload must not silently become a bogus turn count —
162+
# drop with a breadcrumb and let run_task's default apply.
163+
assert "max_turns" not in _capture({"repo_url": "r", "max_turns": True})
164+
assert "max_turns" not in _capture({"repo_url": "r", "max_turns": 3.9})
165+
# Valid inputs still pass: a real int and an int-valued string / float.
166+
assert _capture({"repo_url": "r", "max_turns": 50})["max_turns"] == 50
167+
assert _capture({"repo_url": "r", "max_turns": "50"})["max_turns"] == 50
168+
assert _capture({"repo_url": "r", "max_turns": 50.0})["max_turns"] == 50
169+
146170
def test_warns_when_dropping_a_known_orchestrator_key(self):
147171
# N4: a KNOWN orchestrator key that run_task doesn't accept is dropped
148172
# (expected today) but logged, so a future "wired one side, forgot the

cdk/src/constructs/ecs-agent-cluster.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,14 @@ export interface EcsAgentClusterProps {
5656
/**
5757
* Artifacts bucket for repo-bound artifact workflows (#299 coding/decompose-v1
5858
* emits its plan JSON here via ``deliver_artifact``). The AgentCore runtime
59-
* gets ``ARTIFACTS_BUCKET_NAME`` in its env; the ECS task needs the SAME env +
60-
* read/write grant or an artifact workflow fails at delivery with
59+
* gets ``ARTIFACTS_BUCKET_NAME`` in its env; the ECS task needs the SAME env
60+
* (but NO bucket grant) or an artifact workflow fails at delivery with
6161
* "ARTIFACTS_BUCKET_NAME is not configured" (live-caught: a :decompose on an
62-
* ecs-configured repo). Read/WRITE because the container DELIVERS the artifact
63-
* (unlike the read-only payload bucket).
62+
* ecs-configured repo). The delivery WRITE goes through the assumed per-task
63+
* SessionRole (scoped to ``artifacts/${aws:PrincipalTag/task_id}/*``), so the
64+
* task role gets only the env var — parity with the AgentCore runtime role,
65+
* which likewise has no direct artifacts grant (see the grant block below for
66+
* the rationale).
6467
*
6568
* NOTE: this wires only ``ARTIFACTS_BUCKET_NAME`` (artifact delivery). It does
6669
* NOT set ``TRACE_ARTIFACTS_BUCKET_NAME`` (telemetry.py reads that for the

cdk/src/stacks/agent.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -622,8 +622,10 @@ export class AgentStack extends Stack {
622622
// #502: read-only grant so the container can fetch its payload from S3.
623623
payloadBucket: ecsPayloadBucket!.bucket,
624624
// #299 ECS-parity: the same bucket the runtime uses for ARTIFACTS_BUCKET_NAME —
625-
// coding/decompose-v1 delivers its plan artifact here (read+write grant in
626-
// the construct). Without this, an ecs-repo :decompose fails at delivery.
625+
// coding/decompose-v1 delivers its plan artifact here. Wires the
626+
// ARTIFACTS_BUCKET_NAME env only; delivery writes go through the per-task
627+
// SessionRole (no direct task-role grant — see construct). Without the
628+
// env, an ecs-repo :decompose fails at delivery.
627629
artifactsBucket: traceArtifactsBucket.bucket,
628630
// Per-session IAM scoping (#209): the ECS task role assumes the same
629631
// SessionRole as the AgentCore runtime for tenant-data access. The

0 commit comments

Comments
 (0)