From 66491efdd1c67cfbe06409fc71ed0b02db840526 Mon Sep 17 00:00:00 2001 From: bgagent <345885+scottschreckengaust@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:35:43 +0000 Subject: [PATCH 1/6] feat(observability): add simplified solution-attribution UA helpers (#319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alternative to PR #338: emit the app/ segment via the SDK-native AWS_SDK_UA_APP_ID env var (botocore + JS v3 read it automatically) using '#' instead of '/' as the separator, so no raw-user-agent-path machinery is needed. Each ua module owns only the static md/#{component} segment; there is no per-request {TRACE} handle, no before-send/middleware, and no module trace state — request correlation stays with X-Ray / structured logs (#245), and connection pools are never re-pinned. CloudFormation stack names are [A-Za-z0-9-], a subset of both the UA-token and app-id charsets, so {STACKNAME} needs no sanitization; the only sanitize() left is defensive cover on the component label. This commit adds the three mirrored helpers + tests (agent/src/ua.py, cdk/src/handlers/shared/ua.ts, cli/src/ua.ts). Each has a wire-capture test asserting both app/ and md/ segments land on a real outbound User-Agent header (and that app/ is omitted when AWS_SDK_UA_APP_ID is unset/empty — the customer opt-out). 12 agent + 12 cdk + 10 cli tests, 100% coverage on the new modules. Wiring the client sites + CDK env threading follow in subsequent commits. Part of #319 Co-Authored-By: Claude Opus 4.8 --- agent/src/ua.py | 76 +++++++++++++++++ agent/tests/test_ua.py | 92 +++++++++++++++++++++ cdk/src/handlers/shared/ua.ts | 98 ++++++++++++++++++++++ cdk/test/handlers/shared/ua.test.ts | 122 ++++++++++++++++++++++++++++ cli/src/ua.ts | 82 +++++++++++++++++++ cli/test/ua.test.ts | 113 ++++++++++++++++++++++++++ 6 files changed, 583 insertions(+) create mode 100644 agent/src/ua.py create mode 100644 agent/tests/test_ua.py create mode 100644 cdk/src/handlers/shared/ua.ts create mode 100644 cdk/test/handlers/shared/ua.test.ts create mode 100644 cli/src/ua.ts create mode 100644 cli/test/ua.test.ts diff --git a/agent/src/ua.py b/agent/src/ua.py new file mode 100644 index 00000000..f49e65db --- /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_ua.py b/agent/tests/test_ua.py new file mode 100644 index 00000000..0f5c17a8 --- /dev/null +++ b/agent/tests/test_ua.py @@ -0,0 +1,92 @@ +"""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/handlers/shared/ua.ts b/cdk/src/handlers/shared/ua.ts new file mode 100644 index 00000000..a7908708 --- /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/test/handlers/shared/ua.test.ts b/cdk/test/handlers/shared/ua.test.ts new file mode 100644 index 00000000..a6089e78 --- /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/cli/src/ua.ts b/cli/src/ua.ts new file mode 100644 index 00000000..488dcc31 --- /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/test/ua.test.ts b/cli/test/ua.test.ts new file mode 100644 index 00000000..db069bd0 --- /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'); + }); +}); From e8968ba8a5c0efc8b7ba855cbfae466c1cb64b03 Mon Sep 17 00:00:00 2001 From: bgagent <345885+scottschreckengaust@users.noreply.github.com> Date: Sat, 13 Jun 2026 18:35:14 +0000 Subject: [PATCH 2/6] feat(agent): wire static md/ solution UA into aws_session + platform sites (#319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session-level user_agent_extra on both the scoped (refreshable) and the plain ambient session covers every tenant_client/tenant_resource caller. New platform_client() carries the static md/ segment (merged into any caller Config) for the 8 direct boto3.client sites that bypass the session by design — logs x5 (shell, server x2, telemetry x2), secrets manager x2 (config), bedrock-agentcore x1 (memory) — plus the ambient STS client used for role chaining. No per-request trace handle and no before-send appender: the md/ segment is fully static, so cached clients and the singleton session pool are never re-pinned. The app/ segment is contributed separately by the SDK from AWS_SDK_UA_APP_ID (threaded by CDK, next commit). 4 new aws_session tests assert the md/ segment rides platform_client, the unscoped tenant_client, a merged caller Config, and the scoped session. Full agent suite green (1070 tests). Part of #319 Co-Authored-By: Claude Opus 4.8 --- agent/src/aws_session.py | 67 +++++++++++++++++++++++++++------ agent/src/config.py | 9 +++-- agent/src/memory.py | 4 +- agent/src/server.py | 8 ++-- agent/src/shell.py | 4 +- agent/src/telemetry.py | 8 ++-- agent/tests/test_aws_session.py | 55 +++++++++++++++++++++++++++ agent/tests/test_ua.py | 4 +- 8 files changed, 129 insertions(+), 30 deletions(-) diff --git a/agent/src/aws_session.py b/agent/src/aws_session.py index 2c6a906c..b1100023 100644 --- a/agent/src/aws_session.py +++ b/agent/src/aws_session.py @@ -128,6 +128,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 @@ -138,8 +140,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( @@ -158,6 +161,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. @@ -209,10 +216,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 @@ -224,20 +240,35 @@ 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`` by merging rather + than overwriting; supplies one carrying just the UA otherwise. (#319) + """ + import ua + + ua_config = ua.client_config() + existing = kwargs.get("config") + kwargs["config"] = existing.merge(ua_config) if existing is not None else 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: @@ -247,4 +278,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 c33dd6cb..d29e2741 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,15 @@ def resolve_linear_api_token(channel_metadata: dict[str, str] | None = None) -> import json from datetime import datetime, timedelta - import boto3 from botocore.exceptions import BotoCoreError, ClientError 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) + from aws_session import platform_client + + 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 9d2654b2..aa89d1e0 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 d9ae1d7c..47f72fde 100644 --- a/agent/src/server.py +++ b/agent/src/server.py @@ -166,10 +166,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): @@ -193,10 +193,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 d6dea355..bf0d7bdc 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 b91f2b4e..560daa7b 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/tests/test_aws_session.py b/agent/tests/test_aws_session.py index fd4e5562..7744f134 100644 --- a/agent/tests/test_aws_session.py +++ b/agent/tests/test_aws_session.py @@ -298,3 +298,58 @@ 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 + + with patch("boto3.client", return_value=MagicMock()) as mk: + platform_client("logs", config=Config(read_timeout=7)) + + cfg = mk.call_args.kwargs["config"] + # Both the caller's setting and our UA survive the merge. + 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 index 0f5c17a8..95ccc7bd 100644 --- a/agent/tests/test_ua.py +++ b/agent/tests/test_ua.py @@ -62,9 +62,7 @@ def _capture_ua(self, monkeypatch, app_id): 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 + ua_header.decode("ascii", "replace") if isinstance(ua_header, bytes) else ua_header ) return AWSResponse("https://x", 200, {}, b"") From 6a1648be9307331587f329d19f041f7c9148fd5b Mon Sep 17 00:00:00 2001 From: bgagent <345885+scottschreckengaust@users.noreply.github.com> Date: Sat, 13 Jun 2026 18:48:40 +0000 Subject: [PATCH 3/6] feat(handlers): carry static md/ solution UA on every SDK client (#319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spread ...abcaUserAgent() into all 60 SDK v3 client constructors across 43 handler files (DynamoDB/Secrets Manager/Lambda/Bedrock/ECS/ BedrockAgentCore). DocumentClient sites instrument the inner DynamoDBClient (shared middleware stack). No withAbcaTrace/middleware — the md/ segment is fully static, so module-level cached clients keep their connection pools; the app/ segment rides native AWS_SDK_UA_APP_ID (threaded next commit). No behavior change beyond the UA header: all 2051 existing CDK tests pass unmodified (the new spread arg merges into the constructor config the tests already assert on / mock). Part of #319 Co-Authored-By: Claude Opus 4.8 --- cdk/src/handlers/approve-task.ts | 3 ++- cdk/src/handlers/cancel-task.ts | 7 ++++--- cdk/src/handlers/cleanup-pending-uploads.ts | 3 ++- cdk/src/handlers/confirm-uploads.ts | 7 ++++--- cdk/src/handlers/create-webhook.ts | 5 +++-- cdk/src/handlers/delete-webhook.ts | 5 +++-- cdk/src/handlers/deny-task.ts | 3 ++- cdk/src/handlers/fanout-task-events.ts | 3 ++- cdk/src/handlers/get-pending.ts | 3 ++- cdk/src/handlers/get-policies.ts | 3 ++- cdk/src/handlers/get-task-events.ts | 3 ++- cdk/src/handlers/get-task.ts | 3 ++- cdk/src/handlers/get-trace-url.ts | 3 ++- cdk/src/handlers/github-webhook.ts | 5 +++-- cdk/src/handlers/linear-link.ts | 3 ++- cdk/src/handlers/linear-webhook-processor.ts | 3 ++- cdk/src/handlers/linear-webhook.ts | 5 +++-- cdk/src/handlers/list-tasks.ts | 3 ++- cdk/src/handlers/list-webhooks.ts | 3 ++- cdk/src/handlers/nudge-task.ts | 3 ++- cdk/src/handlers/reconcile-concurrency.ts | 3 ++- cdk/src/handlers/reconcile-stranded-tasks.ts | 3 ++- cdk/src/handlers/shared/agentcore-browser.ts | 3 ++- cdk/src/handlers/shared/context-hydration.ts | 5 +++-- cdk/src/handlers/shared/create-task-core.ts | 7 ++++--- cdk/src/handlers/shared/github-webhook-verify.ts | 3 ++- cdk/src/handlers/shared/linear-issue-lookup.ts | 3 ++- cdk/src/handlers/shared/linear-oauth-resolver.ts | 5 +++-- cdk/src/handlers/shared/linear-verify.ts | 5 +++-- cdk/src/handlers/shared/memory.ts | 3 ++- cdk/src/handlers/shared/orchestrator.ts | 5 +++-- cdk/src/handlers/shared/repo-config.ts | 3 ++- cdk/src/handlers/shared/slack-verify.ts | 3 ++- cdk/src/handlers/shared/strategies/agentcore-strategy.ts | 3 ++- cdk/src/handlers/shared/strategies/ecs-strategy.ts | 3 ++- cdk/src/handlers/slack-command-processor.ts | 3 ++- cdk/src/handlers/slack-commands.ts | 3 ++- cdk/src/handlers/slack-events.ts | 7 ++++--- cdk/src/handlers/slack-interactions.ts | 3 ++- cdk/src/handlers/slack-link.ts | 3 ++- cdk/src/handlers/slack-oauth-callback.ts | 5 +++-- cdk/src/handlers/webhook-authorizer.ts | 3 ++- cdk/src/handlers/webhook-create-task.ts | 3 ++- 43 files changed, 103 insertions(+), 60 deletions(-) diff --git a/cdk/src/handlers/approve-task.ts b/cdk/src/handlers/approve-task.ts index 393ade46..d4ad64da 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 } 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 72b3864f..3181b8c9 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 d5a4c055..71a7060a 100644 --- a/cdk/src/handlers/cleanup-pending-uploads.ts +++ b/cdk/src/handlers/cleanup-pending-uploads.ts @@ -44,8 +44,9 @@ 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 ddb = new DynamoDBClient({ ...abcaUserAgent() }); const s3 = new S3Client({}); const TASK_TABLE = process.env.TASK_TABLE_NAME!; diff --git a/cdk/src/handlers/confirm-uploads.ts b/cdk/src/handlers/confirm-uploads.ts index 2457a4d3..1f417215 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 ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); const s3Client = new S3Client({}); -const lambdaClient = process.env.ORCHESTRATOR_FUNCTION_ARN ? new LambdaClient({}) : undefined; +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 614290e4..b016caed 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 7ac52ee1..61930198 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 eb043c99..0f3afbdb 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 } 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 3eabd8c4..058253a6 100644 --- a/cdk/src/handlers/fanout-task-events.ts +++ b/cdk/src/handlers/fanout-task-events.ts @@ -53,6 +53,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 @@ -362,7 +363,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 7abb4505..88d39a13 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 } 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 eb8621ed..07618598 100644 --- a/cdk/src/handlers/get-policies.ts +++ b/cdk/src/handlers/get-policies.ts @@ -32,8 +32,9 @@ import { formatMinuteBucket } from './shared/rate-limit'; 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 f631a182..68cdbd8b 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, @@ -32,7 +33,7 @@ import { parseLimit, } 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 25c51ac5..6c05e779 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 bdf1a553..d3799318 100644 --- a/cdk/src/handlers/get-trace-url.ts +++ b/cdk/src/handlers/get-trace-url.ts @@ -28,8 +28,9 @@ 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 ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ ...abcaUserAgent() })); const s3 = new S3Client({}); const TABLE_NAME = process.env.TASK_TABLE_NAME!; const TRACE_BUCKET_NAME = process.env.TRACE_ARTIFACTS_BUCKET_NAME!; diff --git a/cdk/src/handlers/github-webhook.ts b/cdk/src/handlers/github-webhook.ts index 82533863..808a3d73 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/linear-link.ts b/cdk/src/handlers/linear-link.ts index 41a480d3..ce478d8b 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 15d2b4b3..b94dc878 100644 --- a/cdk/src/handlers/linear-webhook-processor.ts +++ b/cdk/src/handlers/linear-webhook-processor.ts @@ -25,8 +25,9 @@ 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'; -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 65fdc5d6..0df10011 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 eff4c859..b54047d4 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!; /** diff --git a/cdk/src/handlers/list-webhooks.ts b/cdk/src/handlers/list-webhooks.ts index 94c4f19d..b7671859 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!; /** diff --git a/cdk/src/handlers/nudge-task.ts b/cdk/src/handlers/nudge-task.ts index 5fbdcb60..6e057560 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 2f716514..7c9cf9bb 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 8688601f..095a82ef 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 deacd937..2d14d552 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'; @@ -88,7 +89,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 e2ef1f20..3122b86e 100644 --- a/cdk/src/handlers/shared/context-hydration.ts +++ b/cdk/src/handlers/shared/context-hydration.ts @@ -23,6 +23,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'; // --------------------------------------------------------------------------- @@ -130,7 +131,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', @@ -313,7 +314,7 @@ export async function screenWithGuardrail(text: string, taskId: string): Promise const tokenCache = new Map(); const CACHE_TTL_MS = 5 * 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 0315b325..3601e0db 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, DEFAULT_MAX_TURNS, 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'; @@ -68,10 +69,10 @@ export interface TaskCreationContext { readonly idempotencyKey?: string; } -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', diff --git a/cdk/src/handlers/shared/github-webhook-verify.ts b/cdk/src/handlers/shared/github-webhook-verify.ts index 5085efa8..69395dec 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 b2373887..44885e14 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 48cc6f89..8e719a5f 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 bb4799d2..64e4746c 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() })); /** Prefix for Linear-related secrets in Secrets Manager. */ export const LINEAR_SECRET_PREFIX = 'bgagent/linear/'; diff --git a/cdk/src/handlers/shared/memory.ts b/cdk/src/handlers/shared/memory.ts index bd8d5025..996bbbd9 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'; // --------------------------------------------------------------------------- @@ -146,7 +147,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 27a8a0a6..f4fd0c24 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!; @@ -431,7 +432,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; diff --git a/cdk/src/handlers/shared/repo-config.ts b/cdk/src/handlers/shared/repo-config.ts index 1753d568..6e881f5b 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 @@ -79,7 +80,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 c7ff6e7e..befd1b1e 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 d10e9bde..63ba8028 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 e032c6ce..a61ad0ad 100644 --- a/cdk/src/handlers/shared/strategies/ecs-strategy.ts +++ b/cdk/src/handlers/shared/strategies/ecs-strategy.ts @@ -21,11 +21,12 @@ import { ECSClient, RunTaskCommand, DescribeTasksCommand, StopTaskCommand } from 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; } diff --git a/cdk/src/handlers/slack-command-processor.ts b/cdk/src/handlers/slack-command-processor.ts index 3832ffa0..144403d7 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 type { SlackCommandPayload } from './slack-commands'; /** @@ -76,7 +77,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 f89b47c8..8d04e520 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 954f53e7..98aaf4df 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 ac25ccff..b90a4471 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 60ba20dd..575fe18e 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 872d5581..9bc36f73 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 91aeb85d..01592bb7 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 4b7a86db..3b44fd5b 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 From db52bb0d47b97bcc3ed9cae50a884f94d4eb1b13 Mon Sep 17 00:00:00 2001 From: bgagent <345885+scottschreckengaust@users.noreply.github.com> Date: Sat, 13 Jun 2026 18:58:32 +0000 Subject: [PATCH 4/6] feat(cli): carry static md/ solution UA on all bgagent SDK clients (#319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applyDefaultAppId() at startup defaults AWS_SDK_UA_APP_ID to the solution id (only when unset — an explicit '' opts out) so the CLI's own SDK calls carry the app/ segment with no per-site code. Spread ...abcaUserAgent() into all 18 AWS SDK v3 client sites (Cognito x3, Secrets Manager, CloudFormation, DynamoDB) across auth/admin/github/ slack/linear; the bgagent REST ApiClient is not an AWS SDK client and is untouched. auth.test.ts asserts the Cognito client constructor receives the md/ customUserAgent pair. Full CLI suite green (365 + new tests). Part of #319 Co-Authored-By: Claude Opus 4.8 --- cli/src/auth.ts | 5 +++-- cli/src/bin/bgagent.ts | 5 +++++ cli/src/commands/admin.ts | 3 ++- cli/src/commands/github.ts | 5 +++-- cli/src/commands/linear.ts | 23 ++++++++++++----------- cli/src/commands/slack.ts | 5 +++-- cli/test/auth.test.ts | 17 +++++++++++++++++ 7 files changed, 45 insertions(+), 18 deletions(-) diff --git a/cli/src/auth.ts b/cli/src/auth.ts index 665a7ce6..f2cdc9cd 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, @@ -126,7 +127,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 bc0ed285..45ea1d01 100644 --- a/cli/src/bin/bgagent.ts +++ b/cli/src/bin/bgagent.ts @@ -41,6 +41,7 @@ import { makeWatchCommand } from '../commands/watch'; import { makeWebhookCommand } from '../commands/webhook'; import { setVerbose } from '../debug'; import { ApiError, CliError } from '../errors'; +import { applyDefaultAppId } from '../ua'; const program = new Command(); @@ -85,6 +86,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/commands/admin.ts b/cli/src/commands/admin.ts index dad20771..d42a815b 100644 --- a/cli/src/commands/admin.ts +++ b/cli/src/commands/admin.ts @@ -29,6 +29,7 @@ import { Command } from 'commander'; import { getConfigDir, loadConfig, SECRET_FILE_MODE } from '../config'; import { CliError } from '../errors'; import { CliConfig } from '../types'; +import { abcaUserAgent } from '../ua'; /** * Generate a strong temporary password meeting Cognito's default policy: @@ -151,7 +152,7 @@ export function makeAdminCommand(): Command { const tempPassword = opts.tempPassword ?? generateTempPassword(); - const cognito = new CognitoIdentityProviderClient({ region }); + const cognito = new CognitoIdentityProviderClient({ region, ...abcaUserAgent() }); try { await cognito.send(new AdminCreateUserCommand({ UserPoolId: config.user_pool_id, diff --git a/cli/src/commands/github.ts b/cli/src/commands/github.ts index 53d811aa..1b43e7a1 100644 --- a/cli/src/commands/github.ts +++ b/cli/src/commands/github.ts @@ -26,6 +26,7 @@ import { import { Command } from 'commander'; import { loadConfig } from '../config'; import { CliError } from '../errors'; +import { abcaUserAgent } from '../ua'; /** Width of the `═` banner rules printed around webhook-info output. */ const BANNER_WIDTH = 72; @@ -116,7 +117,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 @@ -166,7 +167,7 @@ export function makeGithubCommand(): Command { // ─── Stack-output helper ───────────────────────────────────────────────────── async function getStackOutput(region: string, stackName: string, outputKey: string): Promise { - 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/commands/linear.ts b/cli/src/commands/linear.ts index 475dbebe..80eab635 100644 --- a/cli/src/commands/linear.ts +++ b/cli/src/commands/linear.ts @@ -45,6 +45,7 @@ import { StoredLinearOauthToken, } from '../linear-oauth'; import { awaitOauthCallback, CALLBACK_URL } from '../oauth-callback-server'; +import { abcaUserAgent } from '../ua'; /** Default label that triggers an ABCA task when applied to a Linear issue. */ const DEFAULT_LABEL_FILTER = 'bgagent'; @@ -597,7 +598,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(); const stored: StoredLinearOauthToken = { access_token: tokenResponse.access_token, @@ -625,7 +626,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 @@ -829,8 +830,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 @@ -1099,7 +1100,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 ─────────────────────────────────── @@ -1218,8 +1219,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', @@ -1342,7 +1343,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: { @@ -1373,7 +1374,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 @@ -1908,7 +1909,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: { @@ -1947,7 +1948,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 bc270fa2..570e3d63 100644 --- a/cli/src/commands/slack.ts +++ b/cli/src/commands/slack.ts @@ -27,6 +27,7 @@ import { Command } from 'commander'; import { ApiClient } from '../api-client'; import { loadConfig } from '../config'; import { formatJson } from '../format'; +import { abcaUserAgent } from '../ua'; export function makeSlackCommand(): Command { const slack = new Command('slack') @@ -208,7 +209,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' }, @@ -345,7 +346,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/test/auth.test.ts b/cli/test/auth.test.ts index 24ab4fec..b28582d8 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'); From 703e9c8e2b7a66ce5a267f406e220b0c569b601e Mon Sep 17 00:00:00 2001 From: bgagent <345885+scottschreckengaust@users.noreply.github.com> Date: Sat, 13 Jun 2026 19:49:46 +0000 Subject: [PATCH 5/6] feat(cdk): thread solution-attribution UA env vars to every surface (#319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `app/` segment is now SDK-native: a stack-level SolutionUaAspect sets AWS_SDK_UA_APP_ID=uksb-wt64nei4u6#{stackName} on every Lambda (current and future, structurally), and the AgentCore runtime + ECS container set the same value explicitly (the Lambda-only aspect can't reach them). botocore and JS v3 both read AWS_SDK_UA_APP_ID natively, so no client code builds the app/ segment. `-c sdkUaAppId=''` opts the whole stack out; any other `-c sdkUaAppId=` value overrides. The `md/#{component}` label is per-surface ABCA_COMPONENT: 'api' (task-api commonEnv), 'orchestr' (orchestrator/reconcilers/cleanup/fanout), 'webhook' (slack/linear/github-screenshot integrations, via a per-construct ComponentUaAspect so every function in the integration — including future ones — is labeled without editing each env block). buildAppId() centralizes the value: defaults to uksb-wt64nei4u6#{stack}, sanitizes a non-CFN override, clips to the documented 50-char cap, and returns undefined for the empty-string opt-out. CloudFormation stack names are [A-Za-z0-9-] (already app-id-safe), so no stack-name sanitization is needed in the default path. New tests: solution-ua-aspect.test.ts (buildAppId vectors + both aspects); task-api/orchestrator template assertions for the component label. Full CDK suite green (2061 tests). Local synth fails only on the pre-existing ec2:DescribeAvailabilityZones cred gap (CI runs the real synth). Part of #319 Co-Authored-By: Claude Opus 4.8 --- cdk/src/constructs/concurrency-reconciler.ts | 2 + cdk/src/constructs/ecs-agent-cluster.ts | 11 +++ cdk/src/constructs/fanout-consumer.ts | 5 + .../github-screenshot-integration.ts | 9 +- cdk/src/constructs/linear-integration.ts | 9 +- cdk/src/constructs/pending-upload-cleanup.ts | 2 + cdk/src/constructs/slack-integration.ts | 9 +- cdk/src/constructs/solution-ua-aspect.ts | 99 +++++++++++++++++++ .../constructs/stranded-task-reconciler.ts | 2 + cdk/src/constructs/task-api.ts | 7 ++ cdk/src/constructs/task-orchestrator.ts | 2 + cdk/src/main.ts | 8 ++ cdk/src/stacks/agent.ts | 14 +++ .../constructs/solution-ua-aspect.test.ts | 84 ++++++++++++++++ cdk/test/constructs/task-api.test.ts | 10 ++ cdk/test/constructs/task-orchestrator.test.ts | 10 ++ 16 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 cdk/src/constructs/solution-ua-aspect.ts create mode 100644 cdk/test/constructs/solution-ua-aspect.test.ts diff --git a/cdk/src/constructs/concurrency-reconciler.ts b/cdk/src/constructs/concurrency-reconciler.ts index 703c7faf..bb786e0d 100644 --- a/cdk/src/constructs/concurrency-reconciler.ts +++ b/cdk/src/constructs/concurrency-reconciler.ts @@ -69,6 +69,8 @@ export class ConcurrencyReconciler extends Construct { timeout: Duration.minutes(5), memorySize: 256, 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 acb89518..1d4e3c0f 100644 --- a/cdk/src/constructs/ecs-agent-cluster.ts +++ b/cdk/src/constructs/ecs-agent-cluster.ts @@ -28,6 +28,7 @@ import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; import { NagSuppressions } from 'cdk-nag'; import { Construct } from 'constructs'; import { AgentSessionRole } from './agent-session-role'; +import { buildAppId } from './solution-ua-aspect'; export interface EcsAgentClusterProps { readonly vpc: ec2.IVpc; @@ -114,6 +115,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), @@ -134,6 +144,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 fce69a11..0b99307e 100644 --- a/cdk/src/constructs/fanout-consumer.ts +++ b/cdk/src/constructs/fanout-consumer.ts @@ -164,6 +164,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 4704f849..31a8a410 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'; /** * Properties for GitHubScreenshotIntegration construct. @@ -119,6 +120,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/linear-integration.ts b/cdk/src/constructs/linear-integration.ts index 3fa701f7..b11d0d8e 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'; /** * Properties for LinearIntegration construct. @@ -109,6 +110,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 2743b1de..382732d1 100644 --- a/cdk/src/constructs/pending-upload-cleanup.ts +++ b/cdk/src/constructs/pending-upload-cleanup.ts @@ -88,6 +88,8 @@ export class PendingUploadCleanup extends Construct { timeout: Duration.seconds(30), memorySize: 256, 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 9b6d2e99..06d0a7cc 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'; /** * Properties for SlackIntegration construct. @@ -100,6 +101,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 00000000..d20e4f50 --- /dev/null +++ b/cdk/src/constructs/solution-ua-aspect.ts @@ -0,0 +1,99 @@ +/** + * 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; + +/** + * 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. Clipped to the documented 50-char value 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 : trimmed.replace(UA_TOKEN_UNSAFE, '-').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 5e9d9be5..3c784a71 100644 --- a/cdk/src/constructs/stranded-task-reconciler.ts +++ b/cdk/src/constructs/stranded-task-reconciler.ts @@ -105,6 +105,8 @@ export class StrandedTaskReconciler extends Construct { timeout: Duration.minutes(5), memorySize: 256, 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 b350a084..c43af28a 100644 --- a/cdk/src/constructs/task-api.ts +++ b/cdk/src/constructs/task-api.ts @@ -443,6 +443,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 ?? 90), + // 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 @@ -931,6 +935,9 @@ export class TaskApi extends Construct { const webhookEnv: Record = { WEBHOOK_TABLE_NAME: props.webhookTable.tableName, WEBHOOK_RETENTION_DAYS: String(props.webhookRetentionDays ?? 30), + // 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) --- diff --git a/cdk/src/constructs/task-orchestrator.ts b/cdk/src/constructs/task-orchestrator.ts index a5cf8be7..c1f68e83 100644 --- a/cdk/src/constructs/task-orchestrator.ts +++ b/cdk/src/constructs/task-orchestrator.ts @@ -219,6 +219,8 @@ export class TaskOrchestrator extends Construct { retentionPeriod: Duration.days(14), }, 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/main.ts b/cdk/src/main.ts index c8bc2cc8..d4f3ceee 100644 --- a/cdk/src/main.ts +++ b/cdk/src/main.ts @@ -19,6 +19,7 @@ import { App, 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,13 @@ 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; +Aspects.of(stack).add(new SolutionUaAspect(buildAppId(stackName, sdkUaAppIdOverride))); + 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 0ee8e5cd..61521d7c 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -46,6 +46,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'; @@ -293,6 +294,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', @@ -341,6 +351,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/solution-ua-aspect.test.ts b/cdk/test/constructs/solution-ua-aspect.test.ts new file mode 100644 index 00000000..39b4d8ce --- /dev/null +++ b/cdk/test/constructs/solution-ua-aspect.test.ts @@ -0,0 +1,84 @@ +/** + * 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('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 32192ff7..acee2d27 100644 --- a/cdk/test/constructs/task-api.test.ts +++ b/cdk/test/constructs/task-api.test.ts @@ -149,6 +149,16 @@ 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('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 ebcba02e..399f8eb1 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', { From 191053179ee8501ca0c9879abe08d8894a1068b1 Mon Sep 17 00:00:00 2001 From: bgagent <345885+scottschreckengaust@users.noreply.github.com> Date: Sat, 13 Jun 2026 19:53:03 +0000 Subject: [PATCH 6/6] docs(agents): require ABCA solution UA on new AWS SDK clients (#319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New "Common mistakes" bullet directing agent/handler/CLI code to the per-surface ua helpers and explaining the app/ (SDK-native via AWS_SDK_UA_APP_ID) vs md/ (explicit per-surface label) split, plus the customer opt-out. Root-level file — no Starlight sync needed. Part of #319 Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 0f6f2c0f..08cf89ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,7 @@ Handler entry tests: `cdk/test/handlers/orchestrate-task.test.ts`, `create-task. - **Editing on `main` directly** — ALWAYS create a worktree with a feature branch for changes, even trivial ones. Main should stay clean; all work flows through worktree → branch → PR → merge. - **Git worktrees** — Always **`git fetch origin main`** before creating a new worktree to ensure you branch from the latest remote state. `node_modules/` and `agent/.venv/` are per-tree (not shared). Run **`mise run install`** in each new worktree before building. All CDK path references (`__dirname`-relative) and mise `config_roots` resolve correctly without extra setup. - **Bumping Cedar engines in isolation** — `cedarpy` (Python, `agent/pyproject.toml`) and `@cedar-policy/cedar-wasm` (TypeScript, `cdk/package.json`) are two language bindings over the same Cedar Rust core. They MUST move together; even patch-version drift between bindings can yield divergent `(decision, matching_rule_ids)` on the same `(policy, input)` — invisible to per-side unit tests, caught (only) by `contracts/cedar-parity/` golden fixtures in CI. If you bump one engine you MUST bump the other to a tested-compatible version AND refresh the parity fixtures in the same commit. Both pins are EXACT (no `^`/`~`). See `docs/design/CEDAR_HITL_GATES.md` §15.6 (decision #23) and the parity-contract banner in `mise.toml`. **DO NOT** accept upstream's "Update branch" or auto-merge suggestions on cedarpy without verifying parity with cedar-wasm. +- **Dropping outbound SDK solution attribution on a new AWS client (#319)** — every outbound AWS API call carries two `User-Agent` segments: `app/uksb-wt64nei4u6#{stack}` and `md/uksb-wt64nei4u6#{component}`. The `app/` segment is **SDK-native** — it comes from the `AWS_SDK_UA_APP_ID` env var (CDK sets it on every Lambda via `SolutionUaAspect`, plus the AgentCore runtime and ECS container), so new clients get it for free as long as they run on a surface where CDK threads that env. The `md/` segment is the per-surface label and must be carried explicitly: in `agent/src/` build clients via `aws_session.tenant_client`/`tenant_resource` (tenant data) or `aws_session.platform_client` (ambient-chain calls) — never naked `boto3.client(...)`; in `cdk/src/handlers/` spread `...abcaUserAgent()` from `shared/ua.ts` into the client constructor; in `cli/src/` spread `...abcaUserAgent()` from `cli/src/ua.ts`. The three `ua` modules (`agent/src/ua.py`, `cdk/src/handlers/shared/ua.ts`, `cli/src/ua.ts`) MUST stay identical in solution id, wire format, and sanitization. (Customer opt-out: `-c sdkUaAppId=''` at deploy, or export `AWS_SDK_UA_APP_ID=''` for the CLI.) ### Tech stack