diff --git a/AGENTS.md b/AGENTS.md index 8c796abd3..c8d6c8908 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`). Carry the `md/` label explicitly: `agent/src/` via `aws_session.tenant_client`/`tenant_resource`/`platform_client` (never naked `boto3.client(...)`); `cdk/src/handlers/` and `cli/src/` spread `...abcaUserAgent()`. 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. - **Package-specific pitfalls** — API type drift, CDK test bundling, Cedar parity, generated docs: see package `AGENTS.md` files. ## Tech stack diff --git a/agent/src/aws_session.py b/agent/src/aws_session.py index 51c022494..1f8a33b90 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,51 @@ 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 ua_config's user_agent_extra win outright; instead + # keep both by combining them into one extra before merging. + combined = f"{caller_extra} {ua.static_user_agent_extra()}" + ua_config = 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 +312,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/config.py b/agent/src/config.py index 523f3174d..b8b0445a8 100644 --- a/agent/src/config.py +++ b/agent/src/config.py @@ -40,10 +40,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 @@ -101,14 +101,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 MCP; 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. 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 9045716a4..69d6c69b3 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 79411ed24..dc571cab2 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 c57b1e23d..2621dd976 100644 --- a/agent/tests/test_aws_session.py +++ b/agent/tests/test_aws_session.py @@ -318,3 +318,79 @@ 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_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_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/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 f21a3f163..2c49b0607 100644 --- a/cdk/src/constructs/ecs-agent-cluster.ts +++ b/cdk/src/constructs/ecs-agent-cluster.ts @@ -31,6 +31,7 @@ import { Construct } 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; @@ -169,6 +170,15 @@ 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. + // `-c sdkUaAppId=''` opts out (buildAppId → undefined → omitted). + const sdkUaAppId = buildAppId( + Stack.of(this).stackName, + this.node.tryGetContext('sdkUaAppId') as string | undefined, + ); + // Container this.taskDefinition.addContainer(this.containerName, { image: ecs.ContainerImage.fromDockerImageAsset(props.agentImageAsset), @@ -207,6 +217,7 @@ export class EcsAgentCluster extends Construct { ...(props.agentSessionRole && { AGENT_SESSION_ROLE_ARN: props.agentSessionRole.role.roleArn, }), + ...(sdkUaAppId ? { AWS_SDK_UA_APP_ID: sdkUaAppId } : {}), }, }); 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 b48c70864..a361c20a3 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; @@ -131,6 +132,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 149bc20ef..c868ac47e 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 d51e043b5..eec37ba81 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'; @@ -31,6 +31,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; @@ -118,6 +119,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 4ad7f5200..b2abb9fb5 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'; @@ -30,6 +30,7 @@ import { NagSuppressions } from 'cdk-nag'; import { Construct } from 'constructs'; 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; @@ -115,6 +116,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..c626dffed --- /dev/null +++ b/cdk/src/constructs/solution-ua-aspect.ts @@ -0,0 +1,116 @@ +/** + * 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('#'); +} + +/** + * 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 : sanitizeAppId(trimmed).slice(0, APP_ID_MAX_LEN); + } + const value = `${SOLUTION_ID}#${stackName.replace(UA_TOKEN_UNSAFE, '-')}`; + return value.slice(0, APP_ID_MAX_LEN); +} + +/** + * 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 305285315..a2b8b1526 100644 --- a/cdk/src/constructs/task-api.ts +++ b/cdk/src/constructs/task-api.ts @@ -515,6 +515,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 @@ -1037,6 +1041,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 --- @@ -1050,6 +1058,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, }); @@ -1119,6 +1129,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) --- @@ -1160,12 +1173,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 a638b369f..c91ff3504 100644 --- a/cdk/src/constructs/task-orchestrator.ts +++ b/cdk/src/constructs/task-orchestrator.ts @@ -252,6 +252,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/approve-task.ts b/cdk/src/handlers/approve-task.ts index d99ff8308..ad5a290a4 100644 --- a/cdk/src/handlers/approve-task.ts +++ b/cdk/src/handlers/approve-task.ts @@ -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 { abcaUserAgent } from './shared/ua'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); 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..3181b8c90 100644 --- a/cdk/src/handlers/cancel-task.ts +++ b/cdk/src/handlers/cancel-task.ts @@ -28,11 +28,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 { abcaUserAgent } from './shared/ua'; import { computeTtlEpoch } from './shared/validation'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); -const agentCoreClient = new BedrockAgentCoreClient({}); -const ecsClient = new ECSClient({}); +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); +const agentCoreClient = new BedrockAgentCoreClient({ ...abcaUserAgent() }); +const ecsClient = new ECSClient({ ...abcaUserAgent() }); 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..e6e044aae 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 { abcaUserAgent } from './shared/ua'; -const ddb = new DynamoDBClient({}); -const s3 = new S3Client({}); +const ddb = new DynamoDBClient({ ...abcaUserAgent() }); +const s3 = new S3Client({ ...abcaUserAgent() }); 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 a45da3444..121673ab6 100644 --- a/cdk/src/handlers/confirm-uploads.ts +++ b/cdk/src/handlers/confirm-uploads.ts @@ -35,11 +35,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 { abcaUserAgent } from './shared/ua'; import { computeTtlEpoch } 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 = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); +const s3Client = new S3Client({ ...abcaUserAgent() }); +const lambdaClient = process.env.ORCHESTRATOR_FUNCTION_ARN ? new LambdaClient({ ...abcaUserAgent() }) : undefined; const TABLE_NAME = process.env.TASK_TABLE_NAME!; const EVENTS_TABLE_NAME = process.env.TASK_EVENTS_TABLE_NAME!; @@ -697,7 +698,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 = new BedrockRuntimeClient({ ...abcaUserAgent() }); } return { guardrailId: process.env.GUARDRAIL_ID, diff --git a/cdk/src/handlers/create-webhook.ts b/cdk/src/handlers/create-webhook.ts index d32a9de54..d94fc6f91 100644 --- a/cdk/src/handlers/create-webhook.ts +++ b/cdk/src/handlers/create-webhook.ts @@ -27,10 +27,11 @@ 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 { abcaUserAgent } from './shared/ua'; import { isValidWebhookName, parseBody } from './shared/validation'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); -const sm = new SecretsManagerClient({}); +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); +const sm = new SecretsManagerClient({ ...abcaUserAgent() }); const TABLE_NAME = process.env.WEBHOOK_TABLE_NAME!; const SECRET_PREFIX = 'bgagent/webhook/'; diff --git a/cdk/src/handlers/delete-webhook.ts b/cdk/src/handlers/delete-webhook.ts index 7ac52ee1a..619301986 100644 --- a/cdk/src/handlers/delete-webhook.ts +++ b/cdk/src/handlers/delete-webhook.ts @@ -26,10 +26,11 @@ 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 { abcaUserAgent } from './shared/ua'; import { computeTtlEpoch } from './shared/validation'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); -const sm = new SecretsManagerClient({}); +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); +const sm = new SecretsManagerClient({ ...abcaUserAgent() }); 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..e93eb06c9 100644 --- a/cdk/src/handlers/deny-task.ts +++ b/cdk/src/handlers/deny-task.ts @@ -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 { abcaUserAgent } from './shared/ua'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); 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 bda4c35e5..4a6173787 100644 --- a/cdk/src/handlers/fanout-task-events.ts +++ b/cdk/src/handlers/fanout-task-events.ts @@ -59,6 +59,7 @@ import { logger } from './shared/logger'; import { coerceNumericOrNull } from './shared/numeric'; import { loadRepoConfig } from './shared/repo-config'; import type { ChannelConfig, TaskNotificationsConfig, TaskRecord } from './shared/types'; +import { abcaUserAgent } from './shared/ua'; import { dispatchSlackEvent, SlackApiError } from './slack-notify'; // Re-export the shared types so existing test imports (and any future @@ -388,7 +389,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 = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); /** * 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..051ce272a 100644 --- a/cdk/src/handlers/get-pending.ts +++ b/cdk/src/handlers/get-pending.ts @@ -26,8 +26,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 { abcaUserAgent } from './shared/ua'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); 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..8ed009d43 100644 --- a/cdk/src/handlers/get-policies.ts +++ b/cdk/src/handlers/get-policies.ts @@ -32,8 +32,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 { abcaUserAgent } from './shared/ua'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); 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..ae57f7091 100644 --- a/cdk/src/handlers/get-task-events.ts +++ b/cdk/src/handlers/get-task-events.ts @@ -25,6 +25,7 @@ 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 { abcaUserAgent } from './shared/ua'; import { decodePaginationToken, encodePaginationToken, @@ -33,7 +34,7 @@ import { ULID_LENGTH, } from './shared/validation'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); 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.ts b/cdk/src/handlers/get-task.ts index 25c51ac51..6c05e779c 100644 --- a/cdk/src/handlers/get-task.ts +++ b/cdk/src/handlers/get-task.ts @@ -25,8 +25,9 @@ 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 { abcaUserAgent } from './shared/ua'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); 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..88b2b707a 100644 --- a/cdk/src/handlers/get-trace-url.ts +++ b/cdk/src/handlers/get-trace-url.ts @@ -28,9 +28,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 { abcaUserAgent } from './shared/ua'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); -const s3 = new S3Client({}); +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); +const s3 = new S3Client({ ...abcaUserAgent() }); 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 a205a051d..e83c2c92b 100644 --- a/cdk/src/handlers/github-webhook-processor.ts +++ b/cdk/src/handlers/github-webhook-processor.ts @@ -29,8 +29,9 @@ import { postIssueComment } from './shared/linear-feedback'; import { extractLinearIdentifier, findLinearIssueByIdentifier } from './shared/linear-issue-lookup'; import { logger } from './shared/logger'; import { buildScreenshotKey, encodeMarkdownUrl, isAllowedScreenshotUrl } from './shared/screenshot-url'; +import { abcaUserAgent } from './shared/ua'; -const s3 = new S3Client({}); +const s3 = new S3Client({ ...abcaUserAgent() }); const SCREENSHOT_BUCKET = process.env.SCREENSHOT_BUCKET_NAME!; // CloudFront distribution domain — `.cloudfront.net`. Used as diff --git a/cdk/src/handlers/github-webhook.ts b/cdk/src/handlers/github-webhook.ts index 82533863a..808a3d739 100644 --- a/cdk/src/handlers/github-webhook.ts +++ b/cdk/src/handlers/github-webhook.ts @@ -27,9 +27,10 @@ import { } from './shared/github-deployment-status'; import { verifyGitHubRequest } from './shared/github-webhook-verify'; import { logger } from './shared/logger'; +import { abcaUserAgent } from './shared/ua'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); -const lambdaClient = new LambdaClient({}); +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); +const lambdaClient = new LambdaClient({ ...abcaUserAgent() }); 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/jira-webhook-processor.ts b/cdk/src/handlers/jira-webhook-processor.ts index 5b50a7de1..5cba51c40 100644 --- a/cdk/src/handlers/jira-webhook-processor.ts +++ b/cdk/src/handlers/jira-webhook-processor.ts @@ -46,10 +46,11 @@ import { } from './shared/jira-task-by-issue'; import { logger } from './shared/logger'; import type { Attachment, PassedAttachmentRecord } from './shared/types'; +import { abcaUserAgent } 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 = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); const PROJECT_MAPPING_TABLE = process.env.JIRA_PROJECT_MAPPING_TABLE_NAME!; const USER_MAPPING_TABLE = process.env.JIRA_USER_MAPPING_TABLE_NAME!; @@ -68,8 +69,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 ? new S3Client({ ...abcaUserAgent() }) : undefined; +const bedrockClient = GUARDRAIL_ID && GUARDRAIL_VERSION ? new BedrockRuntimeClient({ ...abcaUserAgent() }) : undefined; const screeningConfig: ScreeningConfig | undefined = bedrockClient && GUARDRAIL_ID && GUARDRAIL_VERSION ? { bedrockClient, guardrailId: GUARDRAIL_ID, guardrailVersion: GUARDRAIL_VERSION } diff --git a/cdk/src/handlers/linear-link.ts b/cdk/src/handlers/linear-link.ts index 41a480d39..ce478d8b1 100644 --- a/cdk/src/handlers/linear-link.ts +++ b/cdk/src/handlers/linear-link.ts @@ -24,9 +24,10 @@ import { ulid } from 'ulid'; import { extractUserId } from './shared/gateway'; import { logger } from './shared/logger'; import { ErrorCode, errorResponse, successResponse } from './shared/response'; +import { abcaUserAgent } from './shared/ua'; import { parseBody } from './shared/validation'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); 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 c290cd03a..05439a942 100644 --- a/cdk/src/handlers/linear-webhook-processor.ts +++ b/cdk/src/handlers/linear-webhook-processor.ts @@ -25,9 +25,10 @@ import { reportIssueFailure } from './shared/linear-feedback'; import { resolveLinearOauthToken } from './shared/linear-oauth-resolver'; import { logger } from './shared/logger'; import type { Attachment } from './shared/types'; +import { abcaUserAgent } from './shared/ua'; import { CODING_WORKFLOW_ID } from './shared/workflows'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); const PROJECT_MAPPING_TABLE = process.env.LINEAR_PROJECT_MAPPING_TABLE_NAME!; const USER_MAPPING_TABLE = process.env.LINEAR_USER_MAPPING_TABLE_NAME!; diff --git a/cdk/src/handlers/linear-webhook.ts b/cdk/src/handlers/linear-webhook.ts index 33f870ba7..a189f55aa 100644 --- a/cdk/src/handlers/linear-webhook.ts +++ b/cdk/src/handlers/linear-webhook.ts @@ -27,9 +27,10 @@ import { verifyLinearRequestForWorkspace, } from './shared/linear-verify'; import { logger } from './shared/logger'; +import { abcaUserAgent } from './shared/ua'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); -const lambdaClient = new LambdaClient({}); +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); +const lambdaClient = new LambdaClient({ ...abcaUserAgent() }); 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-tasks.ts b/cdk/src/handlers/list-tasks.ts index 6527bf183..491af3979 100644 --- a/cdk/src/handlers/list-tasks.ts +++ b/cdk/src/handlers/list-tasks.ts @@ -25,9 +25,10 @@ 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 { abcaUserAgent } from './shared/ua'; import { decodePaginationToken, encodePaginationToken, parseLimit, parseStatusFilter } from './shared/validation'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); 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..e49a2cb70 100644 --- a/cdk/src/handlers/list-webhooks.ts +++ b/cdk/src/handlers/list-webhooks.ts @@ -25,9 +25,10 @@ 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 { abcaUserAgent } from './shared/ua'; import { decodePaginationToken, encodePaginationToken, parseLimit } from './shared/validation'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); 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..59438cd50 100644 --- a/cdk/src/handlers/nudge-task.ts +++ b/cdk/src/handlers/nudge-task.ts @@ -28,8 +28,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 { abcaUserAgent } from './shared/ua'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); 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/reconcile-concurrency.ts b/cdk/src/handlers/reconcile-concurrency.ts index 2f7165146..7c9cf9bbe 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 { abcaUserAgent } from './shared/ua'; -const ddb = new DynamoDBClient({}); +const ddb = new DynamoDBClient({ ...abcaUserAgent() }); 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-tasks.ts b/cdk/src/handlers/reconcile-stranded-tasks.ts index 8688601f3..095a82efa 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 { abcaUserAgent } from './shared/ua'; -const ddb = new DynamoDBClient({}); +const ddb = new DynamoDBClient({ ...abcaUserAgent() }); 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..79cefbaea 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 { abcaUserAgent } 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 = new BedrockAgentCoreClient({ region: REGION, ...abcaUserAgent() }); 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..c332de1e4 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 { abcaUserAgent } 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) ? new BedrockRuntimeClient({ ...abcaUserAgent() }) : 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 = new SecretsManagerClient({ ...abcaUserAgent() }); /** * 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 680aadf80..b19186db4 100644 --- a/cdk/src/handlers/shared/create-task-core.ts +++ b/cdk/src/handlers/shared/create-task-core.ts @@ -53,6 +53,7 @@ import { type TaskRecord, toTaskDetail, } from './types'; +import { abcaUserAgent } from './ua'; import { computeTtlEpoch, hasTaskSpec, isValidIdempotencyKey, isValidRepo, isValidTaskDescriptionLength, MAX_ATTACHMENT_SIZE_BYTES, MAX_TASK_DESCRIPTION_LENGTH, 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 +91,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 = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); +const lambdaClient = process.env.ORCHESTRATOR_FUNCTION_ARN ? new LambdaClient({ ...abcaUserAgent() }) : undefined; const bedrockClient = (process.env.GUARDRAIL_ID && process.env.GUARDRAIL_VERSION) - ? new BedrockRuntimeClient({}) : undefined; + ? new BedrockRuntimeClient({ ...abcaUserAgent() }) : 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 +104,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 ? new S3Client({ ...abcaUserAgent() }) : 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..42fdd0a77 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 { abcaUserAgent } from './ua'; -const sm = new SecretsManagerClient({}); +const sm = new SecretsManagerClient({ ...abcaUserAgent() }); /** * In-memory secret cache (5-minute TTL). Same pattern as `linear-verify.ts` diff --git a/cdk/src/handlers/shared/linear-issue-lookup.ts b/cdk/src/handlers/shared/linear-issue-lookup.ts index b23738875..44885e143 100644 --- a/cdk/src/handlers/shared/linear-issue-lookup.ts +++ b/cdk/src/handlers/shared/linear-issue-lookup.ts @@ -21,8 +21,9 @@ import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocumentClient, ScanCommand } from '@aws-sdk/lib-dynamodb'; import { resolveLinearOauthToken } from './linear-oauth-resolver'; import { logger } from './logger'; +import { abcaUserAgent } from './ua'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); /** * Linear issue identifier shape, e.g. `ABCA-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 48cc6f895..8e719a5f8 100644 --- a/cdk/src/handlers/shared/linear-oauth-resolver.ts +++ b/cdk/src/handlers/shared/linear-oauth-resolver.ts @@ -25,6 +25,7 @@ import { } from '@aws-sdk/client-secrets-manager'; import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb'; import { logger } from './logger'; +import { abcaUserAgent } from './ua'; /** * Lambda-side resolver for the per-workspace Linear OAuth token written @@ -152,8 +153,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 ?? DynamoDBDocumentClient.from(new DynamoDBClient({ region, ...abcaUserAgent() })); + const sm = options.secretsManagerClient ?? new SecretsManagerClient({ region, ...abcaUserAgent() }); // ─── 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..bb22d3943 100644 --- a/cdk/src/handlers/shared/linear-verify.ts +++ b/cdk/src/handlers/shared/linear-verify.ts @@ -24,9 +24,10 @@ import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; import { isUsableHmacSecret } from './hmac-secret'; import { getOauthSecretStrict, getRegistryRowStrict } from './linear-oauth-resolver'; import { logger } from './logger'; +import { abcaUserAgent } from './ua'; -const sm = new SecretsManagerClient({}); -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const sm = new SecretsManagerClient({ ...abcaUserAgent() }); +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); // 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..b92c790c7 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 { abcaUserAgent } 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 = new BedrockAgentCoreClient({ ...abcaUserAgent() }); } return agentCoreClient; } diff --git a/cdk/src/handlers/shared/orchestrator.ts b/cdk/src/handlers/shared/orchestrator.ts index 7308c4532..9571b5aae 100644 --- a/cdk/src/handlers/shared/orchestrator.ts +++ b/cdk/src/handlers/shared/orchestrator.ts @@ -29,10 +29,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 { abcaUserAgent } 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 = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); const TABLE_NAME = process.env.TASK_TABLE_NAME!; const EVENTS_TABLE_NAME = process.env.TASK_EVENTS_TABLE_NAME!; @@ -458,7 +459,7 @@ export async function hydrateAndTransition(task: TaskRecord, blueprintConfig?: B ? { guardrailId: process.env.GUARDRAIL_ID, guardrailVersion: process.env.GUARDRAIL_VERSION, - bedrockClient: new BedrockRuntimeClient({}), + bedrockClient: new BedrockRuntimeClient({ ...abcaUserAgent() }), } : undefined; @@ -507,7 +508,7 @@ export async function hydrateAndTransition(task: TaskRecord, blueprintConfig?: B task.task_id, task.user_id, { - s3Client: new S3Client({}), + s3Client: new S3Client({ ...abcaUserAgent() }), 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..e9260bb7f 100644 --- a/cdk/src/handlers/shared/repo-config.ts +++ b/cdk/src/handlers/shared/repo-config.ts @@ -20,6 +20,7 @@ import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb'; import { logger } from './logger'; +import { abcaUserAgent } from './ua'; /** * Per-repository configuration written by the Blueprint CDK construct @@ -90,7 +91,7 @@ export interface BlueprintConfig { readonly approval_gate_cap?: number; } -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); /** * 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..349ab8ad0 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 { abcaUserAgent } from './ua'; -const sm = new SecretsManagerClient({}); +const sm = new SecretsManagerClient({ ...abcaUserAgent() }); /** 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..63ba80288 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 { abcaUserAgent } from '../ua'; let sharedClient: BedrockAgentCoreClient | undefined; function getClient(): BedrockAgentCoreClient { if (!sharedClient) { - sharedClient = new BedrockAgentCoreClient({}); + sharedClient = new BedrockAgentCoreClient({ ...abcaUserAgent() }); } return sharedClient; } diff --git a/cdk/src/handlers/shared/strategies/ecs-strategy.ts b/cdk/src/handlers/shared/strategies/ecs-strategy.ts index a45ef1290..6bcc49480 100644 --- a/cdk/src/handlers/shared/strategies/ecs-strategy.ts +++ b/cdk/src/handlers/shared/strategies/ecs-strategy.ts @@ -22,11 +22,12 @@ 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 { abcaUserAgent } from '../ua'; let sharedClient: ECSClient | undefined; function getClient(): ECSClient { if (!sharedClient) { - sharedClient = new ECSClient({}); + sharedClient = new ECSClient({ ...abcaUserAgent() }); } return sharedClient; } @@ -34,7 +35,7 @@ function getClient(): ECSClient { let sharedS3Client: S3Client | undefined; function getS3Client(): S3Client { if (!sharedS3Client) { - sharedS3Client = new S3Client({}); + sharedS3Client = new S3Client({ ...abcaUserAgent() }); } return sharedS3Client; } diff --git a/cdk/src/handlers/shared/ua.ts b/cdk/src/handlers/shared/ua.ts new file mode 100644 index 000000000..a79087089 --- /dev/null +++ b/cdk/src/handlers/shared/ua.ts @@ -0,0 +1,98 @@ +/** + * 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. + */ + +/** + * 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()]] }; +} diff --git a/cdk/src/handlers/slack-command-processor.ts b/cdk/src/handlers/slack-command-processor.ts index 26cec8afb..8741e3318 100644 --- a/cdk/src/handlers/slack-command-processor.ts +++ b/cdk/src/handlers/slack-command-processor.ts @@ -25,6 +25,7 @@ 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 { abcaUserAgent } from './shared/ua'; import { CODING_WORKFLOW_ID } from './shared/workflows'; import type { SlackCommandPayload } from './slack-commands'; @@ -77,7 +78,7 @@ function normalizeEvent(event: RawEvent): CommandProcessorEvent { return { ...event, source: 'slash' }; } -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); 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..8d04e520b 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 { abcaUserAgent } from './shared/ua'; -const lambdaClient = new LambdaClient({}); +const lambdaClient = new LambdaClient({ ...abcaUserAgent() }); 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 954f53e73..98aaf4df4 100644 --- a/cdk/src/handlers/slack-events.ts +++ b/cdk/src/handlers/slack-events.ts @@ -25,11 +25,12 @@ 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 { abcaUserAgent } 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 = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); +const sm = new SecretsManagerClient({ ...abcaUserAgent() }); +const lambdaClient = new LambdaClient({ ...abcaUserAgent() }); 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..b90a4471a 100644 --- a/cdk/src/handlers/slack-interactions.ts +++ b/cdk/src/handlers/slack-interactions.ts @@ -22,8 +22,9 @@ import { DynamoDBDocumentClient, GetCommand, UpdateCommand } from '@aws-sdk/lib- import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { logger } from './shared/logger'; import { getSlackSecret, SLACK_SECRET_PREFIX, verifySlackRequest } from './shared/slack-verify'; +import { abcaUserAgent } from './shared/ua'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); 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..575fe18e7 100644 --- a/cdk/src/handlers/slack-link.ts +++ b/cdk/src/handlers/slack-link.ts @@ -24,9 +24,10 @@ import { ulid } from 'ulid'; import { extractUserId } from './shared/gateway'; import { logger } from './shared/logger'; import { ErrorCode, errorResponse, successResponse } from './shared/response'; +import { abcaUserAgent } from './shared/ua'; import { parseBody } from './shared/validation'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); 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..9bc36f737 100644 --- a/cdk/src/handlers/slack-oauth-callback.ts +++ b/cdk/src/handlers/slack-oauth-callback.ts @@ -23,9 +23,10 @@ import { DynamoDBDocumentClient, 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 { abcaUserAgent } from './shared/ua'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); -const sm = new SecretsManagerClient({}); +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); +const sm = new SecretsManagerClient({ ...abcaUserAgent() }); 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..01592bb73 100644 --- a/cdk/src/handlers/webhook-authorizer.ts +++ b/cdk/src/handlers/webhook-authorizer.ts @@ -22,8 +22,9 @@ import { DynamoDBDocumentClient, 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 { abcaUserAgent } from './shared/ua'; -const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); 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..eb6ec70f5 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 { abcaUserAgent } from './shared/ua'; import { parseBody } from './shared/validation'; -const sm = new SecretsManagerClient({}); +const sm = new SecretsManagerClient({ ...abcaUserAgent() }); 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 06a98deb3..2fc3d4be0 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -49,6 +49,7 @@ import { LinearIntegration } from '../constructs/linear-integration'; 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 { StrandedTaskReconciler } from '../constructs/stranded-task-reconciler'; import { TaskApi } from '../constructs/task-api'; import { TaskApprovalsTable } from '../constructs/task-approvals-table'; @@ -307,6 +308,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', @@ -359,6 +369,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..d117f61d8 --- /dev/null +++ b/cdk/test/constructs/solution-ua-aspect.test.ts @@ -0,0 +1,107 @@ +/** + * 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('#'); + }); + + 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 ebcba02e8..399f8eb19 100644 --- a/cdk/test/constructs/task-orchestrator.test.ts +++ b/cdk/test/constructs/task-orchestrator.test.ts @@ -145,6 +145,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..a6089e78a --- /dev/null +++ b/cdk/test/handlers/shared/ua.test.ts @@ -0,0 +1,122 @@ +/** + * 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 { abcaUserAgent, 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']] }); + }); +}); + +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'); + }); +}); diff --git a/cdk/test/stacks/agent.test.ts b/cdk/test/stacks/agent.test.ts index 9319c3f53..a21da5731 100644 --- a/cdk/test/stacks/agent.test.ts +++ b/cdk/test/stacks/agent.test.ts @@ -17,8 +17,9 @@ * SOFTWARE. */ -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', () => { @@ -532,3 +533,55 @@ describe('AgentStack with the ECS substrate gate (--context compute_type=ecs)', template.hasOutput('ComputeSubstrate', { Value: '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); + }); + + test('every solution Lambda carries AWS_SDK_UA_APP_ID (traverses nested scope)', () => { + const functions = template.findResources('AWS::Lambda::Function'); + // CDK synthesizes its own custom-resource provider Lambdas (S3 + // auto-delete, VPC default-SG restriction). Those are framework-owned + // CfnResource-backed handlers, not `lambda.Function` constructs, so the + // aspect's `instanceof lambda.Function` guard does not visit them. They + // fire only at deploy time and are not part of the runtime solution + // traffic; every ABCA-authored Lambda IS covered. + const solutionFnIds = Object.keys(functions).filter( + (id) => !/CustomResourceProviderHandler/.test(id), + ); + // Sanity: this stack has many solution Lambdas across nested constructs. + expect(solutionFnIds.length).toBeGreaterThan(10); + for (const fnId of solutionFnIds) { + const envVars = functions[fnId].Properties.Environment?.Variables ?? {}; + expect(envVars.AWS_SDK_UA_APP_ID).toBe('uksb-wt64nei4u6#UaAgentStack'); + } + }); + + 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/src/auth.ts b/cli/src/auth.ts index 091fc8a4d..46a69d687 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 { abcaUserAgent } 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 = new CognitoIdentityProviderClient({ region: config.region, ...abcaUserAgent() }); 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 = new CognitoIdentityProviderClient({ region: config.region, ...abcaUserAgent() }); 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..c27a2b6fa 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 { abcaUserAgent } 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 new CognitoIdentityProviderClient({ region, ...abcaUserAgent() }); } /** 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..e43dbc192 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 { abcaUserAgent } 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 = new SecretsManagerClient({ region, ...abcaUserAgent() }); // 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..5594c9e52 100644 --- a/cli/src/commands/jira.ts +++ b/cli/src/commands/jira.ts @@ -59,6 +59,7 @@ import { } from '../jira-oauth'; import { awaitOauthCallback, CALLBACK_URL } from '../oauth-callback-server'; import { promptSecret } from '../prompt-secret'; +import { abcaUserAgent } from '../ua'; /** Default label that triggers an ABCA task when applied to a Jira issue. */ const DEFAULT_LABEL_FILTER = 'bgagent'; @@ -481,7 +482,7 @@ function extractCognitoSub(): string { async function getStackOutput(region: string, stackName: string, outputKey: string): Promise { try { - const cfn = new CloudFormationClient({ region }); + const cfn = new CloudFormationClient({ region, ...abcaUserAgent() }); 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 +678,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 = new SecretsManagerClient({ region, ...abcaUserAgent() }); const now = new Date().toISOString(); const stored: StoredJiraOauthToken = { access_token: tokenResponse.access_token, @@ -697,7 +698,7 @@ export function makeJiraCommand(): Command { console.log(` ✓ (${secretName})`); // ─── Step 5: Persist registry row ──────────────────────────────── - const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region })); + const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region, ...abcaUserAgent() })); // 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 +825,7 @@ export function makeJiraCommand(): Command { ); } - const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region })); + const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region, ...abcaUserAgent() })); const registry = await ddb.send(new GetCommand({ TableName: registryTableName, Key: { jira_cloud_id: cloudId }, @@ -845,7 +846,7 @@ export function makeJiraCommand(): Command { } const proxyUrl = validateJiraAppActorProxyUrl(opts.proxyUrl); - const sm = new SecretsManagerClient({ region }); + const sm = new SecretsManagerClient({ region, ...abcaUserAgent() }); const secretResult = await sm.send(new GetSecretValueCommand({ SecretId: row.oauth_secret_arn as string, })); @@ -956,8 +957,8 @@ export function makeJiraCommand(): Command { } const callerCognitoSub = extractCognitoSub(); - const sm = new SecretsManagerClient({ region }); - const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region })); + const sm = new SecretsManagerClient({ region, ...abcaUserAgent() }); + const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region, ...abcaUserAgent() })); const registry = await ddb.send(new GetCommand({ TableName: workspaceRegistryTable!, @@ -1162,7 +1163,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 = DynamoDBDocumentClient.from(new DynamoDBClient({ region, ...abcaUserAgent() })); await ddb.send(new PutCommand({ TableName: tableName, Item: { diff --git a/cli/src/commands/linear.ts b/cli/src/commands/linear.ts index aac4fe0a6..58572bb4a 100644 --- a/cli/src/commands/linear.ts +++ b/cli/src/commands/linear.ts @@ -49,6 +49,7 @@ import { } from '../linear-oauth'; import { awaitOauthCallback, CALLBACK_URL } from '../oauth-callback-server'; import { promptSecret } from '../prompt-secret'; +import { abcaUserAgent } from '../ua'; /** Default label that triggers an ABCA task when applied to a Linear issue. */ const DEFAULT_LABEL_FILTER = 'bgagent'; @@ -601,7 +602,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 = new SecretsManagerClient({ region, ...abcaUserAgent() }); 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 @@ -673,7 +674,7 @@ export function makeLinearCommand(): Command { console.log(` ✓ (${secretName})`); // ─── Step 5: Persist registry + user-mapping rows ───────────── - const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region })); + const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region, ...abcaUserAgent() })); // Best-effort: fetch team keys so the screenshot processor can // prefix-route Linear issue lookups (e.g. ABCA-42 → workspace @@ -900,8 +901,8 @@ export function makeLinearCommand(): Command { ); } - const sm = new SecretsManagerClient({ region }); - const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region })); + const sm = new SecretsManagerClient({ region, ...abcaUserAgent() }); + const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region, ...abcaUserAgent() })); // ─── Linear OAuth app credentials ────────────────────────────── // Always prompt — never accept secrets via flags (shell history @@ -1170,7 +1171,7 @@ export function makeLinearCommand(): Command { const config = loadConfig(); const region = opts.region || config.region; - const sm = new SecretsManagerClient({ region }); + const sm = new SecretsManagerClient({ region, ...abcaUserAgent() }); const secretName = linearOauthSecretName(slug); // ─── Read existing bundle ─────────────────────────────────── @@ -1289,8 +1290,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 = new SecretsManagerClient({ region, ...abcaUserAgent() }); + const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region, ...abcaUserAgent() })); const registryScan = await ddb.send(new ScanCommand({ TableName: workspaceRegistryTable!, FilterExpression: 'workspace_slug = :slug AND #status = :active', @@ -1413,7 +1414,7 @@ export function makeLinearCommand(): Command { } const now = new Date().toISOString(); - const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region })); + const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region, ...abcaUserAgent() })); await ddb.send(new PutCommand({ TableName: tableName, Item: { @@ -1444,7 +1445,7 @@ export function makeLinearCommand(): Command { .action(async (opts) => { const config = loadConfig(); const region = opts.region || config.region; - const sm = new SecretsManagerClient({ region }); + const sm = new SecretsManagerClient({ region, ...abcaUserAgent() }); // Resolve the set of workspace slugs to query. Either an // explicit `--slug` (one workspace) or every Linear workspace @@ -1895,7 +1896,7 @@ export async function autoLinkTokenOwner(args: { return; } - const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region: args.region })); + const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region: args.region, ...abcaUserAgent() })); await ddb.send(new PutCommand({ TableName: args.userMappingTable, Item: { @@ -1934,7 +1935,7 @@ function extractCognitoSub(): string { async function getStackOutput(region: string, stackName: string, outputKey: string): Promise { try { - const cfn = new CloudFormationClient({ region }); + const cfn = new CloudFormationClient({ region, ...abcaUserAgent() }); 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 dd72b0ac9..bcf2588e4 100644 --- a/cli/src/commands/slack.ts +++ b/cli/src/commands/slack.ts @@ -28,6 +28,7 @@ import { ApiClient } from '../api-client'; import { loadConfig } from '../config'; import { formatJson } from '../format'; import { promptSecret } from '../prompt-secret'; +import { abcaUserAgent } from '../ua'; export function makeSlackCommand(): Command { const slack = new Command('slack') @@ -209,7 +210,7 @@ async function promptAndStoreCredentials(region: string, arns: SecretArns): Prom // Store in Secrets Manager. console.log(''); - const sm = new SecretsManagerClient({ region }); + const sm = new SecretsManagerClient({ region, ...abcaUserAgent() }); const secrets = [ { id: arns.signingSecretArn, value: signingSecret, label: 'signing secret' }, @@ -287,7 +288,7 @@ function findRepoRoot(): string { async function getStackOutput(region: string, stackName: string, outputKey: string): Promise { try { - const cfn = new CloudFormationClient({ region }); + const cfn = new CloudFormationClient({ region, ...abcaUserAgent() }); 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..0a610bf42 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 { abcaUserAgent } 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 DynamoDBDocumentClient.from(new DynamoDBClient({ region, ...abcaUserAgent() })); } /** A region-scoped low-level DynamoDB client (raw AttributeValue maps). */ export function lowLevelClient(region: string): DynamoDBClient { - return new DynamoDBClient({ region }); + return new DynamoDBClient({ region, ...abcaUserAgent() }); } diff --git a/cli/src/github-token.ts b/cli/src/github-token.ts index 534c8aca2..0ab9ee881 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 { abcaUserAgent } 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 = new SecretsManagerClient({ region, ...abcaUserAgent() }); 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 = new SecretsManagerClient({ region, ...abcaUserAgent() }); await sm.send(new PutSecretValueCommand({ SecretId: secretArn, SecretString: token, diff --git a/cli/src/platform-doctor.ts b/cli/src/platform-doctor.ts index 2d9611601..12f78268e 100644 --- a/cli/src/platform-doctor.ts +++ b/cli/src/platform-doctor.ts @@ -27,6 +27,7 @@ import { isGithubTokenConfigured } from './github-token'; import { PLATFORM_REPO_DEFAULTS } from './repo-display'; import { countActiveRepos } from './repo-lookup'; import { getStackOutput } from './stack-outputs'; +import { abcaUserAgent } from './ua'; /** * Default foundation model checked when no onboarded repo specifies model_id. @@ -132,7 +133,7 @@ async function checkCognitoConfig( }; } - const cognito = new CognitoIdentityProviderClient({ region }); + const cognito = new CognitoIdentityProviderClient({ region, ...abcaUserAgent() }); try { await cognito.send(new DescribeUserPoolCommand({ UserPoolId: userPoolId })); await cognito.send(new DescribeUserPoolClientCommand({ @@ -211,7 +212,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 = new BedrockClient({ region, ...abcaUserAgent() }); 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..f22953085 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 { abcaUserAgent } 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 = new BedrockAgentCoreControlClient({ region, ...abcaUserAgent() }); 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..da4ed26c0 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 { abcaUserAgent } 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 = new CloudFormationClient({ region, ...abcaUserAgent() }); 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..488dcc319 --- /dev/null +++ b/cli/src/ua.ts @@ -0,0 +1,82 @@ +/** + * 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. + */ + +/** 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)]] }; +} diff --git a/cli/src/webhook-test.ts b/cli/src/webhook-test.ts index 916a628f0..a5b63bcb6 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 { abcaUserAgent } 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 = new SecretsManagerClient({ region, ...abcaUserAgent() }); 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..db069bd0a --- /dev/null +++ b/cli/test/ua.test.ts @@ -0,0 +1,113 @@ +/** + * 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 { CognitoIdentityProviderClient, ListUsersCommand } from '@aws-sdk/client-cognito-identity-provider'; +import { abcaUserAgent, applyDefaultAppId, APP_ID_ENV, 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'); + }); +});