diff --git a/AGENTS.md b/AGENTS.md index 1420838ca..e456791c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,7 @@ Branch names: `(feat|fix|chore|docs)/-short-description` (e.g. `do - **Branch without issue number** — Unauthorized work. - **`MISE_EXPERIMENTAL=1`** — Required for `mise //cdk:build` and other namespaced tasks ([CONTRIBUTING.md](./CONTRIBUTING.md)). - **`prek install` fails** — Another hook manager owns `core.hooksPath`; see [CONTRIBUTING.md](./CONTRIBUTING.md). +- **Dropping solution UA on a new AWS client (#319)** — every outbound AWS call carries `md/uksb-wt64nei4u6#{component}` (the `app/` segment is SDK-native via `AWS_SDK_UA_APP_ID`, set by `SolutionUaAspect`). Construct clients through the attributed factory: `cdk/src/` and `cli/src/` via `makeClient(Ctor, cfg)` / `makeDocClient(cfg)` (`cdk/src/handlers/shared/ua.ts`, `cli/src/ua.ts`); `agent/src/` via `aws_session.tenant_client`/`tenant_resource` (tenant-scoped) or `aws_session.platform_client` (unscoped, still attributed). A naked `new XxxClient({})` / `boto3.client(...)` silently loses solution attribution. Keep the three `ua` modules (`agent/src/ua.py`, `cdk/src/handlers/shared/ua.ts`, `cli/src/ua.ts`) identical in id/wire-format/sanitization. - **Transitive npm pin applied in only one place** — `integrations/jira-forge-app` is a standalone npm project *outside* the yarn workspaces, so a root `package.json` `resolutions` bump does **not** reach it. Mirror the pin into `integrations/jira-forge-app/package.json` `overrides` and re-lock (`npm install --package-lock-only`). `mise run drift-prevention` (`check:transitive-pin-sync`) fails locally if they drift. See [#712](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/712). - **Package-specific pitfalls** — API type drift, CDK test bundling, Cedar parity, generated docs: see package `AGENTS.md` files. diff --git a/agent/AGENTS.md b/agent/AGENTS.md index eec51f81e..bb13fa087 100644 --- a/agent/AGENTS.md +++ b/agent/AGENTS.md @@ -91,3 +91,4 @@ def test_a(): - **Cedar parity** — `cedarpy==4.8.4` (agent) and `@cedar-policy/cedar-wasm` 4.8.2 (cdk) must move together. See [cdk/AGENTS.md](../cdk/AGENTS.md) and `docs/design/CEDAR_HITL_GATES.md` §15.6. - **Forgotten consumer** — Progress event schema changes need `cli/src/commands/watch.ts` and `test_progress_writer.py` updates. - **Image bundle** — CDK deploys this tree; root `mise run build` always runs agent quality. +- **Un-attributed AWS SDK client (#319)** — build clients via `aws_session.tenant_client`/`tenant_resource` (tenant-scoped) or `aws_session.platform_client` (unscoped, still attributed); a naked `boto3.client(...)` silently drops solution attribution. diff --git a/agent/src/aws_session.py b/agent/src/aws_session.py index 51c022494..0f2e8f079 100644 --- a/agent/src/aws_session.py +++ b/agent/src/aws_session.py @@ -146,6 +146,8 @@ def _build_scoped_session(role_arn: str) -> Any: ) from botocore.session import get_session as get_botocore_session + import ua + region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") task_id = _tags.get("task_id", "") # Role session name must be <=64 chars and match [\w+=,.@-]. task_id is a @@ -156,8 +158,9 @@ def _build_scoped_session(role_arn: str) -> Any: # A dedicated STS client built from the *ambient* (compute-role) chain. # This is the role-chaining caller; the assumed SessionRole credentials it - # returns must NOT be used to build it, or refresh would recurse. - sts_client = boto3.client("sts", region_name=region) + # returns must NOT be used to build it, or refresh would recurse. Carries + # the static md/ UA segment so the assume-role call is attributed too. + sts_client = boto3.client("sts", region_name=region, config=ua.client_config()) def _refresh() -> dict[str, str]: resp = sts_client.assume_role( @@ -176,6 +179,10 @@ def _refresh() -> dict[str, str]: } botocore_session = get_botocore_session() + # Static md/ solution-attribution segment at the session level: it + # propagates to every client AND resource derived from this session, so + # all tenant-data calls carry it. (#319) + botocore_session.user_agent_extra = ua.static_user_agent_extra() # Deferred: the first assume_role happens on first credential use, not now, # so a transient STS hiccup at startup doesn't crash the agent before it # has even begun. @@ -227,10 +234,19 @@ def get_session() -> Any: ) from exc else: # Scoping not requested (local/dev/tests, or pre-provisioning): - # plain ambient session, behaviorally identical to pre-feature code. - _session = boto3.Session( - region_name=os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") - ) + # plain ambient session. Built from an explicit botocore session so + # the static md/ solution-attribution segment rides every derived + # client/resource (propagation requires the botocore session). (#319) + from botocore.session import get_session as get_botocore_session + + import ua + + botocore_session = get_botocore_session() + botocore_session.user_agent_extra = ua.static_user_agent_extra() + region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") + if region: + botocore_session.set_config_variable("region", region) + _session = boto3.Session(botocore_session=botocore_session) _scoped = False return _session @@ -242,20 +258,56 @@ def is_scoped() -> bool: return bool(_scoped) +def _merge_ua_config(kwargs: dict[str, Any]) -> dict[str, Any]: + """Return ``kwargs`` with the static md/ UA merged into any ``config``. + + Preserves a caller-supplied ``botocore.config.Config``. Non-colliding keys + (``read_timeout`` etc.) survive via ``Config.merge``. ``user_agent_extra`` + is the one key that *does* collide: botocore's ``merge`` gives precedence to + the argument, so a naive merge would silently drop the caller's extra. We + therefore *concatenate* both extras (caller first, then ours) so neither is + lost — matching the scoped-session path, which keeps both segments. (#319) + """ + from botocore.config import Config + + import ua + + ua_config = ua.client_config() + existing = kwargs.get("config") + if existing is None: + kwargs["config"] = ua_config + return kwargs + + caller_extra = getattr(existing, "user_agent_extra", None) + if caller_extra: + # merge() would let our user_agent_extra win outright; instead keep both + # by combining them into one extra. Overlay that combined UA onto + # ua_config (Config.merge lets the argument win only on the keys it + # sets — here just user_agent_extra), so every OTHER key ua_config + # carries survives. Falling through to the shared existing.merge below + # then preserves every caller key too, keeping this branch consistent + # with the no-collision path. (#319 review) + combined = f"{caller_extra} {ua.static_user_agent_extra()}" + ua_config = ua_config.merge(Config(user_agent_extra=combined)) + kwargs["config"] = existing.merge(ua_config) + return kwargs + + def tenant_client(service_name: str, **kwargs: Any) -> Any: """boto3 client for tenant data. When the per-task SessionRole is configured, the client is built from the - tag-scoped, refreshable session. Otherwise it delegates directly to - ``boto3.client`` — behaviorally identical to the pre-feature code path - (and transparent to callers/tests that mock ``boto3.client``). + tag-scoped, refreshable session (which already carries the static md/ UA at + the session level). Otherwise it delegates directly to ``boto3.client`` — + behaviorally identical to the pre-feature code path (transparent to + callers/tests that mock ``boto3.client``) but with the md/ UA merged in. """ session = get_session() if is_scoped(): return session.client(service_name, **kwargs) import boto3 - return boto3.client(service_name, **kwargs) + return boto3.client(service_name, **_merge_ua_config(kwargs)) def tenant_resource(service_name: str, **kwargs: Any) -> Any: @@ -265,4 +317,18 @@ def tenant_resource(service_name: str, **kwargs: Any) -> Any: return session.resource(service_name, **kwargs) import boto3 - return boto3.resource(service_name, **kwargs) + return boto3.resource(service_name, **_merge_ua_config(kwargs)) + + +def platform_client(service_name: str, **kwargs: Any) -> Any: + """boto3 client for **platform** (non-tenant) calls on the ambient chain. + + For the direct ``boto3.client(...)`` sites that deliberately bypass the + scoped session (CloudWatch Logs, Secrets Manager, bedrock-agentcore): they + talk to platform resources, not tenant data, so they use the compute role's + ambient credentials — but should still carry the static md/ solution + attribution. Merges the UA into any caller ``config``. (#319) + """ + import boto3 + + return boto3.client(service_name, **_merge_ua_config(kwargs)) diff --git a/agent/src/bedrock_creds_helper.py b/agent/src/bedrock_creds_helper.py index 6f3ede392..5f5e4d022 100644 --- a/agent/src/bedrock_creds_helper.py +++ b/agent/src/bedrock_creds_helper.py @@ -145,8 +145,13 @@ def resolve_credentials() -> dict[str, str]: return _ambient_credentials() try: - import boto3 + # boto3 is imported here (not just via platform_client, which imports it + # lazily at call time) so a missing SDK still fails open to ambient creds + # — instead of raising an uncaught ImportError. (#319) + import boto3 # noqa: F401 -- availability probe for the fail-open below from botocore.exceptions import BotoCoreError, ClientError + + from aws_session import platform_client except ImportError as exc: # boto3 missing/broken in the image is a packaging defect, not the # expected assume-role failure — name it explicitly so it can't hide. @@ -157,7 +162,7 @@ def resolve_credentials() -> dict[str, str]: task_id = next((t["Value"] for t in tags if t.get("Key") == "task_id"), "") session_name = f"abca-bedrock-{task_id}"[:64] or "abca-bedrock" try: - resp = boto3.client("sts", region_name=region).assume_role( + resp = platform_client("sts", region_name=region).assume_role( RoleArn=role_arn, RoleSessionName=session_name, DurationSeconds=_CHAINED_SESSION_DURATION_S, diff --git a/agent/src/config.py b/agent/src/config.py index 477c6c087..a579f0453 100644 --- a/agent/src/config.py +++ b/agent/src/config.py @@ -54,10 +54,10 @@ def resolve_github_token() -> str: return cached secret_arn = os.environ.get("GITHUB_TOKEN_SECRET_ARN") if secret_arn: - import boto3 + from aws_session import platform_client region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") - client = boto3.client("secretsmanager", region_name=region) + client = platform_client("secretsmanager", region_name=region) resp = client.get_secret_value(SecretId=secret_arn) token = resp["SecretString"] # Cache in env so downstream tools (git, gh CLI) work unchanged @@ -114,14 +114,19 @@ def resolve_linear_api_token(channel_metadata: dict[str, str] | None = None) -> import json from datetime import datetime, timedelta - import boto3 + # boto3 is imported here (not just via platform_client, which imports it + # lazily at call time) so a missing SDK still degrades gracefully — skip + # Linear MCP — instead of raising an uncaught ImportError. (#319) + import boto3 # noqa: F401 -- availability probe for the graceful skip below from botocore.exceptions import BotoCoreError, ClientError + + from aws_session import platform_client except ImportError as e: log("WARN", f"resolve_linear_api_token: boto3 unavailable ({e}); skipping") # nosemgrep: py-silent-success-masking -- optional Linear reactions token; boto3 unavailable return "" - sm = boto3.client("secretsmanager", region_name=region) + sm = platform_client("secretsmanager", region_name=region) def _fetch_token() -> dict | None: """Fetch + parse the per-workspace OAuth secret. @@ -415,13 +420,18 @@ def resolve_jira_oauth_token(channel_metadata: dict[str, str] | None = None) -> import json from datetime import datetime - import boto3 + # boto3 is imported here (not just via platform_client, which imports it + # lazily at call time) so a missing SDK still degrades gracefully — skip + # Jira feedback — instead of raising an uncaught ImportError. (#319) + import boto3 # noqa: F401 -- availability probe for the graceful skip below from botocore.exceptions import BotoCoreError, ClientError + + from aws_session import platform_client except ImportError as e: log("WARN", f"resolve_jira_oauth_token: boto3 unavailable ({e}); skipping") return "" # nosemgrep: py-silent-success-masking -- Jira feedback is advisory - sm = boto3.client("secretsmanager", region_name=region) + sm = platform_client("secretsmanager", region_name=region) def _fetch_token() -> dict | None: resp = sm.get_secret_value(SecretId=secret_arn) diff --git a/agent/src/memory.py b/agent/src/memory.py index 9d2654b20..aa89d1e03 100644 --- a/agent/src/memory.py +++ b/agent/src/memory.py @@ -35,12 +35,12 @@ def _get_client(): global _client if _client is not None: return _client - import boto3 + from aws_session import platform_client region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") if not region: raise ValueError("AWS_REGION or AWS_DEFAULT_REGION must be set for memory operations") - _client = boto3.client("bedrock-agentcore", region_name=region) + _client = platform_client("bedrock-agentcore", region_name=region) return _client diff --git a/agent/src/server.py b/agent/src/server.py index 4bb307ea3..4c2674c16 100644 --- a/agent/src/server.py +++ b/agent/src/server.py @@ -171,10 +171,10 @@ def _warn_cw_write_blocking(log_group: str, task_id: str | None, stamped: str) - covers both writers. """ try: - import boto3 + from aws_session import platform_client region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") - client = boto3.client("logs", region_name=region) + client = platform_client("logs", region_name=region) stream = f"server_warn/{task_id or 'server'}" with _ctx_for_debug.suppress(client.exceptions.ResourceAlreadyExistsException): @@ -198,10 +198,10 @@ def _warn_cw_write_blocking(log_group: str, task_id: str | None, stamped: str) - def _debug_cw_write_blocking(log_group: str, task_id: str | None, stamped: str) -> None: """Blocking CloudWatch write — only called from a background thread.""" try: - import boto3 + from aws_session import platform_client region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") - client = boto3.client("logs", region_name=region) + client = platform_client("logs", region_name=region) stream = f"server_debug/{task_id or 'server'}" with _ctx_for_debug.suppress(client.exceptions.ResourceAlreadyExistsException): diff --git a/agent/src/shell.py b/agent/src/shell.py index 5d91ed4f8..54fb900f4 100644 --- a/agent/src/shell.py +++ b/agent/src/shell.py @@ -75,10 +75,10 @@ def _log_error_cw_blocking(log_group: str, task_id: str | None, stamped: str) -> fire on the absence of the expected stream, not on this helper). """ try: - import boto3 + from aws_session import platform_client region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") - client = boto3.client("logs", region_name=region) + client = platform_client("logs", region_name=region) stream = f"agent_error/{task_id or 'unknown'}" with contextlib.suppress(client.exceptions.ResourceAlreadyExistsException): client.create_log_stream(logGroupName=log_group, logStreamName=stream) diff --git a/agent/src/telemetry.py b/agent/src/telemetry.py index b91f2b4e0..560daa7b6 100644 --- a/agent/src/telemetry.py +++ b/agent/src/telemetry.py @@ -56,10 +56,10 @@ def _emit_metrics_to_cloudwatch(json_payload: dict) -> None: try: import contextlib - import boto3 + from aws_session import platform_client region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") - client = boto3.client("logs", region_name=region) + client = platform_client("logs", region_name=region) task_id = json_payload.get("task_id", "unknown") log_stream = f"metrics/{task_id}" @@ -164,10 +164,10 @@ def _ensure_client(self): import contextlib - import boto3 + from aws_session import platform_client region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") - self._client = boto3.client("logs", region_name=region) + self._client = platform_client("logs", region_name=region) log_stream = f"trajectory/{self._task_id}" with contextlib.suppress(self._client.exceptions.ResourceAlreadyExistsException): diff --git a/agent/src/ua.py b/agent/src/ua.py new file mode 100644 index 000000000..f49e65db1 --- /dev/null +++ b/agent/src/ua.py @@ -0,0 +1,76 @@ +"""Outbound AWS SDK User-Agent solution attribution (#319). + +Every AWS API call made by the agent carries two ABCA solution-attribution +segments in the ``User-Agent`` header: + + app/uksb-wt64nei4u6#{STACKNAME} <- native AWS_SDK_UA_APP_ID env (no code here) + md/uksb-wt64nei4u6#agent <- static, baked once at construction + +**The ``app/`` segment is emitted by the SDK itself.** Both botocore and the +JS v3 SDK read the ``AWS_SDK_UA_APP_ID`` environment variable natively and +render it as ``app/{value}`` (botocore ``configprovider.py`` maps it to the +``user_agent_appid`` config; the value charset *includes* ``#``, so the +``uksb-wt64nei4u6#{stack}`` form survives verbatim). CDK sets that env var on +every Lambda / AgentCore runtime / ECS container, so this module contributes +**nothing** to ``app/`` — and a customer can suppress it by setting the env +var to the empty string. (This is the key simplification over the original +``/``-separated design, which had to bypass the native field because ``/`` is +not a legal app-id character. Using ``#`` keeps it native.) + +This module owns only the **static ``md/`` segment** — a stable +per-component label baked once via ``user_agent_extra`` at session/client +construction. There is intentionally no per-request trace handle and no +event/middleware machinery: connection pools are never re-pinned, and +request correlation is owned by X-Ray / structured-log request ids (#245), +not the User-Agent. + +The TypeScript counterparts are ``cdk/src/handlers/shared/ua.ts`` and +``cli/src/ua.ts`` — the solution id, wire format, and sanitization rules +must stay identical across all three. +""" + +from __future__ import annotations + +import string +from typing import Any + +# AWS solution-attribution id for ABCA. Also appears (deploy-time +# counterpart, #292) in the CloudFormation stack description in +# ``cdk/src/main.ts`` and in the TS mirrors of this module. Per-surface +# literal by design. +SOLUTION_ID = "uksb-wt64nei4u6" + +# Stable per-component label: this surface IS the Python agent runtime. +COMPONENT = "agent" + +# RFC 7230 token charset (the UA product-token alphabet). '#' is the +# scheme's structural separator and is deliberately NOT here, so a hostile +# component/label value cannot inject extra segments. +_ALLOWED = frozenset(string.ascii_letters + string.digits + "!$%&'*+-.^_`|~") + + +def sanitize_ua_value(raw: str) -> str: + """Replace every non-UA-token char (incl. non-ASCII) with ``-``.""" + return "".join(c if c in _ALLOWED else "-" for c in raw) + + +def static_user_agent_extra() -> str: + """The static ``md/`` segment baked at client/session construction. + + Always ``md/{SOLUTION_ID}#{COMPONENT}`` — the ``app/`` segment is + contributed separately by the SDK from ``AWS_SDK_UA_APP_ID`` and is not + this module's concern. + """ + return f"md/{SOLUTION_ID}#{sanitize_ua_value(COMPONENT)}" + + +def client_config() -> Any: + """``botocore.config.Config`` carrying the static ``md/`` segment. + + For direct ``boto3.client(...)`` call sites that don't go through a + shared session (see ``aws_session.platform_client``). Merge-friendly: + callers that already pass a ``Config`` should use ``.merge(...)``. + """ + from botocore.config import Config + + return Config(user_agent_extra=static_user_agent_extra()) diff --git a/agent/tests/test_aws_session.py b/agent/tests/test_aws_session.py index 3a7a67d5d..d058ad0ec 100644 --- a/agent/tests/test_aws_session.py +++ b/agent/tests/test_aws_session.py @@ -318,3 +318,116 @@ def test_overlong_value_truncated_to_256(self, monkeypatch): assert len(tags["repo"]) == _MAX_TAG_VALUE_LEN == 256 # Untruncated values are passed through unchanged. assert tags["user_id"] == "u-1" + + +class TestSolutionUserAgent: + """The static md/ solution-attribution segment (#319) rides every client.""" + + def test_platform_client_carries_md_segment(self, monkeypatch): + monkeypatch.setenv("AWS_REGION", "us-east-1") + from aws_session import platform_client + + with patch("boto3.client", return_value=MagicMock(name="logs")) as mk: + platform_client("logs", region_name="us-east-1") + + cfg = mk.call_args.kwargs["config"] + assert cfg.user_agent_extra == "md/uksb-wt64nei4u6#agent" + + def test_unscoped_tenant_client_carries_md_segment(self, monkeypatch): + # No SESSION_ROLE_ARN -> unscoped path delegates to boto3.client. + monkeypatch.setenv("AWS_REGION", "us-east-1") + from aws_session import tenant_client + + with patch("boto3.client", return_value=MagicMock(name="ddb")) as mk: + tenant_client("dynamodb") + + cfg = mk.call_args.kwargs["config"] + assert cfg.user_agent_extra == "md/uksb-wt64nei4u6#agent" + + def test_caller_config_is_merged_not_overwritten(self, monkeypatch): + from botocore.config import Config + + monkeypatch.setenv("AWS_REGION", "us-east-1") + from aws_session import platform_client + + # A caller that sets BOTH a non-colliding key (read_timeout) and the + # colliding key (user_agent_extra). botocore's Config.merge gives + # precedence to the argument, so a naive merge would drop 'caller/1.0'; + # _merge_ua_config concatenates instead so both segments survive. + with patch("boto3.client", return_value=MagicMock()) as mk: + platform_client("logs", config=Config(read_timeout=7, user_agent_extra="caller/1.0")) + + cfg = mk.call_args.kwargs["config"] + # Non-colliding caller key survives. + assert cfg.read_timeout == 7 + # Both the caller's UA extra and our md/ segment survive the merge. + assert "caller/1.0" in cfg.user_agent_extra + assert "md/uksb-wt64nei4u6#agent" in cfg.user_agent_extra + + def test_caller_config_without_ua_extra_gets_md_segment(self, monkeypatch): + from botocore.config import Config + + monkeypatch.setenv("AWS_REGION", "us-east-1") + from aws_session import platform_client + + # No colliding user_agent_extra: non-colliding key survives and our md/ + # segment is applied. + with patch("boto3.client", return_value=MagicMock()) as mk: + platform_client("logs", config=Config(read_timeout=7)) + + cfg = mk.call_args.kwargs["config"] + assert cfg.read_timeout == 7 + assert cfg.user_agent_extra == "md/uksb-wt64nei4u6#agent" + + def test_merge_ua_config_preserves_other_caller_config_keys(self): + from botocore.config import Config + + import aws_session + + # The collision branch must merge the combined UA onto the caller's OWN + # Config so every other caller key survives — a fresh Config would drop + # connect_timeout (and any other key the caller carried). (#319) + caller = Config(read_timeout=7, connect_timeout=3, user_agent_extra="caller/1.0") + merged = aws_session._merge_ua_config({"config": caller})["config"] + assert merged.read_timeout == 7 + assert merged.connect_timeout == 3 # <-- dropped today + assert "caller/1.0" in merged.user_agent_extra + assert "md/uksb-wt64nei4u6#agent" in merged.user_agent_extra + + def test_collision_branch_preserves_ua_config_own_keys(self): + from botocore.config import Config + + import aws_session + + # Symmetry with the no-collision branch: the collision branch must keep + # every key ua.client_config() carries, not just user_agent_extra. If it + # rebuilds a fresh Config(user_agent_extra=...), any OTHER key the helper + # grows (here read_timeout=99) is silently dropped. A colliding caller + # user_agent_extra forces the collision branch. (#319 review) + ua_config = Config(user_agent_extra="md/uksb-wt64nei4u6#agent", read_timeout=99) + caller = Config(user_agent_extra="caller/1.0", connect_timeout=3) + with patch("ua.client_config", return_value=ua_config): + merged = aws_session._merge_ua_config({"config": caller})["config"] + # Our-side key from ua_config survives (dropped by the fresh-Config form). + assert merged.read_timeout == 99 + # Caller's own key survives. + assert merged.connect_timeout == 3 + # Both UA extras survive the collision. + assert "caller/1.0" in merged.user_agent_extra + assert "md/uksb-wt64nei4u6#agent" in merged.user_agent_extra + + def test_scoped_session_sets_session_level_extra(self, monkeypatch): + monkeypatch.setenv("AWS_REGION", "us-east-1") + monkeypatch.setenv(SESSION_ROLE_ARN_ENV, "arn:aws:iam::111122223333:role/abca-session") + configure_session(user_id="u-1", repo="owner/repo", task_id="t-abc") + + fake_botocore_session = MagicMock(name="botocore-session") + with ( + patch("boto3.client", return_value=MagicMock(name="sts")), + patch("boto3.Session", return_value=MagicMock(name="boto3-session")), + patch("botocore.credentials.DeferredRefreshableCredentials"), + patch("botocore.session.get_session", return_value=fake_botocore_session), + ): + get_session() + + assert fake_botocore_session.user_agent_extra == "md/uksb-wt64nei4u6#agent" diff --git a/agent/tests/test_bedrock_creds_helper.py b/agent/tests/test_bedrock_creds_helper.py index 426aad8db..23f4c2a7d 100644 --- a/agent/tests/test_bedrock_creds_helper.py +++ b/agent/tests/test_bedrock_creds_helper.py @@ -74,6 +74,52 @@ def test_resolve_assumes_role_with_session_tags(attr_file): } +def test_resolve_obtains_sts_client_via_platform_client(attr_file): + """The STS client is built through aws_session.platform_client (carrying the + md/ solution User-Agent), not a naked boto3.client. (#319)""" + tags = build_session_tags("u1", "owner/repo", "task123") + helper.write_attribution_file("arn:aws:iam::1:role/SR", tags, attr_file) + + expiry = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC) + sts = MagicMock() + sts.assume_role.return_value = { + "Credentials": { + "AccessKeyId": "AK", + "SecretAccessKey": "SK", + "SessionToken": "TK", + "Expiration": expiry, + } + } + with patch("aws_session.platform_client", return_value=sts) as pc: + creds = helper.resolve_credentials() + pc.assert_called_with("sts", region_name=os.environ.get("AWS_REGION")) + assert creds["AccessKeyId"] == "AK" + + +def test_resolve_fails_open_when_boto3_imports_unavailable(attr_file, capsys): + """If the in-function boto3/platform_client import fails, the helper degrades + to ambient creds rather than raising — the graceful (fail-open) guard holds.""" + import builtins + + helper.write_attribution_file( + "arn:aws:iam::1:role/SR", build_session_tags("u", "r", "t"), attr_file + ) + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "boto3" or name == "aws_session": + raise ImportError(f"simulated missing {name}") + return real_import(name, *args, **kwargs) + + with ( + patch("builtins.__import__", side_effect=fake_import), + patch("botocore.session.get_session", return_value=_ambient()), + ): + creds = helper.resolve_credentials() + assert creds["AccessKeyId"] == "AMB" + assert "boto3 unavailable" in capsys.readouterr().err + + def test_resolve_fails_open_when_no_attribution_file(attr_file): # File never written → fall back to ambient creds, never raise. frozen = SimpleNamespace(access_key="AMB", secret_key="S", token="T") diff --git a/agent/tests/test_config.py b/agent/tests/test_config.py index a848e3a56..41e0aedcf 100644 --- a/agent/tests/test_config.py +++ b/agent/tests/test_config.py @@ -636,6 +636,56 @@ def test_resolves_from_secrets_manager_and_caches_in_env(self, monkeypatch): # Reset for other tests. monkeypatch.delenv("JIRA_API_TOKEN", raising=False) + def test_obtains_secretsmanager_client_via_platform_client(self, monkeypatch): + """The Secrets Manager client is built through aws_session.platform_client + (carrying the md/ solution User-Agent), not a naked boto3.client. (#319)""" + from datetime import datetime, timedelta + + monkeypatch.delenv("JIRA_API_TOKEN", raising=False) + monkeypatch.setenv("AWS_REGION", "us-east-1") + future = (datetime.now(UTC) + timedelta(hours=12)).isoformat().replace("+00:00", "Z") + mock_sm = MagicMock() + mock_sm.get_secret_value.return_value = { + "SecretString": __import__("json").dumps( + { + "access_token": "jira_via_platform_client", + "refresh_token": "rt", + "expires_at": future, + "scope": "read:jira-work", + "client_id": "c", + "client_secret": "s", + "cloud_id": "cloud-uuid", + "site_url": "https://acme.atlassian.net", + "installed_at": "x", + "updated_at": "x", + "installed_by_platform_user_id": "u", + } + ), + } + with patch("aws_session.platform_client", return_value=mock_sm) as pc: + resolved = resolve_jira_oauth_token({"jira_oauth_secret_arn": "arn:test"}) + assert resolved == "jira_via_platform_client" + pc.assert_called_with("secretsmanager", region_name="us-east-1") + monkeypatch.delenv("JIRA_API_TOKEN", raising=False) + + def test_graceful_skip_when_boto3_imports_unavailable(self, monkeypatch): + """If the in-function boto3/platform_client import fails, the resolver + degrades to '' rather than raising — the graceful-skip guard is preserved.""" + import builtins + + monkeypatch.delenv("JIRA_API_TOKEN", raising=False) + monkeypatch.setenv("AWS_REGION", "us-east-1") + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "boto3" or name == "aws_session": + raise ImportError(f"simulated missing {name}") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + assert resolve_jira_oauth_token({"jira_oauth_secret_arn": "arn:test"}) == "" + monkeypatch.delenv("JIRA_API_TOKEN", raising=False) + def test_resolves_forge_app_actor_even_when_oauth_token_is_expiring(self, monkeypatch): """Forge credentials are independent of the human 3LO token lifetime.""" import json diff --git a/agent/tests/test_ua.py b/agent/tests/test_ua.py new file mode 100644 index 000000000..95ccc7bdc --- /dev/null +++ b/agent/tests/test_ua.py @@ -0,0 +1,90 @@ +"""Unit + wire-capture tests for ua.py (#319, simplified app-id design).""" + +import contextlib + +import boto3 +import pytest +from botocore.awsrequest import AWSResponse +from botocore.config import Config + +import ua + + +class TestSanitize: + @pytest.mark.parametrize( + "raw,expected", + [ + ("agent", "agent"), + ("a/b", "a-b"), # '/' is not a UA token char + ("a#b", "a-b"), # '#' is the scheme separator — must be stripped + ("héllo", "h-llo"), # non-ASCII -> '-' + ("a b", "a-b"), # space -> '-' + ("ok-_.~!", "ok-_.~!"), # legal token chars pass through + ], + ) + def test_sanitize_vectors(self, raw, expected): + assert ua.sanitize_ua_value(raw) == expected + + +class TestStaticUserAgentExtra: + def test_is_static_md_segment_only(self): + # The app/ segment is the SDK's job (native AWS_SDK_UA_APP_ID); this + # module emits only the md/ component segment. + assert ua.static_user_agent_extra() == "md/uksb-wt64nei4u6#agent" + + def test_no_app_segment_built_here(self): + assert "app/" not in ua.static_user_agent_extra() + + def test_client_config_carries_extra(self): + cfg = ua.client_config() + assert cfg.user_agent_extra == "md/uksb-wt64nei4u6#agent" + + +class TestWireCapture: + """Capture the real outbound User-Agent header via a before-send stub + that short-circuits the HTTP send (no network).""" + + def _capture_ua(self, monkeypatch, app_id): + if app_id is None: + monkeypatch.delenv("AWS_SDK_UA_APP_ID", raising=False) + else: + monkeypatch.setenv("AWS_SDK_UA_APP_ID", app_id) + + client = boto3.client( + "sts", + region_name="us-east-1", + aws_access_key_id="x", + aws_secret_access_key="y", + config=Config(user_agent_extra=ua.static_user_agent_extra()), + ) + captured = {} + + def _grab(request, **_kwargs): + ua_header = request.headers.get("User-Agent") + captured["ua"] = ( + ua_header.decode("ascii", "replace") if isinstance(ua_header, bytes) else ua_header + ) + return AWSResponse("https://x", 200, {}, b"") + + client.meta.events.register("before-send.sts.*", _grab) + with contextlib.suppress(Exception): + # The short-circuit stub returns an empty body, so parsing fails; + # we only need the header captured by _grab before that. + client.get_caller_identity() + return captured["ua"] + + def test_both_segments_present(self, monkeypatch): + ua_header = self._capture_ua(monkeypatch, "uksb-wt64nei4u6#backgroundagent-dev") + assert "app/uksb-wt64nei4u6#backgroundagent-dev" in ua_header + assert "md/uksb-wt64nei4u6#agent" in ua_header + + def test_app_segment_omitted_when_env_unset(self, monkeypatch): + ua_header = self._capture_ua(monkeypatch, None) + assert "app/uksb-wt64nei4u6" not in ua_header + # md/ still present — it does not depend on the env var + assert "md/uksb-wt64nei4u6#agent" in ua_header + + def test_app_segment_omitted_when_env_empty(self, monkeypatch): + ua_header = self._capture_ua(monkeypatch, "") + assert "app/uksb-wt64nei4u6" not in ua_header + assert "md/uksb-wt64nei4u6#agent" in ua_header diff --git a/cdk/AGENTS.md b/cdk/AGENTS.md index 97b58d274..40fc722b3 100644 --- a/cdk/AGENTS.md +++ b/cdk/AGENTS.md @@ -94,3 +94,4 @@ beforeAll(() => { - **Lambda bundling in unit tests** — `Template.fromStack()` synths the stack but bundling is disabled via `CDK_CONTEXT_JSON`. Do not re-enable globally; opt in per-test with `postCliContext` only when asserting on bundle output. Details: `test/setup/disable-bundling.ts`, #366. - **Cedar engine drift** — `@cedar-policy/cedar-wasm` and `cedarpy` share a Rust core. Bump both + parity fixtures in one commit. See `docs/design/CEDAR_HITL_GATES.md` §15.6 and `mise.toml` parity banner. - **Types out of sync** — `cdk/src/handlers/shared/types.ts` and `cli/src/types.ts` must match; CI runs `check-types-sync`. +- **Un-attributed AWS SDK client (#319)** — build clients via `makeClient(Ctor, cfg)` / `makeDocClient(cfg)` from `src/handlers/shared/ua.ts`; a naked `new XxxClient({})` silently drops solution attribution. diff --git a/cdk/src/constructs/concurrency-reconciler.ts b/cdk/src/constructs/concurrency-reconciler.ts index e66ee1a8c..c0fed93dd 100644 --- a/cdk/src/constructs/concurrency-reconciler.ts +++ b/cdk/src/constructs/concurrency-reconciler.ts @@ -78,6 +78,8 @@ export class ConcurrencyReconciler extends Construct { timeout: Duration.minutes(RECONCILER_TIMEOUT_MINUTES), memorySize: RECONCILER_MEMORY_MB, environment: { + // Solution-attribution component label (#319): orchestration plane. + ABCA_COMPONENT: 'orchestr', TASK_TABLE_NAME: props.taskTable.tableName, USER_CONCURRENCY_TABLE_NAME: props.userConcurrencyTable.tableName, }, diff --git a/cdk/src/constructs/ecs-agent-cluster.ts b/cdk/src/constructs/ecs-agent-cluster.ts index ebf475a0c..3d00b3922 100644 --- a/cdk/src/constructs/ecs-agent-cluster.ts +++ b/cdk/src/constructs/ecs-agent-cluster.ts @@ -31,6 +31,7 @@ import { Construct, type Node } from 'constructs'; import { AgentMemory } from './agent-memory'; import { AgentSessionRole } from './agent-session-role'; import { resolveBedrockModelIds } from './bedrock-models'; +import { buildAppId } from './solution-ua-aspect'; export interface EcsAgentClusterProps { readonly vpc: ec2.IVpc; @@ -341,6 +342,16 @@ export class EcsAgentCluster extends Construct { ], }); + // Outbound SDK solution attribution (#319): botocore reads + // AWS_SDK_UA_APP_ID natively → `app/uksb-wt64nei4u6#{stack}`. The + // Lambda-only stack aspect can't reach this container, so set it here on + // the shared base env so BOTH task defs (build + planning) carry it. + // `-c sdkUaAppId=''` opts out (buildAppId → undefined → omitted). + const sdkUaAppId = buildAppId( + Stack.of(this).stackName, + this.node.tryGetContext('sdkUaAppId') as string | undefined, + ); + // The container spec shared by both task defs — image, logging, env are // IDENTICAL; only the enclosing task def's cpu/mem differ. BUILD_VERIFY_TIMEOUT_S // is a build-tier concern (a read-only planner never runs the post-agent build @@ -367,6 +378,9 @@ export class EcsAgentCluster extends Construct { ...(props.agentSessionRole && { AGENT_SESSION_ROLE_ARN: props.agentSessionRole.role.roleArn, }), + // #319 outbound SDK solution attribution — set on the shared base so both + // task defs emit `app/uksb-wt64nei4u6#{stack}`. + ...(sdkUaAppId ? { AWS_SDK_UA_APP_ID: sdkUaAppId } : {}), }; const image = ecs.ContainerImage.fromDockerImageAsset(props.agentImageAsset); const makeTaskDef = ( diff --git a/cdk/src/constructs/fanout-consumer.ts b/cdk/src/constructs/fanout-consumer.ts index 3438c8842..dff1db505 100644 --- a/cdk/src/constructs/fanout-consumer.ts +++ b/cdk/src/constructs/fanout-consumer.ts @@ -197,6 +197,11 @@ export class FanOutConsumer extends Construct { }, }); + // Solution-attribution component label (#319): fan-out is part of the + // orchestration plane. The universal `app/` segment (AWS_SDK_UA_APP_ID) is + // set by the stack-level SolutionUaAspect. + this.fn.addEnvironment('ABCA_COMPONENT', 'orchestr'); + // GitHub dispatcher plumbing. Each grant/env var is guarded so the // fan-out plane still deploys cleanly in a dev environment that // hasn't onboarded the RepoTable or a platform GitHub token yet — diff --git a/cdk/src/constructs/github-screenshot-integration.ts b/cdk/src/constructs/github-screenshot-integration.ts index 6005afe38..7c0dd8292 100644 --- a/cdk/src/constructs/github-screenshot-integration.ts +++ b/cdk/src/constructs/github-screenshot-integration.ts @@ -18,7 +18,7 @@ */ import * as path from 'path'; -import { ArnFormat, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import { ArnFormat, Aspects, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; import * as apigw from 'aws-cdk-lib/aws-apigateway'; import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; @@ -30,6 +30,7 @@ import * as sqs from 'aws-cdk-lib/aws-sqs'; import { NagSuppressions } from 'cdk-nag'; import { Construct } from 'constructs'; import { ScreenshotBucket } from './screenshot-bucket'; +import { ComponentUaAspect } from './solution-ua-aspect'; /** Async screenshot-processor Lambda timeout (seconds). */ const PROCESSOR_TIMEOUT_SECONDS = 120; @@ -140,6 +141,12 @@ export class GitHubScreenshotIntegration extends Construct { constructor(scope: Construct, id: string, props: GitHubScreenshotIntegrationProps) { super(scope, id); + // Solution-attribution component label (#319): every Lambda in this GitHub + // screenshot integration is part of the webhook ingest surface. One aspect + // labels them all (and any future function added here); the universal + // `app/` segment is set by the stack-level aspect. + Aspects.of(this).add(new ComponentUaAspect('webhook')); + const removalPolicy = props.removalPolicy ?? RemovalPolicy.DESTROY; // --- Screenshot bucket (private; served via CloudFront with OAC) --- diff --git a/cdk/src/constructs/jira-integration.ts b/cdk/src/constructs/jira-integration.ts index 6e7af329a..101da31a8 100644 --- a/cdk/src/constructs/jira-integration.ts +++ b/cdk/src/constructs/jira-integration.ts @@ -18,7 +18,7 @@ */ import * as path from 'path'; -import { ArnFormat, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import { ArnFormat, Aspects, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; import * as apigw from 'aws-cdk-lib/aws-apigateway'; import * as cognito from 'aws-cdk-lib/aws-cognito'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; @@ -32,6 +32,7 @@ import { Construct } from 'constructs'; import { JiraProjectMappingTable } from './jira-project-mapping-table'; import { JiraUserMappingTable } from './jira-user-mapping-table'; import { JiraWorkspaceRegistryTable } from './jira-workspace-registry-table'; +import { ComponentUaAspect } from './solution-ua-aspect'; /** Default task-record retention used for TTL computation (days). */ const DEFAULT_TASK_RETENTION_DAYS = 90; @@ -156,6 +157,13 @@ export class JiraIntegration extends Construct { constructor(scope: Construct, id: string, props: JiraIntegrationProps) { super(scope, id); + // Solution-attribution component label (#319): every Lambda in this Jira + // integration is part of the webhook ingest surface. One aspect labels + // them all (and any future function added here) without per-function env + // edits; the universal `app/` segment is set by the stack-level aspect. + // Matches slack/linear/github-screenshot integrations. + Aspects.of(this).add(new ComponentUaAspect('webhook')); + const removalPolicy = props.removalPolicy ?? RemovalPolicy.DESTROY; // --- DynamoDB tables --- diff --git a/cdk/src/constructs/linear-integration.ts b/cdk/src/constructs/linear-integration.ts index 965fef0d6..ff8627aeb 100644 --- a/cdk/src/constructs/linear-integration.ts +++ b/cdk/src/constructs/linear-integration.ts @@ -18,7 +18,7 @@ */ import * as path from 'path'; -import { ArnFormat, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import { ArnFormat, Aspects, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; import * as apigw from 'aws-cdk-lib/aws-apigateway'; import * as cognito from 'aws-cdk-lib/aws-cognito'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; @@ -32,6 +32,7 @@ import { Construct } from 'constructs'; import { LinearProjectMappingTable } from './linear-project-mapping-table'; import { LinearUserMappingTable } from './linear-user-mapping-table'; import { LinearWorkspaceRegistryTable } from './linear-workspace-registry-table'; +import { ComponentUaAspect } from './solution-ua-aspect'; /** Default task-record retention used for TTL computation (days). */ const DEFAULT_TASK_RETENTION_DAYS = 90; @@ -164,6 +165,12 @@ export class LinearIntegration extends Construct { constructor(scope: Construct, id: string, props: LinearIntegrationProps) { super(scope, id); + // Solution-attribution component label (#319): every Lambda in this Linear + // integration is part of the webhook ingest surface. One aspect labels + // them all (and any future function added here); the universal `app/` + // segment is set by the stack-level aspect. + Aspects.of(this).add(new ComponentUaAspect('webhook')); + const removalPolicy = props.removalPolicy ?? RemovalPolicy.DESTROY; // --- DynamoDB tables --- diff --git a/cdk/src/constructs/pending-upload-cleanup.ts b/cdk/src/constructs/pending-upload-cleanup.ts index 5409b6a51..adf8b5b44 100644 --- a/cdk/src/constructs/pending-upload-cleanup.ts +++ b/cdk/src/constructs/pending-upload-cleanup.ts @@ -103,6 +103,8 @@ export class PendingUploadCleanup extends Construct { timeout: Duration.seconds(CLEANUP_TIMEOUT_SECONDS), memorySize: CLEANUP_MEMORY_MB, environment: { + // Solution-attribution component label (#319): orchestration plane. + ABCA_COMPONENT: 'orchestr', TASK_TABLE_NAME: props.taskTable.tableName, TASK_EVENTS_TABLE_NAME: props.taskEventsTable.tableName, ATTACHMENTS_BUCKET_NAME: props.attachmentsBucket.bucketName, diff --git a/cdk/src/constructs/slack-integration.ts b/cdk/src/constructs/slack-integration.ts index 25e67434f..a7224b92a 100644 --- a/cdk/src/constructs/slack-integration.ts +++ b/cdk/src/constructs/slack-integration.ts @@ -18,7 +18,7 @@ */ import * as path from 'path'; -import { ArnFormat, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import { ArnFormat, Aspects, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; import * as apigw from 'aws-cdk-lib/aws-apigateway'; import * as cognito from 'aws-cdk-lib/aws-cognito'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; @@ -31,6 +31,7 @@ import { Construct } from 'constructs'; import { SlackChannelMappingTable } from './slack-channel-mapping-table'; import { SlackInstallationTable } from './slack-installation-table'; import { SlackUserMappingTable } from './slack-user-mapping-table'; +import { ComponentUaAspect } from './solution-ua-aspect'; /** Default task-record retention used for TTL computation (days). */ const DEFAULT_TASK_RETENTION_DAYS = 90; @@ -119,6 +120,12 @@ export class SlackIntegration extends Construct { constructor(scope: Construct, id: string, props: SlackIntegrationProps) { super(scope, id); + // Solution-attribution component label (#319): every Lambda in this Slack + // integration is part of the webhook ingest surface. One aspect labels + // them all (and any future function added here) without per-function env + // edits; the universal `app/` segment is set by the stack-level aspect. + Aspects.of(this).add(new ComponentUaAspect('webhook')); + const removalPolicy = props.removalPolicy ?? RemovalPolicy.DESTROY; // --- DynamoDB Tables --- diff --git a/cdk/src/constructs/solution-ua-aspect.ts b/cdk/src/constructs/solution-ua-aspect.ts new file mode 100644 index 000000000..26bd766ad --- /dev/null +++ b/cdk/src/constructs/solution-ua-aspect.ts @@ -0,0 +1,127 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { IAspect } from 'aws-cdk-lib'; +import * as lambda from 'aws-cdk-lib/aws-lambda'; +import { IConstruct } from 'constructs'; + +/** ABCA solution-attribution id (#319). Mirrors the deploy-time token in + * `main.ts` and the per-surface `ua` helpers. */ +export const SOLUTION_ID = 'uksb-wt64nei4u6'; + +/** Documented app-id value cap. Over-limit only warns (never truncates), but + * we clip defensively so a long stack name can't produce a noisy log. */ +const APP_ID_MAX_LEN = 50; + +/** UA-token charset; `#` is the scheme separator, so it is excluded here. */ +const UA_TOKEN_UNSAFE = /[^A-Za-z0-9!$%&'*+\-.^_`|~]/g; + +/** + * Sanitize an app-id value while preserving the `#` solution separator. + * + * The app-id value charset (`UA_VALUE_ESCAPE_REGEX` in the SDK) includes `#`, + * which is the canonical `uksb-wt64nei4u6#{stack}` separator. Sanitizing the + * whole string with `UA_TOKEN_UNSAFE` (which excludes `#`) would mangle that + * separator to `-`, so we split on `#`, sanitize each segment independently, + * and rejoin — preserving `#` boundaries while stripping any other unsafe char. + */ +function sanitizeAppId(value: string): string { + return value + .split('#') + .map((segment) => segment.replace(UA_TOKEN_UNSAFE, '-')) + .join('#'); +} + +/** + * Clip an app-id value to the documented cap, then drop a `#` left dangling at + * the tail by the clip. Internal `#` separators are untouched — only a trailing + * one (produced when the 50-char boundary lands right after a separator) is + * stripped, so the SDK never renders a cosmetic `app/…#`. + */ +function clipAppId(value: string): string { + const clipped = value.slice(0, APP_ID_MAX_LEN); + return clipped.endsWith('#') ? clipped.slice(0, -1) : clipped; +} + +/** + * Build the `AWS_SDK_UA_APP_ID` value for a deployment. + * + * `uksb-wt64nei4u6#{stackName}` — the SDK reads this env var natively and + * renders `app/uksb-wt64nei4u6#{stackName}` on every request, so no client + * code is involved. CloudFormation stack names are `[A-Za-z0-9-]` (already a + * subset of the app-id charset), but a non-CFN override value is sanitized + * defensively. Both branches preserve `#` as the solution separator (only + * other unsafe chars become `-`), then clip to the documented 50-char cap. + * + * Returns `undefined` for an explicit empty override — the caller then omits + * the env var entirely, which is the customer opt-out (no `app/` segment). + */ +export function buildAppId(stackName: string, override?: string): string | undefined { + if (override !== undefined) { + const trimmed = override.trim(); + return trimmed === '' ? undefined : clipAppId(sanitizeAppId(trimmed)); + } + const value = `${SOLUTION_ID}#${stackName.replace(UA_TOKEN_UNSAFE, '-')}`; + return clipAppId(value); +} + +/** + * Aspect that sets `AWS_SDK_UA_APP_ID` on every Lambda function in scope so + * the SDK-native `app/` solution-attribution segment rides every outbound AWS + * API call — current and future functions alike, without per-function wiring + * (the structural guarantee a hand-threaded env var can't make). The + * per-surface `ABCA_COMPONENT` (the `md/` label) is still set on each + * construct's env block; this aspect owns only the universal app-id. + * + * Applied once at the stack level. A `undefined` appId (empty override) makes + * this a no-op, so the customer opt-out leaves no `app/` segment anywhere. + */ +export class SolutionUaAspect implements IAspect { + public constructor(private readonly appId: string | undefined) {} + + public visit(node: IConstruct): void { + if (this.appId === undefined) { + return; + } + if (node instanceof lambda.Function) { + node.addEnvironment('AWS_SDK_UA_APP_ID', this.appId); + } + } +} + +/** + * Aspect that sets `ABCA_COMPONENT` (the `md/` solution-attribution label) on + * every Lambda function in scope. Applied at a construct scope so all of an + * integration's functions share one component label (`webhook`, …) without + * hand-editing each function's `environment` block — and any future function + * added to that construct is covered automatically. + * + * Apply this only to scopes whose functions all share the one label; surfaces + * that set `ABCA_COMPONENT` directly in their env block (task-api `api`, + * orchestrator/reconcilers `orchestr`) do not use this aspect. + */ +export class ComponentUaAspect implements IAspect { + public constructor(private readonly component: string) {} + + public visit(node: IConstruct): void { + if (node instanceof lambda.Function) { + node.addEnvironment('ABCA_COMPONENT', this.component); + } + } +} diff --git a/cdk/src/constructs/stranded-task-reconciler.ts b/cdk/src/constructs/stranded-task-reconciler.ts index 897d86c8b..cb3dbdbc6 100644 --- a/cdk/src/constructs/stranded-task-reconciler.ts +++ b/cdk/src/constructs/stranded-task-reconciler.ts @@ -123,6 +123,8 @@ export class StrandedTaskReconciler extends Construct { timeout: Duration.minutes(RECONCILER_TIMEOUT_MINUTES), memorySize: RECONCILER_MEMORY_MB, environment: { + // Solution-attribution component label (#319): orchestration plane. + ABCA_COMPONENT: 'orchestr', TASK_TABLE_NAME: props.taskTable.tableName, TASK_EVENTS_TABLE_NAME: props.taskEventsTable.tableName, USER_CONCURRENCY_TABLE_NAME: props.userConcurrencyTable.tableName, diff --git a/cdk/src/constructs/task-api.ts b/cdk/src/constructs/task-api.ts index 526815bbb..f90c4d0ef 100644 --- a/cdk/src/constructs/task-api.ts +++ b/cdk/src/constructs/task-api.ts @@ -524,6 +524,10 @@ export class TaskApi extends Construct { TASK_TABLE_NAME: props.taskTable.tableName, TASK_EVENTS_TABLE_NAME: props.taskEventsTable.tableName, TASK_RETENTION_DAYS: String(props.taskRetentionDays ?? DEFAULT_TASK_RETENTION_DAYS), + // Solution-attribution component label (#319): the `md/` segment for the + // REST API surface. The universal `app/` segment (AWS_SDK_UA_APP_ID) is + // set separately by the stack-level SolutionUaAspect. + ABCA_COMPONENT: 'api', }; // The Node.js Lambda runtime ships an AWS SDK, but its pinned version // lags current. `@aws-sdk/client-bedrock-agentcore` in particular has @@ -1046,6 +1050,10 @@ export class TaskApi extends Construct { const apiKeyEnv: Record = { API_KEY_TABLE_NAME: props.apiKeyTable.tableName, API_KEY_RETENTION_DAYS: String(props.apiKeyRetentionDays ?? DEFAULT_WEBHOOK_RETENTION_DAYS), + // Solution-attribution component label (#319): API-key management is + // part of the REST API surface. apiKeyEnv does NOT spread commonEnv, so + // set it explicitly here rather than relying on the `api` default fallback. + ABCA_COMPONENT: 'api', }; // --- Unified authorizer: Cognito JWT OR platform API key --- @@ -1059,6 +1067,8 @@ export class TaskApi extends Construct { API_KEY_REQUIRED_SCOPE: 'webhooks:manage', USER_POOL_ID: this.userPool.userPoolId, APP_CLIENT_ID: this.appClient.userPoolClientId, + // #319: REST API surface (see apiKeyEnv above). + ABCA_COMPONENT: 'api', }, bundling: commonBundling, }); @@ -1128,6 +1138,9 @@ export class TaskApi extends Construct { const webhookEnv: Record = { WEBHOOK_TABLE_NAME: props.webhookTable.tableName, WEBHOOK_RETENTION_DAYS: String(props.webhookRetentionDays ?? DEFAULT_WEBHOOK_RETENTION_DAYS), + // Solution-attribution component label (#319): webhook ingest surface. + // (webhookEnv does NOT spread commonEnv, so set it explicitly here.) + ABCA_COMPONENT: 'webhook', }; // --- Webhook management Lambdas (Cognito-authenticated) --- @@ -1169,12 +1182,16 @@ export class TaskApi extends Construct { }); // --- Webhook task creation Lambda --- + // Same env as createTask, but this is the webhook ingest surface, so + // relabel the #319 solution-attribution component to `webhook` (matches + // the sibling webhook Lambdas above; createTaskEnv inherits `api`). + const webhookCreateTaskEnv: Record = { ...createTaskEnv, ABCA_COMPONENT: 'webhook' }; const webhookCreateTaskFn = new lambda.NodejsFunction(this, 'WebhookCreateTaskFn', { entry: path.join(handlersDir, 'webhook-create-task.ts'), handler: 'handler', runtime: Runtime.NODEJS_24_X, architecture: Architecture.ARM_64, - environment: createTaskEnv, + environment: webhookCreateTaskEnv, bundling: attachmentScreeningBundling, memorySize: HEAVY_ATTACHMENT_HANDLER_MEMORY_MB, timeout: Duration.seconds(API_HANDLER_TIMEOUT_SECONDS), diff --git a/cdk/src/constructs/task-orchestrator.ts b/cdk/src/constructs/task-orchestrator.ts index 561fb2db6..364a2e4ce 100644 --- a/cdk/src/constructs/task-orchestrator.ts +++ b/cdk/src/constructs/task-orchestrator.ts @@ -267,6 +267,8 @@ export class TaskOrchestrator extends Construct { retentionPeriod: Duration.days(DURABLE_RETENTION_DAYS), }, environment: { + // Solution-attribution component label (#319): orchestration plane. + ABCA_COMPONENT: 'orchestr', TASK_TABLE_NAME: props.taskTable.tableName, TASK_EVENTS_TABLE_NAME: props.taskEventsTable.tableName, USER_CONCURRENCY_TABLE_NAME: props.userConcurrencyTable.tableName, diff --git a/cdk/src/handlers/api-key-authorizer.ts b/cdk/src/handlers/api-key-authorizer.ts index 26a2ab88c..ae6613f97 100644 --- a/cdk/src/handlers/api-key-authorizer.ts +++ b/cdk/src/handlers/api-key-authorizer.ts @@ -17,15 +17,15 @@ * SOFTWARE. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb'; +import { GetCommand } from '@aws-sdk/lib-dynamodb'; import { CognitoJwtVerifier } from 'aws-jwt-verify'; import type { APIGatewayRequestAuthorizerEvent, APIGatewayAuthorizerResult } from 'aws-lambda'; import { hashApiKeySecret, parseApiKey, timingSafeHashEqual } from './shared/api-key'; import { logger } from './shared/logger'; import type { ApiKeyRecord } from './shared/types'; +import { makeDocClient } from './shared/ua'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = makeDocClient(); const TABLE_NAME = process.env.API_KEY_TABLE_NAME!; /** diff --git a/cdk/src/handlers/approve-task.ts b/cdk/src/handlers/approve-task.ts index d99ff8308..5c9a701c4 100644 --- a/cdk/src/handlers/approve-task.ts +++ b/cdk/src/handlers/approve-task.ts @@ -17,8 +17,8 @@ * SOFTWARE. */ -import { DynamoDBClient, TransactionCanceledException } from '@aws-sdk/client-dynamodb'; -import { DynamoDBDocumentClient, PutCommand, TransactWriteCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import { TransactionCanceledException } from '@aws-sdk/client-dynamodb'; +import { PutCommand, TransactWriteCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { ulid } from 'ulid'; import { VALID_APPROVAL_SCOPE_PREFIXES, parseApprovalScope } from './shared/approval-scope'; @@ -27,8 +27,9 @@ import { logger } from './shared/logger'; import { formatMinuteBucket, RATE_LIMIT_ROW_TTL_SECONDS } from './shared/rate-limit'; import { ErrorCode, errorResponse, successResponse } from './shared/response'; import type { ApprovalRequest, ApprovalResponse, ApprovalScope } from './shared/types'; +import { makeDocClient } from './shared/ua'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = makeDocClient(); const TASK_TABLE_NAME = process.env.TASK_TABLE_NAME; const TASK_APPROVALS_TABLE_NAME = process.env.TASK_APPROVALS_TABLE_NAME; const EVENTS_TABLE_NAME = process.env.TASK_EVENTS_TABLE_NAME; diff --git a/cdk/src/handlers/cancel-task.ts b/cdk/src/handlers/cancel-task.ts index 72b3864fd..0f90c3d75 100644 --- a/cdk/src/handlers/cancel-task.ts +++ b/cdk/src/handlers/cancel-task.ts @@ -18,9 +18,8 @@ */ import { BedrockAgentCoreClient, StopRuntimeSessionCommand } from '@aws-sdk/client-bedrock-agentcore'; -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { ECSClient, StopTaskCommand } from '@aws-sdk/client-ecs'; -import { DynamoDBDocumentClient, GetCommand, PutCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import { GetCommand, PutCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { ulid } from 'ulid'; import { TaskStatus, TERMINAL_STATUSES } from '../constructs/task-status'; @@ -28,11 +27,12 @@ import { extractUserId } from './shared/gateway'; import { logger } from './shared/logger'; import { ErrorCode, errorResponse, successResponse } from './shared/response'; import type { TaskRecord } from './shared/types'; +import { makeClient, makeDocClient } from './shared/ua'; import { computeTtlEpoch } from './shared/validation'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); -const agentCoreClient = new BedrockAgentCoreClient({}); -const ecsClient = new ECSClient({}); +const ddb = makeDocClient(); +const agentCoreClient = makeClient(BedrockAgentCoreClient); +const ecsClient = makeClient(ECSClient); const TABLE_NAME = process.env.TASK_TABLE_NAME!; const EVENTS_TABLE_NAME = process.env.TASK_EVENTS_TABLE_NAME!; const TASK_RETENTION_DAYS = Number(process.env.TASK_RETENTION_DAYS ?? '90'); diff --git a/cdk/src/handlers/cleanup-pending-uploads.ts b/cdk/src/handlers/cleanup-pending-uploads.ts index d5a4c0557..2b950cd21 100644 --- a/cdk/src/handlers/cleanup-pending-uploads.ts +++ b/cdk/src/handlers/cleanup-pending-uploads.ts @@ -44,9 +44,10 @@ import { DeleteObjectsCommand, ListObjectVersionsCommand, S3Client } from '@aws- import { ulid } from 'ulid'; import { ATTACHMENT_OBJECT_KEY_PREFIX } from '../constructs/attachments-bucket'; import { logger } from './shared/logger'; +import { makeClient } from './shared/ua'; -const ddb = new DynamoDBClient({}); -const s3 = new S3Client({}); +const ddb = makeClient(DynamoDBClient); +const s3 = makeClient(S3Client); const TASK_TABLE = process.env.TASK_TABLE_NAME!; const EVENTS_TABLE = process.env.TASK_EVENTS_TABLE_NAME!; diff --git a/cdk/src/handlers/confirm-uploads.ts b/cdk/src/handlers/confirm-uploads.ts index a5f5aa6e4..4e55829a3 100644 --- a/cdk/src/handlers/confirm-uploads.ts +++ b/cdk/src/handlers/confirm-uploads.ts @@ -21,10 +21,9 @@ // attachments, and transitions the task from PENDING_UPLOADS to SUBMITTED. // Tests: cdk/test/handlers/confirm-uploads.test.ts -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; import { DeleteObjectsCommand, GetObjectCommand, HeadObjectCommand, S3Client } from '@aws-sdk/client-s3'; -import { DynamoDBDocumentClient, GetCommand, PutCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import { GetCommand, PutCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda'; import { ulid } from 'ulid'; import { ATTACHMENT_OBJECT_KEY_PREFIX } from '../constructs/attachments-bucket'; @@ -35,11 +34,12 @@ import { estimateImageTokensFromBuffer } from './shared/image-tokens'; import { logger } from './shared/logger'; import { ErrorCode, errorResponse, successResponse } from './shared/response'; import { type AttachmentRecord, createAttachmentRecord, type TaskRecord, toTaskDetail } from './shared/types'; +import { makeClient, makeDocClient } from './shared/ua'; import { computeTtlEpoch, MAX_TOTAL_ATTACHMENT_SIZE_BYTES } from './shared/validation'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); -const s3Client = new S3Client({}); -const lambdaClient = process.env.ORCHESTRATOR_FUNCTION_ARN ? new LambdaClient({}) : undefined; +const ddb = makeDocClient(); +const s3Client = makeClient(S3Client); +const lambdaClient = process.env.ORCHESTRATOR_FUNCTION_ARN ? makeClient(LambdaClient) : undefined; const TABLE_NAME = process.env.TASK_TABLE_NAME!; const EVENTS_TABLE_NAME = process.env.TASK_EVENTS_TABLE_NAME!; @@ -721,7 +721,7 @@ async function buildScreeningConfig(): Promise { if (!process.env.GUARDRAIL_ID || !process.env.GUARDRAIL_VERSION) return undefined; if (!_bedrockClient) { const { BedrockRuntimeClient } = await import('@aws-sdk/client-bedrock-runtime'); - _bedrockClient = new BedrockRuntimeClient({}); + _bedrockClient = makeClient(BedrockRuntimeClient); } return { guardrailId: process.env.GUARDRAIL_ID, diff --git a/cdk/src/handlers/create-api-key.ts b/cdk/src/handlers/create-api-key.ts index 667aaace9..484210d90 100644 --- a/cdk/src/handlers/create-api-key.ts +++ b/cdk/src/handlers/create-api-key.ts @@ -17,8 +17,7 @@ * SOFTWARE. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; +import { PutCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { ulid } from 'ulid'; import { generateApiKey, validateScopes } from './shared/api-key'; @@ -26,9 +25,10 @@ import { extractUserId } from './shared/gateway'; import { logger } from './shared/logger'; import { ErrorCode, errorResponse, successResponse } from './shared/response'; import type { ApiKeyRecord, ApiKeyScope, CreateApiKeyRequest, CreateApiKeyResponse } from './shared/types'; +import { makeDocClient } from './shared/ua'; import { isValidWebhookName, parseBody } from './shared/validation'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = makeDocClient(); const TABLE_NAME = process.env.API_KEY_TABLE_NAME!; /** Default scope granted when the caller omits `scopes`. */ diff --git a/cdk/src/handlers/create-webhook.ts b/cdk/src/handlers/create-webhook.ts index d32a9de54..0dc1728cb 100644 --- a/cdk/src/handlers/create-webhook.ts +++ b/cdk/src/handlers/create-webhook.ts @@ -18,19 +18,19 @@ */ import * as crypto from 'crypto'; -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { CreateSecretCommand, DeleteSecretCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; -import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; +import { PutCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { ulid } from 'ulid'; import { extractUserId } from './shared/gateway'; import { logger } from './shared/logger'; import { ErrorCode, errorResponse, successResponse } from './shared/response'; import type { CreateWebhookRequest, CreateWebhookResponse, WebhookRecord } from './shared/types'; +import { makeClient, makeDocClient } from './shared/ua'; import { isValidWebhookName, parseBody } from './shared/validation'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); -const sm = new SecretsManagerClient({}); +const ddb = makeDocClient(); +const sm = makeClient(SecretsManagerClient); const TABLE_NAME = process.env.WEBHOOK_TABLE_NAME!; const SECRET_PREFIX = 'bgagent/webhook/'; diff --git a/cdk/src/handlers/delete-api-key.ts b/cdk/src/handlers/delete-api-key.ts index 6d99e6e85..d91509d32 100644 --- a/cdk/src/handlers/delete-api-key.ts +++ b/cdk/src/handlers/delete-api-key.ts @@ -17,17 +17,17 @@ * SOFTWARE. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { DynamoDBDocumentClient, GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import { GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { ulid } from 'ulid'; import { extractUserId } from './shared/gateway'; import { logger } from './shared/logger'; import { ErrorCode, errorResponse, successResponse } from './shared/response'; import { type ApiKeyRecord, toApiKeyDetail } from './shared/types'; +import { makeDocClient } from './shared/ua'; import { computeTtlEpoch } from './shared/validation'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = makeDocClient(); const TABLE_NAME = process.env.API_KEY_TABLE_NAME!; const API_KEY_RETENTION_DAYS = Number(process.env.API_KEY_RETENTION_DAYS ?? '30'); diff --git a/cdk/src/handlers/delete-webhook.ts b/cdk/src/handlers/delete-webhook.ts index 7ac52ee1a..f6ccfde73 100644 --- a/cdk/src/handlers/delete-webhook.ts +++ b/cdk/src/handlers/delete-webhook.ts @@ -17,19 +17,19 @@ * SOFTWARE. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { DeleteSecretCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; -import { DynamoDBDocumentClient, GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import { GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { ulid } from 'ulid'; import { extractUserId } from './shared/gateway'; import { logger } from './shared/logger'; import { ErrorCode, errorResponse, successResponse } from './shared/response'; import { type WebhookRecord, toWebhookDetail } from './shared/types'; +import { makeClient, makeDocClient } from './shared/ua'; import { computeTtlEpoch } from './shared/validation'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); -const sm = new SecretsManagerClient({}); +const ddb = makeDocClient(); +const sm = makeClient(SecretsManagerClient); const TABLE_NAME = process.env.WEBHOOK_TABLE_NAME!; const SECRET_PREFIX = 'bgagent/webhook/'; const WEBHOOK_RETENTION_DAYS = Number(process.env.WEBHOOK_RETENTION_DAYS ?? '30'); diff --git a/cdk/src/handlers/deny-task.ts b/cdk/src/handlers/deny-task.ts index 182a6b6c5..6758d9886 100644 --- a/cdk/src/handlers/deny-task.ts +++ b/cdk/src/handlers/deny-task.ts @@ -17,8 +17,8 @@ * SOFTWARE. */ -import { DynamoDBClient, TransactionCanceledException } from '@aws-sdk/client-dynamodb'; -import { DynamoDBDocumentClient, PutCommand, TransactWriteCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import { TransactionCanceledException } from '@aws-sdk/client-dynamodb'; +import { PutCommand, TransactWriteCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { ulid } from 'ulid'; import { scanDenyReason } from './shared/deny-reason-scanner'; @@ -27,8 +27,9 @@ import { logger } from './shared/logger'; import { formatMinuteBucket, RATE_LIMIT_ROW_TTL_SECONDS } from './shared/rate-limit'; import { ErrorCode, errorResponse, successResponse } from './shared/response'; import { DENY_REASON_MAX_LENGTH, type DenyRequest, type DenyResponse } from './shared/types'; +import { makeDocClient } from './shared/ua'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = makeDocClient(); const TASK_TABLE_NAME = process.env.TASK_TABLE_NAME; const TASK_APPROVALS_TABLE_NAME = process.env.TASK_APPROVALS_TABLE_NAME; const EVENTS_TABLE_NAME = process.env.TASK_EVENTS_TABLE_NAME; diff --git a/cdk/src/handlers/fanout-task-events.ts b/cdk/src/handlers/fanout-task-events.ts index 1f5379eb7..a04f4e69a 100644 --- a/cdk/src/handlers/fanout-task-events.ts +++ b/cdk/src/handlers/fanout-task-events.ts @@ -38,8 +38,7 @@ * wiring lands. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { DynamoDBDocumentClient, GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import { GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; import type { DynamoDBBatchItemFailure, DynamoDBBatchResponse, @@ -65,6 +64,7 @@ import { coerceNumericOrNull } from './shared/numeric'; import { loadRepoConfig } from './shared/repo-config'; import { encodeMarkdownUrl } from './shared/screenshot-url'; import type { ChannelConfig, TaskNotificationsConfig, TaskRecord } from './shared/types'; +import { makeDocClient } from './shared/ua'; import { dispatchSlackEvent, SlackApiError } from './slack-notify'; import { TaskStatus } from '../constructs/task-status'; @@ -404,7 +404,7 @@ export function shouldFanOut(event: FanOutEvent, overrides?: TaskNotificationsCo * internally (the Slack API rejecting a message — e.g. * ``channel_not_found`` — is not recoverable by a Lambda retry). */ -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = makeDocClient(); /** * Slack dispatcher — hands the event to the in-module diff --git a/cdk/src/handlers/get-pending.ts b/cdk/src/handlers/get-pending.ts index 42563beda..713a231dd 100644 --- a/cdk/src/handlers/get-pending.ts +++ b/cdk/src/handlers/get-pending.ts @@ -17,8 +17,7 @@ * SOFTWARE. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { DynamoDBDocumentClient, QueryCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import { QueryCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { ulid } from 'ulid'; import { extractUserId } from './shared/gateway'; @@ -26,8 +25,9 @@ import { logger } from './shared/logger'; import { formatMinuteBucket, RATE_LIMIT_ROW_TTL_SECONDS } from './shared/rate-limit'; import { ErrorCode, errorResponse, successResponse } from './shared/response'; import type { GetPendingResponse, PendingApprovalSummary, Severity } from './shared/types'; +import { makeDocClient } from './shared/ua'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = makeDocClient(); const TASK_APPROVALS_TABLE_NAME = process.env.TASK_APPROVALS_TABLE_NAME; if (!TASK_APPROVALS_TABLE_NAME) { throw new Error('get-pending handler requires TASK_APPROVALS_TABLE_NAME env var'); diff --git a/cdk/src/handlers/get-policies.ts b/cdk/src/handlers/get-policies.ts index 0af7b8ba0..8a0a033be 100644 --- a/cdk/src/handlers/get-policies.ts +++ b/cdk/src/handlers/get-policies.ts @@ -17,8 +17,7 @@ * SOFTWARE. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { DynamoDBDocumentClient, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import { UpdateCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { ulid } from 'ulid'; import { @@ -32,8 +31,9 @@ import { formatMinuteBucket, RATE_LIMIT_ROW_TTL_SECONDS } from './shared/rate-li import { checkRepoOnboarded, loadRepoConfig } from './shared/repo-config'; import { ErrorCode, errorResponse, successResponse } from './shared/response'; import type { GetPoliciesResponse, PolicyRuleSummary } from './shared/types'; +import { makeDocClient } from './shared/ua'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = makeDocClient(); const TASK_APPROVALS_TABLE_NAME = process.env.TASK_APPROVALS_TABLE_NAME; const POLICIES_RATE_LIMIT_PER_MINUTE = Number(process.env.POLICIES_RATE_LIMIT_PER_MINUTE ?? '30'); diff --git a/cdk/src/handlers/get-task-events.ts b/cdk/src/handlers/get-task-events.ts index 832b0bffb..0f05f45f1 100644 --- a/cdk/src/handlers/get-task-events.ts +++ b/cdk/src/handlers/get-task-events.ts @@ -17,14 +17,14 @@ * SOFTWARE. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { DynamoDBDocumentClient, GetCommand, QueryCommand } from '@aws-sdk/lib-dynamodb'; +import { GetCommand, QueryCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { ulid } from 'ulid'; import { extractUserId } from './shared/gateway'; import { logger } from './shared/logger'; import { ErrorCode, errorResponse, paginatedResponse } from './shared/response'; import type { EventRecord, TaskRecord } from './shared/types'; +import { makeDocClient } from './shared/ua'; import { decodePaginationToken, encodePaginationToken, @@ -33,7 +33,7 @@ import { ULID_LENGTH, } from './shared/validation'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = makeDocClient(); const TABLE_NAME = process.env.TASK_TABLE_NAME!; const EVENTS_TABLE_NAME = process.env.TASK_EVENTS_TABLE_NAME!; const LOG_LEVEL = (process.env.LOG_LEVEL ?? 'INFO').toUpperCase(); diff --git a/cdk/src/handlers/get-task-replay.ts b/cdk/src/handlers/get-task-replay.ts index 705dcd17a..645250759 100644 --- a/cdk/src/handlers/get-task-replay.ts +++ b/cdk/src/handlers/get-task-replay.ts @@ -17,8 +17,7 @@ * SOFTWARE. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { DynamoDBDocumentClient, GetCommand, QueryCommand } from '@aws-sdk/lib-dynamodb'; +import { GetCommand, QueryCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { ulid } from 'ulid'; import { extractUserId } from './shared/gateway'; @@ -26,8 +25,9 @@ import { logger } from './shared/logger'; import { coerceNumericOrNull } from './shared/numeric'; import { ErrorCode, errorResponse, successResponse } from './shared/response'; import type { EventRecord, ReplayBundle, ReplayEvent, ReplayTruncation, TaskRecord, VerificationReport } from './shared/types'; +import { makeDocClient } from './shared/ua'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = makeDocClient(); const TABLE_NAME = process.env.TASK_TABLE_NAME!; const EVENTS_TABLE_NAME = process.env.TASK_EVENTS_TABLE_NAME!; diff --git a/cdk/src/handlers/get-task.ts b/cdk/src/handlers/get-task.ts index 25c51ac51..cb32e562a 100644 --- a/cdk/src/handlers/get-task.ts +++ b/cdk/src/handlers/get-task.ts @@ -17,16 +17,16 @@ * SOFTWARE. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb'; +import { GetCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { ulid } from 'ulid'; import { extractUserId } from './shared/gateway'; import { logger } from './shared/logger'; import { ErrorCode, errorResponse, successResponse } from './shared/response'; import { type TaskRecord, toTaskDetail } from './shared/types'; +import { makeDocClient } from './shared/ua'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = makeDocClient(); const TABLE_NAME = process.env.TASK_TABLE_NAME!; /** diff --git a/cdk/src/handlers/get-trace-url.ts b/cdk/src/handlers/get-trace-url.ts index bdf1a5534..d0f84b90a 100644 --- a/cdk/src/handlers/get-trace-url.ts +++ b/cdk/src/handlers/get-trace-url.ts @@ -17,9 +17,8 @@ * SOFTWARE. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { GetObjectCommand, HeadObjectCommand, S3Client } from '@aws-sdk/client-s3'; -import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb'; +import { GetCommand } from '@aws-sdk/lib-dynamodb'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { ulid } from 'ulid'; @@ -28,9 +27,10 @@ import { extractUserId } from './shared/gateway'; import { logger } from './shared/logger'; import { ErrorCode, errorResponse, successResponse } from './shared/response'; import type { TaskRecord } from './shared/types'; +import { makeClient, makeDocClient } from './shared/ua'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); -const s3 = new S3Client({}); +const ddb = makeDocClient(); +const s3 = makeClient(S3Client); const TABLE_NAME = process.env.TASK_TABLE_NAME!; const TRACE_BUCKET_NAME = process.env.TRACE_ARTIFACTS_BUCKET_NAME!; diff --git a/cdk/src/handlers/github-webhook-processor.ts b/cdk/src/handlers/github-webhook-processor.ts index 922f4b39c..39f60cacf 100644 --- a/cdk/src/handlers/github-webhook-processor.ts +++ b/cdk/src/handlers/github-webhook-processor.ts @@ -17,9 +17,8 @@ * SOFTWARE. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; -import { DynamoDBDocumentClient, GetCommand, QueryCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import { GetCommand, QueryCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; import { captureScreenshot } from './shared/agentcore-browser'; import { resolveGitHubToken } from './shared/context-hydration'; import { upsertTaskComment } from './shared/github-comment'; @@ -37,9 +36,10 @@ import { import { logger } from './shared/logger'; import { isIntegrationNode } from './shared/orchestration-integration-node'; import { buildScreenshotKey, encodeMarkdownUrl, extractTaskIdFromBranch, isAllowedScreenshotUrl } from './shared/screenshot-url'; +import { makeClient, makeDocClient } from './shared/ua'; -const s3 = new S3Client({}); -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const s3 = makeClient(S3Client); +const ddb = makeDocClient(); // Optional — when set, the processor persists the screenshot's public URL onto // the deploy task's TaskRecord (keyed by the taskId in the deploy branch) so // the orchestration reconciler can embed the integration node's combined diff --git a/cdk/src/handlers/github-webhook.ts b/cdk/src/handlers/github-webhook.ts index 82533863a..270b0d0f0 100644 --- a/cdk/src/handlers/github-webhook.ts +++ b/cdk/src/handlers/github-webhook.ts @@ -17,9 +17,9 @@ * SOFTWARE. */ -import { ConditionalCheckFailedException, DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb'; import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; -import { DeleteCommand, DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; +import { DeleteCommand, PutCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { type GitHubDeploymentStatusPayload, @@ -27,9 +27,10 @@ import { } from './shared/github-deployment-status'; import { verifyGitHubRequest } from './shared/github-webhook-verify'; import { logger } from './shared/logger'; +import { makeClient, makeDocClient } from './shared/ua'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); -const lambdaClient = new LambdaClient({}); +const ddb = makeDocClient(); +const lambdaClient = makeClient(LambdaClient); const WEBHOOK_SECRET_ARN = process.env.GITHUB_WEBHOOK_SECRET_ARN!; const DEDUP_TABLE_NAME = process.env.GITHUB_WEBHOOK_DEDUP_TABLE_NAME!; diff --git a/cdk/src/handlers/iteration-heartbeat-sweep.ts b/cdk/src/handlers/iteration-heartbeat-sweep.ts index 9d7e1de55..7a6d56df1 100644 --- a/cdk/src/handlers/iteration-heartbeat-sweep.ts +++ b/cdk/src/handlers/iteration-heartbeat-sweep.ts @@ -39,8 +39,9 @@ import { DynamoDBClient, QueryCommand } from '@aws-sdk/client-dynamodb'; import { planHeartbeat, type HeartbeatTaskView } from './shared/iteration-heartbeat'; import { logger } from './shared/logger'; import { makeLinearChannel } from './shared/orchestration-channel-linear'; +import { makeClient } from './shared/ua'; -const ddb = new DynamoDBClient({}); +const ddb = makeClient(DynamoDBClient); const TASK_TABLE = process.env.TASK_TABLE_NAME!; const STATUS_INDEX = process.env.TASK_STATUS_INDEX_NAME ?? 'StatusIndex'; const WORKSPACE_REGISTRY_TABLE = process.env.LINEAR_WORKSPACE_REGISTRY_TABLE_NAME; diff --git a/cdk/src/handlers/jira-link.ts b/cdk/src/handlers/jira-link.ts index febdc9875..72eaa67ef 100644 --- a/cdk/src/handlers/jira-link.ts +++ b/cdk/src/handlers/jira-link.ts @@ -17,16 +17,16 @@ * SOFTWARE. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { DynamoDBDocumentClient, GetCommand, PutCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb'; +import { GetCommand, PutCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { ulid } from 'ulid'; import { extractUserId } from './shared/gateway'; import { logger } from './shared/logger'; import { ErrorCode, errorResponse, successResponse } from './shared/response'; +import { makeDocClient } from './shared/ua'; import { parseBody } from './shared/validation'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = makeDocClient(); const USER_MAPPING_TABLE = process.env.JIRA_USER_MAPPING_TABLE_NAME!; diff --git a/cdk/src/handlers/jira-webhook-processor.ts b/cdk/src/handlers/jira-webhook-processor.ts index 5b50a7de1..ad490a47d 100644 --- a/cdk/src/handlers/jira-webhook-processor.ts +++ b/cdk/src/handlers/jira-webhook-processor.ts @@ -19,9 +19,8 @@ import * as crypto from 'crypto'; import { BedrockRuntimeClient, ApplyGuardrailCommand } from '@aws-sdk/client-bedrock-runtime'; -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { S3Client } from '@aws-sdk/client-s3'; -import { DynamoDBDocumentClient, GetCommand, ScanCommand } from '@aws-sdk/lib-dynamodb'; +import { GetCommand, ScanCommand } from '@aws-sdk/lib-dynamodb'; import { ulid } from 'ulid'; import type { ScreeningConfig } from './shared/attachment-screening'; import { @@ -46,10 +45,11 @@ import { } from './shared/jira-task-by-issue'; import { logger } from './shared/logger'; import type { Attachment, PassedAttachmentRecord } from './shared/types'; +import { makeClient, makeDocClient } from './shared/ua'; import { MAX_TASK_DESCRIPTION_LENGTH } from './shared/validation'; import { CODING_WORKFLOW_ID } from './shared/workflows'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = makeDocClient(); const PROJECT_MAPPING_TABLE = process.env.JIRA_PROJECT_MAPPING_TABLE_NAME!; const USER_MAPPING_TABLE = process.env.JIRA_USER_MAPPING_TABLE_NAME!; @@ -68,8 +68,8 @@ const MAX_IDEMPOTENCY_KEY_LENGTH = 128; const ATTACHMENTS_BUCKET = process.env.ATTACHMENTS_BUCKET_NAME; const GUARDRAIL_ID = process.env.GUARDRAIL_ID; const GUARDRAIL_VERSION = process.env.GUARDRAIL_VERSION; -const s3Client = ATTACHMENTS_BUCKET ? new S3Client({}) : undefined; -const bedrockClient = GUARDRAIL_ID && GUARDRAIL_VERSION ? new BedrockRuntimeClient({}) : undefined; +const s3Client = ATTACHMENTS_BUCKET ? makeClient(S3Client) : undefined; +const bedrockClient = GUARDRAIL_ID && GUARDRAIL_VERSION ? makeClient(BedrockRuntimeClient) : undefined; const screeningConfig: ScreeningConfig | undefined = bedrockClient && GUARDRAIL_ID && GUARDRAIL_VERSION ? { bedrockClient, guardrailId: GUARDRAIL_ID, guardrailVersion: GUARDRAIL_VERSION } diff --git a/cdk/src/handlers/jira-webhook.ts b/cdk/src/handlers/jira-webhook.ts index 80ae5cc70..bb9f68320 100644 --- a/cdk/src/handlers/jira-webhook.ts +++ b/cdk/src/handlers/jira-webhook.ts @@ -17,9 +17,9 @@ * SOFTWARE. */ -import { ConditionalCheckFailedException, DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb'; import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; -import { DeleteCommand, DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; +import { DeleteCommand, PutCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { isWebhookTimestampFresh, @@ -27,9 +27,10 @@ import { verifyJiraRequestForTenant, } from './shared/jira-verify'; import { logger } from './shared/logger'; +import { makeClient, makeDocClient } from './shared/ua'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); -const lambdaClient = new LambdaClient({}); +const ddb = makeDocClient(); +const lambdaClient = makeClient(LambdaClient); const WEBHOOK_SECRET_ARN = process.env.JIRA_WEBHOOK_SECRET_ARN!; const DEDUP_TABLE_NAME = process.env.JIRA_WEBHOOK_DEDUP_TABLE_NAME!; diff --git a/cdk/src/handlers/linear-link.ts b/cdk/src/handlers/linear-link.ts index 41a480d39..a9f5c62a8 100644 --- a/cdk/src/handlers/linear-link.ts +++ b/cdk/src/handlers/linear-link.ts @@ -17,16 +17,16 @@ * SOFTWARE. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { DynamoDBDocumentClient, GetCommand, PutCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb'; +import { GetCommand, PutCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { ulid } from 'ulid'; import { extractUserId } from './shared/gateway'; import { logger } from './shared/logger'; import { ErrorCode, errorResponse, successResponse } from './shared/response'; +import { makeDocClient } from './shared/ua'; import { parseBody } from './shared/validation'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = makeDocClient(); const USER_MAPPING_TABLE = process.env.LINEAR_USER_MAPPING_TABLE_NAME!; diff --git a/cdk/src/handlers/linear-webhook-processor.ts b/cdk/src/handlers/linear-webhook-processor.ts index 7375ff8bc..40fbd268e 100644 --- a/cdk/src/handlers/linear-webhook-processor.ts +++ b/cdk/src/handlers/linear-webhook-processor.ts @@ -19,9 +19,8 @@ import * as crypto from 'crypto'; import { BedrockRuntimeClient, ApplyGuardrailCommand } from '@aws-sdk/client-bedrock-runtime'; -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { S3Client } from '@aws-sdk/client-s3'; -import { DynamoDBDocumentClient, GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import { GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; import { ulid } from 'ulid'; import type { ScreeningConfig } from './shared/attachment-screening'; import { buildClarifyResumeDescription, isClarifyHold } from './shared/clarify-resume'; @@ -77,11 +76,12 @@ import { upsertEpicPanel } from './shared/orchestration-rollup'; import { claimCommentAck, clearRollupClaim, deriveOrchestrationId, loadOrchestration, setChildOwnAttachments, setRetryCommentId, setStatusCommentId, type OrchestrationChildRow, type OrchestrationReleaseContext } from './shared/orchestration-store'; import { DEFAULT_LABEL_FILTER, hasHelpLabel, HELP_SUFFIX } from './shared/trigger-label'; import type { Attachment, PassedAttachmentRecord } from './shared/types'; +import { makeClient, makeDocClient } from './shared/ua'; import { MAX_ATTACHMENTS_PER_TASK, MAX_TASK_DESCRIPTION_LENGTH } from './shared/validation'; import { CODING_WORKFLOW_ID } from './shared/workflows'; import { TERMINAL_STATUSES, type TaskStatusType } from '../constructs/task-status'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = makeDocClient(); const PROJECT_MAPPING_TABLE = process.env.LINEAR_PROJECT_MAPPING_TABLE_NAME!; const USER_MAPPING_TABLE = process.env.LINEAR_USER_MAPPING_TABLE_NAME!; @@ -102,8 +102,8 @@ const MAX_CONCURRENT = Number(process.env.MAX_CONCURRENT_TASKS_PER_USER ?? '10') const ATTACHMENTS_BUCKET = process.env.ATTACHMENTS_BUCKET_NAME; const GUARDRAIL_ID = process.env.GUARDRAIL_ID; const GUARDRAIL_VERSION = process.env.GUARDRAIL_VERSION; -const attachmentsS3Client = ATTACHMENTS_BUCKET ? new S3Client({}) : undefined; -const attachmentsBedrockClient = GUARDRAIL_ID && GUARDRAIL_VERSION ? new BedrockRuntimeClient({}) : undefined; +const attachmentsS3Client = ATTACHMENTS_BUCKET ? makeClient(S3Client) : undefined; +const attachmentsBedrockClient = GUARDRAIL_ID && GUARDRAIL_VERSION ? makeClient(BedrockRuntimeClient) : undefined; const attachmentsScreeningConfig: ScreeningConfig | undefined = attachmentsBedrockClient && GUARDRAIL_ID && GUARDRAIL_VERSION ? { bedrockClient: attachmentsBedrockClient, guardrailId: GUARDRAIL_ID, guardrailVersion: GUARDRAIL_VERSION } diff --git a/cdk/src/handlers/linear-webhook.ts b/cdk/src/handlers/linear-webhook.ts index 31f74794f..0cba78c70 100644 --- a/cdk/src/handlers/linear-webhook.ts +++ b/cdk/src/handlers/linear-webhook.ts @@ -17,9 +17,9 @@ * SOFTWARE. */ -import { ConditionalCheckFailedException, DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb'; import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; -import { DeleteCommand, DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; +import { DeleteCommand, PutCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { isWebhookTimestampFresh, @@ -27,9 +27,10 @@ import { verifyLinearRequestForWorkspace, } from './shared/linear-verify'; import { logger } from './shared/logger'; +import { makeClient, makeDocClient } from './shared/ua'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); -const lambdaClient = new LambdaClient({}); +const ddb = makeDocClient(); +const lambdaClient = makeClient(LambdaClient); const WEBHOOK_SECRET_ARN = process.env.LINEAR_WEBHOOK_SECRET_ARN!; const DEDUP_TABLE_NAME = process.env.LINEAR_WEBHOOK_DEDUP_TABLE_NAME!; diff --git a/cdk/src/handlers/list-api-keys.ts b/cdk/src/handlers/list-api-keys.ts index 704dfd381..0453d47df 100644 --- a/cdk/src/handlers/list-api-keys.ts +++ b/cdk/src/handlers/list-api-keys.ts @@ -17,17 +17,17 @@ * SOFTWARE. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { DynamoDBDocumentClient, QueryCommand } from '@aws-sdk/lib-dynamodb'; +import { QueryCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { ulid } from 'ulid'; import { extractUserId } from './shared/gateway'; import { logger } from './shared/logger'; import { ErrorCode, errorResponse, paginatedResponse } from './shared/response'; import { type ApiKeyRecord, toApiKeyDetail } from './shared/types'; +import { makeDocClient } from './shared/ua'; import { decodePaginationToken, encodePaginationToken, parseLimit } from './shared/validation'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = makeDocClient(); const TABLE_NAME = process.env.API_KEY_TABLE_NAME!; /** Default page size when the caller omits ``?limit=``. */ diff --git a/cdk/src/handlers/list-tasks.ts b/cdk/src/handlers/list-tasks.ts index 6527bf183..7d273a601 100644 --- a/cdk/src/handlers/list-tasks.ts +++ b/cdk/src/handlers/list-tasks.ts @@ -17,17 +17,17 @@ * SOFTWARE. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { DynamoDBDocumentClient, QueryCommand } from '@aws-sdk/lib-dynamodb'; +import { QueryCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { ulid } from 'ulid'; import { extractUserId } from './shared/gateway'; import { logger } from './shared/logger'; import { ErrorCode, errorResponse, paginatedResponse } from './shared/response'; import { type TaskRecord, toTaskSummary } from './shared/types'; +import { makeDocClient } from './shared/ua'; import { decodePaginationToken, encodePaginationToken, parseLimit, parseStatusFilter } from './shared/validation'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = makeDocClient(); const TABLE_NAME = process.env.TASK_TABLE_NAME!; /** Default page size when the caller omits ``?limit=``. */ diff --git a/cdk/src/handlers/list-webhooks.ts b/cdk/src/handlers/list-webhooks.ts index 5d4171ff1..7fb7fc219 100644 --- a/cdk/src/handlers/list-webhooks.ts +++ b/cdk/src/handlers/list-webhooks.ts @@ -17,17 +17,17 @@ * SOFTWARE. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { DynamoDBDocumentClient, QueryCommand } from '@aws-sdk/lib-dynamodb'; +import { QueryCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { ulid } from 'ulid'; import { extractUserId } from './shared/gateway'; import { logger } from './shared/logger'; import { ErrorCode, errorResponse, paginatedResponse } from './shared/response'; import { type WebhookRecord, toWebhookDetail } from './shared/types'; +import { makeDocClient } from './shared/ua'; import { decodePaginationToken, encodePaginationToken, parseLimit } from './shared/validation'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = makeDocClient(); const TABLE_NAME = process.env.WEBHOOK_TABLE_NAME!; /** Default page size when the caller omits ``?limit=``. */ diff --git a/cdk/src/handlers/nudge-task.ts b/cdk/src/handlers/nudge-task.ts index e8610dd92..88058774d 100644 --- a/cdk/src/handlers/nudge-task.ts +++ b/cdk/src/handlers/nudge-task.ts @@ -17,8 +17,7 @@ * SOFTWARE. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { DynamoDBDocumentClient, GetCommand, PutCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import { GetCommand, PutCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { ulid } from 'ulid'; import { TERMINAL_STATUSES } from '../constructs/task-status'; @@ -28,8 +27,9 @@ import { logger } from './shared/logger'; import { formatMinuteBucket } from './shared/rate-limit'; import { ErrorCode, errorResponse, successResponse } from './shared/response'; import { NUDGE_MAX_MESSAGE_LENGTH, type NudgeRecord, type NudgeRequest, type TaskRecord } from './shared/types'; +import { makeDocClient } from './shared/ua'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = makeDocClient(); const TASK_TABLE_NAME = process.env.TASK_TABLE_NAME; const NUDGES_TABLE_NAME = process.env.NUDGES_TABLE_NAME; if (!TASK_TABLE_NAME || !NUDGES_TABLE_NAME) { diff --git a/cdk/src/handlers/orchestration-reconciler.ts b/cdk/src/handlers/orchestration-reconciler.ts index 6fe131989..8b5a082ae 100644 --- a/cdk/src/handlers/orchestration-reconciler.ts +++ b/cdk/src/handlers/orchestration-reconciler.ts @@ -36,10 +36,8 @@ * terminal event neither double-releases nor regresses state. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { BatchGetCommand, - DynamoDBDocumentClient, GetCommand, QueryCommand, UpdateCommand, @@ -73,10 +71,11 @@ import { type OrchestrationChildRow, } from './shared/orchestration-store'; import { encodeMarkdownUrl } from './shared/screenshot-url'; +import { makeDocClient } from './shared/ua'; import { OrchestrationTable } from '../constructs/orchestration-table'; import { TaskStatus, TERMINAL_STATUSES, type TaskStatusType } from '../constructs/task-status'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = makeDocClient(); const ORCHESTRATION_TABLE = process.env.ORCHESTRATION_TABLE_NAME!; const TASK_TABLE = process.env.TASK_TABLE_NAME!; // Registry table for the parent rollup comment's per-workspace OAuth diff --git a/cdk/src/handlers/reconcile-concurrency.ts b/cdk/src/handlers/reconcile-concurrency.ts index 2f7165146..008f0e8a2 100644 --- a/cdk/src/handlers/reconcile-concurrency.ts +++ b/cdk/src/handlers/reconcile-concurrency.ts @@ -19,8 +19,9 @@ import { DynamoDBClient, ScanCommand, QueryCommand, UpdateItemCommand } from '@aws-sdk/client-dynamodb'; import { logger } from './shared/logger'; +import { makeClient } from './shared/ua'; -const ddb = new DynamoDBClient({}); +const ddb = makeClient(DynamoDBClient); const TASK_TABLE = process.env.TASK_TABLE_NAME!; const CONCURRENCY_TABLE = process.env.USER_CONCURRENCY_TABLE_NAME!; diff --git a/cdk/src/handlers/reconcile-stranded-orchestrations.ts b/cdk/src/handlers/reconcile-stranded-orchestrations.ts index a9a25ba5a..af59d7713 100644 --- a/cdk/src/handlers/reconcile-stranded-orchestrations.ts +++ b/cdk/src/handlers/reconcile-stranded-orchestrations.ts @@ -51,9 +51,7 @@ * safe. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { - DynamoDBDocumentClient, ScanCommand, GetCommand, UpdateCommand, @@ -67,9 +65,10 @@ import { ORCHESTRATION_META_SK, type OrchestrationChildRow, } from './shared/orchestration-store'; +import { makeDocClient } from './shared/ua'; import { TaskStatus, type TaskStatusType } from '../constructs/task-status'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = makeDocClient(); const ORCHESTRATION_TABLE = process.env.ORCHESTRATION_TABLE_NAME!; const TASK_TABLE = process.env.TASK_TABLE_NAME!; // Throttle the sweep's releases to the user's free concurrency budget diff --git a/cdk/src/handlers/reconcile-stranded-tasks.ts b/cdk/src/handlers/reconcile-stranded-tasks.ts index 8688601f3..723492db4 100644 --- a/cdk/src/handlers/reconcile-stranded-tasks.ts +++ b/cdk/src/handlers/reconcile-stranded-tasks.ts @@ -48,8 +48,9 @@ import { } from '@aws-sdk/client-dynamodb'; import { ulid } from 'ulid'; import { logger } from './shared/logger'; +import { makeClient } from './shared/ua'; -const ddb = new DynamoDBClient({}); +const ddb = makeClient(DynamoDBClient); const TASK_TABLE = process.env.TASK_TABLE_NAME!; const EVENTS_TABLE = process.env.TASK_EVENTS_TABLE_NAME!; const CONCURRENCY_TABLE = process.env.USER_CONCURRENCY_TABLE_NAME!; diff --git a/cdk/src/handlers/shared/agentcore-browser.ts b/cdk/src/handlers/shared/agentcore-browser.ts index 45ea48b02..b92c73c7b 100644 --- a/cdk/src/handlers/shared/agentcore-browser.ts +++ b/cdk/src/handlers/shared/agentcore-browser.ts @@ -28,6 +28,7 @@ import { HttpRequest } from '@smithy/protocol-http'; import { SignatureV4 } from '@smithy/signature-v4'; import WebSocket, { type RawData } from 'ws'; import { logger } from './logger'; +import { makeClient } from './ua'; const REGION = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? 'us-east-1'; @@ -97,7 +98,7 @@ interface CdpMessage { */ export async function captureScreenshot(url: string, opts: { timeoutMs?: number } = {}): Promise { const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const client = new BedrockAgentCoreClient({ region: REGION }); + const client = makeClient(BedrockAgentCoreClient, { region: REGION }); const startResp = await client.send(new StartBrowserSessionCommand({ browserIdentifier: AWS_BROWSER_IDENTIFIER, diff --git a/cdk/src/handlers/shared/context-hydration.ts b/cdk/src/handlers/shared/context-hydration.ts index 46f6604aa..7bf917146 100644 --- a/cdk/src/handlers/shared/context-hydration.ts +++ b/cdk/src/handlers/shared/context-hydration.ts @@ -24,6 +24,7 @@ import { logger } from './logger'; import { loadMemoryContext, type MemoryContext } from './memory'; import { sanitizeExternalContent } from './sanitization'; import { type TaskRecord } from './types'; +import { makeClient } from './ua'; import { workflowIsReadOnly, workflowUsesPr } from './workflows'; // --------------------------------------------------------------------------- @@ -131,7 +132,7 @@ const USER_PROMPT_TOKEN_BUDGET = Number(process.env.USER_PROMPT_TOKEN_BUDGET ?? const GITHUB_API_TIMEOUT_MS = 30_000; const GUARDRAIL_ID = process.env.GUARDRAIL_ID; const GUARDRAIL_VERSION = process.env.GUARDRAIL_VERSION; -const bedrockClient = (GUARDRAIL_ID && GUARDRAIL_VERSION) ? new BedrockRuntimeClient({}) : undefined; +const bedrockClient = (GUARDRAIL_ID && GUARDRAIL_VERSION) ? makeClient(BedrockRuntimeClient) : undefined; if (GUARDRAIL_ID && !GUARDRAIL_VERSION) { logger.error('GUARDRAIL_ID is set but GUARDRAIL_VERSION is missing — guardrail screening disabled', { metric_type: 'guardrail_misconfiguration', @@ -346,7 +347,7 @@ const tokenCache = new Map(); const SECRET_CACHE_TTL_MINUTES = 5; const CACHE_TTL_MS = SECRET_CACHE_TTL_MINUTES * 60 * 1000; // 5 minutes -const smClient = new SecretsManagerClient({}); +const smClient = makeClient(SecretsManagerClient); /** * Resolve the GitHub token from Secrets Manager with per-ARN caching. diff --git a/cdk/src/handlers/shared/create-task-core.ts b/cdk/src/handlers/shared/create-task-core.ts index 24fcd41f3..8a4b3f94e 100644 --- a/cdk/src/handlers/shared/create-task-core.ts +++ b/cdk/src/handlers/shared/create-task-core.ts @@ -22,10 +22,9 @@ // Tests: cdk/test/handlers/shared/create-task-core.test.ts, cdk/test/handlers/create-task.test.ts import { BedrockRuntimeClient, ApplyGuardrailCommand } from '@aws-sdk/client-bedrock-runtime'; -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; import { PutObjectCommand, DeleteObjectsCommand, S3Client } from '@aws-sdk/client-s3'; -import { DynamoDBDocumentClient, PutCommand, QueryCommand, GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import { PutCommand, QueryCommand, GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; import { createPresignedPost } from '@aws-sdk/s3-presigned-post'; import type { APIGatewayProxyResult } from 'aws-lambda'; import { ulid } from 'ulid'; @@ -53,6 +52,7 @@ import { type TaskRecord, toTaskDetail, } from './types'; +import { makeClient, makeDocClient } from './ua'; import { computeTtlEpoch, hasTaskSpec, isValidIdempotencyKey, isValidRepo, isValidTaskDescriptionLength, MAX_ATTACHMENT_SIZE_BYTES, MAX_TASK_DESCRIPTION_LENGTH, MAX_TOTAL_ATTACHMENT_SIZE_BYTES, validateAttachments, validateMaxBudgetUsd, validateMaxTurns, validatePrNumber } from './validation'; import { disallowedWorkflowModel, getWorkflowDescriptor, isValidWorkflowRef, resolveWorkflowRef, resolveWorkflowRefError } from './workflows'; import { ATTACHMENT_OBJECT_KEY_PREFIX } from '../../constructs/attachments-bucket'; @@ -90,10 +90,10 @@ export interface TaskCreationContext { readonly preScreenedAttachments?: readonly AttachmentRecord[]; } -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); -const lambdaClient = process.env.ORCHESTRATOR_FUNCTION_ARN ? new LambdaClient({}) : undefined; +const ddb = makeDocClient(); +const lambdaClient = process.env.ORCHESTRATOR_FUNCTION_ARN ? makeClient(LambdaClient) : undefined; const bedrockClient = (process.env.GUARDRAIL_ID && process.env.GUARDRAIL_VERSION) - ? new BedrockRuntimeClient({}) : undefined; + ? makeClient(BedrockRuntimeClient) : undefined; if (process.env.GUARDRAIL_ID && !process.env.GUARDRAIL_VERSION) { logger.error('GUARDRAIL_ID is set but GUARDRAIL_VERSION is missing — guardrail screening disabled', { metric_type: 'guardrail_misconfiguration', @@ -103,7 +103,7 @@ const TABLE_NAME = process.env.TASK_TABLE_NAME!; const EVENTS_TABLE_NAME = process.env.TASK_EVENTS_TABLE_NAME!; const TASK_RETENTION_DAYS = Number(process.env.TASK_RETENTION_DAYS ?? '90'); const ATTACHMENTS_BUCKET = process.env.ATTACHMENTS_BUCKET_NAME; -const s3Client = ATTACHMENTS_BUCKET ? new S3Client({}) : undefined; +const s3Client = ATTACHMENTS_BUCKET ? makeClient(S3Client) : undefined; /** Human-readable description of a workflow's required-input contract (for 400s). */ function describeRequiredInputs(requiredInputs: { allOf?: readonly string[]; oneOf?: readonly string[] }): string { diff --git a/cdk/src/handlers/shared/github-webhook-verify.ts b/cdk/src/handlers/shared/github-webhook-verify.ts index e87978c0e..e3ba9fc47 100644 --- a/cdk/src/handlers/shared/github-webhook-verify.ts +++ b/cdk/src/handlers/shared/github-webhook-verify.ts @@ -21,8 +21,9 @@ import * as crypto from 'crypto'; import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; import { isUsableHmacSecret } from './hmac-secret'; import { logger } from './logger'; +import { makeClient } from './ua'; -const sm = new SecretsManagerClient({}); +const sm = makeClient(SecretsManagerClient); /** * In-memory secret cache (5-minute TTL). Same pattern as `linear-verify.ts` diff --git a/cdk/src/handlers/shared/jira-oauth-resolver.ts b/cdk/src/handlers/shared/jira-oauth-resolver.ts index fd5cfba29..e66bd677b 100644 --- a/cdk/src/handlers/shared/jira-oauth-resolver.ts +++ b/cdk/src/handlers/shared/jira-oauth-resolver.ts @@ -17,7 +17,6 @@ * SOFTWARE. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { GetSecretValueCommand, PutSecretValueCommand, @@ -30,6 +29,7 @@ import { validateJiraAppActorProxyUrl, } from './jira-app-actor'; import { logger } from './logger'; +import { makeClient, makeDocClient } from './ua'; /** * Lambda-side resolver for the per-tenant Jira Cloud OAuth token written @@ -194,8 +194,8 @@ export async function resolveJiraOutboundAuth( options: ResolverOptions = {}, ): Promise { const region = options.region ?? process.env.AWS_REGION ?? 'us-east-1'; - const ddb = options.dynamoDbClient ?? DynamoDBDocumentClient.from(new DynamoDBClient({ region })); - const sm = options.secretsManagerClient ?? new SecretsManagerClient({ region }); + const ddb = options.dynamoDbClient ?? makeDocClient({ region }); + const sm = options.secretsManagerClient ?? makeClient(SecretsManagerClient, { region }); const row = await getRegistryRow(ddb, registryTableName, cloudId); if (!row || row.status !== 'active') { @@ -276,8 +276,8 @@ export async function resolveJiraOauthToken( options: ResolverOptions = {}, ): Promise { const region = options.region ?? process.env.AWS_REGION ?? 'us-east-1'; - const ddb = options.dynamoDbClient ?? DynamoDBDocumentClient.from(new DynamoDBClient({ region })); - const sm = options.secretsManagerClient ?? new SecretsManagerClient({ region }); + const ddb = options.dynamoDbClient ?? makeDocClient({ region }); + const sm = options.secretsManagerClient ?? makeClient(SecretsManagerClient, { region }); const forceRefresh = options.forceRefresh ?? false; // ─── Step 1: Registry row ──────────────────────────────────────── diff --git a/cdk/src/handlers/shared/jira-verify.ts b/cdk/src/handlers/shared/jira-verify.ts index 0e34899c1..cf01f9155 100644 --- a/cdk/src/handlers/shared/jira-verify.ts +++ b/cdk/src/handlers/shared/jira-verify.ts @@ -18,15 +18,14 @@ */ import * as crypto from 'crypto'; -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; -import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; import { isUsableHmacSecret } from './hmac-secret'; import { getOauthSecretStrict, getRegistryRowStrict } from './jira-oauth-resolver'; import { logger } from './logger'; +import { makeClient, makeDocClient } from './ua'; -const sm = new SecretsManagerClient({}); -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const sm = makeClient(SecretsManagerClient); +const ddb = makeDocClient(); /** Prefix for Jira-related secrets in Secrets Manager. */ export const JIRA_SECRET_PREFIX = 'bgagent/jira/'; diff --git a/cdk/src/handlers/shared/linear-issue-lookup.ts b/cdk/src/handlers/shared/linear-issue-lookup.ts index 9073f5291..7e2dc4970 100644 --- a/cdk/src/handlers/shared/linear-issue-lookup.ts +++ b/cdk/src/handlers/shared/linear-issue-lookup.ts @@ -17,12 +17,12 @@ * SOFTWARE. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { DynamoDBDocumentClient, ScanCommand } from '@aws-sdk/lib-dynamodb'; +import { ScanCommand } from '@aws-sdk/lib-dynamodb'; import { resolveLinearOauthToken } from './linear-oauth-resolver'; import { logger } from './logger'; +import { makeDocClient } from './ua'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = makeDocClient(); /** * Linear issue identifier shape, e.g. `ENG-42`. Linear identifiers are diff --git a/cdk/src/handlers/shared/linear-oauth-resolver.ts b/cdk/src/handlers/shared/linear-oauth-resolver.ts index 4efb0fdf8..027805579 100644 --- a/cdk/src/handlers/shared/linear-oauth-resolver.ts +++ b/cdk/src/handlers/shared/linear-oauth-resolver.ts @@ -17,7 +17,6 @@ * SOFTWARE. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { GetSecretValueCommand, PutSecretValueCommand, @@ -25,6 +24,7 @@ import { } from '@aws-sdk/client-secrets-manager'; import { DynamoDBDocumentClient, GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; import { logger } from './logger'; +import { makeClient, makeDocClient } from './ua'; /** * Lambda-side resolver for the per-workspace Linear OAuth token written @@ -166,8 +166,8 @@ export async function resolveLinearOauthToken( options: ResolverOptions = {}, ): Promise { const region = options.region ?? process.env.AWS_REGION ?? 'us-east-1'; - const ddb = options.dynamoDbClient ?? DynamoDBDocumentClient.from(new DynamoDBClient({ region })); - const sm = options.secretsManagerClient ?? new SecretsManagerClient({ region }); + const ddb = options.dynamoDbClient ?? makeDocClient({ region }); + const sm = options.secretsManagerClient ?? makeClient(SecretsManagerClient, { region }); // ─── Step 1: Registry row ──────────────────────────────────────── const row = await getRegistryRow(ddb, registryTableName, linearWorkspaceId); diff --git a/cdk/src/handlers/shared/linear-verify.ts b/cdk/src/handlers/shared/linear-verify.ts index deff3baf2..bc60db78a 100644 --- a/cdk/src/handlers/shared/linear-verify.ts +++ b/cdk/src/handlers/shared/linear-verify.ts @@ -18,15 +18,14 @@ */ import * as crypto from 'crypto'; -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; -import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; import { isUsableHmacSecret } from './hmac-secret'; import { getOauthSecretStrict, getRegistryRowStrict } from './linear-oauth-resolver'; import { logger } from './logger'; +import { makeClient, makeDocClient } from './ua'; -const sm = new SecretsManagerClient({}); -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const sm = makeClient(SecretsManagerClient); +const ddb = makeDocClient(); // In-memory secret cache with 5-minute TTL (same pattern as slack-verify.ts). const secretCache = new Map(); diff --git a/cdk/src/handlers/shared/memory.ts b/cdk/src/handlers/shared/memory.ts index 0af995b03..a4a7a5fbf 100644 --- a/cdk/src/handlers/shared/memory.ts +++ b/cdk/src/handlers/shared/memory.ts @@ -25,6 +25,7 @@ import { } from '@aws-sdk/client-bedrock-agentcore'; import { logger } from './logger'; import { sanitizeExternalContent } from './sanitization'; +import { makeClient } from './ua'; import type { TaskStatusType } from '../../constructs/task-status'; // --------------------------------------------------------------------------- @@ -155,7 +156,7 @@ function processMemoryRecords( let agentCoreClient: BedrockAgentCoreClient | undefined; function getClient(): BedrockAgentCoreClient { if (!agentCoreClient) { - agentCoreClient = new BedrockAgentCoreClient({}); + agentCoreClient = makeClient(BedrockAgentCoreClient); } return agentCoreClient; } diff --git a/cdk/src/handlers/shared/orchestration-channel-slack.ts b/cdk/src/handlers/shared/orchestration-channel-slack.ts index 4bc13fd9c..a0e902362 100644 --- a/cdk/src/handlers/shared/orchestration-channel-slack.ts +++ b/cdk/src/handlers/shared/orchestration-channel-slack.ts @@ -269,4 +269,3 @@ export function makeSlackChannel(secretPrefix: string = SLACK_SECRET_PREFIX): Ch // fetchChildGraph: omitted — no dependency model; graphs arrive declaratively. } satisfies Channel as Channel; } - diff --git a/cdk/src/handlers/shared/orchestrator.ts b/cdk/src/handlers/shared/orchestrator.ts index 5e38ba561..22475f80a 100644 --- a/cdk/src/handlers/shared/orchestrator.ts +++ b/cdk/src/handlers/shared/orchestrator.ts @@ -17,9 +17,8 @@ * SOFTWARE. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { S3Client } from '@aws-sdk/client-s3'; -import { DynamoDBDocumentClient, GetCommand, PutCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import { GetCommand, PutCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; import { ulid } from 'ulid'; import { AttachmentBudgetExceededError, AttachmentConfigurationError, AttachmentResolutionError, hydrateContext, resolveGitHubToken } from './context-hydration'; import { logger, type Logger } from './logger'; @@ -29,10 +28,11 @@ import { computePromptVersion } from './prompt-version'; import { loadRepoConfig, type BlueprintConfig, type ComputeType } from './repo-config'; import { resolveUrlAttachments } from './resolve-url-attachments'; import { APPROVAL_GATE_CAP_MAX, APPROVAL_GATE_CAP_MIN, type AgentAttachmentPayload, type AttachmentRecord, type TaskRecord } from './types'; +import { makeClient, makeDocClient } from './ua'; import { computeTtlEpoch, DEFAULT_MAX_TURNS } from './validation'; import { TaskStatus, TERMINAL_STATUSES, VALID_TRANSITIONS, type TaskStatusType } from '../../constructs/task-status'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = makeDocClient(); const TABLE_NAME = process.env.TASK_TABLE_NAME!; const EVENTS_TABLE_NAME = process.env.TASK_EVENTS_TABLE_NAME!; @@ -472,7 +472,7 @@ export async function hydrateAndTransition(task: TaskRecord, blueprintConfig?: B ? { guardrailId: process.env.GUARDRAIL_ID, guardrailVersion: process.env.GUARDRAIL_VERSION, - bedrockClient: new BedrockRuntimeClient({}), + bedrockClient: makeClient(BedrockRuntimeClient), } : undefined; @@ -521,7 +521,7 @@ export async function hydrateAndTransition(task: TaskRecord, blueprintConfig?: B task.task_id, task.user_id, { - s3Client: new S3Client({}), + s3Client: makeClient(S3Client), bucketName: ATTACHMENTS_BUCKET_NAME, screeningConfig, githubToken, diff --git a/cdk/src/handlers/shared/repo-config.ts b/cdk/src/handlers/shared/repo-config.ts index a8303cd98..00823dd6d 100644 --- a/cdk/src/handlers/shared/repo-config.ts +++ b/cdk/src/handlers/shared/repo-config.ts @@ -17,9 +17,9 @@ * SOFTWARE. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb'; +import { GetCommand } from '@aws-sdk/lib-dynamodb'; import { logger } from './logger'; +import { makeDocClient } from './ua'; /** * Per-repository configuration written by the Blueprint CDK construct @@ -90,7 +90,7 @@ export interface BlueprintConfig { readonly approval_gate_cap?: number; } -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = makeDocClient(); /** * Combined result of a single RepoTable GetItem used by the submit diff --git a/cdk/src/handlers/shared/slack-verify.ts b/cdk/src/handlers/shared/slack-verify.ts index 633f00235..c3ee23aee 100644 --- a/cdk/src/handlers/shared/slack-verify.ts +++ b/cdk/src/handlers/shared/slack-verify.ts @@ -21,8 +21,9 @@ import * as crypto from 'crypto'; import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; import { isUsableHmacSecret } from './hmac-secret'; import { logger } from './logger'; +import { makeClient } from './ua'; -const sm = new SecretsManagerClient({}); +const sm = makeClient(SecretsManagerClient); /** Prefix for Slack-related secrets in Secrets Manager. */ export const SLACK_SECRET_PREFIX = 'bgagent/slack/'; diff --git a/cdk/src/handlers/shared/strategies/agentcore-strategy.ts b/cdk/src/handlers/shared/strategies/agentcore-strategy.ts index d10e9bde2..762387abe 100644 --- a/cdk/src/handlers/shared/strategies/agentcore-strategy.ts +++ b/cdk/src/handlers/shared/strategies/agentcore-strategy.ts @@ -22,11 +22,12 @@ import { BedrockAgentCoreClient, InvokeAgentRuntimeCommand, StopRuntimeSessionCo import type { ComputeStrategy, SessionHandle, SessionStatus } from '../compute-strategy'; import { logger } from '../logger'; import type { BlueprintConfig } from '../repo-config'; +import { makeClient } from '../ua'; let sharedClient: BedrockAgentCoreClient | undefined; function getClient(): BedrockAgentCoreClient { if (!sharedClient) { - sharedClient = new BedrockAgentCoreClient({}); + sharedClient = makeClient(BedrockAgentCoreClient); } return sharedClient; } diff --git a/cdk/src/handlers/shared/strategies/ecs-strategy.ts b/cdk/src/handlers/shared/strategies/ecs-strategy.ts index c685d94f6..75853ee39 100644 --- a/cdk/src/handlers/shared/strategies/ecs-strategy.ts +++ b/cdk/src/handlers/shared/strategies/ecs-strategy.ts @@ -22,12 +22,13 @@ import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client import type { ComputeStrategy, SessionHandle, SessionStatus } from '../compute-strategy'; import { logger } from '../logger'; import type { BlueprintConfig } from '../repo-config'; +import { makeClient } from '../ua'; import { DEFAULT_MAX_TURNS } from '../validation'; let sharedClient: ECSClient | undefined; function getClient(): ECSClient { if (!sharedClient) { - sharedClient = new ECSClient({}); + sharedClient = makeClient(ECSClient); } return sharedClient; } @@ -35,7 +36,7 @@ function getClient(): ECSClient { let sharedS3Client: S3Client | undefined; function getS3Client(): S3Client { if (!sharedS3Client) { - sharedS3Client = new S3Client({}); + sharedS3Client = makeClient(S3Client); } return sharedS3Client; } diff --git a/cdk/src/handlers/shared/ua.ts b/cdk/src/handlers/shared/ua.ts new file mode 100644 index 000000000..be8ad8fc1 --- /dev/null +++ b/cdk/src/handlers/shared/ua.ts @@ -0,0 +1,124 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Outbound AWS SDK User-Agent solution attribution (#319). + * + * Every AWS API call made by the Lambda handlers carries two ABCA + * solution-attribution segments in the `User-Agent` header: + * + * app/uksb-wt64nei4u6#{STACKNAME} <- native AWS_SDK_UA_APP_ID env (no code here) + * md/uksb-wt64nei4u6#{COMPONENT} <- static, baked once at construction + * + * **The `app/` segment is emitted by the SDK itself.** The JS v3 SDK reads + * the `AWS_SDK_UA_APP_ID` environment variable natively (`util-user-agent-node` + * `NODE_APP_ID_CONFIG_OPTIONS.environmentVariableSelector`) and renders it as + * `app/{value}`. The app-id value charset *includes* `#` (`UA_VALUE_ESCAPE_REGEX` + * permits it), so the `uksb-wt64nei4u6#{stack}` form survives verbatim. CDK + * sets that env var on every Lambda, so this module contributes **nothing** to + * `app/` — and a customer can suppress it by setting the env var to `''`. + * (This is the key simplification over the original `/`-separated design, + * which had to bypass the native field because `/` is not a legal app-id + * character. Using `#` keeps it native.) + * + * This module owns only the **static `md/` segment** — a stable per-component + * label baked once via `customUserAgent` at client construction. There is + * intentionally no per-request trace handle and no middleware machinery: + * module-level cached clients are never re-pinned, and request correlation is + * owned by X-Ray / structured-log request ids (#245), not the User-Agent. + * + * Counterparts: `agent/src/ua.py` (Python agent runtime) and `cli/src/ua.ts` + * (bgagent CLI). Solution id, wire format, and sanitization rules must stay + * identical across all three. + */ + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; + +/** + * AWS solution-attribution id for ABCA. Deploy-time counterpart (#292) lives + * in the CloudFormation stack description in `cdk/src/main.ts`. Per-surface + * literal by design. + */ +export const SOLUTION_ID = 'uksb-wt64nei4u6'; + +/** + * Env var carrying the stable per-component label (`api`, `webhook`, + * `orchestr`) — set per-Lambda by the CDK constructs. Shared handler modules + * are bundled into multiple Lambdas, so identity must come from the + * environment, not from code. + */ +export const COMPONENT_ENV = 'ABCA_COMPONENT'; + +/** Default component label when ABCA_COMPONENT is absent (REST API surface). */ +const DEFAULT_COMPONENT = 'api'; + +/** + * RFC 7230 token charset (the UA product-token alphabet). `#` is the scheme's + * structural separator and is deliberately excluded so a hostile label cannot + * inject extra segments. Mirrors `_ALLOWED` in `agent/src/ua.py`. + */ +const UA_TOKEN_SAFE = /[^A-Za-z0-9!$%&'*+\-.^_`|~]/g; + +/** Replace every non-UA-token char (incl. non-ASCII) with `-`. */ +export function sanitizeUaValue(raw: string): string { + return raw.replace(UA_TOKEN_SAFE, '-'); +} + +/** The component label for this Lambda (from env, sanitized). */ +function componentLabel(): string { + return sanitizeUaValue(process.env[COMPONENT_ENV]?.trim() || DEFAULT_COMPONENT); +} + +/** + * Client config fragment carrying the static ABCA `md/` segment. + * + * Spread into any SDK v3 client constructor: + * `new DynamoDBClient({ ...abcaUserAgent() })`. The entry is a `[name, value]` + * user-agent pair `['md/uksb-wt64nei4u6', component]`, which the SDK renders + * as `md/uksb-wt64nei4u6#component` (the `#` comes from the SDK's own + * name#value join). The `app/` segment is contributed separately by the SDK + * from `AWS_SDK_UA_APP_ID` and is not produced here. + */ +export function abcaUserAgent(): { customUserAgent: [string, string][] } { + return { customUserAgent: [[`md/${SOLUTION_ID}`, componentLabel()]] }; +} + +/** + * The single attributed way to construct an AWS SDK v3 client. Composes the + * static `md/` segment ({@link abcaUserAgent}) into the client config so + * omission is impossible at the call site. Caller-supplied opts (region, + * timeouts) are preserved — and a caller who supplies their own + * `customUserAgent` pairs keeps them, with the ABCA `md/` pair appended rather + * than overwritten (the SDK renders all pairs). No call site sets + * `customUserAgent` today, so the merge is latent hardening; it keeps the + * factory composable instead of clobbering. (#319) + */ +export function makeClient( + Ctor: new (cfg: any) => C, + cfg: Record = {}, +): C { + const callerUa = (cfg.customUserAgent as [string, string][] | undefined) ?? []; + return new Ctor({ ...cfg, customUserAgent: [...callerUa, ...abcaUserAgent().customUserAgent] }); +} + +/** Attributed `DynamoDBDocumentClient` — the wrapper form, in one call. */ +export function makeDocClient(cfg: Record = {}): DynamoDBDocumentClient { + return DynamoDBDocumentClient.from(makeClient(DynamoDBClient, cfg)); +} diff --git a/cdk/src/handlers/slack-command-processor.ts b/cdk/src/handlers/slack-command-processor.ts index d432d80a1..7de9129b4 100644 --- a/cdk/src/handlers/slack-command-processor.ts +++ b/cdk/src/handlers/slack-command-processor.ts @@ -18,13 +18,13 @@ */ import * as crypto from 'crypto'; -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { DynamoDBDocumentClient, GetCommand, PutCommand } from '@aws-sdk/lib-dynamodb'; +import { GetCommand, PutCommand } from '@aws-sdk/lib-dynamodb'; import { createTaskCore } from './shared/create-task-core'; import { logger } from './shared/logger'; import { slackFetch } from './shared/slack-api'; import { getSlackSecret, SLACK_SECRET_PREFIX } from './shared/slack-verify'; import type { Attachment } from './shared/types'; +import { makeDocClient } from './shared/ua'; import { CODING_WORKFLOW_ID } from './shared/workflows'; import type { SlackCommandPayload } from './slack-commands'; @@ -77,7 +77,7 @@ function normalizeEvent(event: RawEvent): CommandProcessorEvent { return { ...event, source: 'slash' }; } -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = makeDocClient(); const USER_MAPPING_TABLE = process.env.SLACK_USER_MAPPING_TABLE_NAME!; const INSTALLATION_TABLE = process.env.SLACK_INSTALLATION_TABLE_NAME!; diff --git a/cdk/src/handlers/slack-commands.ts b/cdk/src/handlers/slack-commands.ts index f89b47c8e..6b8199404 100644 --- a/cdk/src/handlers/slack-commands.ts +++ b/cdk/src/handlers/slack-commands.ts @@ -21,8 +21,9 @@ import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { logger } from './shared/logger'; import { getSlackSecret, verifySlackRequest } from './shared/slack-verify'; +import { makeClient } from './shared/ua'; -const lambdaClient = new LambdaClient({}); +const lambdaClient = makeClient(LambdaClient); const SIGNING_SECRET_ARN = process.env.SLACK_SIGNING_SECRET_ARN!; const PROCESSOR_FUNCTION_NAME = process.env.SLACK_COMMAND_PROCESSOR_FUNCTION_NAME!; diff --git a/cdk/src/handlers/slack-events.ts b/cdk/src/handlers/slack-events.ts index c5e06eb07..09d20e348 100644 --- a/cdk/src/handlers/slack-events.ts +++ b/cdk/src/handlers/slack-events.ts @@ -17,19 +17,19 @@ * SOFTWARE. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; import { DeleteSecretCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; -import { DynamoDBDocumentClient, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import { UpdateCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { logger } from './shared/logger'; import { slackFetch } from './shared/slack-api'; import { getSlackSecret, SLACK_SECRET_PREFIX, verifySlackRequest } from './shared/slack-verify'; +import { makeClient, makeDocClient } from './shared/ua'; import type { MentionEvent, SlackFileRef } from './slack-command-processor'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); -const sm = new SecretsManagerClient({}); -const lambdaClient = new LambdaClient({}); +const ddb = makeDocClient(); +const sm = makeClient(SecretsManagerClient); +const lambdaClient = makeClient(LambdaClient); const TABLE_NAME = process.env.SLACK_INSTALLATION_TABLE_NAME!; const SIGNING_SECRET_ARN = process.env.SLACK_SIGNING_SECRET_ARN!; diff --git a/cdk/src/handlers/slack-interactions.ts b/cdk/src/handlers/slack-interactions.ts index ac25ccff4..e5137381f 100644 --- a/cdk/src/handlers/slack-interactions.ts +++ b/cdk/src/handlers/slack-interactions.ts @@ -17,13 +17,13 @@ * SOFTWARE. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { DynamoDBDocumentClient, GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import { GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { logger } from './shared/logger'; import { getSlackSecret, SLACK_SECRET_PREFIX, verifySlackRequest } from './shared/slack-verify'; +import { makeDocClient } from './shared/ua'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = makeDocClient(); const SIGNING_SECRET_ARN = process.env.SLACK_SIGNING_SECRET_ARN!; const TASK_TABLE = process.env.TASK_TABLE_NAME!; diff --git a/cdk/src/handlers/slack-link.ts b/cdk/src/handlers/slack-link.ts index 60ba20dd3..32f8d182c 100644 --- a/cdk/src/handlers/slack-link.ts +++ b/cdk/src/handlers/slack-link.ts @@ -17,16 +17,16 @@ * SOFTWARE. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { DynamoDBDocumentClient, GetCommand, PutCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb'; +import { GetCommand, PutCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { ulid } from 'ulid'; import { extractUserId } from './shared/gateway'; import { logger } from './shared/logger'; import { ErrorCode, errorResponse, successResponse } from './shared/response'; +import { makeDocClient } from './shared/ua'; import { parseBody } from './shared/validation'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = makeDocClient(); const USER_MAPPING_TABLE = process.env.SLACK_USER_MAPPING_TABLE_NAME!; diff --git a/cdk/src/handlers/slack-oauth-callback.ts b/cdk/src/handlers/slack-oauth-callback.ts index 872d5581d..bbbae5b28 100644 --- a/cdk/src/handlers/slack-oauth-callback.ts +++ b/cdk/src/handlers/slack-oauth-callback.ts @@ -17,15 +17,15 @@ * SOFTWARE. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { CreateSecretCommand, RestoreSecretCommand, SecretsManagerClient, UpdateSecretCommand, ResourceNotFoundException, InvalidRequestException } from '@aws-sdk/client-secrets-manager'; -import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; +import { PutCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { logger } from './shared/logger'; import { getSlackSecret, SLACK_SECRET_PREFIX } from './shared/slack-verify'; +import { makeClient, makeDocClient } from './shared/ua'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); -const sm = new SecretsManagerClient({}); +const ddb = makeDocClient(); +const sm = makeClient(SecretsManagerClient); const TABLE_NAME = process.env.SLACK_INSTALLATION_TABLE_NAME!; const CLIENT_ID_SECRET_ARN = process.env.SLACK_CLIENT_ID_SECRET_ARN!; diff --git a/cdk/src/handlers/webhook-authorizer.ts b/cdk/src/handlers/webhook-authorizer.ts index 91aeb85d2..14fd2bcde 100644 --- a/cdk/src/handlers/webhook-authorizer.ts +++ b/cdk/src/handlers/webhook-authorizer.ts @@ -17,13 +17,13 @@ * SOFTWARE. */ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb'; +import { GetCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayRequestAuthorizerEvent, APIGatewayAuthorizerResult } from 'aws-lambda'; import { logger } from './shared/logger'; import type { WebhookRecord } from './shared/types'; +import { makeDocClient } from './shared/ua'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = makeDocClient(); const TABLE_NAME = process.env.WEBHOOK_TABLE_NAME!; function generatePolicy( diff --git a/cdk/src/handlers/webhook-create-task.ts b/cdk/src/handlers/webhook-create-task.ts index ec3ae7444..f3e53c0c1 100644 --- a/cdk/src/handlers/webhook-create-task.ts +++ b/cdk/src/handlers/webhook-create-task.ts @@ -27,9 +27,10 @@ import { isUsableHmacSecret } from './shared/hmac-secret'; import { logger } from './shared/logger'; import { ErrorCode, errorResponse } from './shared/response'; import type { CreateTaskRequest } from './shared/types'; +import { makeClient } from './shared/ua'; import { parseBody } from './shared/validation'; -const sm = new SecretsManagerClient({}); +const sm = makeClient(SecretsManagerClient); const SECRET_PREFIX = 'bgagent/webhook/'; // In-memory secret cache with 5-minute TTL diff --git a/cdk/src/main.ts b/cdk/src/main.ts index c8bc2cc88..a43abc723 100644 --- a/cdk/src/main.ts +++ b/cdk/src/main.ts @@ -17,8 +17,9 @@ * SOFTWARE. */ -import { App, Aspects, Tags } from 'aws-cdk-lib'; +import { App, AspectPriority, Aspects, Tags } from 'aws-cdk-lib'; import { AwsSolutionsChecks } from 'cdk-nag'; +import { buildAppId, SolutionUaAspect } from './constructs/solution-ua-aspect'; import { AgentStack } from './stacks/agent'; // for development, use account/region from cdk cli @@ -42,6 +43,17 @@ const stack = new AgentStack( }, ); +// Outbound SDK solution attribution (#319): set AWS_SDK_UA_APP_ID on every +// Lambda so the SDK emits `app/uksb-wt64nei4u6#{stackName}` natively. One +// Aspect covers current and future functions structurally. Override via +// `-c sdkUaAppId=...`; `-c sdkUaAppId=''` opts out (no app/ segment anywhere). +const sdkUaAppIdOverride = app.node.tryGetContext('sdkUaAppId') as string | undefined; +// MUTATING priority so the env var is set before cdk-nag (priority 500) +// inspects the synthesized functions — matches the agent stack's aspects. +Aspects.of(stack).add(new SolutionUaAspect(buildAppId(stackName, sdkUaAppIdOverride)), { + priority: AspectPriority.MUTATING, +}); + const computeType = app.node.tryGetContext('compute_type') ?? 'agentcore'; // Route53 Resolver resources where tag changes trigger replacement cascades. diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index ea37c3d4e..91ef0ea26 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -52,6 +52,7 @@ import { OrchestrationTable } from '../constructs/orchestration-table'; import { PendingUploadCleanup } from '../constructs/pending-upload-cleanup'; import { RepoTable } from '../constructs/repo-table'; import { SlackIntegration } from '../constructs/slack-integration'; +import { buildAppId } from '../constructs/solution-ua-aspect'; import { StrandedOrchestrationReconciler } from '../constructs/stranded-orchestration-reconciler'; import { StrandedTaskReconciler } from '../constructs/stranded-task-reconciler'; import { TaskApi } from '../constructs/task-api'; @@ -313,6 +314,15 @@ export class AgentStack extends Stack { // // One runtime, invoked by OrchestratorFn via SigV4. See // `docs/design/INTERACTIVE_AGENTS.md` §3.1 and AD-1. + // Outbound SDK solution attribution (#319): the same app-id the + // SolutionUaAspect sets on Lambdas, computed here so the AgentCore runtime + // and ECS container (which the Lambda-only Aspect can't reach) carry it + // too. Respects the `-c sdkUaAppId` override / empty-string opt-out. + const sdkUaAppId = buildAppId( + this.stackName, + this.node.tryGetContext('sdkUaAppId') as string | undefined, + ); + const runtimeEnvironmentVariables = { GITHUB_TOKEN_SECRET_ARN: githubTokenSecret.secretArn, AWS_REGION: process.env.AWS_REGION ?? 'us-east-1', @@ -365,6 +375,10 @@ export class AgentStack extends Stack { CLAUDE_CONFIG_DIR: '/mnt/workspace/.claude-config', npm_config_cache: '/mnt/workspace/.npm-cache', // ENABLE_CLI_TELEMETRY: '1', + // Outbound SDK solution attribution (#319): botocore reads + // AWS_SDK_UA_APP_ID natively → `app/uksb-wt64nei4u6#{stack}`. The + // Lambda-only Aspect can't reach this runtime, so set it explicitly. + ...(sdkUaAppId ? { AWS_SDK_UA_APP_ID: sdkUaAppId } : {}), }; const runtimeNetworkConfig = agentcore.RuntimeNetworkConfiguration.usingVpc(this, { diff --git a/cdk/test/constructs/jira-integration.test.ts b/cdk/test/constructs/jira-integration.test.ts index d2dbe5fec..72ece28ba 100644 --- a/cdk/test/constructs/jira-integration.test.ts +++ b/cdk/test/constructs/jira-integration.test.ts @@ -72,4 +72,16 @@ describe('JiraIntegration construct', () => { }), }); }); + + test('every Lambda carries ABCA_COMPONENT=webhook via ComponentUaAspect (#319)', () => { + // The Jira integration was the one integration missing ComponentUaAspect, + // so its Lambdas fell back to the `api` label. Match slack/linear/github. + const functions = template.findResources('AWS::Lambda::Function'); + const fnIds = Object.keys(functions); + expect(fnIds.length).toBeGreaterThan(0); + for (const fnId of fnIds) { + const envVars = functions[fnId].Properties.Environment?.Variables ?? {}; + expect(envVars).toHaveProperty('ABCA_COMPONENT', 'webhook'); + } + }); }); diff --git a/cdk/test/constructs/solution-ua-aspect.test.ts b/cdk/test/constructs/solution-ua-aspect.test.ts new file mode 100644 index 000000000..8d68668ca --- /dev/null +++ b/cdk/test/constructs/solution-ua-aspect.test.ts @@ -0,0 +1,114 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { App, Aspects, Stack } from 'aws-cdk-lib'; +import { Template } from 'aws-cdk-lib/assertions'; +import * as lambda from 'aws-cdk-lib/aws-lambda'; +import { buildAppId, ComponentUaAspect, SolutionUaAspect } from '../../src/constructs/solution-ua-aspect'; + +describe('buildAppId', () => { + test('defaults to uksb-wt64nei4u6#{stackName}', () => { + expect(buildAppId('backgroundagent-dev')).toBe('uksb-wt64nei4u6#backgroundagent-dev'); + }); + + test('CloudFormation-legal stack names pass through unsanitized', () => { + // CFN names are [A-Za-z0-9-]; all already UA-token-safe. + expect(buildAppId('ABCA-Prod-123')).toBe('uksb-wt64nei4u6#ABCA-Prod-123'); + }); + + test('clips to the documented 50-char value cap', () => { + const appId = buildAppId('a'.repeat(80)); + expect(appId).toBeDefined(); + expect(appId!.length).toBe(50); + expect(appId!.startsWith('uksb-wt64nei4u6#aaaa')).toBe(true); + }); + + test('explicit override is used verbatim (sanitized)', () => { + expect(buildAppId('stack', 'custom-value')).toBe('custom-value'); + expect(buildAppId('stack', 'has/slash')).toBe('has-slash'); + }); + + test('override preserves the # solution separator (no re-mangling)', () => { + // Regression: sanitizing the whole override with UA_TOKEN_UNSAFE mangled + // `#`->`-`, reintroducing the `app-uksb-wt64nei4u6-{stack}` form this + // design exists to avoid. `#` must survive as the segment boundary. + expect(buildAppId('stack', 'uksb-wt64nei4u6#my-stack')).toBe('uksb-wt64nei4u6#my-stack'); + }); + + test('override sanitizes unsafe chars per #-delimited segment', () => { + // Multi-`#`: every `#` is preserved; only other unsafe chars become `-`. + expect(buildAppId('stack', 'uksb-wt64nei4u6#my#stack')).toBe('uksb-wt64nei4u6#my#stack'); + // `#` + slash mix: slashes within a segment sanitize, `#` stays. + expect(buildAppId('stack', 'uksb-wt64nei4u6#a/b')).toBe('uksb-wt64nei4u6#a-b'); + expect(buildAppId('stack', 'a/b#c/d')).toBe('a-b#c-d'); + }); + + test('over-length override clips while preserving an earlier #', () => { + const appId = buildAppId('stack', `uksb-wt64nei4u6#${'x'.repeat(80)}`); + expect(appId).toBeDefined(); + expect(appId!.length).toBe(50); + expect(appId!.startsWith('uksb-wt64nei4u6#xxxx')).toBe(true); + expect(appId).toContain('#'); + }); + + it('does not emit a trailing # when the 50-char clip lands on a separator', () => { + const first = 'a'.repeat(49); + const out = buildAppId('stack', `${first}#tail`); // clip at 50 lands right after '#' + expect(out!.endsWith('#')).toBe(false); + expect(out!.length).toBeLessThanOrEqual(50); + }); + + test('empty-string override opts out (undefined)', () => { + expect(buildAppId('stack', '')).toBeUndefined(); + expect(buildAppId('stack', ' ')).toBeUndefined(); + }); +}); + +function envVarsOfFirstFunction(aspects: (stack: Stack) => void): Record { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new lambda.Function(stack, 'Fn', { + runtime: lambda.Runtime.NODEJS_20_X, + handler: 'index.handler', + code: lambda.Code.fromInline('exports.handler = async () => {};'), + }); + aspects(stack); + const fns = Template.fromStack(stack).findResources('AWS::Lambda::Function'); + const first = Object.values(fns)[0] as { Properties?: { Environment?: { Variables?: Record } } }; + return first.Properties?.Environment?.Variables ?? {}; +} + +describe('SolutionUaAspect', () => { + test('sets AWS_SDK_UA_APP_ID on every Lambda', () => { + const vars = envVarsOfFirstFunction((s) => Aspects.of(s).add(new SolutionUaAspect('uksb-wt64nei4u6#dev'))); + expect(vars.AWS_SDK_UA_APP_ID).toBe('uksb-wt64nei4u6#dev'); + }); + + test('undefined appId (opt-out) sets nothing', () => { + const vars = envVarsOfFirstFunction((s) => Aspects.of(s).add(new SolutionUaAspect(undefined))); + expect(vars.AWS_SDK_UA_APP_ID).toBeUndefined(); + }); +}); + +describe('ComponentUaAspect', () => { + test('sets ABCA_COMPONENT on every Lambda in scope', () => { + const vars = envVarsOfFirstFunction((s) => Aspects.of(s).add(new ComponentUaAspect('webhook'))); + expect(vars.ABCA_COMPONENT).toBe('webhook'); + }); +}); diff --git a/cdk/test/constructs/task-api.test.ts b/cdk/test/constructs/task-api.test.ts index 79fd7fa9a..c0a748e6a 100644 --- a/cdk/test/constructs/task-api.test.ts +++ b/cdk/test/constructs/task-api.test.ts @@ -74,6 +74,37 @@ function createStackWithWebhooks(overrides?: Partial): { stack: St return { stack, template }; } +// Full surface: webhookTable AND apiKeyTable both provided (all tables in the +// same app/stack — CDK forbids cross-app resource references). +function createStackWithWebhooksAndApiKeys(): { stack: Stack; template: Template } { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + + const taskTable = new dynamodb.Table(stack, 'TaskTable', { + partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, + }); + const taskEventsTable = new dynamodb.Table(stack, 'TaskEventsTable', { + partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'event_id', type: dynamodb.AttributeType.STRING }, + }); + const webhookTable = new dynamodb.Table(stack, 'WebhookTable', { + partitionKey: { name: 'webhook_id', type: dynamodb.AttributeType.STRING }, + }); + const apiKeyTable = new dynamodb.Table(stack, 'ApiKeyTable', { + partitionKey: { name: 'key_id', type: dynamodb.AttributeType.STRING }, + }); + + new TaskApi(stack, 'TaskApi', { + taskTable, + taskEventsTable, + webhookTable, + apiKeyTable, + }); + + const template = Template.fromStack(stack); + return { stack, template }; +} + describe('TaskApi construct', () => { let baseTemplate: Template; let webhookTemplate: Template; @@ -160,6 +191,50 @@ describe('TaskApi construct', () => { } }); + test('REST API Lambdas carry the ABCA_COMPONENT=api solution-attribution label (#319)', () => { + const functions = baseTemplate.findResources('AWS::Lambda::Function'); + const fnIds = Object.keys(functions); + expect(fnIds.length).toBeGreaterThan(0); + for (const fnId of fnIds) { + const envVars = functions[fnId].Properties.Environment?.Variables ?? {}; + expect(envVars).toHaveProperty('ABCA_COMPONENT', 'api'); + } + }); + + test('webhook + api-key Lambdas carry the correct ABCA_COMPONENT label (#319)', () => { + // Exercise the full surface: both webhookTable AND apiKeyTable set, so the + // webhook ingest Lambdas (including WebhookCreateTaskFn, which inherits the + // createTask env) are labeled `webhook`, and the API-key management Lambdas + // are labeled `api`. The base-template test above never sees these, so it + // passed vacuously for the webhook/api-key surfaces. + const { template } = createStackWithWebhooksAndApiKeys(); + const functions = template.findResources('AWS::Lambda::Function'); + + // WebhookCreateTaskFn is the trap: same env as createTask (which is `api`), + // but it is a webhook surface and must be relabeled `webhook`. + const webhookCreate = Object.entries(functions).find(([id]) => id.includes('WebhookCreateTaskFn')); + expect(webhookCreate).toBeDefined(); + expect(webhookCreate![1].Properties.Environment?.Variables?.ABCA_COMPONENT).toBe('webhook'); + + // API-key management Lambdas (Create/List/Delete + authorizer) are `api`. + const apiKeyFns = Object.entries(functions).filter(([id]) => + id.includes('ApiKey') || id.includes('CreateApiKey') || id.includes('ListApiKeys') || id.includes('DeleteApiKey'), + ); + expect(apiKeyFns.length).toBeGreaterThan(0); + for (const [, fn] of apiKeyFns) { + expect(fn.Properties.Environment?.Variables?.ABCA_COMPONENT).toBe('api'); + } + + // Webhook management Lambdas (Create/List/Delete/Authorizer) are `webhook`. + const webhookMgmtFns = Object.entries(functions).filter(([id]) => + (id.includes('CreateWebhook') || id.includes('ListWebhooks') || id.includes('DeleteWebhook') || id.includes('WebhookAuthorizer')), + ); + expect(webhookMgmtFns.length).toBeGreaterThan(0); + for (const [, fn] of webhookMgmtFns) { + expect(fn.Properties.Environment?.Variables?.ABCA_COMPONENT).toBe('webhook'); + } + }); + test('creates API resources for /tasks and /tasks/{task_id}', () => { baseTemplate.hasResourceProperties('AWS::ApiGateway::Resource', { PathPart: 'tasks', diff --git a/cdk/test/constructs/task-orchestrator.test.ts b/cdk/test/constructs/task-orchestrator.test.ts index 271ea0f01..7027a135f 100644 --- a/cdk/test/constructs/task-orchestrator.test.ts +++ b/cdk/test/constructs/task-orchestrator.test.ts @@ -147,6 +147,16 @@ describe('TaskOrchestrator construct', () => { }); }); + test('orchestrator Lambda carries the ABCA_COMPONENT=orchestr label (#319)', () => { + baseTemplate.hasResourceProperties('AWS::Lambda::Function', { + Environment: { + Variables: Match.objectLike({ + ABCA_COMPONENT: 'orchestr', + }), + }, + }); + }); + test('respects custom maxConcurrentTasksPerUser', () => { const { template } = createStack({ maxConcurrentTasksPerUser: 5 }); template.hasResourceProperties('AWS::Lambda::Function', { diff --git a/cdk/test/handlers/shared/ua.test.ts b/cdk/test/handlers/shared/ua.test.ts new file mode 100644 index 000000000..52b8ab8b8 --- /dev/null +++ b/cdk/test/handlers/shared/ua.test.ts @@ -0,0 +1,173 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DynamoDBClient, ListTablesCommand } from '@aws-sdk/client-dynamodb'; +import { S3Client } from '@aws-sdk/client-s3'; +import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { abcaUserAgent, makeClient, makeDocClient, sanitizeUaValue } from '../../../src/handlers/shared/ua'; + +// `abcaUserAgent` / `componentLabel` read process.env at call time, so a plain +// import suffices — no module reload needed. The wire-capture cases likewise +// rely on the SDK reading AWS_SDK_UA_APP_ID at client construction. + +describe('sanitizeUaValue', () => { + test.each([ + ['api', 'api'], + ['a/b', 'a-b'], + ['a#b', 'a-b'], + ['héllo', 'h-llo'], + ['a b', 'a-b'], + ['ok-_.~!', 'ok-_.~!'], + ])('sanitizes %p -> %p', (raw, expected) => { + expect(sanitizeUaValue(raw)).toBe(expected); + }); +}); + +describe('abcaUserAgent', () => { + const prev = process.env.ABCA_COMPONENT; + afterEach(() => { + if (prev === undefined) delete process.env.ABCA_COMPONENT; + else process.env.ABCA_COMPONENT = prev; + }); + + test('uses ABCA_COMPONENT when set', () => { + process.env.ABCA_COMPONENT = 'orchestr'; + expect(abcaUserAgent()).toEqual({ customUserAgent: [['md/uksb-wt64nei4u6', 'orchestr']] }); + }); + + test('defaults to api when env unset', () => { + delete process.env.ABCA_COMPONENT; + expect(abcaUserAgent()).toEqual({ customUserAgent: [['md/uksb-wt64nei4u6', 'api']] }); + }); + + test('sanitizes a hostile component label', () => { + process.env.ABCA_COMPONENT = 'evil#injected'; + expect(abcaUserAgent()).toEqual({ customUserAgent: [['md/uksb-wt64nei4u6', 'evil-injected']] }); + }); +}); + +// Per-surface lock-in for #319 review item 2: tasks 4/5 wired the Jira/api-key +// handlers through the factory, so the `webhook`/`api` labels now land in a real +// `md/` segment. These assert the exact emitted pair per surface. +describe('component label lands in the md/ segment', () => { + afterEach(() => { + delete process.env.ABCA_COMPONENT; + }); + + it('emits md/…#webhook when ABCA_COMPONENT=webhook', () => { + process.env.ABCA_COMPONENT = 'webhook'; + expect(abcaUserAgent().customUserAgent).toEqual([['md/uksb-wt64nei4u6', 'webhook']]); + }); + + it('falls back to api when unset', () => { + delete process.env.ABCA_COMPONENT; + expect(abcaUserAgent().customUserAgent).toEqual([['md/uksb-wt64nei4u6', 'api']]); + }); +}); + +describe('wire-capture: emitted User-Agent header', () => { + /** + * Drive a real DynamoDBClient through its full middleware stack with a stub + * requestHandler that records the outbound `user-agent` header and returns a + * minimal response — no network. The header is captured before the (invalid) + * response is returned, so the later deserialization error is irrelevant. + * Asserts the md/ segment (from customUserAgent) and the app/ segment (from + * native AWS_SDK_UA_APP_ID). + */ + async function captureUserAgent(appId?: string): Promise { + const prevAppId = process.env.AWS_SDK_UA_APP_ID; + if (appId === undefined) delete process.env.AWS_SDK_UA_APP_ID; + else process.env.AWS_SDK_UA_APP_ID = appId; + + let captured = ''; + const client = new DynamoDBClient({ + region: 'us-east-1', + credentials: { accessKeyId: 'x', secretAccessKey: 'y' }, + ...abcaUserAgent(), + requestHandler: { + async handle(request: { headers: Record }) { + captured = request.headers['user-agent'] ?? request.headers['User-Agent'] ?? ''; + return { response: { statusCode: 200, headers: {}, body: undefined } }; + }, + updateHttpClientConfig() {}, + httpHandlerConfigs() { + return {}; + }, + } as never, + }); + + try { + await client.send(new ListTablesCommand({})); + } catch { + // The stub body is not a valid protocol response; we only need the header. + } finally { + if (prevAppId === undefined) delete process.env.AWS_SDK_UA_APP_ID; + else process.env.AWS_SDK_UA_APP_ID = prevAppId; + } + return captured; + } + + test('carries both app/ and md/ segments when AWS_SDK_UA_APP_ID set', async () => { + const ua = await captureUserAgent('uksb-wt64nei4u6#backgroundagent-dev'); + expect(ua).toContain('app/uksb-wt64nei4u6#backgroundagent-dev'); + expect(ua).toContain('md/uksb-wt64nei4u6#api'); + }); + + test('omits app/ when AWS_SDK_UA_APP_ID unset, keeps md/', async () => { + const ua = await captureUserAgent(undefined); + expect(ua).not.toContain('app/uksb-wt64nei4u6'); + expect(ua).toContain('md/uksb-wt64nei4u6#api'); + }); + + test('omits app/ when AWS_SDK_UA_APP_ID empty (opt-out), keeps md/', async () => { + const ua = await captureUserAgent(''); + expect(ua).not.toContain('app/uksb-wt64nei4u6'); + expect(ua).toContain('md/uksb-wt64nei4u6#api'); + }); +}); + +describe('makeClient', () => { + it('spreads the md/ user-agent into the constructed client config', async () => { + const c = makeClient(S3Client, { region: 'us-east-1' }); + const cfg = c.config; + expect(await cfg.region()).toBe('us-east-1'); // caller opt preserved + expect((cfg as any).customUserAgent).toEqual(abcaUserAgent().customUserAgent); + }); + + it('defaults cfg to {} when omitted', () => { + expect(() => makeClient(S3Client)).not.toThrow(); + }); + + it('makeDocClient returns an attributed DynamoDBDocumentClient', () => { + const doc = makeDocClient({ region: 'us-east-1' }); + expect(doc).toBeInstanceOf(DynamoDBDocumentClient); + }); + + it('composes a caller-supplied customUserAgent instead of clobbering it', () => { + const c = makeClient(S3Client, { + region: 'us-east-1', + customUserAgent: [['caller/1.0', 'x']], + }); + // Caller's pair survives, ABCA md/ pair is appended (not overwritten). + expect((c.config as any).customUserAgent).toEqual([ + ['caller/1.0', 'x'], + ...abcaUserAgent().customUserAgent, + ]); + }); +}); diff --git a/cdk/test/stacks/agent.test.ts b/cdk/test/stacks/agent.test.ts index 2fa8d10b6..82cb22dfc 100644 --- a/cdk/test/stacks/agent.test.ts +++ b/cdk/test/stacks/agent.test.ts @@ -19,8 +19,9 @@ import * as fs from 'fs'; import * as path from 'path'; -import { App } from 'aws-cdk-lib'; +import { App, AspectPriority, Aspects } from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; +import { buildAppId, SolutionUaAspect } from '../../src/constructs/solution-ua-aspect'; import { AgentStack } from '../../src/stacks/agent'; describe('AgentStack', () => { @@ -749,3 +750,70 @@ describe('AgentStack with the ECS substrate gate (--context compute_type=ecs)', }); }); }); + +describe('AgentStack solution attribution (#319): AWS_SDK_UA_APP_ID via stack-level aspect', () => { + let template: Template; + + beforeAll(() => { + const app = new App(); + const stack = new AgentStack(app, 'UaAgentStack', { + env: { account: '123456789012', region: 'us-east-1' }, + }); + // Mirror main.ts: the SolutionUaAspect is applied at the stack scope, not + // inside AgentStack. It must reach every Lambda in the tree — including the + // ones nested several construct levels deep (integrations, orchestrator), + // not just functions declared directly under the stack. + Aspects.of(stack).add(new SolutionUaAspect(buildAppId('UaAgentStack')), { + priority: AspectPriority.MUTATING, + }); + template = Template.fromStack(stack); + }); + + // CDK synthesizes its own framework-owned Lambdas that are NOT part of the + // ABCA solution surface: the S3 auto-delete and VPC default-SG-restriction + // custom-resource provider handlers (CfnResource-backed, so the aspect's + // `instanceof lambda.Function` guard cannot visit them), plus the + // `AWS679f53fac002430cb0da5b7982bd2287…` `cr.AwsCustomResource` singleton + // (which CDK happens to give the env var today, but whose attribution we do + // not want to depend on across CDK upgrades). Every framework-owned id is + // enumerated explicitly so the coverage assertion below cannot silently + // stop covering an ABCA Lambda by relabelling it as "framework". + const FRAMEWORK_LAMBDA_ID = + /^(CustomResourceProviderHandler|CustomS3AutoDeleteObjects|CustomVpcRestrictDefaultSG|AWS679f53fac002430cb0da5b7982bd2287)/; + + test('every solution Lambda carries AWS_SDK_UA_APP_ID (traverses nested scope)', () => { + const functions = template.findResources('AWS::Lambda::Function'); + const abcaLambdas = Object.entries(functions).filter( + ([id]) => !FRAMEWORK_LAMBDA_ID.test(id), + ); + // exact count — update when adding/removing a Lambda construct (#319). + // A loose `toBeGreaterThan` let a whole integration construct disappear + // unnoticed; the exact count fails if a Lambda is dropped OR if a new one + // is added without being attributed below. + expect(abcaLambdas.length).toBe(45); + // Every ABCA-authored Lambda must carry the canonical `#` app-id. Collect + // any offenders so a failure names the exact logical id(s) that are naked. + const unattributed = abcaLambdas + .filter( + ([, fn]) => + fn.Properties?.Environment?.Variables?.AWS_SDK_UA_APP_ID !== + 'uksb-wt64nei4u6#UaAgentStack', + ) + .map(([id]) => id); + expect(unattributed).toEqual([]); + }); + + test('nested integration Lambdas (Jira/Slack/Linear) inherit the app-id', () => { + // The trap: these functions live inside integration constructs several + // scopes below the stack. The env-var still resolves the canonical `#` + // form (not the mangled `-` variant). + const functions = template.findResources('AWS::Lambda::Function'); + const nested = Object.entries(functions).filter(([id]) => + /Jira|Slack|Linear/.test(id), + ); + expect(nested.length).toBeGreaterThan(0); + for (const [, fn] of nested) { + expect(fn.Properties.Environment?.Variables?.AWS_SDK_UA_APP_ID).toBe('uksb-wt64nei4u6#UaAgentStack'); + } + }); +}); diff --git a/cli/AGENTS.md b/cli/AGENTS.md index a44f6da51..658eb68d8 100644 --- a/cli/AGENTS.md +++ b/cli/AGENTS.md @@ -83,3 +83,4 @@ test('renders snapshot from combined payload', async () => { - **API type drift** — Update both `cli/src/types.ts` and `cdk/src/handlers/shared/types.ts` in the same PR. See [cdk/AGENTS.md](../cdk/AGENTS.md). - **Exit code leaks** — Command tests must reset `process.exitCode` or Jest exits non-zero despite green assertions. +- **Un-attributed AWS SDK client (#319)** — build clients via `makeClient(Ctor, cfg)` / `makeDocClient(cfg)` from `src/ua.ts`; a naked `new XxxClient({})` silently drops solution attribution. diff --git a/cli/src/auth.ts b/cli/src/auth.ts index 091fc8a4d..1d725f535 100644 --- a/cli/src/auth.ts +++ b/cli/src/auth.ts @@ -26,6 +26,7 @@ import { loadConfig, loadCredentials, saveCredentials } from './config'; import { debug } from './debug'; import { CliError } from './errors'; import { Credentials } from './types'; +import { makeClient } from './ua'; const TOKEN_REFRESH_BUFFER_MINUTES = 5; const TOKEN_REFRESH_BUFFER_MS = TOKEN_REFRESH_BUFFER_MINUTES * 60 * 1000; @@ -45,7 +46,7 @@ let inFlightRefresh: Promise | null = null; export async function login(username: string, password: string): Promise { const config = loadConfig(); debug(`Cognito region: ${config.region}, client_id: ${config.client_id}, user_pool_id: ${config.user_pool_id}`); - const client = new CognitoIdentityProviderClient({ region: config.region }); + const client = makeClient(CognitoIdentityProviderClient, { region: config.region }); const result = await client.send(new InitiateAuthCommand({ AuthFlow: AuthFlowType.USER_PASSWORD_AUTH, @@ -121,7 +122,7 @@ function isExpired(creds: Credentials): boolean { async function refreshToken(creds: Credentials): Promise { const config = loadConfig(); - const client = new CognitoIdentityProviderClient({ region: config.region }); + const client = makeClient(CognitoIdentityProviderClient, { region: config.region }); try { const result = await client.send(new InitiateAuthCommand({ diff --git a/cli/src/bin/bgagent.ts b/cli/src/bin/bgagent.ts index 47a797cd7..5c9535f24 100644 --- a/cli/src/bin/bgagent.ts +++ b/cli/src/bin/bgagent.ts @@ -48,6 +48,7 @@ import { makeWatchCommand } from '../commands/watch'; import { makeWebhookCommand } from '../commands/webhook'; import { setVerbose } from '../debug'; import { CliError } from '../errors'; +import { applyDefaultAppId } from '../ua'; const program = new Command(); @@ -99,6 +100,10 @@ program.addCommand(makeAdminCommand()); // program object. Commands under ``cli/src/commands/*`` already export // ``makeXxxCommand()`` factories for direct invocation in tests. if (require.main === module) { + // Default the SDK solution-attribution app-id for this process (#319) before + // any AWS SDK client is constructed. Only sets it when unset, so an operator + // exporting AWS_SDK_UA_APP_ID='' (or any value) keeps full control. + applyDefaultAppId(); program .parseAsync(process.argv) .catch((err: unknown) => { diff --git a/cli/src/cognito-admin.ts b/cli/src/cognito-admin.ts index e72a387b9..ac37f39d7 100644 --- a/cli/src/cognito-admin.ts +++ b/cli/src/cognito-admin.ts @@ -31,6 +31,7 @@ import { CliError } from './errors'; import { DEFAULT_STACK_NAME, resolveOperatorContext } from './operator-context'; import { getStackOutput, resolveConfigureBundleFromStack } from './stack-outputs'; import { CliConfig } from './types'; +import { makeClient } from './ua'; export interface CognitoAdminContext { readonly region: string; @@ -95,7 +96,7 @@ async function resolveConfigureBundle( } export function cognitoClient(region: string): CognitoIdentityProviderClient { - return new CognitoIdentityProviderClient({ region }); + return makeClient(CognitoIdentityProviderClient, { region }); } /** Permissive email-shape check — Cognito does the real validation. */ diff --git a/cli/src/commands/github.ts b/cli/src/commands/github.ts index 783a84c4e..9542cce2e 100644 --- a/cli/src/commands/github.ts +++ b/cli/src/commands/github.ts @@ -33,6 +33,7 @@ import { import { DEFAULT_STACK_NAME } from '../operator-context'; import { promptSecret } from '../prompt-secret'; import { getStackOutput } from '../stack-outputs'; +import { makeClient } from '../ua'; /** Width of the `═` banner rules printed around webhook-info output. */ const BANNER_WIDTH = 72; @@ -123,7 +124,7 @@ export function makeGithubCommand(): Command { ); } - const sm = new SecretsManagerClient({ region }); + const sm = makeClient(SecretsManagerClient, { region }); // Show whether a secret is already configured so the operator // doesn't accidentally rotate it without realising. Linear's diff --git a/cli/src/commands/jira.ts b/cli/src/commands/jira.ts index f0ab69b12..6427c738c 100644 --- a/cli/src/commands/jira.ts +++ b/cli/src/commands/jira.ts @@ -20,7 +20,6 @@ import { execFile } from 'child_process'; import * as readline from 'readline'; import { CloudFormationClient, DescribeStacksCommand } from '@aws-sdk/client-cloudformation'; -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { CreateSecretCommand, GetSecretValueCommand, @@ -29,7 +28,6 @@ import { SecretsManagerClient, } from '@aws-sdk/client-secrets-manager'; import { - DynamoDBDocumentClient, GetCommand, PutCommand, UpdateCommand, @@ -59,6 +57,7 @@ import { } from '../jira-oauth'; import { awaitOauthCallback, CALLBACK_URL } from '../oauth-callback-server'; import { promptSecret } from '../prompt-secret'; +import { makeClient, makeDocClient } from '../ua'; /** Default label that triggers an ABCA task when applied to a Jira issue. */ const DEFAULT_LABEL_FILTER = 'bgagent'; @@ -481,7 +480,7 @@ function extractCognitoSub(): string { async function getStackOutput(region: string, stackName: string, outputKey: string): Promise { try { - const cfn = new CloudFormationClient({ region }); + const cfn = makeClient(CloudFormationClient, { region }); const result = await cfn.send(new DescribeStacksCommand({ StackName: stackName })); const outputs = result.Stacks?.[0]?.Outputs ?? []; const output = outputs.find((o) => o.OutputKey === outputKey); @@ -677,7 +676,7 @@ export function makeJiraCommand(): Command { // ─── Step 4: Persist token to per-tenant Secrets Manager ───────── process.stdout.write(' → Storing OAuth token...'); - const sm = new SecretsManagerClient({ region }); + const sm = makeClient(SecretsManagerClient, { region }); const now = new Date().toISOString(); const stored: StoredJiraOauthToken = { access_token: tokenResponse.access_token, @@ -697,7 +696,7 @@ export function makeJiraCommand(): Command { console.log(` ✓ (${secretName})`); // ─── Step 5: Persist registry row ──────────────────────────────── - const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region })); + const ddb = makeDocClient({ region }); // Update instead of replacing the row so re-running OAuth setup keeps // app-actor audit metadata written by `jira app-setup`. await ddb.send(new UpdateCommand({ @@ -824,7 +823,7 @@ export function makeJiraCommand(): Command { ); } - const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region })); + const ddb = makeDocClient({ region }); const registry = await ddb.send(new GetCommand({ TableName: registryTableName, Key: { jira_cloud_id: cloudId }, @@ -845,7 +844,7 @@ export function makeJiraCommand(): Command { } const proxyUrl = validateJiraAppActorProxyUrl(opts.proxyUrl); - const sm = new SecretsManagerClient({ region }); + const sm = makeClient(SecretsManagerClient, { region }); const secretResult = await sm.send(new GetSecretValueCommand({ SecretId: row.oauth_secret_arn as string, })); @@ -956,8 +955,8 @@ export function makeJiraCommand(): Command { } const callerCognitoSub = extractCognitoSub(); - const sm = new SecretsManagerClient({ region }); - const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region })); + const sm = makeClient(SecretsManagerClient, { region }); + const ddb = makeDocClient({ region }); const registry = await ddb.send(new GetCommand({ TableName: workspaceRegistryTable!, @@ -1162,7 +1161,7 @@ export function makeJiraCommand(): Command { const statusOnPr = opts.statusOnPr?.trim() || undefined; const now = new Date().toISOString(); - const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region })); + const ddb = makeDocClient({ region }); await ddb.send(new PutCommand({ TableName: tableName, Item: { diff --git a/cli/src/commands/linear.ts b/cli/src/commands/linear.ts index 57e4d0ac3..db8aad5f5 100644 --- a/cli/src/commands/linear.ts +++ b/cli/src/commands/linear.ts @@ -20,7 +20,6 @@ import { execFile } from 'child_process'; import * as readline from 'readline'; import { CloudFormationClient, DescribeStacksCommand } from '@aws-sdk/client-cloudformation'; -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { CreateSecretCommand, GetSecretValueCommand, @@ -49,6 +48,7 @@ import { } from '../linear-oauth'; import { awaitOauthCallback, CALLBACK_URL } from '../oauth-callback-server'; import { promptSecret } from '../prompt-secret'; +import { makeClient, makeDocClient } from '../ua'; /** Default label that triggers an ABCA task when applied to a Linear issue. */ const DEFAULT_LABEL_FILTER = 'bgagent'; @@ -615,7 +615,7 @@ export function makeLinearCommand(): Command { // ─── Step 4: Persist token to per-workspace Secrets Manager ─── process.stdout.write(' → Storing OAuth token...'); - const sm = new SecretsManagerClient({ region }); + const sm = makeClient(SecretsManagerClient, { region }); const now = new Date().toISOString(); // Preserve any EXISTING per-workspace webhook signing secret before the // OAuth overwrite below. Re-running `setup` on an already-installed @@ -688,7 +688,7 @@ export function makeLinearCommand(): Command { console.log(` ✓ (${secretName})`); // ─── Step 5: Persist registry + user-mapping rows ───────────── - const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region })); + const ddb = makeDocClient({ region }); // Best-effort: fetch team keys so the screenshot processor can // prefix-route Linear issue lookups (e.g. ENG-42 → the workspace @@ -917,8 +917,8 @@ export function makeLinearCommand(): Command { ); } - const sm = new SecretsManagerClient({ region }); - const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region })); + const sm = makeClient(SecretsManagerClient, { region }); + const ddb = makeDocClient({ region }); // ─── Linear OAuth app credentials ────────────────────────────── // Always prompt — never accept secrets via flags (shell history @@ -1192,7 +1192,7 @@ export function makeLinearCommand(): Command { const config = loadConfig(); const region = opts.region || config.region; - const sm = new SecretsManagerClient({ region }); + const sm = makeClient(SecretsManagerClient, { region }); const secretName = linearOauthSecretName(slug); // ─── Read existing bundle ─────────────────────────────────── @@ -1311,8 +1311,8 @@ export function makeLinearCommand(): Command { const callerCognitoSub = extractCognitoSub(); // ─── Resolve workspace + OAuth secret arn ────────────────────── - const sm = new SecretsManagerClient({ region }); - const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region })); + const sm = makeClient(SecretsManagerClient, { region }); + const ddb = makeDocClient({ region }); const registryScan = await ddb.send(new ScanCommand({ TableName: workspaceRegistryTable!, FilterExpression: 'workspace_slug = :slug AND #status = :active', @@ -1435,7 +1435,7 @@ export function makeLinearCommand(): Command { } const now = new Date().toISOString(); - const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region })); + const ddb = makeDocClient({ region }); await ddb.send(new PutCommand({ TableName: tableName, Item: { @@ -1466,7 +1466,7 @@ export function makeLinearCommand(): Command { .action(async (opts) => { const config = loadConfig(); const region = opts.region || config.region; - const sm = new SecretsManagerClient({ region }); + const sm = makeClient(SecretsManagerClient, { region }); // Resolve the set of workspace slugs to query. Either an // explicit `--slug` (one workspace) or every Linear workspace @@ -1917,7 +1917,7 @@ export async function autoLinkTokenOwner(args: { return; } - const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region: args.region })); + const ddb = makeDocClient({ region: args.region }); await ddb.send(new PutCommand({ TableName: args.userMappingTable, Item: { @@ -1956,7 +1956,7 @@ function extractCognitoSub(): string { async function getStackOutput(region: string, stackName: string, outputKey: string): Promise { try { - const cfn = new CloudFormationClient({ region }); + const cfn = makeClient(CloudFormationClient, { region }); const result = await cfn.send(new DescribeStacksCommand({ StackName: stackName })); const outputs = result.Stacks?.[0]?.Outputs ?? []; const output = outputs.find((o) => o.OutputKey === outputKey); diff --git a/cli/src/commands/slack.ts b/cli/src/commands/slack.ts index 6777025c3..893bd7840 100644 --- a/cli/src/commands/slack.ts +++ b/cli/src/commands/slack.ts @@ -22,14 +22,14 @@ import * as fs from 'fs'; import * as path from 'path'; import * as readline from 'readline'; import { CloudFormationClient, DescribeStacksCommand } from '@aws-sdk/client-cloudformation'; -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { PutSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; -import { DynamoDBDocumentClient, PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb'; +import { PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb'; import { Command } from 'commander'; import { ApiClient } from '../api-client'; import { loadConfig } from '../config'; import { formatJson } from '../format'; import { promptSecret } from '../prompt-secret'; +import { makeClient, makeDocClient } from '../ua'; export function makeSlackCommand(): Command { const slack = new Command('slack') @@ -192,7 +192,7 @@ export function makeSlackCommand(): Command { const teamId = await resolveSlackTeamId(region, installationTable, opts.teamId); const now = new Date().toISOString(); - const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region })); + const ddb = makeDocClient({ region }); await ddb.send(new PutCommand({ TableName: tableName, Item: { @@ -234,7 +234,7 @@ export async function resolveSlackTeamId( process.exit(1); } - const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region })); + const ddb = makeDocClient({ region }); const result = await ddb.send(new ScanCommand({ TableName: installationTable, FilterExpression: '#s = :active', @@ -311,7 +311,7 @@ async function promptAndStoreCredentials(region: string, arns: SecretArns): Prom // Store in Secrets Manager. console.log(''); - const sm = new SecretsManagerClient({ region }); + const sm = makeClient(SecretsManagerClient, { region }); const secrets = [ { id: arns.signingSecretArn, value: signingSecret, label: 'signing secret' }, @@ -389,7 +389,7 @@ function findRepoRoot(): string { async function getStackOutput(region: string, stackName: string, outputKey: string): Promise { try { - const cfn = new CloudFormationClient({ region }); + const cfn = makeClient(CloudFormationClient, { region }); const result = await cfn.send(new DescribeStacksCommand({ StackName: stackName })); const outputs = result.Stacks?.[0]?.Outputs ?? []; const output = outputs.find((o) => o.OutputKey === outputKey); diff --git a/cli/src/dynamo-clients.ts b/cli/src/dynamo-clients.ts index 54884bb46..c1363cd6b 100644 --- a/cli/src/dynamo-clients.ts +++ b/cli/src/dynamo-clients.ts @@ -19,13 +19,14 @@ import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { makeClient, makeDocClient } from './ua'; /** A region-scoped DynamoDB document client (marshalls native JS values). */ export function documentClient(region: string): DynamoDBDocumentClient { - return DynamoDBDocumentClient.from(new DynamoDBClient({ region })); + return makeDocClient({ region }); } /** A region-scoped low-level DynamoDB client (raw AttributeValue maps). */ export function lowLevelClient(region: string): DynamoDBClient { - return new DynamoDBClient({ region }); + return makeClient(DynamoDBClient, { region }); } diff --git a/cli/src/github-token.ts b/cli/src/github-token.ts index 534c8aca2..60b7c36d7 100644 --- a/cli/src/github-token.ts +++ b/cli/src/github-token.ts @@ -25,6 +25,7 @@ import { import { CliError } from './errors'; import { loadActiveRepoConfig } from './repo-lookup'; import { getStackOutput } from './stack-outputs'; +import { makeClient } from './ua'; export type GithubTokenSecretSource = 'explicit' | 'blueprint' | 'platform'; @@ -105,7 +106,7 @@ export async function isGithubTokenConfigured( region: string, secretArn: string, ): Promise { - const sm = new SecretsManagerClient({ region }); + const sm = makeClient(SecretsManagerClient, { region }); try { const cur = await sm.send(new GetSecretValueCommand({ SecretId: secretArn })); if (!cur.SecretString || cur.SecretString.length === 0) { @@ -130,7 +131,7 @@ export async function putGithubToken( secretArn: string, token: string, ): Promise { - const sm = new SecretsManagerClient({ region }); + const sm = makeClient(SecretsManagerClient, { region }); await sm.send(new PutSecretValueCommand({ SecretId: secretArn, SecretString: token, diff --git a/cli/src/linear-auth-health.ts b/cli/src/linear-auth-health.ts index 2c84d8cc2..570bb4767 100644 --- a/cli/src/linear-auth-health.ts +++ b/cli/src/linear-auth-health.ts @@ -49,6 +49,7 @@ import { import { ScanCommand } from '@aws-sdk/lib-dynamodb'; import { documentClient } from './dynamo-clients'; import { verifyLinearRefreshAndPersist } from './linear-oauth'; +import { makeClient } from './ua'; /** Linear's GraphQL endpoint — a cheap authenticated probe target. */ const LINEAR_GRAPHQL_ENDPOINT = 'https://api.linear.app/graphql'; @@ -235,7 +236,7 @@ export async function checkLinearWorkspaceAuth( exclusiveStartKey = page.LastEvaluatedKey; } while (exclusiveStartKey); - const sm = new SecretsManagerClient({ region }); + const sm = makeClient(SecretsManagerClient, { region }); const out: LinearWorkspaceAuthHealth[] = []; for (const row of rows) { @@ -359,7 +360,7 @@ function describeState(state: LinearAuthState, expiresAt?: string, revokedAt?: s * an error, never as health). */ export function makeLinearRefreshVerifier(region: string): LinearRefreshVerifier { - const sm = new SecretsManagerClient({ region }); + const sm = makeClient(SecretsManagerClient, { region }); return async ({ oauthSecretArn }) => verifyLinearRefreshAndPersist({ readSecret: async () => { const res = await sm.send(new GetSecretValueCommand({ SecretId: oauthSecretArn })); diff --git a/cli/src/platform-doctor.ts b/cli/src/platform-doctor.ts index f7fd80cd7..b68b816cf 100644 --- a/cli/src/platform-doctor.ts +++ b/cli/src/platform-doctor.ts @@ -28,6 +28,7 @@ import { checkLinearWorkspaceAuth, type LinearProbe, type LinearRefreshVerifier import { PLATFORM_REPO_DEFAULTS } from './repo-display'; import { countActiveRepos } from './repo-lookup'; import { getStackOutput } from './stack-outputs'; +import { makeClient } from './ua'; /** * Default foundation model checked when no onboarded repo specifies model_id. @@ -146,7 +147,7 @@ async function checkCognitoConfig( }; } - const cognito = new CognitoIdentityProviderClient({ region }); + const cognito = makeClient(CognitoIdentityProviderClient, { region }); try { await cognito.send(new DescribeUserPoolCommand({ UserPoolId: userPoolId })); await cognito.send(new DescribeUserPoolClientCommand({ @@ -225,7 +226,7 @@ async function checkActiveRepos( async function checkBedrockModel(region: string, modelId: string): Promise { const id = 'bedrock_model'; const label = `Bedrock model catalog (${modelId})`; - const bedrock = new BedrockClient({ region }); + const bedrock = makeClient(BedrockClient, { region }); try { await bedrock.send(new GetFoundationModelCommand({ modelIdentifier: modelId })); return { diff --git a/cli/src/runtime-status.ts b/cli/src/runtime-status.ts index e005df2c6..f1bfccc88 100644 --- a/cli/src/runtime-status.ts +++ b/cli/src/runtime-status.ts @@ -23,6 +23,7 @@ import { } from '@aws-sdk/client-bedrock-agentcore-control'; import { PLATFORM_REPO_DEFAULTS } from './repo-display'; import { listRepoConfigs, RepoConfigRow } from './repo-lookup'; +import { makeClient } from './ua'; export interface BlueprintRuntimeBinding { readonly repo: string; @@ -108,7 +109,7 @@ async function probeAgentCoreRuntime( ): Promise { try { const { agentRuntimeId, agentRuntimeVersion } = parseAgentRuntimeArn(runtimeArn); - const client = new BedrockAgentCoreControlClient({ region }); + const client = makeClient(BedrockAgentCoreControlClient, { region }); const response = await client.send(new GetAgentRuntimeCommand({ agentRuntimeId, agentRuntimeVersion, diff --git a/cli/src/stack-outputs.ts b/cli/src/stack-outputs.ts index 7cd33d64d..699cf1ae5 100644 --- a/cli/src/stack-outputs.ts +++ b/cli/src/stack-outputs.ts @@ -20,6 +20,7 @@ import { CloudFormationClient, DescribeStacksCommand } from '@aws-sdk/client-cloudformation'; import { CliError } from './errors'; import { CliConfig } from './types'; +import { makeClient } from './ua'; export interface StackOutputEntry { readonly key: string; @@ -39,7 +40,7 @@ export function resolveOperatorRegion(opts: { region?: string }, configuredRegio } async function describeStack(region: string, stackName: string) { - const cf = new CloudFormationClient({ region }); + const cf = makeClient(CloudFormationClient, { region }); try { const result = await cf.send(new DescribeStacksCommand({ StackName: stackName })); const stack = result.Stacks?.[0]; diff --git a/cli/src/ua.ts b/cli/src/ua.ts new file mode 100644 index 000000000..3a6d68765 --- /dev/null +++ b/cli/src/ua.ts @@ -0,0 +1,108 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Outbound AWS SDK User-Agent solution attribution (#319) — CLI surface. + * + * Every AWS API call made by the `bgagent` CLI carries two ABCA + * solution-attribution segments in the `User-Agent` header: + * + * app/uksb-wt64nei4u6 <- native AWS_SDK_UA_APP_ID env (defaulted below) + * md/uksb-wt64nei4u6#cli <- static, baked once at construction + * + * **The `app/` segment is emitted by the SDK itself** from the + * `AWS_SDK_UA_APP_ID` environment variable (read natively by JS v3). The CLI + * has no deploy-time env wiring, so {@link applyDefaultAppId} sets a default + * value at process startup — but only when the env var is unset, so an + * operator who exports `AWS_SDK_UA_APP_ID=''` (or any other value) keeps full + * control and can opt out. + * + * This module otherwise owns only the **static `md/` segment** — a stable + * `cli` label baked once via `customUserAgent` at client construction. No + * per-request trace, no middleware. + * + * Counterparts: `agent/src/ua.py` and `cdk/src/handlers/shared/ua.ts`. + * Solution id, wire format, and sanitization rules must stay identical. + */ + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; + +/** AWS solution-attribution id for ABCA. Per-surface literal by design. */ +export const SOLUTION_ID = 'uksb-wt64nei4u6'; + +/** Stable per-component label: this surface IS the bgagent CLI. */ +export const COMPONENT = 'cli'; + +/** Standard AWS SDK env var the JS v3 SDK reads natively for the `app/` segment. */ +export const APP_ID_ENV = 'AWS_SDK_UA_APP_ID'; + +/** + * RFC 7230 token charset. `#` is the scheme's structural separator and is + * deliberately excluded. Mirrors `_ALLOWED` in `agent/src/ua.py`. + */ +const UA_TOKEN_SAFE = /[^A-Za-z0-9!$%&'*+\-.^_`|~]/g; + +/** Replace every non-UA-token char (incl. non-ASCII) with `-`. */ +export function sanitizeUaValue(raw: string): string { + return raw.replace(UA_TOKEN_SAFE, '-'); +} + +/** + * Set `AWS_SDK_UA_APP_ID` to the ABCA solution id when the operator has not + * already set it. Called once at CLI startup. Never overrides an existing + * value — including an explicit empty string, which is a deliberate opt-out. + */ +export function applyDefaultAppId(): void { + if (process.env[APP_ID_ENV] === undefined) { + process.env[APP_ID_ENV] = SOLUTION_ID; + } +} + +/** + * Client config fragment carrying the static ABCA `md/` segment. Spread into + * any SDK v3 client constructor: `new CognitoIdentityProviderClient({ region, + * ...abcaUserAgent() })`. Renders `md/uksb-wt64nei4u6#cli`. + */ +export function abcaUserAgent(): { customUserAgent: [string, string][] } { + return { customUserAgent: [[`md/${SOLUTION_ID}`, sanitizeUaValue(COMPONENT)]] }; +} + +/** + * The single attributed way to construct an AWS SDK v3 client. Composes the + * static `md/` segment ({@link abcaUserAgent}) into the client config so + * omission is impossible at the call site. Caller-supplied opts (region, + * timeouts) are preserved — and a caller who supplies their own + * `customUserAgent` pairs keeps them, with the ABCA `md/` pair appended rather + * than overwritten (the SDK renders all pairs). No call site sets + * `customUserAgent` today, so the merge is latent hardening; it keeps the + * factory composable instead of clobbering. (#319) + */ +export function makeClient( + Ctor: new (cfg: any) => C, + cfg: Record = {}, +): C { + const callerUa = (cfg.customUserAgent as [string, string][] | undefined) ?? []; + return new Ctor({ ...cfg, customUserAgent: [...callerUa, ...abcaUserAgent().customUserAgent] }); +} + +/** Attributed `DynamoDBDocumentClient` — the wrapper form, in one call. */ +export function makeDocClient(cfg: Record = {}): DynamoDBDocumentClient { + return DynamoDBDocumentClient.from(makeClient(DynamoDBClient, cfg)); +} diff --git a/cli/src/webhook-test.ts b/cli/src/webhook-test.ts index 916a628f0..1d94ca688 100644 --- a/cli/src/webhook-test.ts +++ b/cli/src/webhook-test.ts @@ -21,6 +21,7 @@ import * as crypto from 'crypto'; import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; import { ApiError, CliError } from './errors'; import type { CreateTaskRequest, CreateTaskResponse, SuccessResponse } from './types'; +import { makeClient } from './ua'; export const WEBHOOK_SECRET_PREFIX = 'bgagent/webhook/'; @@ -43,7 +44,7 @@ export function signWebhookBody(secret: string, body: string): string { /** Fetch webhook HMAC secret from Secrets Manager (operator credentials). */ export async function fetchWebhookSecret(region: string, webhookId: string): Promise { - const sm = new SecretsManagerClient({ region }); + const sm = makeClient(SecretsManagerClient, { region }); let result; try { result = await sm.send(new GetSecretValueCommand({ diff --git a/cli/test/auth.test.ts b/cli/test/auth.test.ts index 24ab4fecd..b28582d8b 100644 --- a/cli/test/auth.test.ts +++ b/cli/test/auth.test.ts @@ -20,6 +20,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { CognitoIdentityProviderClient } from '@aws-sdk/client-cognito-identity-provider'; import { getAuthToken, login } from '../src/auth'; import { saveConfig, saveCredentials } from '../src/config'; @@ -72,6 +73,22 @@ describe('auth', () => { expect(creds.token_expiry).toBeDefined(); }); + test('constructs the Cognito client with the ABCA solution User-Agent (#319)', async () => { + mockSend.mockResolvedValue({ + AuthenticationResult: { + IdToken: 'id-token-123', + RefreshToken: 'refresh-token-123', + ExpiresIn: 3600, + }, + }); + + await login('user@example.com', 'password123'); + + const calls = (CognitoIdentityProviderClient as unknown as jest.Mock).mock.calls; + const ctorArg = calls[calls.length - 1][0]; + expect(ctorArg.customUserAgent).toEqual([['md/uksb-wt64nei4u6', 'cli']]); + }); + test('throws on missing auth result', async () => { mockSend.mockResolvedValue({ AuthenticationResult: null }); await expect(login('user@example.com', 'pass')).rejects.toThrow('Unexpected authentication response'); diff --git a/cli/test/ua.test.ts b/cli/test/ua.test.ts new file mode 100644 index 000000000..91990508b --- /dev/null +++ b/cli/test/ua.test.ts @@ -0,0 +1,134 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { CloudFormationClient } from '@aws-sdk/client-cloudformation'; +import { CognitoIdentityProviderClient, ListUsersCommand } from '@aws-sdk/client-cognito-identity-provider'; +import { abcaUserAgent, applyDefaultAppId, APP_ID_ENV, makeClient, makeDocClient, sanitizeUaValue, SOLUTION_ID } from '../src/ua'; + +describe('sanitizeUaValue', () => { + test.each([ + ['cli', 'cli'], + ['a/b', 'a-b'], + ['a#b', 'a-b'], + ['héllo', 'h-llo'], + ])('sanitizes %p -> %p', (raw, expected) => { + expect(sanitizeUaValue(raw)).toBe(expected); + }); +}); + +describe('abcaUserAgent', () => { + test('emits the static cli md/ segment', () => { + expect(abcaUserAgent()).toEqual({ customUserAgent: [['md/uksb-wt64nei4u6', 'cli']] }); + }); +}); + +describe('applyDefaultAppId', () => { + const prev = process.env[APP_ID_ENV]; + afterEach(() => { + if (prev === undefined) delete process.env[APP_ID_ENV]; + else process.env[APP_ID_ENV] = prev; + }); + + test('sets the solution id when env unset', () => { + delete process.env[APP_ID_ENV]; + applyDefaultAppId(); + expect(process.env[APP_ID_ENV]).toBe(SOLUTION_ID); + }); + + test('never overrides an existing value', () => { + process.env[APP_ID_ENV] = 'customer-value'; + applyDefaultAppId(); + expect(process.env[APP_ID_ENV]).toBe('customer-value'); + }); + + test('respects an explicit empty-string opt-out', () => { + process.env[APP_ID_ENV] = ''; + applyDefaultAppId(); + expect(process.env[APP_ID_ENV]).toBe(''); + }); +}); + +describe('wire-capture: emitted User-Agent header', () => { + async function captureUserAgent(appId?: string): Promise { + const prevAppId = process.env[APP_ID_ENV]; + if (appId === undefined) delete process.env[APP_ID_ENV]; + else process.env[APP_ID_ENV] = appId; + + let captured = ''; + const client = new CognitoIdentityProviderClient({ + region: 'us-east-1', + credentials: { accessKeyId: 'x', secretAccessKey: 'y' }, + ...abcaUserAgent(), + requestHandler: { + async handle(request: { headers: Record }) { + captured = request.headers['user-agent'] ?? request.headers['User-Agent'] ?? ''; + return { response: { statusCode: 200, headers: {}, body: undefined } }; + }, + updateHttpClientConfig() {}, + httpHandlerConfigs() { + return {}; + }, + } as never, + }); + + try { + // Drives the middleware stack; the stub captures the header before the + // (invalid) response triggers a deserialization error we ignore. + await client.send(new ListUsersCommand({ UserPoolId: 'x' })); + } catch { + // stub body is not a valid protocol response — we only want the header + } finally { + if (prevAppId === undefined) delete process.env[APP_ID_ENV]; + else process.env[APP_ID_ENV] = prevAppId; + } + return captured; + } + + test('carries both app/ and md/ segments when AWS_SDK_UA_APP_ID set', async () => { + const ua = await captureUserAgent('uksb-wt64nei4u6'); + expect(ua).toContain('app/uksb-wt64nei4u6'); + expect(ua).toContain('md/uksb-wt64nei4u6#cli'); + }); + + test('omits app/ when AWS_SDK_UA_APP_ID empty, keeps md/', async () => { + const ua = await captureUserAgent(''); + expect(ua).not.toContain('app/uksb-wt64nei4u6'); + expect(ua).toContain('md/uksb-wt64nei4u6#cli'); + }); +}); + +describe('makeClient (cli)', () => { + it('spreads md/ UA into client config', () => { + const c = makeClient(CloudFormationClient, { region: 'us-east-1' }); + expect((c.config as any).customUserAgent).toEqual(abcaUserAgent().customUserAgent); + }); + it('makeDocClient is attributed', () => { + expect(() => makeDocClient({ region: 'us-east-1' })).not.toThrow(); + }); + it('composes a caller-supplied customUserAgent instead of clobbering it', () => { + const c = makeClient(CloudFormationClient, { + region: 'us-east-1', + customUserAgent: [['caller/1.0', 'x']], + }); + expect((c.config as any).customUserAgent).toEqual([ + ['caller/1.0', 'x'], + ...abcaUserAgent().customUserAgent, + ]); + }); +});