Skip to content

Commit 53a13cb

Browse files
krokokobgagent
andauthored
feat: Bedrock cost attribution — session tags, request metadata, and operator FinOps guidance (#215) (#521)
* docs(design): Bedrock cost attribution design (#215) Design for per-user/per-repo Bedrock spend attribution. Key finding: Bedrock is invoked by the Claude Code CLI subprocess, not the agent's boto3, so both tracks (IAM session tags + request metadata) are wired via Claude Code config (awsCredentialExport, ANTHROPIC_CUSTOM_HEADERS) and a new BedrockInvokeRole — not by extending aws_session.py. Refs #215 * feat(cost): Bedrock cost attribution via session tags + request metadata (#215) Attribute Bedrock model-inference spend per user/repo. Bedrock is invoked by the Claude Code subprocess (CLAUDE_CODE_USE_BEDROCK=1), so attribution is wired through Claude Code's config, not the agent's boto3. Track 1 — IAM session-tag chargeback (CUR 2.0 / Cost Explorer): - Grant bedrock:InvokeModel* on the existing AgentSessionRole (reuse, not a new role) via grantInvoke, mirroring the compute-role grant exactly so cross-region profiles never AccessDenied. Compute role keeps its grant. - bedrock_creds_helper.py assumes the SessionRole with {user_id,repo,task_id} STS tags and emits creds JSON for Claude Code's awsCredentialExport, which refreshes before the 1h role-chaining cap. Fails OPEN to ambient creds (billing control, not isolation). awsCredentialExport lives in root-owned /etc/claude-code/managed-settings.json so the untrusted repo can't override it (RCE boundary). Track 2 — per-call forensics (model-invocation logs): - Set X-Amzn-Bedrock-Request-Metadata via ANTHROPIC_CUSTOM_HEADERS on the subprocess env (one container = one task, so static-per-process is per-task; process-env so the repo can't alter it). SigV4 signed-headers behavior to be validated live (AC#3 documented-blocker path). Track 3 — operator guide COST_ATTRIBUTION.md + cross-links, plus a prominent warning that in-app cost_usd is a client-side SDK estimate (authoritative source is AWS Cost Explorer / CUR 2.0), mirroring the Claude Agent SDK cost-tracking caveat. Align claude-agent-sdk 0.2.110 (bundles CLI 2.1.191) with the npm CLI pin. Tests: CDK Bedrock grant present/absent; helper assume + fail-open paths; runner file+header wiring. #211 tenant-isolation path untouched. Refs #215 * refactor(cdk): collect invokable Bedrock models in a loop (pre-empt #434) PR #434 replaces the six named model/profile bindings in agent.ts with a loop over a single source-of-truth id list. Our #215 SessionRole grant referenced those bindings by name, so the merge would break compilation. Adopt #434's loop+collection shape now: build each foundation model + its cross-region profile in a loop, grant the runtime, and collect into one list passed to AgentSessionRole.invokableModels. Behavior is byte-for-byte identical in synth; the eventual #434 merge becomes a one-line swap of the local id array for resolveBedrockModelIds(this.node). Refs #215, #434 * fix(agent): make Bedrock creds-helper fail-open paths observable (#215 review) Silent-failure review flagged that bedrock_creds_helper.py degraded silently: a persistent assume-role denial would drop chargeback for weeks with no signal pointing back to this code — the 'invisible degradation' AI004 forbids even when the fallback itself is intended. - Add _warn() (stderr only — stdout is the credential channel Claude Code parses, so shell.log/fd1 is unusable here). - Log every fail-open path; distinguish severities: absent file (benign) vs present-but-unreadable (write bug), and expected ClientError/BotoCoreError assume failure vs UNEXPECTED errors. - Narrow the assume catch to (ClientError, BotoCoreError); catch ImportError on boto3 separately (packaging defect, not AccessDenied). All still fail open. Behavior unchanged (still fail-open to ambient creds); degradations are now visible and correlatable. Tests cover each distinguished path + its diagnostic. Refs #215 * docs(215): note the deliberate ANTHROPIC_CUSTOM_HEADERS env exception Security review (LOW/accepted): unlike tenant-data tags, the request-metadata header lives on os.environ because Claude Code reads it from there. Document why that's safe (self-referential non-secret values; json.dumps escaping blocks header injection) in both the code and the design doc, so it reads as intent rather than an oversight against the 'tenant ids out of os.environ' discipline. Refs #215 * docs(215): correct cost-allocation tag activation steps The IAM-principal tag keys can't be pre-activated — they only appear in the Billing console after the platform makes tagged Bedrock calls. Fix the ordering (deploy → run task → wait ≤24h → activate), point to Billing → Cost allocation tags (not Tag Editor / Resource Groups, which lists resource types), and note the capability may not be enabled in every account/region yet. Refs #215 * fix(cdk): enable Bedrock model-invocation logging on deploy (#215) The ModelInvocationLogging custom resource sent largeDataDeliveryS3Config with an empty bucketName. Bedrock rejects that client-side (ValidationException, 'min length: 3'), and ignoreErrorCodesMatching: '.*' swallowed it while onUpdate never re-fired (static props) — so a fresh deploy silently left model-invocation logging DISABLED, and Bedrock recorded no requestMetadata (#215 Track 2 produced nothing to query). Found during live verification of task 01KWD7S.... - Omit largeDataDeliveryS3Config entirely (optional; only for S3 large-data delivery, which this stack doesn't use). The 'required by API schema' comment was wrong. - Narrow ignoreErrorCodesMatching from '.*' to transient service errors only (Throttling/ServiceUnavailable/InternalServer) so a client-side misconfiguration fails the deploy loudly instead of disabling logging silently. - Tests: assert the CR never sends largeDataDeliveryS3Config and never uses a catch-all error ignore. - Docs: COST_ATTRIBUTION.md now tells operators to verify logging is on in the agent's Region (get-model-invocation-logging-configuration) and how to re-enable it, since metadata is only recorded when logging is active. Verified live: with logging on, invocation logs show requestMetadata.{user_id, repo,task_id} and the abca-bedrock-<task_id> session ARN — Tracks 1 and 2 both confirmed working end-to-end. Refs #215 * fix(cdk): grant iam:PassRole for Bedrock invocation-logging custom resource (#215) With the empty-bucket validation error fixed, PutModelInvocationLoggingConfiguration now actually reaches Bedrock at deploy — and fails because the custom resource's Lambda role lacks iam:PassRole on BedrockLoggingRole (the role it hands to the Bedrock service to write the log group). This was masked by the earlier client-side ValidationException that ignoreErrorCodesMatching: '.*' swallowed. Add iam:PassRole scoped to the BedrockLoggingRole ARN (not a wildcard). Test asserts the grant is present. Refs #215 --------- Co-authored-by: bgagent <bgagent@noreply.github.com>
1 parent 27d7959 commit 53a13cb

22 files changed

Lines changed: 1081 additions & 28 deletions

agent/Dockerfile

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,14 @@ RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - && \
4646

4747
# Install Claude Code CLI (the Python SDK requires this binary)
4848
# Then update known vulnerable transitive packages where fixed versions exist.
49+
# Pinned 2.1.191 to match the CLI bundled by claude-agent-sdk 0.2.110 (see
50+
# agent/pyproject.toml) — the SDK and the on-PATH CLI must agree on the control
51+
# protocol. This version also has the awsCredentialExport behavior #215 needs:
52+
# returned creds are cached until 5 min before the JSON's `Expiration`, so an
53+
# 8 h task re-assumes the 1 h-capped SessionRole before expiry. Older builds
54+
# only refreshed hourly on a timer, racing the role-chaining cap.
4955
RUN npm install -g npm@latest && \
50-
npm install -g @anthropic-ai/claude-code@2.1.142 && \
56+
npm install -g @anthropic-ai/claude-code@2.1.191 && \
5157
CLAUDE_NPM_ROOT="$(npm root -g)/@anthropic-ai/claude-code" && \
5258
npm --prefix "${CLAUDE_NPM_ROOT}" update tar minimatch glob cross-spawn picomatch
5359

@@ -81,6 +87,13 @@ COPY contracts/ /app/contracts/
8187
# ``WorkflowValidationError: workflow '...' not found at /app/workflows/...``.
8288
COPY agent/workflows/ /app/workflows/
8389
COPY agent/prepare-commit-msg.sh /app/
90+
# Claude Code managed settings (#215). The highest-precedence settings layer —
91+
# loaded regardless of setting_sources and unoverridable by the untrusted cloned
92+
# repo's project .claude/settings.json. Carries awsCredentialExport so Bedrock
93+
# calls use session-tagged, refreshable credentials for cost attribution.
94+
# Placing awsCredentialExport (an arbitrary command) anywhere the target repo
95+
# can influence would be RCE with the compute role, so it lives ONLY here.
96+
COPY agent/managed-settings.json /etc/claude-code/managed-settings.json
8497

8598
# Create non-root user (Claude Code CLI refuses bypassPermissions as root)
8699
RUN useradd -m -s /bin/bash agent && \

agent/managed-settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"awsCredentialExport": "/app/.venv/bin/python /app/src/bedrock_creds_helper.py"
3+
}

agent/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ dependencies = [
1616
# would degrade gracefully — but for now we keep the dep to
1717
# preserve the clean code path.
1818
"bedrock-agentcore==1.9.1", #https://pypi.org/project/bedrock-agentcore/
19-
"claude-agent-sdk==0.2.82", #https://github.com/anthropics/claude-agent-sdk-python/releases/tag/v0.2.82
19+
"claude-agent-sdk==0.2.110", #https://github.com/anthropics/claude-agent-sdk-python/releases/tag/v0.2.110 (bundles claude CLI 2.1.191; kept in lockstep with the npm CLI pin in the Dockerfile, #215)
2020
"requests==2.34.2", #https://pypi.org/project/requests/
2121
"fastapi==0.136.1", #https://pypi.org/project/fastapi/
2222
"uvicorn==0.47.0", #https://pypi.org/project/uvicorn/

agent/src/aws_session.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,19 @@ class SessionScopingError(RuntimeError):
7878
"""
7979

8080

81+
def build_session_tags(user_id: str, repo: str, task_id: str) -> list[dict[str, str]]:
82+
"""Build the AssumeRole ``Tags`` list from tenant identifiers.
83+
84+
Only non-empty values are included. Values are truncated to the IAM limit
85+
so an over-long repo slug can never make ``AssumeRole`` fail. Shared by the
86+
in-process tenant-data session (:func:`_session_tags`) and the out-of-process
87+
Bedrock credential helper (``bedrock_creds_helper.py``) so both mint the
88+
same ``{user_id, repo, task_id}`` tags from one definition.
89+
"""
90+
pairs = (("user_id", user_id), ("repo", repo), ("task_id", task_id))
91+
return [{"Key": key, "Value": value[:_MAX_TAG_VALUE_LEN]} for key, value in pairs if value]
92+
93+
8194
def configure_session(user_id: str, repo: str, task_id: str) -> None:
8295
"""Record session-tag values in private module state for later use.
8396
@@ -115,6 +128,11 @@ def _session_tags() -> list[dict[str, str]]:
115128
return [{"Key": key, "Value": value[:_MAX_TAG_VALUE_LEN]} for key, value in _tags.items()]
116129

117130

131+
# Public alias of the IAM tag-value length cap, for the Bedrock credential
132+
# helper which builds tags from CLI args rather than module state.
133+
MAX_TAG_VALUE_LEN = _MAX_TAG_VALUE_LEN
134+
135+
118136
def _build_scoped_session(role_arn: str) -> Any:
119137
"""Build a boto3 Session backed by refreshable assumed-role credentials.
120138

agent/src/bedrock_creds_helper.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
#!/usr/bin/env python3
2+
"""Credential helper for Claude Code's Bedrock calls (#215, cost attribution).
3+
4+
Claude Code (``CLAUDE_CODE_USE_BEDROCK=1``) makes every ``InvokeModel`` call —
5+
not the agent's boto3 — so the per-task tenant-data SessionRole in
6+
``aws_session.py`` cannot tag those calls. Instead Claude Code's
7+
``awsCredentialExport`` setting (in the image's managed-settings layer) runs
8+
this script, captures its JSON stdout, and signs Bedrock requests with the
9+
returned credentials. With a real ``Expiration`` it re-runs ~5 min before
10+
expiry, so an 8 h task survives the 1 h role-chaining cap.
11+
12+
Goal: assume the per-task SessionRole with ``{user_id, repo, task_id}`` STS
13+
session tags so Bedrock spend is attributable per user/repo in AWS Cost
14+
Explorer / CUR 2.0 (``iamPrincipal/*`` dimensions, after the operator activates
15+
the cost-allocation tags). The same role already carries the tenant-data grants;
16+
Track-1 only adds ``bedrock:InvokeModel*`` to it (see ``agent-session-role.ts``).
17+
18+
**Fails OPEN.** Bedrock attribution is a billing/observability control, not a
19+
tenant-isolation one (contrast ``aws_session.py``, which fails closed). If the
20+
attribution config is absent or the assume-role fails, this helper emits the
21+
**ambient** compute-role credentials so Bedrock keeps working untagged — losing
22+
chargeback granularity is not a security incident, and the compute role retains
23+
``InvokeModel`` precisely so this fallback works.
24+
25+
The role ARN and tag values are read from a 0600 JSON file the agent writes at
26+
startup (``write_attribution_file``), not from the environment — so the tenant
27+
identifiers are not inherited by the untrusted repo subprocesses the agent
28+
spawns, matching the discipline in ``aws_session.py``.
29+
30+
Output shape (consumed by Claude Code's awsCredentialExport):
31+
32+
{"Credentials": {"AccessKeyId": "...", "SecretAccessKey": "...",
33+
"SessionToken": "...", "Expiration": "<ISO8601>"}}
34+
"""
35+
36+
from __future__ import annotations
37+
38+
import json
39+
import os
40+
import sys
41+
from typing import Any
42+
43+
# Fixed path the agent writes (0600) and this helper reads. A fixed path is
44+
# required because the managed-settings ``awsCredentialExport`` command is
45+
# static (baked into the image) and cannot carry per-task arguments.
46+
ATTRIBUTION_FILE_ENV = "BEDROCK_ATTRIBUTION_FILE"
47+
DEFAULT_ATTRIBUTION_FILE = "/home/agent/.bedrock-attribution.json"
48+
49+
# Role chaining caps the assumed session at 1 hour; request the max the cap
50+
# allows. Claude Code refreshes ~5 min before the returned Expiration.
51+
_CHAINED_SESSION_DURATION_S = 3600
52+
53+
54+
def attribution_file_path() -> str:
55+
return os.environ.get(ATTRIBUTION_FILE_ENV, "").strip() or DEFAULT_ATTRIBUTION_FILE
56+
57+
58+
def write_attribution_file(
59+
role_arn: str, tags: list[dict[str, str]], path: str | None = None
60+
) -> str:
61+
"""Persist the SessionRole ARN + STS tags for the helper to read.
62+
63+
Written 0600 and owned by the agent user. Returns the path written. Called
64+
by the agent at startup (see ``runner._setup_agent_env``) only when a
65+
SessionRole is configured; absence is the fail-open signal.
66+
"""
67+
target = path or attribution_file_path()
68+
payload = json.dumps({"role_arn": role_arn, "tags": tags})
69+
# Create with 0600 from the start (os.open + O_CREAT honors mode, modulo
70+
# umask) so the secret-adjacent file is never briefly world-readable.
71+
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
72+
with os.fdopen(fd, "w") as fh:
73+
fh.write(payload)
74+
return target
75+
76+
77+
def _warn(message: str) -> None:
78+
"""Emit a diagnostic to stderr.
79+
80+
This process's **stdout is the credential channel** — Claude Code parses it
81+
as the ``awsCredentialExport`` JSON result — so diagnostics MUST go to
82+
stderr or they would corrupt the credential envelope. (This is also why
83+
``shell.log``, which writes to fd 1, is unusable here.) Every fail-open path
84+
logs through here so a silent, weeks-long loss of cost attribution is
85+
instead a visible, correlatable signal — the fallback stays open, but it is
86+
never invisible.
87+
"""
88+
print(f"[bedrock-creds] {message}", file=sys.stderr)
89+
90+
91+
def _emit(creds: dict[str, str]) -> None:
92+
json.dump({"Credentials": creds}, sys.stdout)
93+
94+
95+
def _frozen_to_creds(frozen: Any, expiry_iso: str | None) -> dict[str, str]:
96+
out = {
97+
"AccessKeyId": frozen.access_key,
98+
"SecretAccessKey": frozen.secret_key,
99+
"SessionToken": frozen.token or "",
100+
}
101+
if expiry_iso:
102+
out["Expiration"] = expiry_iso
103+
return out
104+
105+
106+
def _ambient_credentials() -> dict[str, str]:
107+
"""Frozen ambient (compute-role) credentials — the fail-open fallback."""
108+
import botocore.session
109+
110+
creds = botocore.session.get_session().get_credentials()
111+
if creds is None:
112+
# No resolvable credentials at all — the deepest degradation. Emit an
113+
# empty object; Claude Code then falls back to its own default-chain
114+
# resolution. Surface it: if that fallback also fails, this stderr line
115+
# is the only breadcrumb.
116+
_warn(
117+
"no resolvable AWS credentials; emitting empty envelope, "
118+
"Claude Code will use its default chain"
119+
)
120+
return {}
121+
return _frozen_to_creds(creds.get_frozen_credentials(), None)
122+
123+
124+
def resolve_credentials() -> dict[str, str]:
125+
"""Return tagged assumed-role creds, or ambient creds on any failure."""
126+
path = attribution_file_path()
127+
try:
128+
with open(path) as fh:
129+
cfg = json.load(fh)
130+
role_arn = cfg["role_arn"]
131+
tags = cfg.get("tags", [])
132+
except FileNotFoundError:
133+
# Attribution not configured (local/dev, or pre-provisioning). Expected
134+
# and benign — debug-level signal only.
135+
_warn("attribution file absent; not configured — using ambient creds")
136+
return _ambient_credentials()
137+
except (OSError, ValueError, KeyError) as exc:
138+
# File present but unreadable/malformed/schema-drifted. This is NOT the
139+
# benign "not configured" case — it points at a write_attribution_file
140+
# bug or a partial write, so it warrants a louder signal.
141+
_warn(
142+
f"attribution file present but unreadable ({type(exc).__name__}: {exc}); "
143+
"using ambient creds"
144+
)
145+
return _ambient_credentials()
146+
147+
try:
148+
import boto3
149+
from botocore.exceptions import BotoCoreError, ClientError
150+
except ImportError as exc:
151+
# boto3 missing/broken in the image is a packaging defect, not the
152+
# expected assume-role failure — name it explicitly so it can't hide.
153+
_warn(f"boto3 unavailable ({exc}); using ambient creds — fix the image")
154+
return _ambient_credentials()
155+
156+
region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
157+
task_id = next((t["Value"] for t in tags if t.get("Key") == "task_id"), "")
158+
session_name = f"abca-bedrock-{task_id}"[:64] or "abca-bedrock"
159+
try:
160+
resp = boto3.client("sts", region_name=region).assume_role(
161+
RoleArn=role_arn,
162+
RoleSessionName=session_name,
163+
DurationSeconds=_CHAINED_SESSION_DURATION_S,
164+
Tags=tags,
165+
)
166+
c = resp["Credentials"]
167+
return {
168+
"AccessKeyId": c["AccessKeyId"],
169+
"SecretAccessKey": c["SecretAccessKey"],
170+
"SessionToken": c["SessionToken"],
171+
"Expiration": c["Expiration"].isoformat(),
172+
}
173+
except (ClientError, BotoCoreError) as exc:
174+
# Expected assume failure: role not yet provisioned, AccessDenied,
175+
# transient STS error. Fail open so Bedrock keeps working on the
176+
# compute role; spend for this task is untagged.
177+
_warn(
178+
f"assume_role failed ({type(exc).__name__}: {exc}); using ambient creds "
179+
"— Bedrock spend will be UNTAGGED"
180+
)
181+
return _ambient_credentials()
182+
except Exception as exc:
183+
# Anything else (unexpected STS response shape, a logic bug here) is NOT
184+
# the expected fallback. Still fail open — this is a billing control, not
185+
# isolation — but flag it distinctly so it isn't mistaken for AccessDenied.
186+
_warn(
187+
f"UNEXPECTED error minting tagged creds ({type(exc).__name__}: {exc}); "
188+
"using ambient creds"
189+
)
190+
return _ambient_credentials()
191+
192+
193+
def main() -> int:
194+
_emit(resolve_credentials())
195+
return 0
196+
197+
198+
if __name__ == "__main__":
199+
sys.exit(main())

agent/src/runner.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,58 @@ def _parse_token_usage(raw_usage: Any) -> TokenUsage:
5959
return TokenUsage(**values)
6060

6161

62+
def _setup_bedrock_cost_attribution(config: TaskConfig) -> None:
63+
"""Wire Bedrock cost attribution for the Claude Code subprocess (#215).
64+
65+
Claude Code makes the ``InvokeModel`` calls, so attribution is configured
66+
through *its* credential + header channels, not the agent's boto3:
67+
68+
1. **Per-user/repo chargeback (CUR 2.0 / Cost Explorer).** Write the
69+
SessionRole ARN + ``{user_id, repo, task_id}`` STS tags to a 0600 file
70+
that ``bedrock_creds_helper.py`` reads. Claude Code's managed-settings
71+
``awsCredentialExport`` runs that helper and signs Bedrock requests with
72+
the tagged assumed-role credentials. Skipped when ``AGENT_SESSION_ROLE_ARN``
73+
is unset (local/dev) — the helper then fails open to ambient creds.
74+
75+
2. **Per-call forensics (model-invocation logs).** Set
76+
``X-Amzn-Bedrock-Request-Metadata`` via ``ANTHROPIC_CUSTOM_HEADERS`` on the
77+
process env. One container = one task = one Claude Code session, so a
78+
static-per-process header is effectively per-task. Set via the process
79+
env (not project settings) so the untrusted cloned repo cannot alter it.
80+
"""
81+
import json
82+
83+
from aws_session import MAX_TAG_VALUE_LEN, build_session_tags
84+
85+
role_arn = os.environ.get("AGENT_SESSION_ROLE_ARN", "").strip()
86+
tags = build_session_tags(config.user_id, config.repo_url, config.task_id)
87+
if role_arn and tags:
88+
try:
89+
from bedrock_creds_helper import write_attribution_file
90+
91+
write_attribution_file(role_arn, tags)
92+
except OSError as exc:
93+
# Fail open: attribution is observability, not isolation. Bedrock
94+
# still works on the compute role; we just lose tagged chargeback.
95+
log("WARN", f"Bedrock attribution file not written ({exc}); spend will be untagged")
96+
97+
# Per-request metadata mirrors the STS tag values. Bedrock limits keys/values
98+
# to 256 chars and records them under ``requestMetadata`` in invocation logs.
99+
#
100+
# Unlike the tenant-data tags (kept out of os.environ so untrusted repo
101+
# subprocesses don't inherit them), this header MUST go on os.environ —
102+
# Claude Code reads ANTHROPIC_CUSTOM_HEADERS from the process env. The
103+
# exposure is acceptable: the values are the task's OWN {user_id, repo,
104+
# task_id} (self-referential, non-secret), so a spawned subprocess learns
105+
# only who it is already running for. json.dumps escapes newlines/quotes, so
106+
# a crafted repo slug cannot inject an extra (newline-separated) header.
107+
metadata = {t["Key"]: t["Value"][:MAX_TAG_VALUE_LEN] for t in tags}
108+
if metadata:
109+
os.environ["ANTHROPIC_CUSTOM_HEADERS"] = (
110+
f"X-Amzn-Bedrock-Request-Metadata: {json.dumps(metadata, separators=(',', ':'))}"
111+
)
112+
113+
62114
def _setup_agent_env(config: TaskConfig) -> tuple[str | None, str | None]:
63115
"""Configure process environment for the Claude Code CLI subprocess.
64116
@@ -72,6 +124,8 @@ def _setup_agent_env(config: TaskConfig) -> tuple[str | None, str | None]:
72124
os.environ["ANTHROPIC_MODEL"] = config.anthropic_model
73125
os.environ["GITHUB_TOKEN"] = config.github_token
74126
os.environ["GH_TOKEN"] = config.github_token
127+
128+
_setup_bedrock_cost_attribution(config)
75129
# DO NOT set ANTHROPIC_LOG — any logging level causes the CLI to write to
76130
# stderr, which fills the OS pipe buffer (64 KB) and deadlocks the
77131
# single-threaded Node.js CLI process (blocked stderr write prevents stdout

0 commit comments

Comments
 (0)