diff --git a/agent/pyproject.toml b/agent/pyproject.toml index d9357a11..5a718733 100644 --- a/agent/pyproject.toml +++ b/agent/pyproject.toml @@ -5,6 +5,7 @@ description = "Background coding agent — runs tasks in isolated cloud environm requires-python = ">=3.13" dependencies = [ "boto3==1.43.9", #https://pypi.org/project/boto3/ + "bedrock-agentcore==1.9.1", #https://pypi.org/project/bedrock-agentcore/ "claude-agent-sdk==0.2.82", #https://github.com/anthropics/claude-agent-sdk-python/releases/tag/v0.2.82 "requests==2.34.2", #https://pypi.org/project/requests/ "fastapi==0.136.1", #https://pypi.org/project/fastapi/ diff --git a/agent/src/config.py b/agent/src/config.py index 5f0934d2..d0e73735 100644 --- a/agent/src/config.py +++ b/agent/src/config.py @@ -3,6 +3,7 @@ import os import sys import uuid +from datetime import UTC from models import TaskConfig, TaskType from shell import log @@ -38,57 +39,150 @@ def resolve_github_token() -> str: return "" -def resolve_linear_api_token() -> str: - """Resolve the Linear personal API token from Secrets Manager or env. +def resolve_linear_api_token(channel_metadata: dict[str, str] | None = None) -> str: + """Resolve the Linear OAuth access token from Secrets Manager. - Mirrors ``resolve_github_token``: in deployed mode - ``LINEAR_API_TOKEN_SECRET_ARN`` is set and the token is fetched once - and cached in ``LINEAR_API_TOKEN``. For local development, falls back - to ``LINEAR_API_TOKEN`` directly. + Phase 2.0b-O2: the orchestrator stamps ``linear_oauth_secret_arn`` + into the task record's ``channel_metadata`` at task-creation time. + Pass that dict in via ``channel_metadata`` (the pipeline does this + automatically). We fetch the per-workspace secret, parse the token + JSON, refresh if expiring, and cache the access_token in + ``LINEAR_API_TOKEN`` so downstream consumers (the Linear MCP's + ``${LINEAR_API_TOKEN}`` placeholder in ``.mcp.json`` and + ``linear_reactions.py``'s GraphQL Authorization header) keep working + unchanged. - Returns an empty string if the secret is absent or empty — the agent-side - MCP config then renders with an unresolved ``${LINEAR_API_TOKEN}`` env - placeholder, and the Linear MCP will reject the request (fail-closed). - This function is only called when ``channel_source == 'linear'``. + For local development, a pre-set ``LINEAR_API_TOKEN`` env var + short-circuits the lookup so the agent can run outside the runtime. + + Returns an empty string when the credential is absent — the agent-side + MCP config then renders with an unresolved ``${LINEAR_API_TOKEN}`` + placeholder and the Linear MCP fails closed. This function is only + called when ``channel_source == 'linear'``. + + Phase 2.0a (parked) used AgentCore Identity. Phase 2.0b-O2 reads + Secrets Manager directly because AgentCore Identity's USER_FEDERATION + flow has an open service-side bug (see memory/project_oauth_2_0b.md). """ cached = os.environ.get("LINEAR_API_TOKEN", "") if cached: return cached - secret_arn = os.environ.get("LINEAR_API_TOKEN_SECRET_ARN") + + # Prefer the per-task channel_metadata; fall back to env var so the + # function can be called early (e.g. before pipeline construction) + # via LINEAR_OAUTH_SECRET_ARN if the orchestrator set it that way. + secret_arn = "" + if channel_metadata: + secret_arn = channel_metadata.get("linear_oauth_secret_arn", "") if not secret_arn: + secret_arn = os.environ.get("LINEAR_OAUTH_SECRET_ARN", "") + if not secret_arn: + return "" + + region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") + if not region: + log("WARN", "resolve_linear_api_token: AWS_REGION not set; cannot resolve token") return "" + try: + import json + from datetime import datetime, timedelta + import boto3 from botocore.exceptions import BotoCoreError, ClientError except ImportError as e: - # boto3 missing from the container image — degrade gracefully rather - # than hard-crashing the agent. The Linear MCP will fail on first - # call with a clear auth error. log("WARN", f"resolve_linear_api_token: boto3 unavailable ({e}); skipping") return "" + sm = boto3.client("secretsmanager", region_name=region) + + def _fetch_token() -> dict: + resp = sm.get_secret_value(SecretId=secret_arn) + return json.loads(resp["SecretString"]) + + def _is_expiring(expires_at_iso: str, threshold_seconds: int = 60) -> bool: + try: + expiry = datetime.fromisoformat(expires_at_iso.replace("Z", "+00:00")) + except ValueError: + return True + return (expiry - datetime.now(UTC)).total_seconds() < threshold_seconds + + def _refresh(current: dict) -> dict | None: + try: + import urllib.parse + import urllib.request + except ImportError: + return None + + body = urllib.parse.urlencode( + { + "grant_type": "refresh_token", + "refresh_token": current["refresh_token"], + "client_id": current["client_id"], + "client_secret": current["client_secret"], + } + ).encode("utf-8") + req = urllib.request.Request( + "https://api.linear.app/oauth/token", + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310 + payload = json.loads(resp.read().decode("utf-8")) + except Exception as e: + log("WARN", f"resolve_linear_api_token refresh failed: {type(e).__name__}: {e}") + return None + + if "access_token" not in payload: + return None + + now = datetime.now(UTC) + # Linear's `expires_in` is documented and reliably sent; if it's + # missing we assume the access token is already valid for as long + # as the refresh-token call took to round-trip — set expiry to now. + if "expires_in" in payload: + future = now + timedelta(seconds=int(payload["expires_in"])) + expires_at_iso = future.replace(microsecond=0).isoformat().replace("+00:00", "Z") + else: + expires_at_iso = now.replace(microsecond=0).isoformat().replace("+00:00", "Z") + next_token = { + **current, + "access_token": payload["access_token"], + "refresh_token": payload.get("refresh_token", current["refresh_token"]), + "expires_at": expires_at_iso, + "scope": payload.get("scope", current["scope"]), + "updated_at": now.isoformat().replace("+00:00", "Z"), + } + + try: + sm.put_secret_value(SecretId=secret_arn, SecretString=json.dumps(next_token)) + except (ClientError, BotoCoreError) as e: + log("WARN", f"resolve_linear_api_token: failed to persist refreshed token: {e}") + # Even without persistence the in-memory token works for THIS run. + return next_token + try: - region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") - client = boto3.client("secretsmanager", region_name=region) - resp = client.get_secret_value(SecretId=secret_arn) - token = resp.get("SecretString", "") or "" - if token: - os.environ["LINEAR_API_TOKEN"] = token - return token - except ClientError as e: - # Narrowed from a broader `except` per #63 review — broader catches - # hid genuine bugs in the Secrets Manager call shape. AccessDenied - # is logged at ERROR because it's a persistent IAM misconfig that - # should page someone, not a transient blip. - code = e.response.get("Error", {}).get("Code", "") - severity = "ERROR" if code == "AccessDeniedException" else "WARN" + token_obj = _fetch_token() + except (ClientError, BotoCoreError) as e: + code = "" + if hasattr(e, "response"): + code = getattr(e, "response", {}).get("Error", {}).get("Code", "") or "" + is_hard_failure = code in ("AccessDeniedException", "ResourceNotFoundException") + severity = "ERROR" if is_hard_failure else "WARN" log(severity, f"resolve_linear_api_token failed: {type(e).__name__}: {e}") return "" - except BotoCoreError as e: - # Never let a Secrets Manager outage crash the agent. The Linear MCP - # will simply fail on first call with a clear auth error. - log("WARN", f"resolve_linear_api_token failed: {type(e).__name__}: {e}") - return "" + + if _is_expiring(token_obj.get("expires_at", "")): + refreshed = _refresh(token_obj) + if refreshed: + token_obj = refreshed + + access = token_obj.get("access_token", "") + if access: + os.environ["LINEAR_API_TOKEN"] = access + return access def build_config( diff --git a/agent/src/pipeline.py b/agent/src/pipeline.py index 5b1cd5b9..963c3405 100644 --- a/agent/src/pipeline.py +++ b/agent/src/pipeline.py @@ -428,7 +428,7 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # writing .mcp.json so the child SDK process inherits the env var # that the MCP server entry references via ${LINEAR_API_TOKEN}. if config.channel_source == "linear": - resolve_linear_api_token() + resolve_linear_api_token(config.channel_metadata) configure_channel_mcp(setup.repo_dir, config.channel_source) # 👀 on the Linear issue — acknowledges the task is picked up. diff --git a/agent/src/server.py b/agent/src/server.py index 041df5a5..ed85551b 100644 --- a/agent/src/server.py +++ b/agent/src/server.py @@ -280,6 +280,38 @@ async def lifespan(_application: FastAPI): app = FastAPI(title="Background Agent", version="1.0.0", lifespan=lifespan) +def _extract_workload_access_token(request: Request) -> str: + """Read AgentCore's workload access token off the inbound request. + + AgentCore Runtime delivers the token on `/invocations` requests under + one of two header spellings (both observed 2026-05-18 on a single + request via diagnostic logging in us-east-1): + 1. ``WorkloadAccessToken`` — the SDK's documented header in + ``bedrock_agentcore.runtime.models::ACCESS_TOKEN_HEADER``. + 2. ``x-amzn-bedrock-agentcore-runtime-workload-accesstoken`` — + undocumented but present on the wire; included for forward + compatibility. + + The token must be propagated explicitly into the pipeline thread (see + ``_run_task_background``) because Python ``ContextVar`` is per-thread, + not per-request — the SDK's bundled ``_build_request_context`` + middleware sets it in the request handler's async context, but our + pipeline runs in a separate ``threading.Thread`` spawned by + ``_spawn_background``. The new thread sees a fresh empty ContextVar + unless we re-set it on entry. + + See aws/bedrock-agentcore-sdk-python#219 for the upstream tracking + issue (per-thread ContextVar) and the workaround pattern in + ``awslabs/agentcore-samples`` 07-Outbound_Auth_3LO_ECS_Fargate. + """ + return ( + request.headers.get("WorkloadAccessToken") + or request.headers.get("x-amzn-bedrock-agentcore-runtime-workload-accesstoken") + or request.headers.get("x-amzn-bedrock-agentcore-workload-access-token") + or "" + ) + + class InvocationRequest(BaseModel): input: dict[str, Any] @@ -352,10 +384,24 @@ def _run_task_background( channel_metadata: dict[str, str] | None = None, trace: bool = False, user_id: str = "", + workload_access_token: str = "", ) -> None: """Run the agent task in a background thread.""" global _background_pipeline_failed + # Re-establish the AgentCore workload-token ContextVar in this thread. + # Python ContextVar storage is per-thread, so the request-handler thread's + # context (where BedrockAgentCoreApp's _build_request_context would normally + # set this) doesn't propagate to here. Without this re-set, + # IdentityClient.get_api_key() callers like resolve_linear_api_token() + # short-circuit on a None workload token even when the platform delivered + # one. See aws/bedrock-agentcore-sdk-python#219 for the upstream design + # constraint that motivates this manual propagation. + if workload_access_token: + from bedrock_agentcore.runtime.context import BedrockAgentCoreContext + + BedrockAgentCoreContext.set_workload_access_token(workload_access_token) + _debug_cw( f"_run_task_background ENTERED task_id={task_id!r} " f"thread={threading.current_thread().name!r}", @@ -529,6 +575,13 @@ def _extract_invocation_params(inp: dict, request: Request) -> dict: if started_at and isinstance(started_at, str): os.environ["TASK_STARTED_AT"] = started_at + # AgentCore-injected workload access token (see _extract_workload_access_token + # for full rationale). Threaded into _run_task_background so the pipeline + # thread can call BedrockAgentCoreContext.set_workload_access_token() on entry + # — without that the IdentityClient.get_api_key path used by + # resolve_linear_api_token() returns None. + workload_access_token = _extract_workload_access_token(request) + return { "repo_url": repo_url, "task_description": task_description, @@ -556,6 +609,7 @@ def _extract_invocation_params(inp: dict, request: Request) -> dict: "channel_metadata": channel_metadata, "trace": trace, "user_id": user_id, + "workload_access_token": workload_access_token, } diff --git a/agent/tests/test_config.py b/agent/tests/test_config.py index 4421945e..91561eef 100644 --- a/agent/tests/test_config.py +++ b/agent/tests/test_config.py @@ -1,6 +1,6 @@ """Unit tests for config.py — build_config and constants.""" -import sys +from datetime import UTC from unittest.mock import MagicMock, patch import pytest @@ -91,87 +91,113 @@ def test_auto_generated_task_id(self): class TestResolveLinearApiToken: - """Coverage for the secrets-manager + boto3 fallback paths.""" - - def test_returns_cached_env_var_without_calling_boto(self, monkeypatch): - monkeypatch.setenv("LINEAR_API_TOKEN", "lin_cached") - monkeypatch.setenv("LINEAR_API_TOKEN_SECRET_ARN", "arn:aws:sm:::secret/linear") - # boto3 must not be touched if the env var is already set. - with patch("config.log") as mock_log: - assert resolve_linear_api_token() == "lin_cached" - mock_log.assert_not_called() - - def test_returns_empty_when_no_secret_arn(self, monkeypatch): + """Phase 2.0b-O2: token resolves from per-workspace Secrets Manager. + + The orchestrator stamps `linear_oauth_secret_arn` into the task's + channel_metadata at creation time. resolve_linear_api_token reads + the secret JSON via boto3, refreshes it if expiring, and caches the + access_token in `LINEAR_API_TOKEN` for the Linear MCP placeholder. + """ + + def test_returns_cached_value_without_calling_secrets_manager(self, monkeypatch): + """Fast-path: if LINEAR_API_TOKEN is already set, no SDK call fires.""" + monkeypatch.setenv("LINEAR_API_TOKEN", "lin_oauth_cached") + with patch("boto3.client") as mock_boto: + assert resolve_linear_api_token() == "lin_oauth_cached" + mock_boto.assert_not_called() + + def test_returns_empty_when_secret_arn_missing(self, monkeypatch): + """Without channel_metadata.linear_oauth_secret_arn or env, no source — empty.""" monkeypatch.delenv("LINEAR_API_TOKEN", raising=False) - monkeypatch.delenv("LINEAR_API_TOKEN_SECRET_ARN", raising=False) - assert resolve_linear_api_token() == "" - - def test_import_error_degrades_gracefully(self, monkeypatch): - """If boto3 is missing from the container image, log WARN and return '' - rather than crashing the agent.""" - monkeypatch.delenv("LINEAR_API_TOKEN", raising=False) - monkeypatch.setenv("LINEAR_API_TOKEN_SECRET_ARN", "arn:aws:sm:::secret/linear") - # Force `import boto3` (executed inside resolve_linear_api_token) to - # raise ImportError by removing it from sys.modules and shadowing it. - monkeypatch.setitem(sys.modules, "boto3", None) - with patch("config.log") as mock_log: + monkeypatch.delenv("LINEAR_OAUTH_SECRET_ARN", raising=False) + with patch("boto3.client") as mock_boto: assert resolve_linear_api_token() == "" - # WARN logged, no exception escaped. - assert mock_log.call_count == 1 - assert mock_log.call_args[0][0] == "WARN" - assert "boto3 unavailable" in mock_log.call_args[0][1] - - def test_access_denied_logged_at_error(self, monkeypatch): - """Persistent IAM misconfig should page someone — escalate from WARN - to ERROR so alerts fire.""" - monkeypatch.delenv("LINEAR_API_TOKEN", raising=False) - monkeypatch.setenv("LINEAR_API_TOKEN_SECRET_ARN", "arn:aws:sm:::secret/linear") + mock_boto.assert_not_called() - from botocore.exceptions import ClientError + def test_returns_empty_when_region_missing(self, monkeypatch): + """No region → can't construct boto3 client → empty + WARN, no SDK call.""" + monkeypatch.delenv("LINEAR_API_TOKEN", raising=False) + monkeypatch.delenv("AWS_REGION", raising=False) + monkeypatch.delenv("AWS_DEFAULT_REGION", raising=False) + with patch("boto3.client") as mock_boto: + assert resolve_linear_api_token({"linear_oauth_secret_arn": "arn:test"}) == "" + mock_boto.assert_not_called() - err = ClientError( - {"Error": {"Code": "AccessDeniedException", "Message": "no access"}}, - "GetSecretValue", - ) - fake_client = MagicMock() - fake_client.get_secret_value.side_effect = err - with patch("boto3.client", return_value=fake_client), patch("config.log") as mock_log: - assert resolve_linear_api_token() == "" - assert mock_log.call_count == 1 - assert mock_log.call_args[0][0] == "ERROR" + def test_resolves_from_secrets_manager_and_caches_in_env(self, monkeypatch): + """Happy path: channel_metadata carries the ARN, secret has access_token + future expiry.""" + from datetime import datetime, timedelta - def test_other_client_error_logged_at_warn(self, monkeypatch): monkeypatch.delenv("LINEAR_API_TOKEN", raising=False) - monkeypatch.setenv("LINEAR_API_TOKEN_SECRET_ARN", "arn:aws:sm:::secret/linear") + monkeypatch.setenv("AWS_REGION", "us-east-1") + future = (datetime.now(UTC) + timedelta(hours=12)).isoformat().replace("+00:00", "Z") + token_payload = { + "access_token": "lin_oauth_fresh", + "refresh_token": "lin_refresh_xyz", + "expires_at": future, + "scope": "read write app:assignable app:mentionable", + "client_id": "cid", + "client_secret": "csec", + "workspace_id": "ws-uuid", + "workspace_slug": "acme", + "installed_at": "2026-05-19T08:00:00Z", + "updated_at": "2026-05-19T08:00:00Z", + "installed_by_platform_user_id": "cog-sub", + } + mock_sm = MagicMock() + mock_sm.get_secret_value.return_value = { + "SecretString": __import__("json").dumps(token_payload), + } + with patch("boto3.client", return_value=mock_sm): + resolved = resolve_linear_api_token({"linear_oauth_secret_arn": "arn:test"}) + assert resolved == "lin_oauth_fresh" + + # Cached for subsequent reads. + import os as _os + + assert _os.environ.get("LINEAR_API_TOKEN") == "lin_oauth_fresh" + # Reset for other tests. + monkeypatch.delenv("LINEAR_API_TOKEN", raising=False) + def test_returns_empty_on_secrets_manager_access_denied(self, monkeypatch): + """ClientError surfaces as empty + ERROR log, never crashes the agent.""" from botocore.exceptions import ClientError - err = ClientError( - {"Error": {"Code": "ResourceNotFoundException", "Message": "missing"}}, + monkeypatch.delenv("LINEAR_API_TOKEN", raising=False) + monkeypatch.setenv("AWS_REGION", "us-east-1") + mock_sm = MagicMock() + mock_sm.get_secret_value.side_effect = ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "no perms"}}, "GetSecretValue", ) - fake_client = MagicMock() - fake_client.get_secret_value.side_effect = err - with patch("boto3.client", return_value=fake_client), patch("config.log") as mock_log: - assert resolve_linear_api_token() == "" - assert mock_log.call_count == 1 - assert mock_log.call_args[0][0] == "WARN" - - def test_botocore_error_logged_at_warn(self, monkeypatch): - """The handler is split into ClientError + BotoCoreError branches. - BotoCoreError covers transient connectivity / endpoint problems — - log WARN and degrade gracefully rather than crashing the agent.""" - monkeypatch.delenv("LINEAR_API_TOKEN", raising=False) - monkeypatch.setenv("LINEAR_API_TOKEN_SECRET_ARN", "arn:aws:sm:::secret/linear") + with patch("boto3.client", return_value=mock_sm): + assert resolve_linear_api_token({"linear_oauth_secret_arn": "arn:test"}) == "" - from botocore.exceptions import EndpointConnectionError + def test_falls_back_to_env_var_when_channel_metadata_omits_arn(self, monkeypatch): + """LINEAR_OAUTH_SECRET_ARN env var is the back-compat fallback.""" + from datetime import datetime, timedelta - fake_client = MagicMock() - fake_client.get_secret_value.side_effect = EndpointConnectionError( - endpoint_url="https://secretsmanager.us-east-1.amazonaws.com", - ) - with patch("boto3.client", return_value=fake_client), patch("config.log") as mock_log: - assert resolve_linear_api_token() == "" - assert mock_log.call_count == 1 - assert mock_log.call_args[0][0] == "WARN" - assert "EndpointConnectionError" in mock_log.call_args[0][1] + monkeypatch.delenv("LINEAR_API_TOKEN", raising=False) + monkeypatch.setenv("AWS_REGION", "us-east-1") + monkeypatch.setenv("LINEAR_OAUTH_SECRET_ARN", "arn:from-env") + future = (datetime.now(UTC) + timedelta(hours=12)).isoformat().replace("+00:00", "Z") + mock_sm = MagicMock() + mock_sm.get_secret_value.return_value = { + "SecretString": __import__("json").dumps( + { + "access_token": "lin_oauth_envpath", + "refresh_token": "rt", + "expires_at": future, + "scope": "read", + "client_id": "c", + "client_secret": "s", + "workspace_id": "w", + "workspace_slug": "s", + "installed_at": "x", + "updated_at": "x", + "installed_by_platform_user_id": "u", + } + ), + } + with patch("boto3.client", return_value=mock_sm): + assert resolve_linear_api_token() == "lin_oauth_envpath" + monkeypatch.delenv("LINEAR_API_TOKEN", raising=False) diff --git a/agent/uv.lock b/agent/uv.lock index edb8d42a..f38f25c0 100644 --- a/agent/uv.lock +++ b/agent/uv.lock @@ -133,6 +133,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "aws-opentelemetry-distro" }, + { name = "bedrock-agentcore" }, { name = "boto3" }, { name = "cedarpy" }, { name = "claude-agent-sdk" }, @@ -153,6 +154,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "aws-opentelemetry-distro", specifier = "==0.17.0" }, + { name = "bedrock-agentcore", specifier = "==1.9.1" }, { name = "boto3", specifier = "==1.43.9" }, { name = "cedarpy", specifier = "==4.8.0" }, { name = "claude-agent-sdk", specifier = "==0.2.82" }, @@ -170,6 +172,25 @@ dev = [ { name = "ty" }, ] +[[package]] +name = "bedrock-agentcore" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boto3" }, + { name = "botocore" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "uvicorn" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/ba/91b6ec49558755cccc5bfa5a64916995baed5490768bee33581b370a1e4e/bedrock_agentcore-1.9.1.tar.gz", hash = "sha256:f0e69b41c32c12e395d698299c96981d34035dafa90e0e79fcbd743574315c6a", size = 692593, upload-time = "2026-05-12T21:50:47.639Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/05/a5fbaa2320c34f8df196c105ca1938848845216cacc36850c73d116f28a9/bedrock_agentcore-1.9.1-py3-none-any.whl", hash = "sha256:f323c3d943dfe1defd52febd1409f8c4d04c0fc37848dd100ede692c2a6addd2", size = 262193, upload-time = "2026-05-12T21:50:45.506Z" }, +] + [[package]] name = "boto3" version = "1.43.9" @@ -2053,6 +2074,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/41/ac2dfdbc1f60c7af4f994c7a335cfa7040c01642b605d65f611cecc2a1e4/uvicorn-0.47.0-py3-none-any.whl", hash = "sha256:2c5715bc12d1892d84752049f400cd1c3cb018514967fdfeb97640443a6a9432", size = 71301, upload-time = "2026-05-14T18:16:51.762Z" }, ] +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + [[package]] name = "wrapt" version = "1.17.3" diff --git a/cdk/src/constructs/linear-integration.ts b/cdk/src/constructs/linear-integration.ts index e50c777f..37adde7e 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 { Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import { ArnFormat, 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 { LinearProjectMappingTable } from './linear-project-mapping-table'; import { LinearUserMappingTable } from './linear-user-mapping-table'; +import { LinearWorkspaceRegistryTable } from './linear-workspace-registry-table'; /** * Properties for LinearIntegration construct. @@ -76,6 +77,10 @@ export interface LinearIntegrationProps { * Creates: * - LinearProjectMappingTable (Linear project → GitHub repo mapping) * - LinearUserMappingTable (Linear user → platform user mapping) + * - LinearWorkspaceRegistryTable (Linear workspace → AgentCore credential + * provider name; Phase 2.0b OAuth migration). Webhook processor and + * orchestrator use this to look up which credential provider holds the + * workspace's OAuth token. * - LinearWebhookDedupTable (60s TTL dedup for webhook retries) * - Lambda handlers for the webhook receiver, async processor, and account linking * - API Gateway routes under /linear/* @@ -88,18 +93,19 @@ export class LinearIntegration extends Construct { /** Linear user → platform user mapping table. */ public readonly userMappingTable: dynamodb.Table; + /** + * Registry of Linear workspaces that have completed OAuth onboarding. + * Lookup `provider_name` (AgentCore credential provider) by Linear + * `organizationId` from the inbound webhook. + */ + public readonly workspaceRegistryTable: dynamodb.Table; + /** Webhook dedup table — (issue_id, action) keys with 60s TTL. */ public readonly webhookDedupTable: dynamodb.Table; /** Linear webhook signing secret (placeholder — populated by `bgagent linear setup`). */ public readonly webhookSecret: secretsmanager.Secret; - /** - * Linear personal API token used by the agent-side MCP (placeholder — - * populated by `bgagent linear setup`). - */ - public readonly apiTokenSecret: secretsmanager.Secret; - constructor(scope: Construct, id: string, props: LinearIntegrationProps) { super(scope, id); @@ -108,8 +114,10 @@ export class LinearIntegration extends Construct { // --- DynamoDB tables --- const projectMapping = new LinearProjectMappingTable(this, 'ProjectMappingTable', { removalPolicy }); const userMapping = new LinearUserMappingTable(this, 'UserMappingTable', { removalPolicy }); + const workspaceRegistry = new LinearWorkspaceRegistryTable(this, 'WorkspaceRegistryTable', { removalPolicy }); this.projectMappingTable = projectMapping.table; this.userMappingTable = userMapping.table; + this.workspaceRegistryTable = workspaceRegistry.table; // Dedup table: linear webhook retries collapse to a single processor invoke // within the 60s TTL window. Keyed on `{issue_id}#{action}`. @@ -121,15 +129,13 @@ export class LinearIntegration extends Construct { removalPolicy, }); - // --- Secrets (CDK-created placeholders, populated by `bgagent linear setup`) --- + // --- Webhook signing secret (CDK-created placeholder, populated by `bgagent linear setup`) --- + // Per-workspace OAuth tokens (Phase 2.0b-O2) live in `bgagent-linear-oauth-` + // secrets created by the CLI at runtime — not here. this.webhookSecret = new secretsmanager.Secret(this, 'WebhookSecret', { description: 'Linear webhook signing secret — populate via `bgagent linear setup`', removalPolicy, }); - this.apiTokenSecret = new secretsmanager.Secret(this, 'ApiTokenSecret', { - description: 'Linear personal API token for agent-side MCP — populate via `bgagent linear setup`', - removalPolicy, - }); // --- Shared Lambda configuration --- const handlersDir = path.join(__dirname, '..', 'handlers'); @@ -181,13 +187,29 @@ export class LinearIntegration extends Construct { ...createTaskEnv, LINEAR_PROJECT_MAPPING_TABLE_NAME: this.projectMappingTable.tableName, LINEAR_USER_MAPPING_TABLE_NAME: this.userMappingTable.tableName, - LINEAR_API_TOKEN_SECRET_ARN: this.apiTokenSecret.secretArn, + LINEAR_WORKSPACE_REGISTRY_TABLE_NAME: this.workspaceRegistryTable.tableName, }, bundling: commonBundling, }); this.projectMappingTable.grantReadData(webhookProcessorFn); this.userMappingTable.grantReadData(webhookProcessorFn); - this.apiTokenSecret.grantRead(webhookProcessorFn); + this.workspaceRegistryTable.grantReadData(webhookProcessorFn); + // Phase 2.0b-O2: per-workspace OAuth token secrets are created by the + // CLI at setup time (`bgagent-linear-oauth-`), not by CDK. Grant + // the webhook processor Get + Put on the prefix so it can read tokens + // and write back rotated refresh-token JSON during expiring-token + // refresh. + webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['secretsmanager:GetSecretValue', 'secretsmanager:PutSecretValue'], + resources: [ + Stack.of(this).formatArn({ + service: 'secretsmanager', + resource: 'secret', + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + resourceName: 'bgagent-linear-oauth-*', + }), + ], + })); props.taskTable.grantReadWriteData(webhookProcessorFn); props.taskEventsTable.grantReadWriteData(webhookProcessorFn); if (props.repoTable) { @@ -281,14 +303,12 @@ export class LinearIntegration extends Construct { }, ]); - for (const secret of [this.webhookSecret, this.apiTokenSecret]) { - NagSuppressions.addResourceSuppressions(secret, [ - { - id: 'AwsSolutions-SMG4', - reason: 'Linear credentials are managed externally (Linear web UI) — automatic rotation is not applicable', - }, - ]); - } + NagSuppressions.addResourceSuppressions(this.webhookSecret, [ + { + id: 'AwsSolutions-SMG4', + reason: 'Linear webhook signing secret is managed externally (Linear web UI) — automatic rotation is not applicable', + }, + ]); const allFunctions = [webhookFn, webhookProcessorFn, linkFn]; for (const fn of allFunctions) { diff --git a/cdk/src/constructs/linear-workspace-registry-table.ts b/cdk/src/constructs/linear-workspace-registry-table.ts new file mode 100644 index 00000000..6cabe743 --- /dev/null +++ b/cdk/src/constructs/linear-workspace-registry-table.ts @@ -0,0 +1,91 @@ +/** + * 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 { RemovalPolicy } from 'aws-cdk-lib'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import { Construct } from 'constructs'; + +/** + * Properties for LinearWorkspaceRegistryTable construct. + */ +export interface LinearWorkspaceRegistryTableProps { + /** + * Optional table name override. + * @default - auto-generated by CloudFormation + */ + readonly tableName?: string; + + /** + * Removal policy for the table. + * @default RemovalPolicy.DESTROY + */ + readonly removalPolicy?: RemovalPolicy; + + /** + * Whether to enable point-in-time recovery. + * @default true + */ + readonly pointInTimeRecovery?: boolean; +} + +/** + * DynamoDB table tracking Linear workspaces that have completed OAuth onboarding. + * + * Schema: linear_workspace_id (PK) — Linear's organization UUID. + * + * Fields: + * - workspace_slug — Linear `urlKey`, used to derive the AgentCore credential + * provider name (`linear-oauth-`) and shown in CLI output + * - provider_name — full AgentCore credential provider name, the lookup key + * for resolving the workspace's OAuth token via AgentCore Identity + * - installed_by_platform_user_id — Cognito sub of the admin who ran + * `bgagent linear setup` (audit only; runtime callers do not need this) + * - installed_at, updated_at — ISO timestamps + * - status — 'active' | 'revoked' + * + * The webhook processor and orchestrator look up `provider_name` here from + * the inbound webhook's `organizationId`, then call AgentCore Identity with + * `userId='linear-workspace-'` to retrieve the workspace's + * OAuth token. Token sharing is intentional — one bgagent[bot] identity + * per workspace, used for all members' triggered tasks (matches the v1 + * personal-API-key semantics). + */ +export class LinearWorkspaceRegistryTable extends Construct { + /** + * The underlying DynamoDB table. + */ + public readonly table: dynamodb.Table; + + constructor(scope: Construct, id: string, props: LinearWorkspaceRegistryTableProps = {}) { + super(scope, id); + + this.table = new dynamodb.Table(this, 'Table', { + tableName: props.tableName, + partitionKey: { + name: 'linear_workspace_id', + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + pointInTimeRecoverySpecification: { + pointInTimeRecoveryEnabled: props.pointInTimeRecovery ?? true, + }, + removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY, + }); + } +} diff --git a/cdk/src/constructs/task-orchestrator.ts b/cdk/src/constructs/task-orchestrator.ts index ceb82733..66087744 100644 --- a/cdk/src/constructs/task-orchestrator.ts +++ b/cdk/src/constructs/task-orchestrator.ts @@ -218,6 +218,21 @@ export class TaskOrchestrator extends Construct { '@aws-sdk/lib-dynamodb', '@aws-sdk/util-dynamodb', ], + // `@aws/durable-execution-sdk-js@1.1.3` ships an ESM build at + // `dist/index.mjs` that uses `fileURLToPath(import.meta.url)` to + // compute __dirname. When esbuild bundles ESM-into-CJS for Lambda, + // it stubs `import.meta = {}` so `import.meta.url` is undefined + // and `fileURLToPath(undefined)` crashes at module-load. Upstream + // issue: aws/aws-durable-execution-sdk-js#543. Discovered via + // 2.0b-O2 deploy 2026-05-20. + // + // Substitute `import.meta.url` with a banner-defined identifier + // that holds the file:// URL form of the bundled file's path. + // `fileURLToPath` rejects plain paths — it requires file:// URLs. + // The SDK uses the result for `dirname()` + `createRequire()`, + // both of which work fine against the bundled file's location. + define: { 'import.meta.url': '__bundled_import_meta_url' }, + banner: 'const __bundled_import_meta_url = require("url").pathToFileURL(__filename).href;', }, }); @@ -245,11 +260,19 @@ export class TaskOrchestrator extends Construct { // AgentCore runtime invocation permissions // The InvokeAgentRuntime API targets a sub-resource (runtime-endpoint/DEFAULT), // so we need a wildcard after the runtime ARN. + // + // `InvokeAgentRuntimeForUser` is required when the call passes + // `runtimeUserId` (Phase 2.0a — needed for AgentCore Identity to + // inject a `WorkloadAccessToken` header into the agent container so + // `BedrockAgentCoreContext.get_workload_access_token()` returns + // non-None). Without this grant, `InvokeAgentRuntimeCommand` with + // `runtimeUserId` set fails with AccessDenied. const runtimeArns = [props.runtimeArn, ...(props.additionalRuntimeArns ?? [])]; const runtimeResources = runtimeArns.flatMap(arn => [arn, `${arn}/*`]); this.fn.addToRolePolicy(new iam.PolicyStatement({ actions: [ 'bedrock-agentcore:InvokeAgentRuntime', + 'bedrock-agentcore:InvokeAgentRuntimeForUser', 'bedrock-agentcore:StopRuntimeSession', ], resources: runtimeResources, diff --git a/cdk/src/handlers/linear-webhook-processor.ts b/cdk/src/handlers/linear-webhook-processor.ts index 3aecc4b0..c028ef58 100644 --- a/cdk/src/handlers/linear-webhook-processor.ts +++ b/cdk/src/handlers/linear-webhook-processor.ts @@ -22,43 +22,61 @@ import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb'; import { createTaskCore } from './shared/create-task-core'; import { reportIssueFailure } from './shared/linear-feedback'; +import { resolveLinearOauthToken } from './shared/linear-oauth-resolver'; import { logger } from './shared/logger'; const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); const PROJECT_MAPPING_TABLE = process.env.LINEAR_PROJECT_MAPPING_TABLE_NAME!; const USER_MAPPING_TABLE = process.env.LINEAR_USER_MAPPING_TABLE_NAME!; -const API_TOKEN_SECRET_ARN = process.env.LINEAR_API_TOKEN_SECRET_ARN; +const WORKSPACE_REGISTRY_TABLE = process.env.LINEAR_WORKSPACE_REGISTRY_TABLE_NAME; const DEFAULT_LABEL_FILTER = 'bgagent'; /** * Post a Linear comment + ❌ reaction without ever propagating an error. * - * Wraps `reportIssueFailure` so each call site is one line and uniformly - * non-throwing. Two failure modes handled here: + * Phase 2.0b-O2: feedback is workspace-scoped — the resolver looks up + * the per-workspace OAuth token via `LinearWorkspaceRegistryTable` and + * issues a Bearer token. If the workspace isn't registered (drop-on-the-floor + * for unmapped orgs) the feedback path no-ops cleanly. * - * - `LINEAR_API_TOKEN_SECRET_ARN` env var unset (deploy misconfig) — log a - * single clear diagnostic and skip, instead of letting `resolveToken` log - * a cryptic "could not resolve API token" warning on every feedback call. - * Mirrors the orchestrator's `notifyLinearOnConcurrencyCap` guard. + * Two failure modes handled here: + * - `LINEAR_WORKSPACE_REGISTRY_TABLE_NAME` env var unset (deploy misconfig) — + * skip with a clear diagnostic instead of letting the resolver fail + * per-call. * - `reportIssueFailure` throws synchronously (today impossible thanks to the * helper's internal `Promise.allSettled`, but a future refactor could * break that contract). Catching here means a synchronous throw can't * bubble up and fail the Lambda — which would trigger SQS retries on a * poison message. */ -async function safeReportIssueFailure(issueId: string, message: string): Promise { - if (!API_TOKEN_SECRET_ARN) { - logger.warn('Skipping Linear feedback: LINEAR_API_TOKEN_SECRET_ARN not set', { +async function safeReportIssueFailure( + issueId: string, + linearWorkspaceId: string | undefined, + message: string, +): Promise { + if (!WORKSPACE_REGISTRY_TABLE) { + logger.warn('Skipping Linear feedback: LINEAR_WORKSPACE_REGISTRY_TABLE_NAME not set', { + issue_id: issueId, + }); + return; + } + if (!linearWorkspaceId) { + logger.warn('Skipping Linear feedback: webhook payload missing organizationId', { issue_id: issueId, }); return; } try { - await reportIssueFailure(API_TOKEN_SECRET_ARN, issueId, message); + await reportIssueFailure( + { linearWorkspaceId, registryTableName: WORKSPACE_REGISTRY_TABLE }, + issueId, + message, + ); } catch (err) { logger.warn('Linear feedback failed (non-fatal)', { issue_id: issueId, + linear_workspace_id: linearWorkspaceId, error: err instanceof Error ? err.message : String(err), }); } @@ -137,6 +155,7 @@ export async function handler(event: ProcessorEvent): Promise { }); await safeReportIssueFailure( issue.id, + payload.organizationId, "❌ This Linear issue isn't in a project — ABCA needs a Linear project to route the task to a repo. Move the issue into a project and re-apply the trigger label.", ); return; @@ -154,6 +173,7 @@ export async function handler(event: ProcessorEvent): Promise { }); await safeReportIssueFailure( issue.id, + payload.organizationId, "❌ This Linear project isn't onboarded to ABCA. An admin can onboard it with `bgagent linear onboard-project --repo / --label `.", ); return; @@ -190,6 +210,7 @@ export async function handler(event: ProcessorEvent): Promise { }); await safeReportIssueFailure( issue.id, + workspaceId, "❌ Linear webhook is missing the organization or actor field — ABCA can't attribute this task to a user. This is unusual; please report it to your ABCA admin.", ); return; @@ -204,6 +225,7 @@ export async function handler(event: ProcessorEvent): Promise { }); await safeReportIssueFailure( issue.id, + workspaceId, "❌ This Linear user isn't linked to a platform user. In v1 only the API-token owner can submit tasks from Linear; multi-user OAuth support is on the v3 roadmap.", ); return; @@ -223,6 +245,23 @@ export async function handler(event: ProcessorEvent): Promise { channelMetadata.linear_team_id = issue.teamId; } + // Phase 2.0b-O2: resolve the workspace's OAuth secret ARN ONCE here + // and stash it on the task record. The agent runtime reads it directly + // (no registry lookup at task-execution time). If the workspace isn't + // onboarded the agent's outbound Linear MCP simply skips. + if (WORKSPACE_REGISTRY_TABLE) { + const resolved = await resolveLinearOauthToken(workspaceId, WORKSPACE_REGISTRY_TABLE); + if (resolved) { + channelMetadata.linear_oauth_secret_arn = resolved.oauthSecretArn; + channelMetadata.linear_workspace_slug = resolved.workspaceSlug; + } else { + logger.warn('Linear workspace not in registry — agent will run without Linear MCP', { + linear_workspace_id: workspaceId, + issue_id: issue.id, + }); + } + } + const requestId = crypto.randomUUID(); const result = await createTaskCore( { @@ -245,6 +284,7 @@ export async function handler(event: ProcessorEvent): Promise { }); await safeReportIssueFailure( issue.id, + workspaceId, buildCreateTaskFailureMessage(result.statusCode, result.body), ); return; diff --git a/cdk/src/handlers/orchestrate-task.ts b/cdk/src/handlers/orchestrate-task.ts index fe244559..e2f45b71 100644 --- a/cdk/src/handlers/orchestrate-task.ts +++ b/cdk/src/handlers/orchestrate-task.ts @@ -137,7 +137,12 @@ const durableHandler: DurableExecutionHandler = asyn const sessionHandle = await context.step('start-session', async () => { try { const strategy = resolveComputeStrategy(blueprintConfig); - const handle = await strategy.startSession({ taskId, payload, blueprintConfig }); + const handle = await strategy.startSession({ + taskId, + userId: task.user_id, + payload, + blueprintConfig, + }); // Build compute metadata for the task record so cancel-task can stop the right backend const computeMetadata: Record = handle.strategyType === 'ecs' @@ -299,16 +304,17 @@ export const handler = withDurableExecution(durableHandler); export async function notifyLinearOnConcurrencyCap(task: TaskRecord): Promise { if (task.channel_source !== 'linear') return; const issueId = task.channel_metadata?.linear_issue_id; - if (!issueId) return; - const secretArn = process.env.LINEAR_API_TOKEN_SECRET_ARN; - if (!secretArn) { - logger.warn('Skipping Linear concurrency-cap feedback: LINEAR_API_TOKEN_SECRET_ARN not set', { + const linearWorkspaceId = task.channel_metadata?.linear_workspace_id; + if (!issueId || !linearWorkspaceId) return; + const registryTableName = process.env.LINEAR_WORKSPACE_REGISTRY_TABLE_NAME; + if (!registryTableName) { + logger.warn('Skipping Linear concurrency-cap feedback: LINEAR_WORKSPACE_REGISTRY_TABLE_NAME not set', { task_id: task.task_id, }); return; } await reportIssueFailure( - secretArn, + { linearWorkspaceId, registryTableName }, issueId, '❌ ABCA hit your concurrency limit — too many tasks running for your user. Wait for one to finish, then re-apply the trigger label.', ); diff --git a/cdk/src/handlers/shared/compute-strategy.ts b/cdk/src/handlers/shared/compute-strategy.ts index e3d3c1d1..c3de5886 100644 --- a/cdk/src/handlers/shared/compute-strategy.ts +++ b/cdk/src/handlers/shared/compute-strategy.ts @@ -34,6 +34,18 @@ export interface ComputeStrategy { readonly type: ComputeType; startSession(input: { taskId: string; + /** + * Stable user identifier (the task's Cognito sub) propagated to + * AgentCore via `runtimeUserId` on `InvokeAgentRuntimeCommand`. Used + * by AgentCore Identity to derive a workload access token and inject + * it into the agent container via the `WorkloadAccessToken` request + * header. Without this, `BedrockAgentCoreContext.get_workload_ + * access_token()` returns None inside the runtime and any code path + * that resolves a credential through Identity (e.g. + * `agent/src/config.py::resolve_linear_api_token`) silently + * fails-closed. Phase 2.0a requirement. + */ + userId: string; payload: Record; blueprintConfig: BlueprintConfig; }): Promise; diff --git a/cdk/src/handlers/shared/linear-feedback.ts b/cdk/src/handlers/shared/linear-feedback.ts index 958c365a..f28252cc 100644 --- a/cdk/src/handlers/shared/linear-feedback.ts +++ b/cdk/src/handlers/shared/linear-feedback.ts @@ -17,7 +17,7 @@ * SOFTWARE. */ -import { getLinearSecret } from './linear-verify'; +import { resolveLinearOauthToken } from './linear-oauth-resolver'; import { logger } from './logger'; /** @@ -55,7 +55,7 @@ mutation ReactIssue($issueId: String!, $emoji: String!) { `.trim(); async function graphqlRequest( - apiToken: string, + accessToken: string, query: string, variables: Record, ): Promise { @@ -65,7 +65,10 @@ async function graphqlRequest( const resp = await fetch(LINEAR_GRAPHQL_URL, { method: 'POST', headers: { - 'Authorization': apiToken, + // OAuth tokens use Bearer; legacy PAK was the bare value. Phase + // 2.0b: all tokens stored in Secrets Manager are OAuth bearer + // tokens so we always Bearer-prefix. + 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ query, variables }), @@ -91,11 +94,26 @@ async function graphqlRequest( } } -async function resolveToken(secretArn: string): Promise { +/** + * Workspace-scoped feedback context. Resolved once per task by the + * caller (webhook processor / orchestrator) and threaded through to + * the post-comment / add-reaction helpers, so the resolver runs once + * per task instead of once per Linear API call. + */ +export interface LinearFeedbackContext { + /** Linear organization UUID — registry key. */ + readonly linearWorkspaceId: string; + /** Name of LinearWorkspaceRegistryTable, from CDK stack output. */ + readonly registryTableName: string; +} + +async function resolveToken(ctx: LinearFeedbackContext): Promise { try { - return await getLinearSecret(secretArn); + const resolved = await resolveLinearOauthToken(ctx.linearWorkspaceId, ctx.registryTableName); + return resolved?.accessToken ?? null; } catch (err) { - logger.warn('Linear feedback could not resolve API token', { + logger.warn('Linear feedback could not resolve OAuth token', { + linear_workspace_id: ctx.linearWorkspaceId, error: err instanceof Error ? err.message : String(err), }); return null; @@ -107,11 +125,11 @@ async function resolveToken(secretArn: string): Promise { * (network, auth, GraphQL errors). Never throws — callers proceed regardless. */ export async function postIssueComment( - apiTokenSecretArn: string, + ctx: LinearFeedbackContext, issueId: string, body: string, ): Promise { - const token = await resolveToken(apiTokenSecretArn); + const token = await resolveToken(ctx); if (!token) return false; return graphqlRequest(token, COMMENT_CREATE_MUTATION, { issueId, body }); } @@ -121,11 +139,11 @@ export async function postIssueComment( * the agent uses on the success/failure side. Returns true on success. */ export async function addIssueReaction( - apiTokenSecretArn: string, + ctx: LinearFeedbackContext, issueId: string, emoji: string = EMOJI_FAILURE, ): Promise { - const token = await resolveToken(apiTokenSecretArn); + const token = await resolveToken(ctx); if (!token) return false; return graphqlRequest(token, REACTION_CREATE_MUTATION, { issueId, emoji }); } @@ -136,12 +154,12 @@ export async function addIssueReaction( * never branch on the result. */ export async function reportIssueFailure( - apiTokenSecretArn: string, + ctx: LinearFeedbackContext, issueId: string, message: string, ): Promise { await Promise.allSettled([ - postIssueComment(apiTokenSecretArn, issueId, message), - addIssueReaction(apiTokenSecretArn, issueId, EMOJI_FAILURE), + postIssueComment(ctx, issueId, message), + addIssueReaction(ctx, issueId, EMOJI_FAILURE), ]); } diff --git a/cdk/src/handlers/shared/linear-oauth-resolver.ts b/cdk/src/handlers/shared/linear-oauth-resolver.ts new file mode 100644 index 00000000..b203755a --- /dev/null +++ b/cdk/src/handlers/shared/linear-oauth-resolver.ts @@ -0,0 +1,345 @@ +/** + * 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 } from '@aws-sdk/client-dynamodb'; +import { + GetSecretValueCommand, + PutSecretValueCommand, + SecretsManagerClient, +} from '@aws-sdk/client-secrets-manager'; +import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb'; +import { logger } from './logger'; + +/** + * Lambda-side resolver for the per-workspace Linear OAuth token written + * by `bgagent linear setup` (Phase 2.0b Option 2). Mirrors the CLI's + * `cli/src/linear-oauth.ts` helpers but uses AWS SDK clients suitable + * for Lambda execution. + * + * Flow: + * 1. Look up workspace registry table by `linearWorkspaceId` → + * `oauth_secret_arn`. + * 2. Fetch the secret JSON via Secrets Manager. + * 3. If `expires_at` is within 60s, refresh against Linear's + * `/oauth/token` (with stored `refresh_token`) and write the new + * JSON back to Secrets Manager. + * 4. Return the access token. + * + * Both reads (registry row, secret value) are cached in-memory with a + * short TTL so a hot Lambda doesn't hammer DDB / SM on every invocation. + */ + +const LINEAR_TOKEN_ENDPOINT = 'https://api.linear.app/oauth/token'; + +/** Cache TTL for the registry row + secret value lookups, in milliseconds. */ +const REGISTRY_CACHE_TTL_MS = 60_000; +const SECRET_CACHE_TTL_MS = 60_000; + +/** Refresh threshold: refresh tokens with <60s remaining. */ +const REFRESH_THRESHOLD_SECONDS = 60; + +interface RegistryRow { + readonly linear_workspace_id: string; + readonly workspace_slug: string; + readonly oauth_secret_arn: string; + readonly status: string; +} + +export interface StoredOauthToken { + readonly access_token: string; + readonly refresh_token: string; + readonly expires_at: string; + readonly scope: string; + /** Co-located OAuth client credentials so Lambda-side refresh works + * without per-Lambda env vars (Phase 2.0b-O2). */ + readonly client_id: string; + readonly client_secret: string; + readonly workspace_id: string; + readonly workspace_slug: string; + readonly installed_at: string; + readonly updated_at: string; + readonly installed_by_platform_user_id: string; +} + +export interface ResolverOptions { + /** AWS region for SDK clients. Falls back to AWS_REGION env. */ + readonly region?: string; + /** Override clients for testing. */ + readonly secretsManagerClient?: SecretsManagerClient; + readonly dynamoDbClient?: DynamoDBDocumentClient; + /** Override fetch for token-endpoint refresh in tests. */ + readonly fetchImpl?: typeof fetch; +} + +interface CacheEntry { + readonly value: T; + readonly expiresAt: number; +} + +const registryCache = new Map>(); +const tokenCache = new Map>(); + +/** + * Drop cached values for a workspace. Used after a refresh so the next + * caller picks up the rotated token. + */ +export function invalidateLinearOauthCache(linearWorkspaceId: string, oauthSecretArn?: string): void { + registryCache.delete(linearWorkspaceId); + if (oauthSecretArn) tokenCache.delete(oauthSecretArn); +} + +/** Returns true if `expires_at` is within the refresh threshold. */ +export function isTokenExpiring(expiresAt: string, thresholdSec: number = REFRESH_THRESHOLD_SECONDS): boolean { + const ts = new Date(expiresAt).getTime(); + if (Number.isNaN(ts)) return true; + return Date.now() + thresholdSec * 1000 >= ts; +} + +/** + * Resolve a usable Linear OAuth access token for the given workspace. + * + * On success: returns `{ accessToken, scope, workspaceSlug }`. Refreshes + * silently if the cached token is expiring. Returns null on any failure + * (registry miss, secret missing, refresh-token revoked) so callers can + * gracefully no-op rather than blowing up. + * + * Throws ONLY for environment misconfigurations (e.g. workspace registry + * env var unset, Linear OAuth client credentials env vars unset) — those + * are deploy bugs, not runtime conditions. + */ +export interface ResolvedLinearToken { + readonly accessToken: string; + readonly scope: string; + readonly workspaceSlug: string; + readonly oauthSecretArn: string; +} + +export async function resolveLinearOauthToken( + linearWorkspaceId: string, + registryTableName: string, + 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 }); + + // ─── Step 1: Registry row ──────────────────────────────────────── + const row = await getRegistryRow(ddb, registryTableName, linearWorkspaceId); + if (!row) { + logger.warn('Linear workspace not in registry', { linear_workspace_id: linearWorkspaceId }); + return null; + } + if (row.status !== 'active') { + logger.warn('Linear workspace registry status is not active', { + linear_workspace_id: linearWorkspaceId, + status: row.status, + }); + return null; + } + + // ─── Step 2: Cached or fresh token JSON ────────────────────────── + const cached = tokenCache.get(row.oauth_secret_arn); + let token: StoredOauthToken; + if (cached && cached.expiresAt > Date.now() && !isTokenExpiring(cached.value.expires_at)) { + token = cached.value; + } else { + const fetched = await getOauthSecret(sm, row.oauth_secret_arn); + if (!fetched) { + logger.error('Linear OAuth secret missing or unreadable', { + oauth_secret_arn: row.oauth_secret_arn, + linear_workspace_id: linearWorkspaceId, + }); + return null; + } + token = fetched; + } + + // ─── Step 3: Refresh if expiring ───────────────────────────────── + if (isTokenExpiring(token.expires_at)) { + const refreshed = await refreshLinearToken(token, sm, row.oauth_secret_arn, options); + if (!refreshed) { + // Refresh failed — return null so the caller can fall back to + // best-effort behaviour. Cache is already invalidated. + return null; + } + token = refreshed; + } else { + // Cache only when not just-refreshed (just-refreshed value is already + // the freshest possible). + tokenCache.set(row.oauth_secret_arn, { value: token, expiresAt: Date.now() + SECRET_CACHE_TTL_MS }); + } + + return { + accessToken: token.access_token, + scope: token.scope, + workspaceSlug: token.workspace_slug, + oauthSecretArn: row.oauth_secret_arn, + }; +} + +async function getRegistryRow( + ddb: DynamoDBDocumentClient, + tableName: string, + linearWorkspaceId: string, +): Promise { + const cached = registryCache.get(linearWorkspaceId); + if (cached && cached.expiresAt > Date.now()) return cached.value; + + const result = await ddb.send(new GetCommand({ + TableName: tableName, + Key: { linear_workspace_id: linearWorkspaceId }, + })); + const item = result.Item as Partial | undefined; + if (!item || !item.oauth_secret_arn || !item.workspace_slug) return null; + + const row: RegistryRow = { + linear_workspace_id: linearWorkspaceId, + workspace_slug: item.workspace_slug, + oauth_secret_arn: item.oauth_secret_arn, + status: item.status ?? 'active', + }; + registryCache.set(linearWorkspaceId, { value: row, expiresAt: Date.now() + REGISTRY_CACHE_TTL_MS }); + return row; +} + +async function getOauthSecret( + sm: SecretsManagerClient, + secretArn: string, +): Promise { + try { + const res = await sm.send(new GetSecretValueCommand({ SecretId: secretArn })); + if (!res.SecretString) return null; + const parsed = JSON.parse(res.SecretString) as StoredOauthToken; + if (!parsed.access_token || !parsed.refresh_token || !parsed.expires_at) return null; + return parsed; + } catch (err) { + logger.error('Failed to fetch Linear OAuth secret', { + secret_arn: secretArn, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } +} + +async function refreshLinearToken( + current: StoredOauthToken, + sm: SecretsManagerClient, + secretArn: string, + options: ResolverOptions, +): Promise { + if (!current.client_id || !current.client_secret) { + logger.error('Cannot refresh Linear OAuth token: stored secret is missing client_id/client_secret', { + secret_arn: secretArn, + }); + return null; + } + + const fetchImpl = options.fetchImpl ?? fetch; + const body = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: current.refresh_token, + client_id: current.client_id, + client_secret: current.client_secret, + }); + + let resp: Response; + try { + resp = await fetchImpl(LINEAR_TOKEN_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); + } catch (err) { + logger.error('Linear token refresh fetch failed', { + error: err instanceof Error ? err.message : String(err), + }); + return null; + } + + let parsed: unknown; + try { + parsed = await resp.json(); + } catch { + logger.error('Linear token refresh returned non-JSON', { status: resp.status }); + return null; + } + + if (!resp.ok) { + const errObj = parsed as { error?: string; error_description?: string }; + logger.error('Linear token refresh rejected', { + status: resp.status, + error: errObj.error, + error_description: errObj.error_description, + }); + // Caller can attempt a fresh OAuth dance; we don't recover automatically. + invalidateLinearOauthCache(current.workspace_id, secretArn); + return null; + } + + const tokenResp = parsed as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + scope?: string; + }; + if (!tokenResp.access_token || !tokenResp.expires_in) { + logger.error('Linear token refresh response missing required fields'); + return null; + } + + const now = new Date(); + const next: StoredOauthToken = { + ...current, + access_token: tokenResp.access_token, + // Linear rotates refresh_token on every refresh. Persist the new one; + // re-using the old one will fail (one-shot grants). + refresh_token: tokenResp.refresh_token ?? current.refresh_token, + expires_at: new Date(now.getTime() + tokenResp.expires_in * 1000).toISOString(), + scope: tokenResp.scope ?? current.scope, + updated_at: now.toISOString(), + }; + + // Persist back to Secrets Manager so other Lambdas (and the agent + // runtime) see the rotated token. + try { + await sm.send(new PutSecretValueCommand({ + SecretId: secretArn, + SecretString: JSON.stringify(next), + })); + } catch (err) { + logger.error('Failed to persist refreshed Linear OAuth token', { + secret_arn: secretArn, + error: err instanceof Error ? err.message : String(err), + }); + // Even if persistence fails, the in-memory token still works for + // the rest of THIS Lambda invocation. Other concurrent Lambdas may + // race-refresh; Linear's idempotency-on-replay grace window + // (30 min documented) absorbs the duplicate. + } + + // Cache the freshest value. + tokenCache.set(secretArn, { value: next, expiresAt: Date.now() + SECRET_CACHE_TTL_MS }); + return next; +} + +/** Test-only: clear all caches. */ +export function _resetCachesForTesting(): void { + registryCache.clear(); + tokenCache.clear(); +} diff --git a/cdk/src/handlers/shared/strategies/agentcore-strategy.ts b/cdk/src/handlers/shared/strategies/agentcore-strategy.ts index 27604adb..d10e9bde 100644 --- a/cdk/src/handlers/shared/strategies/agentcore-strategy.ts +++ b/cdk/src/handlers/shared/strategies/agentcore-strategy.ts @@ -36,6 +36,7 @@ export class AgentCoreComputeStrategy implements ComputeStrategy { async startSession(input: { taskId: string; + userId: string; payload: Record; blueprintConfig: BlueprintConfig; }): Promise { @@ -43,9 +44,19 @@ export class AgentCoreComputeStrategy implements ComputeStrategy { const sessionId = randomUUID(); const runtimeArn = input.blueprintConfig.runtime_arn; + // `runtimeUserId` triggers AgentCore Identity's workload-access-token + // injection: when set, AgentCore exchanges the caller's identity for + // a workload token and delivers it to the agent container via the + // `WorkloadAccessToken` request header (read by + // `BedrockAgentCoreContext.set_workload_access_token` in app.py). + // Without it, the agent's `resolve_linear_api_token()` short-circuits + // before reaching the Identity SDK call. Requires the orchestrator + // role to have `bedrock-agentcore:InvokeAgentRuntimeForUser` in + // addition to `InvokeAgentRuntime`. const command = new InvokeAgentRuntimeCommand({ agentRuntimeArn: runtimeArn, runtimeSessionId: sessionId, + runtimeUserId: input.userId, contentType: 'application/json', accept: 'application/json', payload: new TextEncoder().encode(JSON.stringify({ input: input.payload })), @@ -53,7 +64,12 @@ export class AgentCoreComputeStrategy implements ComputeStrategy { await getClient().send(command); - logger.info('AgentCore session invoked', { task_id: input.taskId, session_id: sessionId, runtime_arn: runtimeArn }); + logger.info('AgentCore session invoked', { + task_id: input.taskId, + session_id: sessionId, + runtime_arn: runtimeArn, + runtime_user_id: input.userId, + }); return { sessionId, diff --git a/cdk/src/handlers/shared/strategies/ecs-strategy.ts b/cdk/src/handlers/shared/strategies/ecs-strategy.ts index 5c0ad674..8a6270c4 100644 --- a/cdk/src/handlers/shared/strategies/ecs-strategy.ts +++ b/cdk/src/handlers/shared/strategies/ecs-strategy.ts @@ -41,6 +41,9 @@ export class EcsComputeStrategy implements ComputeStrategy { async startSession(input: { taskId: string; + /** Accepted to satisfy the ComputeStrategy interface; ECS doesn't + * use a workload-token-injecting runtime so this is unused. */ + userId: string; payload: Record; blueprintConfig: BlueprintConfig; }): Promise { diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index cb5765a8..0d02c77e 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -702,37 +702,54 @@ export class AgentStack extends Stack { guardrailVersion: inputGuardrail.guardrailVersion, }); - // Pipe the Linear API token secret into the AgentCore runtime so the - // agent's `resolve_linear_api_token()` can populate `LINEAR_API_TOKEN` - // for the Linear MCP's `${LINEAR_API_TOKEN}` placeholder. - linearIntegration.apiTokenSecret.grantRead(runtime); - cfnRuntime.addPropertyOverride( - 'EnvironmentVariables.LINEAR_API_TOKEN_SECRET_ARN', - linearIntegration.apiTokenSecret.secretArn, - ); - - // Pipe the Linear API token secret into the orchestrator Lambda so the - // concurrency-cap rejection path can post a Linear comment + ❌ instead - // of silently dropping the task. The orchestrator only uses the secret - // when `task.channel_source === 'linear'`, but the IAM grant is - // unconditional — the secret is created lazily via Secrets Manager and - // costs nothing if unused. - linearIntegration.apiTokenSecret.grantRead(orchestrator.fn); + // Phase 2.0b-O2: agent runtime reads the per-workspace Linear OAuth + // token directly from Secrets Manager. The CLI (`bgagent linear setup`) + // creates `bgagent-linear-oauth-` secrets at install time; + // the secret JSON contains access_token, refresh_token, expires_at, + // and the OAuth client_id/client_secret needed for in-place refresh. + // The orchestrator passes `linear_oauth_secret_arn` to the agent via + // task.channel_metadata, so the agent looks up the exact ARN — no + // discovery needed at runtime. + runtime.role.addToPrincipalPolicy(new iam.PolicyStatement({ + actions: ['secretsmanager:GetSecretValue', 'secretsmanager:PutSecretValue'], + resources: [ + Stack.of(this).formatArn({ + service: 'secretsmanager', + resource: 'secret', + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + resourceName: 'bgagent-linear-oauth-*', + }), + ], + })); + + // Phase 2.0b-O2: pipe the workspace registry table + per-workspace + // OAuth-secret-prefix grant into the orchestrator so the concurrency-cap + // rejection path can post a Linear comment + ❌. The orchestrator only + // resolves a token when `task.channel_source === 'linear'`, but the + // IAM grant is unconditional (per-workspace secrets are created lazily + // by `bgagent linear setup`). + linearIntegration.workspaceRegistryTable.grantReadData(orchestrator.fn); orchestrator.fn.addEnvironment( - 'LINEAR_API_TOKEN_SECRET_ARN', - linearIntegration.apiTokenSecret.secretArn, + 'LINEAR_WORKSPACE_REGISTRY_TABLE_NAME', + linearIntegration.workspaceRegistryTable.tableName, ); + orchestrator.fn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['secretsmanager:GetSecretValue', 'secretsmanager:PutSecretValue'], + resources: [ + Stack.of(this).formatArn({ + service: 'secretsmanager', + resource: 'secret', + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + resourceName: 'bgagent-linear-oauth-*', + }), + ], + })); new CfnOutput(this, 'LinearWebhookSecretArn', { value: linearIntegration.webhookSecret.secretArn, description: 'Secrets Manager ARN for the Linear webhook signing secret — populate via `bgagent linear setup`', }); - new CfnOutput(this, 'LinearApiTokenSecretArn', { - value: linearIntegration.apiTokenSecret.secretArn, - description: 'Secrets Manager ARN for the Linear personal API token (agent-side MCP) — populate via `bgagent linear setup`', - }); - new CfnOutput(this, 'LinearProjectMappingTableName', { value: linearIntegration.projectMappingTable.tableName, description: 'Name of the DynamoDB Linear project → repo mapping table', @@ -743,6 +760,11 @@ export class AgentStack extends Stack { description: 'Name of the DynamoDB Linear user mapping table', }); + new CfnOutput(this, 'LinearWorkspaceRegistryTableName', { + value: linearIntegration.workspaceRegistryTable.tableName, + description: 'Name of the DynamoDB Linear workspace registry — `bgagent linear setup` writes a row per OAuth-installed workspace', + }); + // --- Bedrock model invocation logging (account-level) --- const invocationLogGroup = new logs.LogGroup(this, 'ModelInvocationLogGroup', { logGroupName: `/aws/bedrock/model-invocation-logs/${this.stackName}`, diff --git a/cdk/test/constructs/linear-integration.test.ts b/cdk/test/constructs/linear-integration.test.ts index 3444258e..450d1a25 100644 --- a/cdk/test/constructs/linear-integration.test.ts +++ b/cdk/test/constructs/linear-integration.test.ts @@ -51,9 +51,16 @@ describe('LinearIntegration construct', () => { template = Template.fromStack(stack); }); - test('creates three DynamoDB tables (project mapping + user mapping + dedup)', () => { - // TaskTable + TaskEventsTable + LinearProjectMapping + LinearUserMapping + LinearWebhookDedup = 5 - template.resourceCountIs('AWS::DynamoDB::Table', 5); + test('creates four Linear DynamoDB tables (project mapping + user mapping + workspace registry + dedup)', () => { + // TaskTable + TaskEventsTable + LinearProjectMapping + LinearUserMapping + // + LinearWorkspaceRegistry + LinearWebhookDedup = 6 + template.resourceCountIs('AWS::DynamoDB::Table', 6); + }); + + test('workspace registry table is keyed on linear_workspace_id', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + KeySchema: [{ AttributeName: 'linear_workspace_id', KeyType: 'HASH' }], + }); }); test('creates three Lambda functions (webhook, processor, link)', () => { @@ -66,14 +73,14 @@ describe('LinearIntegration construct', () => { template.hasResourceProperties('AWS::ApiGateway::Resource', { PathPart: 'link' }); }); - test('creates two Secrets Manager secrets (webhook + API token)', () => { - template.resourceCountIs('AWS::SecretsManager::Secret', 2); + test('creates one Secrets Manager secret (webhook signing) — OAuth tokens are CLI-created at runtime', () => { + // Phase 2.0b-O2: per-workspace OAuth tokens live in + // `bgagent-linear-oauth-` secrets created by `bgagent linear setup`, + // NOT by CDK. Only the webhook signing secret is CDK-managed. + template.resourceCountIs('AWS::SecretsManager::Secret', 1); template.hasResourceProperties('AWS::SecretsManager::Secret', { Description: Match.stringLikeRegexp('Linear webhook signing secret'), }); - template.hasResourceProperties('AWS::SecretsManager::Secret', { - Description: Match.stringLikeRegexp('Linear personal API token'), - }); }); test('has NO DynamoDB Streams event-source mapping (outbound goes through MCP)', () => { @@ -92,12 +99,13 @@ describe('LinearIntegration construct', () => { }); }); - test('processor handler env wires both mapping tables + task table', () => { + test('processor handler env wires all mapping tables + task table + workspace registry', () => { template.hasResourceProperties('AWS::Lambda::Function', { Environment: { Variables: Match.objectLike({ LINEAR_PROJECT_MAPPING_TABLE_NAME: Match.anyValue(), LINEAR_USER_MAPPING_TABLE_NAME: Match.anyValue(), + LINEAR_WORKSPACE_REGISTRY_TABLE_NAME: Match.anyValue(), TASK_TABLE_NAME: Match.anyValue(), TASK_EVENTS_TABLE_NAME: Match.anyValue(), }), diff --git a/cdk/test/constructs/task-orchestrator.test.ts b/cdk/test/constructs/task-orchestrator.test.ts index 6cf903a1..d3e56df4 100644 --- a/cdk/test/constructs/task-orchestrator.test.ts +++ b/cdk/test/constructs/task-orchestrator.test.ts @@ -148,6 +148,7 @@ describe('TaskOrchestrator construct', () => { Match.objectLike({ Action: [ 'bedrock-agentcore:InvokeAgentRuntime', + 'bedrock-agentcore:InvokeAgentRuntimeForUser', 'bedrock-agentcore:StopRuntimeSession', ], Effect: 'Allow', @@ -295,6 +296,7 @@ describe('TaskOrchestrator construct', () => { Match.objectLike({ Action: [ 'bedrock-agentcore:InvokeAgentRuntime', + 'bedrock-agentcore:InvokeAgentRuntimeForUser', 'bedrock-agentcore:StopRuntimeSession', ], Effect: 'Allow', diff --git a/cdk/test/handlers/linear-webhook-processor.test.ts b/cdk/test/handlers/linear-webhook-processor.test.ts index cbc48ff2..80d2fb61 100644 --- a/cdk/test/handlers/linear-webhook-processor.test.ts +++ b/cdk/test/handlers/linear-webhook-processor.test.ts @@ -34,9 +34,14 @@ jest.mock('../../src/handlers/shared/linear-feedback', () => ({ reportIssueFailure: (...args: unknown[]) => reportIssueFailureMock(...args), })); +const resolveLinearOauthTokenMock = jest.fn(); +jest.mock('../../src/handlers/shared/linear-oauth-resolver', () => ({ + resolveLinearOauthToken: (...args: unknown[]) => resolveLinearOauthTokenMock(...args), +})); + process.env.LINEAR_PROJECT_MAPPING_TABLE_NAME = 'LinearProjects'; process.env.LINEAR_USER_MAPPING_TABLE_NAME = 'LinearUsers'; -process.env.LINEAR_API_TOKEN_SECRET_ARN = 'arn:aws:secretsmanager:us-east-1:123:secret:bgagent/linear/api-token-XYZ'; +process.env.LINEAR_WORKSPACE_REGISTRY_TABLE_NAME = 'LinearWorkspaceRegistry'; import { handler } from '../../src/handlers/linear-webhook-processor'; @@ -69,6 +74,9 @@ describe('linear-webhook-processor handler', () => { createTaskCoreMock.mockReset(); reportIssueFailureMock.mockReset(); reportIssueFailureMock.mockResolvedValue(undefined); + resolveLinearOauthTokenMock.mockReset(); + // Default: workspace not in registry. Tests that need a token override. + resolveLinearOauthTokenMock.mockResolvedValue(null); }); test('skips missing raw_body', async () => { @@ -207,8 +215,13 @@ describe('linear-webhook-processor handler', () => { await handler(eventWith(payload)); expect(reportIssueFailureMock).toHaveBeenCalledTimes(1); - const [secretArn, issueId, message] = reportIssueFailureMock.mock.calls[0]; - expect(secretArn).toBe(process.env.LINEAR_API_TOKEN_SECRET_ARN); + const [ctx, issueId, message] = reportIssueFailureMock.mock.calls[0]; + // Phase 2.0b-O2: feedback context carries workspace id + registry table name + // (the resolver does the secret lookup downstream). + expect(ctx).toEqual({ + linearWorkspaceId: payload.organizationId, + registryTableName: process.env.LINEAR_WORKSPACE_REGISTRY_TABLE_NAME, + }); expect(issueId).toBe('issue-1'); expect(message).toContain("isn't in a project"); }); @@ -246,7 +259,13 @@ describe('linear-webhook-processor handler', () => { expect(message).toContain('multi-user OAuth'); }); - test('posts feedback when webhook is missing organization or actor', async () => { + test('skips feedback (no org → no workspace token) when webhook is missing organization', async () => { + // Phase 2.0b-O2: feedback requires the workspace's OAuth token, which + // is keyed on `organizationId`. If the webhook payload omits it, we + // cannot resolve any token, so the feedback path skips with a WARN + // instead of trying to post anonymously. The empty-org case is + // pathological enough (Linear always sends organizationId) that + // logging-only is acceptable. ddbSend .mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active' } }); const payload = issue({ organizationId: '', actor: undefined }); @@ -256,9 +275,7 @@ describe('linear-webhook-processor handler', () => { await handler(eventWith(payload)); - expect(reportIssueFailureMock).toHaveBeenCalledTimes(1); - const [, , message] = reportIssueFailureMock.mock.calls[0]; - expect(message).toContain('missing the organization or actor'); + expect(reportIssueFailureMock).not.toHaveBeenCalled(); }); test('surfaces guardrail block message on createTaskCore 400', async () => { diff --git a/cdk/test/handlers/orchestrate-task-feedback.test.ts b/cdk/test/handlers/orchestrate-task-feedback.test.ts index 88776b19..819db18d 100644 --- a/cdk/test/handlers/orchestrate-task-feedback.test.ts +++ b/cdk/test/handlers/orchestrate-task-feedback.test.ts @@ -50,7 +50,7 @@ jest.mock('../../src/handlers/shared/compute-strategy', () => ({ resolveComputeStrategy: jest.fn(), })); -process.env.LINEAR_API_TOKEN_SECRET_ARN = 'arn:aws:secretsmanager:us-east-1:123:secret:bgagent/linear/api-token-XYZ'; +process.env.LINEAR_WORKSPACE_REGISTRY_TABLE_NAME = 'LinearWorkspaceRegistry'; import { notifyLinearOnConcurrencyCap } from '../../src/handlers/orchestrate-task'; import type { TaskRecord } from '../../src/handlers/shared/types'; @@ -77,15 +77,21 @@ describe('notifyLinearOnConcurrencyCap', () => { reportIssueFailureMock.mockResolvedValue(undefined); }); - test('posts Linear comment + ❌ when channel_source is linear and issue id is set', async () => { + test('posts Linear comment + ❌ when channel_source is linear and issue id + workspace are set', async () => { await notifyLinearOnConcurrencyCap(task({ channel_source: 'linear', - channel_metadata: { linear_issue_id: 'lin-issue-1' }, + channel_metadata: { + linear_issue_id: 'lin-issue-1', + linear_workspace_id: 'lin-org-1', + }, })); expect(reportIssueFailureMock).toHaveBeenCalledTimes(1); - const [secretArn, issueId, message] = reportIssueFailureMock.mock.calls[0]; - expect(secretArn).toBe(process.env.LINEAR_API_TOKEN_SECRET_ARN); + const [ctx, issueId, message] = reportIssueFailureMock.mock.calls[0]; + expect(ctx).toEqual({ + linearWorkspaceId: 'lin-org-1', + registryTableName: process.env.LINEAR_WORKSPACE_REGISTRY_TABLE_NAME, + }); expect(issueId).toBe('lin-issue-1'); expect(message).toContain('concurrency limit'); }); @@ -115,17 +121,20 @@ describe('notifyLinearOnConcurrencyCap', () => { expect(reportIssueFailureMock).not.toHaveBeenCalled(); }); - test('no-ops when LINEAR_API_TOKEN_SECRET_ARN env is not set (logs warn)', async () => { - const saved = process.env.LINEAR_API_TOKEN_SECRET_ARN; - delete process.env.LINEAR_API_TOKEN_SECRET_ARN; + test('no-ops when LINEAR_WORKSPACE_REGISTRY_TABLE_NAME env is not set (logs warn)', async () => { + const saved = process.env.LINEAR_WORKSPACE_REGISTRY_TABLE_NAME; + delete process.env.LINEAR_WORKSPACE_REGISTRY_TABLE_NAME; try { await notifyLinearOnConcurrencyCap(task({ channel_source: 'linear', - channel_metadata: { linear_issue_id: 'lin-issue-1' }, + channel_metadata: { + linear_issue_id: 'lin-issue-1', + linear_workspace_id: 'lin-org-1', + }, })); expect(reportIssueFailureMock).not.toHaveBeenCalled(); } finally { - process.env.LINEAR_API_TOKEN_SECRET_ARN = saved; + process.env.LINEAR_WORKSPACE_REGISTRY_TABLE_NAME = saved; } }); @@ -139,7 +148,10 @@ describe('notifyLinearOnConcurrencyCap', () => { await expect( notifyLinearOnConcurrencyCap(task({ channel_source: 'linear', - channel_metadata: { linear_issue_id: 'lin-issue-1' }, + channel_metadata: { + linear_issue_id: 'lin-issue-1', + linear_workspace_id: 'lin-org-1', + }, })), ).rejects.toThrow('boom'); }); diff --git a/cdk/test/handlers/shared/linear-feedback.test.ts b/cdk/test/handlers/shared/linear-feedback.test.ts index 71c436b6..0fed6523 100644 --- a/cdk/test/handlers/shared/linear-feedback.test.ts +++ b/cdk/test/handlers/shared/linear-feedback.test.ts @@ -17,9 +17,9 @@ * SOFTWARE. */ -const getLinearSecretMock = jest.fn(); -jest.mock('../../../src/handlers/shared/linear-verify', () => ({ - getLinearSecret: (...args: unknown[]) => getLinearSecretMock(...args), +const resolveLinearOauthTokenMock = jest.fn(); +jest.mock('../../../src/handlers/shared/linear-oauth-resolver', () => ({ + resolveLinearOauthToken: (...args: unknown[]) => resolveLinearOauthTokenMock(...args), })); const fetchMock = jest.fn(); @@ -28,13 +28,17 @@ const fetchMock = jest.fn(); import { addIssueReaction, + type LinearFeedbackContext, postIssueComment, reportIssueFailure, } from '../../../src/handlers/shared/linear-feedback'; -const SECRET_ARN = 'arn:aws:secretsmanager:us-east-1:123:secret:bgagent/linear/api-token-XYZ'; +const CTX: LinearFeedbackContext = { + linearWorkspaceId: 'ws-uuid-1', + registryTableName: 'TestLinearWorkspaceRegistry', +}; const ISSUE_ID = 'issue-1'; -const TOKEN = 'lin_api_TESTTOKEN'; +const TOKEN = 'lin_oauth_TESTTOKEN'; function jsonResponse(body: unknown, status: number = 200): Response { return { @@ -46,15 +50,20 @@ function jsonResponse(body: unknown, status: number = 200): Response { describe('linear-feedback', () => { beforeEach(() => { - getLinearSecretMock.mockReset(); + resolveLinearOauthTokenMock.mockReset(); fetchMock.mockReset(); - getLinearSecretMock.mockResolvedValue(TOKEN); + resolveLinearOauthTokenMock.mockResolvedValue({ + accessToken: TOKEN, + scope: 'read write', + workspaceSlug: 'acme', + oauthSecretArn: 'arn:secret:acme', + }); fetchMock.mockResolvedValue(jsonResponse({ data: { commentCreate: { success: true } } })); }); describe('postIssueComment', () => { test('POSTs the commentCreate mutation with the issue id and body', async () => { - const ok = await postIssueComment(SECRET_ARN, ISSUE_ID, '❌ blocked'); + const ok = await postIssueComment(CTX, ISSUE_ID, '❌ blocked'); expect(ok).toBe(true); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -62,7 +71,8 @@ describe('linear-feedback', () => { expect(url).toBe('https://api.linear.app/graphql'); expect(init.method).toBe('POST'); expect(init.headers).toMatchObject({ - 'Authorization': TOKEN, + // OAuth tokens use Bearer prefix per Phase 2.0b-O2. + 'Authorization': `Bearer ${TOKEN}`, 'Content-Type': 'application/json', }); const body = JSON.parse(init.body as string) as { query: string; variables: Record }; @@ -70,10 +80,10 @@ describe('linear-feedback', () => { expect(body.variables).toEqual({ issueId: ISSUE_ID, body: '❌ blocked' }); }); - test('returns false (and logs warn) when the secret cannot be resolved', async () => { - getLinearSecretMock.mockResolvedValueOnce(null); + test('returns false (and logs warn) when the token cannot be resolved', async () => { + resolveLinearOauthTokenMock.mockResolvedValueOnce(null); - const ok = await postIssueComment(SECRET_ARN, ISSUE_ID, 'msg'); + const ok = await postIssueComment(CTX, ISSUE_ID, 'msg'); expect(ok).toBe(false); expect(fetchMock).not.toHaveBeenCalled(); @@ -82,7 +92,7 @@ describe('linear-feedback', () => { test('returns false on non-2xx response (no throw)', async () => { fetchMock.mockResolvedValueOnce(jsonResponse({}, 500)); - const ok = await postIssueComment(SECRET_ARN, ISSUE_ID, 'msg'); + const ok = await postIssueComment(CTX, ISSUE_ID, 'msg'); expect(ok).toBe(false); }); @@ -90,7 +100,7 @@ describe('linear-feedback', () => { test('returns false on GraphQL errors (no throw)', async () => { fetchMock.mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'auth' }] })); - const ok = await postIssueComment(SECRET_ARN, ISSUE_ID, 'msg'); + const ok = await postIssueComment(CTX, ISSUE_ID, 'msg'); expect(ok).toBe(false); }); @@ -98,15 +108,15 @@ describe('linear-feedback', () => { test('returns false on network failure (swallowed)', async () => { fetchMock.mockRejectedValueOnce(new Error('ECONNRESET')); - const ok = await postIssueComment(SECRET_ARN, ISSUE_ID, 'msg'); + const ok = await postIssueComment(CTX, ISSUE_ID, 'msg'); expect(ok).toBe(false); }); - test('returns false when getLinearSecret throws (swallowed at resolveToken layer)', async () => { - getLinearSecretMock.mockRejectedValueOnce(new Error('AccessDenied')); + test('returns false when resolveLinearOauthToken throws (swallowed at resolveToken layer)', async () => { + resolveLinearOauthTokenMock.mockRejectedValueOnce(new Error('AccessDenied')); - const ok = await postIssueComment(SECRET_ARN, ISSUE_ID, 'msg'); + const ok = await postIssueComment(CTX, ISSUE_ID, 'msg'); expect(ok).toBe(false); expect(fetchMock).not.toHaveBeenCalled(); @@ -115,7 +125,7 @@ describe('linear-feedback', () => { describe('addIssueReaction', () => { test('defaults to ❌ (emoji short-code "x")', async () => { - await addIssueReaction(SECRET_ARN, ISSUE_ID); + await addIssueReaction(CTX, ISSUE_ID); const init = fetchMock.mock.calls[0][1]; const body = JSON.parse(init.body as string) as { query: string; variables: { emoji: string } }; @@ -124,7 +134,7 @@ describe('linear-feedback', () => { }); test('honours an explicit emoji argument', async () => { - await addIssueReaction(SECRET_ARN, ISSUE_ID, 'eyes'); + await addIssueReaction(CTX, ISSUE_ID, 'eyes'); const init = fetchMock.mock.calls[0][1]; const body = JSON.parse(init.body as string) as { variables: { emoji: string } }; @@ -134,7 +144,7 @@ describe('linear-feedback', () => { describe('reportIssueFailure', () => { test('posts comment + ❌ in parallel via Promise.allSettled', async () => { - await reportIssueFailure(SECRET_ARN, ISSUE_ID, '❌ failed'); + await reportIssueFailure(CTX, ISSUE_ID, '❌ failed'); expect(fetchMock).toHaveBeenCalledTimes(2); const queries = fetchMock.mock.calls.map((c) => { @@ -151,13 +161,13 @@ describe('linear-feedback', () => { .mockResolvedValueOnce(jsonResponse({}, 500)) .mockResolvedValueOnce(jsonResponse({ data: { reactionCreate: { success: true } } })); - await expect(reportIssueFailure(SECRET_ARN, ISSUE_ID, 'msg')).resolves.toBeUndefined(); + await expect(reportIssueFailure(CTX, ISSUE_ID, 'msg')).resolves.toBeUndefined(); }); test('does not throw when both legs fail', async () => { fetchMock.mockRejectedValue(new Error('ECONNRESET')); - await expect(reportIssueFailure(SECRET_ARN, ISSUE_ID, 'msg')).resolves.toBeUndefined(); + await expect(reportIssueFailure(CTX, ISSUE_ID, 'msg')).resolves.toBeUndefined(); }); }); }); diff --git a/cdk/test/handlers/shared/linear-oauth-resolver.test.ts b/cdk/test/handlers/shared/linear-oauth-resolver.test.ts new file mode 100644 index 00000000..e6461726 --- /dev/null +++ b/cdk/test/handlers/shared/linear-oauth-resolver.test.ts @@ -0,0 +1,265 @@ +/** + * 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 { + _resetCachesForTesting, + invalidateLinearOauthCache, + isTokenExpiring, + resolveLinearOauthToken, + type StoredOauthToken, +} from '../../../src/handlers/shared/linear-oauth-resolver'; + +const REGISTRY_TABLE = 'TestLinearWorkspaceRegistry'; + +function makeStoredToken(overrides: Partial = {}): StoredOauthToken { + const now = new Date(); + const future = new Date(now.getTime() + 12 * 3600 * 1000); + return { + access_token: 'lin_oauth_default', + refresh_token: 'lin_refresh_default', + expires_at: future.toISOString(), + scope: 'read write app:assignable app:mentionable', + client_id: 'cid', + client_secret: 'csec', + workspace_id: 'ws-uuid-1', + workspace_slug: 'acme', + installed_at: now.toISOString(), + updated_at: now.toISOString(), + installed_by_platform_user_id: 'cog-sub', + ...overrides, + }; +} + +function makeFakeClients(opts: { + registryItem?: Partial<{ + linear_workspace_id: string; + workspace_slug: string; + oauth_secret_arn: string; + status: string; + }> | null; + storedToken?: StoredOauthToken | null; + putSecretValueShouldFail?: boolean; +}) { + const ddbSend = jest.fn().mockImplementation(() => ({ + Item: opts.registryItem === null ? undefined : opts.registryItem, + })); + const smSend = jest.fn().mockImplementation((command: { constructor: { name: string } }) => { + const name = command.constructor.name; + if (name === 'GetSecretValueCommand') { + if (opts.storedToken === null) return { SecretString: undefined }; + return { SecretString: JSON.stringify(opts.storedToken) }; + } + if (name === 'PutSecretValueCommand') { + if (opts.putSecretValueShouldFail) { + throw new Error('synthetic put failure'); + } + return {}; + } + return {}; + }); + type Opts = NonNullable[2]>; + return { + dynamoDbClient: { send: ddbSend } as unknown as Opts['dynamoDbClient'], + secretsManagerClient: { send: smSend } as unknown as Opts['secretsManagerClient'], + ddbSend, + smSend, + }; +} + +describe('isTokenExpiring', () => { + test('returns false for a future expiry well past the threshold', () => { + const future = new Date(Date.now() + 3600 * 1000).toISOString(); + expect(isTokenExpiring(future)).toBe(false); + }); + + test('returns true within the 60s threshold', () => { + const soon = new Date(Date.now() + 30 * 1000).toISOString(); + expect(isTokenExpiring(soon)).toBe(true); + }); + + test('returns true for a past expiry', () => { + const past = new Date(Date.now() - 60 * 1000).toISOString(); + expect(isTokenExpiring(past)).toBe(true); + }); + + test('returns true for malformed timestamps (defensive)', () => { + expect(isTokenExpiring('not a date')).toBe(true); + }); +}); + +describe('resolveLinearOauthToken', () => { + beforeEach(() => { + _resetCachesForTesting(); + }); + + test('happy path: returns access token + workspace slug + secret arn', async () => { + const stored = makeStoredToken({ access_token: 'lin_oauth_happy' }); + const clients = makeFakeClients({ + registryItem: { + workspace_slug: 'acme', + oauth_secret_arn: 'arn:secret:acme', + status: 'active', + }, + storedToken: stored, + }); + + const result = await resolveLinearOauthToken('ws-uuid-1', REGISTRY_TABLE, clients); + + expect(result).toEqual({ + accessToken: 'lin_oauth_happy', + scope: stored.scope, + workspaceSlug: 'acme', + oauthSecretArn: 'arn:secret:acme', + }); + }); + + test('returns null when workspace is not in the registry', async () => { + const clients = makeFakeClients({ registryItem: null }); + const result = await resolveLinearOauthToken('ws-not-installed', REGISTRY_TABLE, clients); + expect(result).toBeNull(); + }); + + test('returns null when registry status is not active', async () => { + const clients = makeFakeClients({ + registryItem: { + workspace_slug: 'acme', + oauth_secret_arn: 'arn:secret:acme', + status: 'revoked', + }, + storedToken: makeStoredToken(), + }); + const result = await resolveLinearOauthToken('ws-uuid-1', REGISTRY_TABLE, clients); + expect(result).toBeNull(); + }); + + test('returns null when secret JSON is missing required fields', async () => { + const clients = makeFakeClients({ + registryItem: { + workspace_slug: 'acme', + oauth_secret_arn: 'arn:secret:acme', + status: 'active', + }, + // Cast: the test deliberately writes a malformed token to assert the + // resolver guards against it. + storedToken: { access_token: 'partial' } as unknown as StoredOauthToken, + }); + const result = await resolveLinearOauthToken('ws-uuid-1', REGISTRY_TABLE, clients); + expect(result).toBeNull(); + }); + + test('refreshes token via Linear /oauth/token when expiring', async () => { + const expiringSoon = new Date(Date.now() + 10 * 1000).toISOString(); + const stored = makeStoredToken({ + access_token: 'lin_oauth_old', + refresh_token: 'rt-old', + expires_at: expiringSoon, + }); + const clients = makeFakeClients({ + registryItem: { + workspace_slug: 'acme', + oauth_secret_arn: 'arn:secret:acme', + status: 'active', + }, + storedToken: stored, + }); + + const fetchImpl = jest.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'lin_oauth_new', + token_type: 'Bearer', + expires_in: 86399, + refresh_token: 'rt-new', + scope: 'read write app:assignable app:mentionable', + }), + }); + + const result = await resolveLinearOauthToken('ws-uuid-1', REGISTRY_TABLE, { + ...clients, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(result?.accessToken).toBe('lin_oauth_new'); + // Refresh body must include client_id+client_secret from the secret JSON. + const sentBody = fetchImpl.mock.calls[0][1]!.body as string; + const sent = new URLSearchParams(sentBody); + expect(sent.get('grant_type')).toBe('refresh_token'); + expect(sent.get('refresh_token')).toBe('rt-old'); + expect(sent.get('client_id')).toBe('cid'); + expect(sent.get('client_secret')).toBe('csec'); + // PutSecretValue should have persisted the rotated token. + const putCalls = clients.smSend.mock.calls.filter( + (c) => c[0]!.constructor.name === 'PutSecretValueCommand', + ); + expect(putCalls).toHaveLength(1); + }); + + test('returns null when refresh request fails', async () => { + const stored = makeStoredToken({ + expires_at: new Date(Date.now() - 1000).toISOString(), + }); + const clients = makeFakeClients({ + registryItem: { + workspace_slug: 'acme', + oauth_secret_arn: 'arn:secret:acme', + status: 'active', + }, + storedToken: stored, + }); + + const fetchImpl = jest.fn().mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ + error: 'invalid_grant', + error_description: 'refresh token revoked', + }), + }); + + const result = await resolveLinearOauthToken('ws-uuid-1', REGISTRY_TABLE, { + ...clients, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(result).toBeNull(); + }); + + test('invalidateLinearOauthCache clears the cache', async () => { + const stored = makeStoredToken(); + const clients = makeFakeClients({ + registryItem: { + workspace_slug: 'acme', + oauth_secret_arn: 'arn:secret:acme', + status: 'active', + }, + storedToken: stored, + }); + + await resolveLinearOauthToken('ws-uuid-1', REGISTRY_TABLE, clients); + // Second call hits the cache, doesn't re-query DDB. + await resolveLinearOauthToken('ws-uuid-1', REGISTRY_TABLE, clients); + const ddbCallsBeforeInvalidate = clients.ddbSend.mock.calls.length; + expect(ddbCallsBeforeInvalidate).toBe(1); + + invalidateLinearOauthCache('ws-uuid-1', 'arn:secret:acme'); + await resolveLinearOauthToken('ws-uuid-1', REGISTRY_TABLE, clients); + expect(clients.ddbSend.mock.calls.length).toBe(2); + }); +}); diff --git a/cdk/test/handlers/shared/strategies/agentcore-strategy.test.ts b/cdk/test/handlers/shared/strategies/agentcore-strategy.test.ts index 46f3f7e7..66a66884 100644 --- a/cdk/test/handlers/shared/strategies/agentcore-strategy.test.ts +++ b/cdk/test/handlers/shared/strategies/agentcore-strategy.test.ts @@ -47,6 +47,7 @@ describe('AgentCoreComputeStrategy', () => { const handle = await strategy.startSession({ taskId: 'TASK001', + userId: 'cognito-user-1', payload: { repo_url: 'org/repo', task_id: 'TASK001' }, blueprintConfig: { compute_type: 'agentcore', runtime_arn: defaultRuntimeArn }, }); @@ -56,6 +57,9 @@ describe('AgentCoreComputeStrategy', () => { const acHandle = handle as Extract; expect(acHandle.runtimeArn).toBe(defaultRuntimeArn); expect(mockSend).toHaveBeenCalledTimes(1); + // runtimeUserId triggers AgentCore Identity workload-token injection. + const invokeInput = mockSend.mock.calls[0][0].input; + expect(invokeInput.runtimeUserId).toBe('cognito-user-1'); }); test('uses runtime_arn from blueprintConfig (single source of truth)', async () => { @@ -65,6 +69,7 @@ describe('AgentCoreComputeStrategy', () => { const handle = await strategy.startSession({ taskId: 'TASK001', + userId: 'cognito-user-1', payload: { repo_url: 'org/repo', task_id: 'TASK001' }, blueprintConfig: { compute_type: 'agentcore', runtime_arn: runtimeArn }, }); @@ -86,11 +91,13 @@ describe('AgentCoreComputeStrategy', () => { await strategy1.startSession({ taskId: 'T1', + userId: 'u1', payload: {}, blueprintConfig: { compute_type: 'agentcore', runtime_arn: defaultRuntimeArn }, }); await strategy2.startSession({ taskId: 'T2', + userId: 'u2', payload: {}, blueprintConfig: { compute_type: 'agentcore', runtime_arn: defaultRuntimeArn }, }); diff --git a/cdk/test/handlers/shared/strategies/ecs-strategy.test.ts b/cdk/test/handlers/shared/strategies/ecs-strategy.test.ts index 0e365a17..44748370 100644 --- a/cdk/test/handlers/shared/strategies/ecs-strategy.test.ts +++ b/cdk/test/handlers/shared/strategies/ecs-strategy.test.ts @@ -57,6 +57,7 @@ describe('EcsComputeStrategy', () => { const strategy = new EcsComputeStrategy(); const handle = await strategy.startSession({ taskId: 'TASK001', + userId: 'cognito-test', payload: { repo_url: 'org/repo', prompt: 'Fix the bug', issue_number: 42, max_turns: 50 }, blueprintConfig: { compute_type: 'ecs', runtime_arn: '' }, }); @@ -109,6 +110,7 @@ describe('EcsComputeStrategy', () => { await expect( strategy.startSession({ taskId: 'TASK001', + userId: 'cognito-test', payload: { repo_url: 'org/repo' }, blueprintConfig: { compute_type: 'ecs', runtime_arn: '' }, }), @@ -123,6 +125,7 @@ describe('EcsComputeStrategy', () => { const strategy = new EcsComputeStrategy(); await strategy.startSession({ taskId: 'TASK001', + userId: 'cognito-test', payload: { repo_url: 'org/repo' }, blueprintConfig: { compute_type: 'ecs', diff --git a/cdk/test/handlers/start-session-composition.test.ts b/cdk/test/handlers/start-session-composition.test.ts index e9592d66..1fe29fbd 100644 --- a/cdk/test/handlers/start-session-composition.test.ts +++ b/cdk/test/handlers/start-session-composition.test.ts @@ -91,7 +91,7 @@ describe('start-session step composition', () => { mockDdbSend.mockResolvedValue({}); // transitionTask + emitTaskEvent const strategy = resolveComputeStrategy(blueprintConfig); - const handle = await strategy.startSession({ taskId, payload, blueprintConfig }); + const handle = await strategy.startSession({ taskId, userId: 'cognito-test', payload, blueprintConfig }); await transitionTask(taskId, TaskStatus.HYDRATING, TaskStatus.RUNNING, { session_id: handle.sessionId, @@ -118,7 +118,7 @@ describe('start-session step composition', () => { const strategy = resolveComputeStrategy(blueprintConfig); try { - await strategy.startSession({ taskId, payload, blueprintConfig }); + await strategy.startSession({ taskId, userId: 'cognito-test', payload, blueprintConfig }); fail('Expected startSession to throw'); } catch (err) { await failTask(taskId, TaskStatus.HYDRATING, `Session start failed: ${String(err)}`, userId, true); @@ -138,7 +138,7 @@ describe('start-session step composition', () => { .mockResolvedValue({}); // failTask calls const strategy = resolveComputeStrategy(blueprintConfig); - const handle = await strategy.startSession({ taskId, payload, blueprintConfig }); + const handle = await strategy.startSession({ taskId, userId: 'cognito-test', payload, blueprintConfig }); try { await transitionTask(taskId, TaskStatus.HYDRATING, TaskStatus.RUNNING, { diff --git a/cdk/test/stacks/agent.test.ts b/cdk/test/stacks/agent.test.ts index 1d70c217..bec1ef15 100644 --- a/cdk/test/stacks/agent.test.ts +++ b/cdk/test/stacks/agent.test.ts @@ -36,12 +36,13 @@ describe('AgentStack', () => { expect(template).toBeDefined(); }); - test('creates exactly 12 DynamoDB tables', () => { + test('creates exactly 13 DynamoDB tables', () => { // task, task-events, repo, user-concurrency, webhook, task-nudges, // task-approvals (Cedar HITL V2), // slack-installation, slack-user-mapping, - // linear-project-mapping, linear-user-mapping, linear-webhook-dedup - template.resourceCountIs('AWS::DynamoDB::Table', 12); + // linear-project-mapping, linear-user-mapping, linear-webhook-dedup, + // linear-workspace-registry (added in Phase 2.0b for OAuth bookkeeping) + template.resourceCountIs('AWS::DynamoDB::Table', 13); }); test('creates TaskApprovalsTable with user_id-status-index GSI', () => { diff --git a/cli/package.json b/cli/package.json index b8b858fe..ca9a1efb 100644 --- a/cli/package.json +++ b/cli/package.json @@ -30,8 +30,10 @@ "typescript": "^5.9.3" }, "dependencies": { + "@aws-sdk/client-bedrock-agentcore": "3.1024.0", + "@aws-sdk/client-bedrock-agentcore-control": "3.1024.0", "@aws-sdk/client-cloudformation": "3.1024.0", - "@aws-sdk/client-cognito-identity-provider": "^3.1021.0", + "@aws-sdk/client-cognito-identity-provider": "3.1024.0", "@aws-sdk/client-dynamodb": "3.1024.0", "@aws-sdk/client-secrets-manager": "3.1024.0", "@aws-sdk/lib-dynamodb": "3.1024.0", diff --git a/cli/src/bin/bgagent.ts b/cli/src/bin/bgagent.ts index a8e61c0a..eecec7b2 100644 --- a/cli/src/bin/bgagent.ts +++ b/cli/src/bin/bgagent.ts @@ -20,6 +20,7 @@ */ import { Command } from 'commander'; +import { makeAdminCommand } from '../commands/admin'; import { makeApproveCommand } from '../commands/approve'; import { makeCancelCommand } from '../commands/cancel'; import { makeConfigureCommand } from '../commands/configure'; @@ -72,6 +73,7 @@ program.addCommand(makeLinearCommand()); program.addCommand(makeWatchCommand()); program.addCommand(makeTraceCommand()); program.addCommand(makeWebhookCommand()); +program.addCommand(makeAdminCommand()); // Execute the CLI only when run directly. Importing this module (e.g. // from a test harness or a wrapper) must not parse the importer's diff --git a/cli/src/commands/admin.ts b/cli/src/commands/admin.ts new file mode 100644 index 00000000..c2101a8f --- /dev/null +++ b/cli/src/commands/admin.ts @@ -0,0 +1,208 @@ +/** + * 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 * as crypto from 'crypto'; +import { + AdminCreateUserCommand, + AdminSetUserPasswordCommand, + CognitoIdentityProviderClient, +} from '@aws-sdk/client-cognito-identity-provider'; +import { Command } from 'commander'; +import { loadConfig } from '../config'; +import { CliError } from '../errors'; +import { CliConfig } from '../types'; + +/** + * Generate a strong temporary password meeting Cognito's default policy: + * min 12 chars, with at least one upper, lower, digit, and symbol. + * + * Uses node crypto for cryptographic randomness; the symbol set excludes + * `'` `"` `\` `` ` `` to keep the password copy-pasteable across shells + * without escaping pain. + */ +export function generateTempPassword(): string { + const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; // ambiguous chars (I/O) removed + const lower = 'abcdefghijkmnpqrstuvwxyz'; // (l/o) removed + const digit = '23456789'; // (0/1) removed + const symbol = '!@#$%^&*()-_=+[]{}<>?'; + const all = upper + lower + digit + symbol; + + const pickFrom = (set: string): string => set[crypto.randomInt(set.length)]; + + // One required char from each class, then 14 more random chars (>= 12 total). + const chars: string[] = [pickFrom(upper), pickFrom(lower), pickFrom(digit), pickFrom(symbol)]; + for (let i = 0; i < 14; i += 1) { + chars.push(pickFrom(all)); + } + + // Fisher-Yates shuffle so the required chars don't land at predictable indices + for (let i = chars.length - 1; i > 0; i -= 1) { + const j = crypto.randomInt(i + 1); + [chars[i], chars[j]] = [chars[j], chars[i]]; + } + return chars.join(''); +} + +/** + * Encode the four configure-fields as a single base64 bundle Alice can paste + * into `bgagent configure --from-bundle`. Bundle is Cognito-only — Linear / + * Slack onboarding is per-deployment, not per-user. + */ +export function encodeBundle(config: CliConfig): string { + const json = JSON.stringify({ + api_url: config.api_url, + region: config.region, + user_pool_id: config.user_pool_id, + client_id: config.client_id, + }); + return Buffer.from(json, 'utf-8').toString('base64'); +} + +/** + * Decode a base64 bundle back to a CliConfig. Throws CliError on malformed + * input. Validates all four required fields are present and non-empty so a + * truncated paste fails fast instead of writing a half-broken config.json. + */ +export function decodeBundle(bundle: string): CliConfig { + let json: string; + try { + json = Buffer.from(bundle.trim(), 'base64').toString('utf-8'); + } catch { + throw new CliError('Invalid bundle: not valid base64.'); + } + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + throw new CliError('Invalid bundle: decoded payload is not JSON.'); + } + if (typeof parsed !== 'object' || parsed === null) { + throw new CliError('Invalid bundle: decoded payload is not an object.'); + } + const obj = parsed as Record; + const missing: string[] = []; + for (const field of ['api_url', 'region', 'user_pool_id', 'client_id']) { + if (typeof obj[field] !== 'string' || (obj[field] as string).length === 0) { + missing.push(field); + } + } + if (missing.length > 0) { + throw new CliError(`Invalid bundle: missing or empty fields ${missing.join(', ')}.`); + } + return { + api_url: obj.api_url as string, + region: obj.region as string, + user_pool_id: obj.user_pool_id as string, + client_id: obj.client_id as string, + }; +} + +/** + * `bgagent admin invite-user ` — wraps Cognito admin-create-user + + * admin-set-user-password and prints a shareable bundle. Requires the caller + * to have AWS credentials with cognito-idp:AdminCreateUser permission on the + * configured user pool (i.e. they're a stack admin / IAM principal, not just + * a Cognito-authenticated end-user). + * + * Bundle distribution is intentionally manual — Slack/1Password/email is + * usually fine, and adding SES introduces verified-identity gates and PII + * handling that aren't worth the polish for a self-hosted tool. + */ +export function makeAdminCommand(): Command { + const admin = new Command('admin').description('Admin commands for managing the deployment'); + + admin.addCommand( + new Command('invite-user') + .description('Create a Cognito user and print a shareable config bundle') + .argument('', 'Email address of the new user') + .option('--region ', 'AWS region (defaults to configured region)') + .option( + '--temp-password ', + 'Temporary password (default: auto-generated, must meet Cognito policy)', + ) + .action(async (email: string, opts) => { + const config = loadConfig(); + const region = opts.region ?? config.region; + + if (!isLikelyEmail(email)) { + throw new CliError( + `'${email}' does not look like a valid email. The Cognito pool requires email as the username.`, + ); + } + + const tempPassword = opts.tempPassword ?? generateTempPassword(); + + const cognito = new CognitoIdentityProviderClient({ region }); + try { + await cognito.send(new AdminCreateUserCommand({ + UserPoolId: config.user_pool_id, + Username: email, + UserAttributes: [ + { Name: 'email', Value: email }, + { Name: 'email_verified', Value: 'true' }, + ], + TemporaryPassword: tempPassword, + MessageAction: 'SUPPRESS', + })); + } catch (err) { + if (err instanceof Error && err.name === 'UsernameExistsException') { + throw new CliError( + `User ${email} already exists. Re-run with a different email, or delete the user first via the AWS console.`, + ); + } + throw err; + } + + await cognito.send(new AdminSetUserPasswordCommand({ + UserPoolId: config.user_pool_id, + Username: email, + Password: tempPassword, + Permanent: true, + })); + + const bundle = encodeBundle(config); + printInviteSummary(email, tempPassword, bundle); + }), + ); + + return admin; +} + +/** Permissive email-shape check — Cognito does the real validation. */ +function isLikelyEmail(value: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); +} + +function printInviteSummary(email: string, tempPassword: string, bundle: string): void { + const bar = '─'.repeat(64); + console.log(); + console.log(`✓ Created Cognito user ${email}`); + console.log('✓ Set permanent password (no first-login change required)'); + console.log(); + console.log('Share with the new teammate:'); + console.log(bar); + console.log(` email: ${email}`); + console.log(` password: ${tempPassword}`); + console.log(` bundle: ${bundle}`); + console.log(bar); + console.log(); + console.log('They run:'); + console.log(` bgagent configure --from-bundle ${bundle}`); + console.log(` bgagent login --username ${email}`); +} diff --git a/cli/src/commands/configure.ts b/cli/src/commands/configure.ts index f994ace6..a5d2eb15 100644 --- a/cli/src/commands/configure.ts +++ b/cli/src/commands/configure.ts @@ -18,13 +18,17 @@ */ import { Command } from 'commander'; +import { decodeBundle } from './admin'; import { saveConfig, tryLoadConfig } from '../config'; import { CliError } from '../errors'; import { CliConfig } from '../types'; /** * All four core fields (api-url, region, user-pool-id, client-id) are required - * the first time — subsequent invocations may update a subset. + * the first time — subsequent invocations may update a subset. `--from-bundle` + * accepts a base64 string (printed by `bgagent admin invite-user`) carrying + * all four fields at once, so a teammate joining a deployment can run a + * single command instead of typing four flags. */ export function makeConfigureCommand(): Command { return new Command('configure') @@ -33,17 +37,32 @@ export function makeConfigureCommand(): Command { .option('--region ', 'AWS region') .option('--user-pool-id ', 'Cognito User Pool ID') .option('--client-id ', 'Cognito App Client ID') + .option('--from-bundle ', 'Base64 config bundle from `bgagent admin invite-user`') .action((opts) => { + // --from-bundle is mutually exclusive with the individual flags. Mixing + // them risks silent overrides; refuse instead of guessing precedence. + const individualFlagsProvided = opts.apiUrl || opts.region || opts.userPoolId || opts.clientId; + if (opts.fromBundle && individualFlagsProvided) { + throw new CliError( + '--from-bundle is mutually exclusive with --api-url / --region / --user-pool-id / --client-id.', + ); + } + const existing = tryLoadConfig(); - const providedFlags = { - ...(opts.apiUrl !== undefined ? { api_url: opts.apiUrl } : {}), - ...(opts.region !== undefined ? { region: opts.region } : {}), - ...(opts.userPoolId !== undefined ? { user_pool_id: opts.userPoolId } : {}), - ...(opts.clientId !== undefined ? { client_id: opts.clientId } : {}), - }; + let providedFields: Partial; + if (opts.fromBundle) { + providedFields = decodeBundle(opts.fromBundle); + } else { + providedFields = { + ...(opts.apiUrl !== undefined ? { api_url: opts.apiUrl } : {}), + ...(opts.region !== undefined ? { region: opts.region } : {}), + ...(opts.userPoolId !== undefined ? { user_pool_id: opts.userPoolId } : {}), + ...(opts.clientId !== undefined ? { client_id: opts.clientId } : {}), + }; + } const merged: Partial = { ...(existing ?? {}), - ...providedFlags, + ...providedFields, }; // All four core fields must be present after merge — enforces first-time @@ -56,14 +75,15 @@ export function makeConfigureCommand(): Command { if (missing.length > 0) { throw new CliError( `Missing required configuration: ${missing.join(', ')}. ` - + 'Provide all four core fields on the first `bgagent configure` call.', + + 'Provide all four core fields on the first `bgagent configure` call ' + + '(or use `--from-bundle` from `bgagent admin invite-user`).', ); } // If the user ran `bgagent configure` with no flags while a complete // config already existed, there is nothing to save — don't print the // misleading "Configuration saved." message. - if (existing !== null && Object.keys(providedFlags).length === 0) { + if (existing !== null && Object.keys(providedFields).length === 0) { console.log('No configuration changes — all flags were omitted.'); return; } diff --git a/cli/src/commands/linear.ts b/cli/src/commands/linear.ts index 99f34b45..0975ad8f 100644 --- a/cli/src/commands/linear.ts +++ b/cli/src/commands/linear.ts @@ -17,15 +17,32 @@ * SOFTWARE. */ +import { execFile } from 'child_process'; import * as readline from 'readline'; import { CloudFormationClient, DescribeStacksCommand } from '@aws-sdk/client-cloudformation'; import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { GetSecretValueCommand, PutSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; +import { + CreateSecretCommand, + GetSecretValueCommand, + PutSecretValueCommand, + ResourceExistsException, + SecretsManagerClient, +} from '@aws-sdk/client-secrets-manager'; import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; import { Command } from 'commander'; import { ApiClient } from '../api-client'; import { loadConfig, loadCredentials } from '../config'; +import { CliError } from '../errors'; import { formatJson } from '../format'; +import { + buildAuthorizationUrl, + computeExpiresAt, + exchangeAuthorizationCode, + generatePkce, + linearOauthSecretName, + StoredLinearOauthToken, +} from '../linear-oauth'; +import { awaitOauthCallback, CALLBACK_URL } from '../oauth-callback-server'; /** Default label that triggers an ABCA task when applied to a Linear issue. */ const DEFAULT_LABEL_FILTER = 'bgagent'; @@ -33,10 +50,214 @@ const DEFAULT_LABEL_FILTER = 'bgagent'; /** Standard RFC 4122 UUID — Linear's `projects.nodes[].id` matches this shape. */ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +/** + * Render the printable Linear OAuth app config. Standalone export so + * `bgagent linear setup` can call it inline (Phase 2.0b setup wizard + * Step 2 — show the user what to paste into Linear's app form). + */ +export interface LinearAppTemplateOptions { + readonly botName?: string; + readonly developerName?: string; + readonly developerUrl?: string; + readonly description?: string; + readonly awsCallbackUrl?: string; +} + +export function renderLinearAppTemplate(opts: LinearAppTemplateOptions = {}): string { + // Defaults match the upstream sample so unmodified `bgagent linear app-template` + // produces a usable config without forcing every operator to invent strings. + // Operators with custom branding override via flags. + const botName = opts.botName ?? 'bgagent[bot]'; + const developerName = opts.developerName ?? 'ABCA'; + const developerUrl = opts.developerUrl ?? 'https://github.com/aws-samples/sample-autonomous-cloud-coding-agents'; + const description = opts.description ?? 'Autonomous Background Coding Agent'; + // The AWS-hosted callback is surfaced by `aws bedrock-agentcore-control + // create-oauth2-credential-provider` once per workspace. If unknown at + // template-render time, print a placeholder the operator must replace. + const awsCallback = opts.awsCallbackUrl + ?? ''; + + const bar = '═'.repeat(72); + return [ + bar, + 'Linear OAuth app template', + bar, + '', + 'Open https://linear.app/settings/api/applications/new and paste:', + '', + ' Application name: bgagent', + ` Developer name: ${developerName}`, + ` Developer URL: ${developerUrl}`, + ` Description: ${description}`, + '', + ' Callback URLs (one per line, NO line wrapping):', + ` ${awsCallback}`, + '', + ` GitHub username: ${botName} ← REQUIRED for actor=app`, + ' Public: OFF', + ' Client credentials: OFF', + ' Webhooks: ON ← REQUIRED for actor=app', + ' Webhook URL: https://example.com/placeholder ← any HTTPS URL', + ' (You do NOT need to subscribe to any events for the OAuth flow itself)', + '', + 'Click Save, copy the Client ID and Client Secret, then return here.', + '', + 'Why these specific fields:', + ' • GitHub username with [bot] suffix gates the actor=app agent flow.', + ' Without it, Linear surfaces a misleading "Invalid redirect_uri" error.', + ' • Webhooks toggle must be ON for the same reason; the URL value is unused', + ' by the OAuth dance and can be a placeholder.', + ' • Wildcard callback URLs are not accepted by Linear; list each URL fully.', + bar, + ].join('\n'); +} + +/** + * Validate a Linear workspace slug. Used to keep the per-workspace + * Secrets Manager secret name (`bgagent-linear-oauth-`) within + * AWS's 64-char limit and to confirm the slug is the Linear `urlKey` + * shape (Linear's `urlKey` matches `[a-zA-Z0-9_-]+`). + */ +const SLUG_RE = /^[a-zA-Z0-9_-]{4,50}$/; + +/** + * Open `url` in the user's default browser. Returns true on best-effort + * success, false if no opener is available (e.g. headless SSH session) so + * callers can fall back to printing the URL. + * + * Uses `child_process.execFile` directly rather than a dependency like + * `open` — no need for a 200-line module to spawn one shell command. + */ +export function openBrowser(url: string): Promise { + return new Promise((resolve) => { + let opener: { cmd: string; args: string[] }; + if (process.platform === 'darwin') { + opener = { cmd: 'open', args: [url] }; + } else if (process.platform === 'win32') { + // `start` is a cmd.exe builtin; URLs need empty title arg + escaping. + opener = { cmd: 'cmd', args: ['/c', 'start', '""', url] }; + } else { + opener = { cmd: 'xdg-open', args: [url] }; + } + execFile(opener.cmd, opener.args, (err) => { + resolve(!err); + }); + }); +} + +/** + * Check whether the LinearWebhookSecret already holds a real Linear + * signing secret (vs CDK's autogenerated placeholder). Used to decide + * whether to prompt for the webhook secret on subsequent setup runs. + * + * Linear's webhook signing secrets start with `lin_wh_` — the placeholder + * is a CDK-generated random JSON-encoded string that doesn't match. + * + * Returns true if a real secret is stored, false otherwise (including + * any error fetching — best-effort; a re-prompt is harmless). + */ +export async function isWebhookSecretConfigured( + client: SecretsManagerClient, + secretArn: string, +): Promise { + try { + const result = await client.send(new GetSecretValueCommand({ SecretId: secretArn })); + const value = result.SecretString; + return typeof value === 'string' && value.startsWith('lin_wh_'); + } catch { + return false; + } +} + +/** + * Generate an opaque, URL-safe `state` value for OAuth CSRF protection. + * 32 bytes of crypto-randomness — enough that collisions and guesses + * are not realistic concerns. + */ +function randomState(): string { + // Lazy import to keep `crypto` out of module-load surface for non-OAuth + // uses of this command file. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { randomBytes } = require('crypto') as typeof import('crypto'); + return randomBytes(32).toString('base64url'); +} + +/** + * Idempotent secret upsert: tries CreateSecret first; if the secret + * already exists (re-running setup, rotating refresh token), falls + * back to PutSecretValue. Returns the secret ARN regardless of which + * branch ran. + * + * The Phase 2.0b-O2 design stores OAuth tokens at runtime (CLI creates + * the secret, not CDK), so the wizard owns this lifecycle. + */ +export async function upsertOauthSecret( + client: SecretsManagerClient, + secretName: string, + payload: StoredLinearOauthToken, + workspaceSlug: string, +): Promise { + const secretString = JSON.stringify(payload); + try { + const create = await client.send(new CreateSecretCommand({ + Name: secretName, + Description: `Linear OAuth token for workspace '${workspaceSlug}' (Phase 2.0b)`, + SecretString: secretString, + // Tags help with cost allocation and the deletion-runbook discoverability. + Tags: [ + { Key: 'bgagent:integration', Value: 'linear' }, + { Key: 'bgagent:linear:workspace_slug', Value: workspaceSlug }, + ], + })); + if (!create.ARN) { + throw new CliError(`CreateSecret returned no ARN for '${secretName}'.`); + } + return create.ARN; + } catch (err) { + if (err instanceof ResourceExistsException) { + const put = await client.send(new PutSecretValueCommand({ + SecretId: secretName, + SecretString: secretString, + })); + if (!put.ARN) { + throw new CliError(`PutSecretValue returned no ARN for '${secretName}'.`); + } + return put.ARN; + } + throw err; + } +} + export function makeLinearCommand(): Command { const linear = new Command('linear') .description('Manage Linear integration'); + linear.addCommand( + new Command('app-template') + .description('Print the field values to paste into Linear\'s OAuth app form') + .option('--bot-name ', 'GitHub username for actor=app (must end with [bot])') + .option('--developer-name ', 'Developer name shown on Linear\'s consent screen') + .option('--developer-url ', 'Developer URL shown on Linear\'s consent screen') + .option('--description ', 'App description shown on Linear\'s consent screen') + .option('--aws-callback-url ', 'AWS-hosted callback URL from create-oauth2-credential-provider') + .action((opts) => { + if (opts.botName && !/\[bot\]$/.test(opts.botName)) { + console.error( + 'Error: --bot-name must end with the literal "[bot]" suffix ' + + `(Linear requires this for actor=app). Got: ${opts.botName}`, + ); + process.exit(1); + } + console.log(renderLinearAppTemplate({ + botName: opts.botName, + developerName: opts.developerName, + developerUrl: opts.developerUrl, + description: opts.description, + awsCallbackUrl: opts.awsCallbackUrl, + })); + }), + ); + linear.addCommand( new Command('link') .description('Link your Linear account using a verification code') @@ -59,59 +280,279 @@ export function makeLinearCommand(): Command { linear.addCommand( new Command('setup') - .description('Populate Linear webhook secret + personal API token in Secrets Manager') + .description('Authorize a Linear workspace via OAuth (Phase 2.0b — direct flow, Secrets Manager storage)') + .argument('', 'Linear workspace urlKey (e.g. "acme" from linear.app/acme/...)') .option('--region ', 'AWS region (defaults to configured region)') .option('--stack-name ', 'CloudFormation stack name', 'backgroundagent-dev') - .action(async (opts) => { + .option('--client-id ', 'Linear OAuth app Client ID (else prompted)') + .option('--client-secret ', 'Linear OAuth app Client Secret (else prompted; prefer interactive)') + .option('--no-browser', 'Print the authorization URL instead of opening a browser (for SSH/headless)') + .option('--rotate-webhook-secret', 'Re-prompt for the webhook signing secret even if one is already configured') + .option('--no-actor-app', 'Drop actor=app from the OAuth flow (diagnostic: isolates whether agent-install is blocking)') + .action(async (slug: string, opts) => { + if (!SLUG_RE.test(slug)) { + throw new CliError( + `Invalid workspace slug '${slug}'. Must be 4-50 chars matching [a-zA-Z0-9_-]. ` + + 'This is the Linear urlKey, e.g. \'acme\' from linear.app/acme/...', + ); + } const config = loadConfig(); const region = opts.region || config.region; + const stackName = opts.stackName; + + // ─── Stack outputs ───────────────────────────────────────────── + const [ + workspaceRegistryTable, + userMappingTable, + webhookSecretArn, + ] = await Promise.all([ + getStackOutput(region, stackName, 'LinearWorkspaceRegistryTableName'), + getStackOutput(region, stackName, 'LinearUserMappingTableName'), + getStackOutput(region, stackName, 'LinearWebhookSecretArn'), + ]); + + const missing: string[] = []; + if (!workspaceRegistryTable) missing.push('LinearWorkspaceRegistryTableName'); + if (!userMappingTable) missing.push('LinearUserMappingTableName'); + if (!webhookSecretArn) missing.push('LinearWebhookSecretArn'); + if (missing.length > 0) { + throw new CliError( + `Stack '${stackName}' is missing outputs ${missing.join(', ')}. ` + + 'Re-deploy with the 2.0b CDK changes (mise //cdk:deploy).', + ); + } - const webhookSecretArn = await getStackOutput(region, opts.stackName, 'LinearWebhookSecretArn'); - const apiTokenSecretArn = await getStackOutput(region, opts.stackName, 'LinearApiTokenSecretArn'); + // ─── Resolve caller identity ────────────────────────────────── + const creds = loadCredentials(); + if (!creds?.id_token) { + throw new CliError('Not authenticated — run `bgagent login` first.'); + } + let cognitoSub: string; + try { + cognitoSub = extractCognitoSub(); + } catch (err) { + throw new CliError( + `Could not read Cognito sub from cached id_token: ${err instanceof Error ? err.message : String(err)}. ` + + 'Run `bgagent login` to refresh credentials.', + ); + } - if (!webhookSecretArn || !apiTokenSecretArn) { - console.error('Could not find Linear secret ARNs in stack outputs. Deploy the stack first.'); - process.exit(1); + // ─── Linear OAuth app credentials ────────────────────────────── + // Prompted up-front so the wizard doesn't get halfway through the + // OAuth dance before realising it can't continue. + console.log(`bgagent linear setup — workspace '${slug}'`); + console.log(` region: ${region}`); + console.log( + '\nLinear OAuth app credentials needed. If you have not created one, run `bgagent linear app-template`' + + ' for the values to paste into Linear → Settings → API → New application.\n', + ); + const clientId = (opts.clientId ?? await promptSecret('Linear Client ID: ')).trim(); + if (!clientId) { + throw new CliError('Client ID is required.'); + } + const clientSecret = (opts.clientSecret ?? await promptSecret('Linear Client Secret: ')).trim(); + if (!clientSecret) { + throw new CliError('Client Secret is required.'); } - const apiBaseUrl = config.api_url.replace(/\/+$/, ''); - console.log('Linear setup — see docs/guides/LINEAR_SETUP_GUIDE.md for the full walkthrough.\n'); - console.log('Required Linear config:'); - console.log(' 1. Create a personal API key at https://linear.app/settings/account/security'); - console.log(` 2. Create a webhook at https://linear.app/settings/api — point it at: ${apiBaseUrl}/linear/webhook`); - console.log(' - Subscribe to: Issues'); - console.log(' - Copy the signing secret from the webhook detail page\n'); + // ─── Step 1: Generate PKCE + open browser to Linear consent ──── + const pkce = generatePkce(); + const state = randomState(); + // `opts.actorApp` is true by default; --no-actor-app sets it false. + // Commander populates `opts.actorApp = false` when --no-actor-app is passed. + const useActorApp = opts.actorApp !== false; + const authorizationUrl = buildAuthorizationUrl({ + clientId, + redirectUri: CALLBACK_URL, + state, + codeChallenge: pkce.codeChallenge, + actorApp: useActorApp, + }); + if (!useActorApp) { + console.log(' ⚠ --no-actor-app: dropping actor=app for diagnosis. Token will not be agent-scoped.'); + } - const webhookSecret = await promptSecret('Webhook signing secret: '); - const apiToken = await promptSecret('Personal API key (lin_api_…): '); + // The localhost callback server starts BEFORE we open the browser + // so it's listening when Linear's redirect arrives. + const callbackPromise = awaitOauthCallback(); - if (!webhookSecret || !apiToken) { - console.error('\n✗ Both values are required. Try again.'); - process.exit(1); + console.log(); + if (opts.browser !== false) { + const opened = await openBrowser(authorizationUrl); + if (opened) { + console.log(' → Opened your browser to the Linear consent screen.'); + console.log(' The browser will redirect to a localhost page after you Authorize — that\'s expected.'); + } else { + console.log(' → Could not open browser automatically. Open this URL manually:'); + console.log(` ${authorizationUrl}`); + } + } else { + console.log(' → --no-browser: open this URL manually:'); + console.log(` ${authorizationUrl}`); } - if (!apiToken.startsWith('lin_api_')) { - console.error('\n✗ Personal API keys start with "lin_api_". Check https://linear.app/settings/account/security.'); - process.exit(1); + + process.stdout.write(' → Waiting for browser callback...'); + const callback = await callbackPromise; + console.log(' ✓'); + + // The localhost callback server resolves with `?session_id=` from + // AWS's redirect, but we're not using AgentCore — for the direct + // flow, the callback server gives us `?code=...&state=...` from + // Linear directly. We need to extract differently. + // + // Reality check: our awaitOauthCallback() is hard-coded to + // extract `session_id`. For Option 2 we need to also capture + // `code` + `state` from the same redirect. The localhost server + // module needs adjusting; for now we use a workaround that reads + // the 'session_id' field which the callback server populated + // from whatever query param was named `session_id`. We override + // by re-reading the actual redirect URL inside the server. + // + // Defensive: callback.sessionId may contain the full session_id + // value when this code is reached against an AgentCore-style + // redirect. We don't reach here in O2 because Linear redirects + // with `code` + `state`, not `session_id`. The callback module + // is updated separately to expose both shapes. + if (!callback.code || !callback.state) { + throw new CliError( + 'Localhost callback did not surface code/state. This indicates the callback ' + + 'server module is in legacy AgentCore-only mode; rebuild the CLI.', + ); + } + if (callback.state !== state) { + throw new CliError( + `OAuth state mismatch (expected '${state}', got '${callback.state}'). ` + + 'Possible CSRF attack or stale tab — re-run setup.', + ); } + // ─── Step 2: Exchange code for access token ─────────────────── + process.stdout.write(' → Exchanging code for access token...'); + const tokenResponse = await exchangeAuthorizationCode({ + code: callback.code, + codeVerifier: pkce.codeVerifier, + redirectUri: CALLBACK_URL, + clientId, + clientSecret, + }); + console.log(' ✓'); + + // ─── Step 3: Fetch workspace identity ───────────────────────── + process.stdout.write(' → Querying Linear viewer + organization...'); + const identity = await queryLinearIdentity(`Bearer ${tokenResponse.access_token}`); + if (!identity) { + throw new CliError( + 'Linear viewer query rejected the access token. This is unexpected — token was just issued. ' + + 'Re-run `bgagent linear setup` if Linear\'s API is recovering from a transient outage.', + ); + } + console.log(` ✓ (${identity.organization.name ?? identity.organization.urlKey ?? identity.organization.id})`); + + if (identity.organization.urlKey && identity.organization.urlKey !== slug) { + console.log( + ` ⚠ Slug '${slug}' does not match Linear's urlKey '${identity.organization.urlKey}'. ` + + 'Re-run with the correct slug to keep the registry key aligned with Linear.', + ); + } + + // ─── Step 4: Persist token to per-workspace Secrets Manager ─── + process.stdout.write(' → Storing OAuth token...'); const sm = new SecretsManagerClient({ region }); - await sm.send(new PutSecretValueCommand({ SecretId: webhookSecretArn, SecretString: webhookSecret })); - console.log(' ✓ Stored webhook signing secret'); - await sm.send(new PutSecretValueCommand({ SecretId: apiTokenSecretArn, SecretString: apiToken })); - console.log(' ✓ Stored personal API token'); - - const userMappingTable = await getStackOutput(region, opts.stackName, 'LinearUserMappingTableName'); - if (!userMappingTable) { - console.error('\n✗ Could not find LinearUserMappingTableName in stack outputs. Deploy the stack first.'); - process.exit(1); + const now = new Date().toISOString(); + const stored: StoredLinearOauthToken = { + access_token: tokenResponse.access_token, + refresh_token: tokenResponse.refresh_token ?? '', + expires_at: computeExpiresAt(tokenResponse.expires_in), + scope: tokenResponse.scope, + // Co-located so Lambda-side refresh works without per-Lambda + // env vars — one secret holds everything needed to renew. + client_id: clientId, + client_secret: clientSecret, + workspace_id: identity.organization.id, + workspace_slug: slug, + installed_at: now, + updated_at: now, + installed_by_platform_user_id: cognitoSub, + }; + if (!stored.refresh_token) { + throw new CliError( + 'Linear did not return a refresh_token. The integration cannot self-renew tokens; ' + + 're-check that the Linear OAuth app permits refresh-token grants.', + ); + } + const secretName = linearOauthSecretName(slug); + const oauthSecretArn = await upsertOauthSecret(sm, secretName, stored, slug); + console.log(` ✓ (${secretName})`); + + // ─── Step 5: Persist registry + user-mapping rows ───────────── + const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region })); + + await ddb.send(new PutCommand({ + TableName: workspaceRegistryTable!, + Item: { + linear_workspace_id: identity.organization.id, + workspace_slug: slug, + oauth_secret_arn: oauthSecretArn, + installed_by_platform_user_id: cognitoSub, + installed_at: now, + updated_at: now, + status: 'active', + }, + })); + console.log(' ✓ Recorded workspace in registry'); + + await ddb.send(new PutCommand({ + TableName: userMappingTable!, + Item: { + linear_identity: `${identity.organization.id}#${identity.viewer.id}`, + platform_user_id: cognitoSub, + linear_workspace_id: identity.organization.id, + linear_user_id: identity.viewer.id, + linked_at: now, + status: 'active', + link_method: 'auto_setup_oauth', + }, + })); + const adminLabel = identity.viewer.name ?? identity.viewer.email ?? identity.viewer.id; + console.log(` ✓ Linked Linear user ${adminLabel} → platform user`); + + // ─── Step 6: Webhook signing secret (workspace-independent) ─── + const alreadyConfigured = await isWebhookSecretConfigured(sm, webhookSecretArn!); + + if (alreadyConfigured && !opts.rotateWebhookSecret) { + console.log(' ✓ Webhook signing secret already configured (use --rotate-webhook-secret to update)'); + } else { + const apiBaseUrl = config.api_url.replace(/\/+$/, ''); + console.log(); + console.log(' Webhook signing secret needed.'); + console.log(' In Linear → Settings → API → Webhooks, create a webhook pointing at:'); + console.log(` ${apiBaseUrl}/linear/webhook`); + console.log(' Subscribe to: Issues. Copy the signing secret from the webhook detail page.'); + console.log(); + const webhookSecret = await promptSecret('Webhook signing secret (lin_wh_…): '); + if (!webhookSecret) { + throw new CliError('Webhook signing secret is required.'); + } + if (!webhookSecret.startsWith('lin_wh_')) { + throw new CliError( + 'Webhook signing secrets start with \'lin_wh_\'. Got something different — re-check the Linear webhook detail page.', + ); + } + await sm.send(new PutSecretValueCommand({ + SecretId: webhookSecretArn!, + SecretString: webhookSecret, + })); + console.log(' ✓ Stored webhook signing secret'); } - await autoLinkTokenOwner({ region, apiToken, userMappingTable }); - console.log('\nNext steps:'); - console.log(' 1. Onboard a Linear project:'); + // ─── Done ────────────────────────────────────────────────────── + console.log(); + console.log('✅ Setup complete.'); + console.log(); + console.log('Next steps:'); + console.log(' 1. Onboard a Linear project to a GitHub repo:'); console.log(' bgagent linear onboard-project --repo owner/repo'); - console.log(' 2. Add the "bgagent" label to a Linear issue in a mapped project — ABCA will pick it up.'); - console.log(' (To link additional Linear users, run `bgagent linear link ` after they generate a code.)'); + console.log(' 2. Add the `bgagent` label to a Linear issue in a mapped project.'); }), ); @@ -320,6 +761,45 @@ interface LinearViewer { interface LinearOrganization { readonly id: string; readonly name?: string; + /** Linear urlKey, e.g. "acme" — Phase 2.0b: used as the workspace slug. */ + readonly urlKey?: string; +} + +/** + * Query the Linear `viewer` + `organization` GraphQL fields with whatever + * Authorization header the caller hands us. Used both by the legacy + * PAK-era auto-link (header value = bare `lin_api_…` token) and the + * Phase 2.0b OAuth dance (header value = `Bearer `). + * + * Returns null on any failure so callers can fall back to a warning + * without blowing up the higher-level flow. + */ +async function queryLinearIdentity( + authorizationHeader: string, +): Promise<{ viewer: LinearViewer; organization: LinearOrganization } | null> { + try { + const res = await fetch('https://api.linear.app/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': authorizationHeader, + }, + body: JSON.stringify({ + query: '{ viewer { id name email } organization { id name urlKey } }', + }), + }); + if (!res.ok) { + throw new Error(`Linear API returned ${res.status}`); + } + const body = await res.json() as { data?: { viewer?: LinearViewer; organization?: LinearOrganization } }; + if (!body.data?.viewer?.id || !body.data.organization?.id) { + throw new Error('Linear API response missing viewer.id or organization.id'); + } + return { viewer: body.data.viewer, organization: body.data.organization }; + } catch (err) { + console.log(` ⚠ Could not query Linear identity: ${err instanceof Error ? err.message : String(err)}`); + return null; + } } /** diff --git a/cli/src/linear-oauth.ts b/cli/src/linear-oauth.ts new file mode 100644 index 00000000..c2ce2902 --- /dev/null +++ b/cli/src/linear-oauth.ts @@ -0,0 +1,273 @@ +/** + * 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 * as crypto from 'crypto'; +import { CliError } from './errors'; + +/** + * Linear OAuth endpoint URLs. Fixed across all workspaces. + */ +export const LINEAR_AUTHORIZE_ENDPOINT = 'https://linear.app/oauth/authorize'; +export const LINEAR_TOKEN_ENDPOINT = 'https://api.linear.app/oauth/token'; + +/** + * Scopes for the agent install. `actor=app` is incompatible with `admin`, + * so we deliberately exclude it. `app:assignable` + `app:mentionable` are + * required for an Agent app install (Phase 2.0b spike, 2026-05-18). + */ +export const LINEAR_OAUTH_SCOPES = [ + 'read', + 'write', + 'app:assignable', + 'app:mentionable', +] as const; + +/** + * Linear OAuth token response shape (RFC 6749 §5.1 + Linear's extensions). + * Verified via direct curl 2026-05-19 — Linear returns `scope` as a + * space-separated string for apps created after Dec 2023, with + * `lin_oauth_…` access tokens and `lin_refresh_…` refresh tokens. + */ +export interface LinearTokenResponse { + readonly access_token: string; + readonly token_type: string; + readonly expires_in: number; + readonly refresh_token?: string; + readonly scope: string; +} + +/** + * Persisted form of a Linear OAuth credential. Stored as the JSON + * `SecretString` of `bgagent-linear-oauth-` in Secrets Manager. + * + * `expires_at` is computed at write time as ISO-8601, so consumers can + * compare against `new Date()` without depending on Linear's + * `expires_in` (relative to issuance) being correct on the wall clock. + * + * `client_id` and `client_secret` are co-located so Lambda-side refresh + * can hit Linear's `/oauth/token` without needing additional environment + * variables — one secret per workspace contains everything the runtime + * needs to renew the access token autonomously. + */ +export interface StoredLinearOauthToken { + readonly access_token: string; + readonly refresh_token: string; + /** ISO-8601 timestamp; if `now >= expires_at - threshold`, refresh first. */ + readonly expires_at: string; + /** Space-separated scope string Linear returned (e.g. "read write app:..."). */ + readonly scope: string; + /** Linear OAuth app Client ID — needed for refresh. */ + readonly client_id: string; + /** Linear OAuth app Client Secret — needed for refresh. */ + readonly client_secret: string; + /** Linear organization UUID; webhook payloads carry this. */ + readonly workspace_id: string; + /** Linear urlKey; matches the suffix on the secret name. */ + readonly workspace_slug: string; + /** ISO-8601 timestamp of the original install (does NOT change on refresh). */ + readonly installed_at: string; + /** ISO-8601 timestamp of the most recent refresh write (or first install). */ + readonly updated_at: string; + /** Cognito sub of the admin who ran `bgagent linear setup`. Audit only. */ + readonly installed_by_platform_user_id: string; +} + +/** + * Build the secret name for a given Linear workspace slug. Matches the + * naming convention encoded in the runtime's IAM policy resource pattern, + * so changes here MUST be matched by the IAM resource pattern in CDK. + */ +export function linearOauthSecretName(workspaceSlug: string): string { + return `bgagent-linear-oauth-${workspaceSlug}`; +} + +/** + * Compute when an access token should be considered "stale and needs + * refresh." We refresh if there's <60s left on the access token — + * gives Lambda invocations a clean buffer to make the upstream call + * without racing the actual expiry. + */ +const REFRESH_THRESHOLD_SECONDS = 60; + +export function isAccessTokenExpiring( + expiresAt: string, + thresholdSeconds: number = REFRESH_THRESHOLD_SECONDS, +): boolean { + const expiry = new Date(expiresAt).getTime(); + if (Number.isNaN(expiry)) { + // Treat malformed expires_at as expired — better to over-refresh than + // proceed with a token that may have rotated under us. + return true; + } + return Date.now() + thresholdSeconds * 1000 >= expiry; +} + +/** + * PKCE pair: a random `code_verifier` and the SHA-256 base64url digest + * (`code_challenge`). Linear supports both `S256` and `plain`; we always + * use `S256` because the wire-format cost is identical and stronger. + * + * Returned `code_verifier` MUST be sent on the token-exchange POST to + * complete PKCE. Without it, Linear rejects with `invalid_grant`. + */ +export function generatePkce(): { codeVerifier: string; codeChallenge: string } { + const verifierBytes = crypto.randomBytes(32); + const codeVerifier = verifierBytes.toString('base64url'); + const challengeBytes = crypto.createHash('sha256').update(codeVerifier).digest(); + const codeChallenge = challengeBytes.toString('base64url'); + return { codeVerifier, codeChallenge }; +} + +/** + * Build the Linear authorization URL the CLI opens in the browser. + * `actorApp: true` adds `actor=app` (the Agent install variant). + */ +export function buildAuthorizationUrl(opts: { + clientId: string; + redirectUri: string; + state: string; + codeChallenge: string; + scopes?: readonly string[]; + actorApp?: boolean; +}): string { + const params = new URLSearchParams({ + client_id: opts.clientId, + redirect_uri: opts.redirectUri, + response_type: 'code', + // RFC 6749 §3.3: scope is a space-separated list. Linear rejects + // comma-separated scopes with "Invalid redirect_uri" — the error + // is misleading; verified by 2.0b smoke test 2026-05-19. + scope: (opts.scopes ?? LINEAR_OAUTH_SCOPES).join(' '), + state: opts.state, + code_challenge: opts.codeChallenge, + code_challenge_method: 'S256', + }); + if (opts.actorApp ?? true) { + params.set('actor', 'app'); + } + return `${LINEAR_AUTHORIZE_ENDPOINT}?${params.toString()}`; +} + +/** + * Exchange an authorization `code` for an access + refresh token by + * POSTing to Linear's `/oauth/token` endpoint. Mirrors the curl shape + * verified by the 2026-05-19 manual smoke test. + * + * Throws CliError with Linear's error_description on failure (the most + * common cause of failure is `invalid_grant` from a reused/expired + * code or `redirect_uri_mismatch`). + */ +export async function exchangeAuthorizationCode(args: { + code: string; + codeVerifier: string; + redirectUri: string; + clientId: string; + clientSecret: string; + fetchImpl?: typeof fetch; +}): Promise { + const fetchImpl = args.fetchImpl ?? fetch; + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code: args.code, + code_verifier: args.codeVerifier, + redirect_uri: args.redirectUri, + client_id: args.clientId, + client_secret: args.clientSecret, + }); + const response = await fetchImpl(LINEAR_TOKEN_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); + return parseTokenResponse(response, 'authorization_code exchange'); +} + +/** + * Refresh an expiring access token. Linear's refresh tokens are + * long-lived (no documented TTL) but rotate every refresh call — + * always persist `refresh_token` from the response back to storage. + */ +export async function refreshAccessToken(args: { + refreshToken: string; + clientId: string; + clientSecret: string; + fetchImpl?: typeof fetch; +}): Promise { + const fetchImpl = args.fetchImpl ?? fetch; + const body = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: args.refreshToken, + client_id: args.clientId, + client_secret: args.clientSecret, + }); + const response = await fetchImpl(LINEAR_TOKEN_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); + return parseTokenResponse(response, 'refresh_token grant'); +} + +async function parseTokenResponse( + response: Response, + contextLabel: string, +): Promise { + let body: unknown; + try { + body = await response.json(); + } catch (err) { + throw new CliError( + `Linear /oauth/token returned non-JSON during ${contextLabel}: HTTP ${response.status}`, + ); + } + if (!response.ok) { + const obj = body as { error?: string; error_description?: string }; + throw new CliError( + `Linear /oauth/token rejected ${contextLabel}: HTTP ${response.status} ` + + `${obj.error ?? 'unknown_error'}: ${obj.error_description ?? '(no description)'}`, + ); + } + if (!isLinearTokenResponse(body)) { + throw new CliError( + `Linear /oauth/token returned an unexpected shape for ${contextLabel}: ` + + `${JSON.stringify(body).slice(0, 200)}`, + ); + } + return body; +} + +function isLinearTokenResponse(value: unknown): value is LinearTokenResponse { + if (typeof value !== 'object' || value === null) return false; + const obj = value as Record; + return ( + typeof obj.access_token === 'string' + && typeof obj.token_type === 'string' + && typeof obj.expires_in === 'number' + && typeof obj.scope === 'string' + ); +} + +/** + * Compute the `expires_at` ISO timestamp from `expires_in` (seconds). + * Centralised so the CLI's initial-install path and the Lambda-side + * refresh path agree on the timestamp shape. + */ +export function computeExpiresAt(expiresInSeconds: number, now: Date = new Date()): string { + return new Date(now.getTime() + expiresInSeconds * 1000).toISOString(); +} diff --git a/cli/src/oauth-callback-server.ts b/cli/src/oauth-callback-server.ts new file mode 100644 index 00000000..a97ae3b1 --- /dev/null +++ b/cli/src/oauth-callback-server.ts @@ -0,0 +1,211 @@ +/** + * 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 * as http from 'http'; +import { URL } from 'url'; +import { CliError } from './errors'; + +/** + * Localhost OAuth callback URL used during `bgagent linear setup`. + * + * HTTP (not HTTPS) is intentional. Per RFC 8252 §7.3 (OAuth 2.0 for + * Native Apps) and Linear's docs, providers MUST treat http://localhost + * URLs as a special case and not require TLS — the connection never + * leaves the host. Using HTTP here removes the self-signed-cert browser + * warning that scared early testers during the Phase 2.0b smoke. + * + * The redirect_uri value sent to Linear MUST byte-match what's configured + * in Linear's app — keep this constant in sync with the LINEAR_SETUP_GUIDE + * playbook entry. + */ +export const CALLBACK_HOST = 'localhost'; +export const CALLBACK_PORT = 8080; +export const CALLBACK_PATH = '/oauth/callback'; +export const CALLBACK_URL = `http://${CALLBACK_HOST}:${CALLBACK_PORT}${CALLBACK_PATH}`; + +const SUCCESS_HTML = ` +bgagent setup + +

✓ Linear authorized

You can close this tab and return to your terminal.

`; + +const FAILURE_HTML = ` +bgagent setup + +

✗ Authorization not captured

The callback URL did not include a session_id. Re-run bgagent linear setup and try again.

`; + +export interface CallbackResult { + /** + * Value of the `session_id` query param if present (AgentCore-style + * redirect). Null in direct-OAuth flows where Linear redirects with + * `code` + `state` instead. + */ + readonly sessionId: string | null; + /** + * OAuth `code` from a direct-Linear redirect (Phase 2.0b Option 2). + * Null in AgentCore-style flows where AWS performs the code-to-token + * exchange itself. + */ + readonly code: string | null; + /** + * OAuth `state` from a direct-Linear redirect — caller MUST verify + * against the value passed into `buildAuthorizationUrl` to prevent + * CSRF. Null in AgentCore-style flows. + */ + readonly state: string | null; +} + +export interface CallbackServerOptions { + /** + * How long to keep the server listening before rejecting with a timeout + * error. The OAuth dance has a 600s server-side ceiling; 700s here + * covers slow-clicking users without holding the process open forever. + * + * @default 700_000 (700 seconds) + */ + readonly timeoutMs?: number; +} + +/** + * Start a one-shot HTTPS server that listens on `https://localhost:8443/oauth/callback`, + * resolves with the captured `session_id` from the first GET it receives, + * then shuts down. + * + * The OAuth dance flow: + * 1. CLI calls `get_resource_oauth2_token(...)` and gets back an + * `authorizationUrl` + `sessionUri`. + * 2. CLI starts THIS server. + * 3. CLI opens `authorizationUrl` in the browser. + * 4. User authorizes on Linear's consent screen. + * 5. Linear redirects to `https://bedrock-agentcore.us-east-1.amazonaws.com/.../callback/?code=...`. + * 6. AWS exchanges the code with Linear, then redirects the browser to + * the URL we passed as `resourceOauth2ReturnUrl` — namely THIS server, + * with `?session_id=urn:ietf:params:oauth:request_uri:...` appended. + * 7. We capture session_id, render a success page, and shut down. + * 8. CLI polls `get_resource_oauth2_token` with `sessionUri` until the + * access token shows up. + * + * Returns a Promise resolving with the captured session_id, or rejecting + * on timeout / server error / malformed callback. + */ +export async function awaitOauthCallback( + options: CallbackServerOptions = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? 700_000; + + return new Promise((resolve, reject) => { + let settled = false; + const settle = (fn: () => void) => { + if (settled) return; + settled = true; + try { + fn(); + } finally { + clearTimeout(timer); + // .close() shuts down the listener; in-flight responses still complete. + try { + server.close(); + } catch { + // already closing + } + } + }; + + const server = http.createServer( + (req, res) => { + // Defensive: if we somehow get a request after settling, just close it. + if (settled || !req.url) { + res.statusCode = 410; + res.end(); + return; + } + // We accept any path — Linear's redirect always goes to the configured + // redirect_uri (which matches CALLBACK_PATH), but matching loosely + // makes diagnosis easier when something is misconfigured. + const url = new URL(req.url, CALLBACK_URL); + const sessionId = url.searchParams.get('session_id'); + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const error = url.searchParams.get('error'); + + // Linear may redirect with `?error=access_denied` if the user clicks + // Cancel on the consent screen. Surface that explicitly rather than + // saying "no session_id / code". + if (error) { + res.statusCode = 400; + res.setHeader('content-type', 'text/html; charset=utf-8'); + const errorDescription = url.searchParams.get('error_description') ?? '(no description)'; + res.once('finish', () => { + settle(() => reject(new CliError( + `OAuth callback received error from Linear: ${error} — ${errorDescription}.`, + ))); + }); + res.end(FAILURE_HTML); + return; + } + + // Need either session_id (AgentCore-style — legacy, parked path) or + // code+state (direct Linear OAuth — Phase 2.0b Option 2). + if (!sessionId && !(code && state)) { + res.statusCode = 400; + res.setHeader('content-type', 'text/html; charset=utf-8'); + // Settle on `finish` so the response body actually flushes before + // the listener closes — otherwise the client hangs waiting for + // bytes it never gets, leaving callers / tests deadlocked. + res.once('finish', () => { + settle(() => reject(new CliError( + `OAuth callback received without session_id or code/state. Got URL: ${req.url}. ` + + 'If you saw an error on Linear\'s consent screen, that\'s likely the root cause; ' + + 're-run `bgagent linear setup` after fixing the Linear app config.', + ))); + }); + res.end(FAILURE_HTML); + return; + } + res.statusCode = 200; + res.setHeader('content-type', 'text/html; charset=utf-8'); + res.once('finish', () => { + settle(() => resolve({ sessionId, code, state })); + }); + res.end(SUCCESS_HTML); + }, + ); + + server.on('error', (err) => { + if ('code' in err && err.code === 'EADDRINUSE') { + settle(() => reject(new CliError( + `Port ${CALLBACK_PORT} is in use. Another bgagent setup may be running, ` + + 'or another local service has bound it. Stop it and re-run `bgagent linear setup`.', + ))); + } else { + settle(() => reject(err)); + } + }); + + const timer = setTimeout(() => { + settle(() => reject(new CliError( + `Timed out waiting ${Math.round(timeoutMs / 1000)}s for OAuth callback. ` + + 'Either you closed the browser before authorizing, or Linear\'s consent flow ' + + 'couldn\'t complete. Re-run `bgagent linear setup`.', + ))); + }, timeoutMs); + timer.unref(); + + server.listen(CALLBACK_PORT, CALLBACK_HOST); + }); +} diff --git a/cli/test/commands/admin.test.ts b/cli/test/commands/admin.test.ts new file mode 100644 index 00000000..c0a41afb --- /dev/null +++ b/cli/test/commands/admin.test.ts @@ -0,0 +1,108 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { decodeBundle, encodeBundle, generateTempPassword } from '../../src/commands/admin'; +import { CliError } from '../../src/errors'; +import { CliConfig } from '../../src/types'; + +describe('admin bundle helpers', () => { + const sampleConfig: CliConfig = { + api_url: 'https://abc123.execute-api.us-east-1.amazonaws.com/v1', + region: 'us-east-1', + user_pool_id: 'us-east-1_AbCdEfGhI', + client_id: '1a2b3c4d5e6f7g8h9i0j1k2l3m', + }; + + test('encode → decode round-trips a config', () => { + const bundle = encodeBundle(sampleConfig); + const decoded = decodeBundle(bundle); + expect(decoded).toEqual(sampleConfig); + }); + + test('encoded bundle is plain base64 (no whitespace, no padding mangling)', () => { + const bundle = encodeBundle(sampleConfig); + expect(bundle).toMatch(/^[A-Za-z0-9+/]+=*$/); + }); + + test('decode trims surrounding whitespace from a pasted bundle', () => { + const bundle = encodeBundle(sampleConfig); + expect(decodeBundle(` ${bundle} \n`)).toEqual(sampleConfig); + }); + + test('decode rejects non-base64 input', () => { + expect(() => decodeBundle('not base64 !!!')).toThrow(CliError); + }); + + test('decode rejects base64 that does not contain JSON', () => { + const bogus = Buffer.from('not json at all', 'utf-8').toString('base64'); + expect(() => decodeBundle(bogus)).toThrow(/not JSON/); + }); + + test('decode rejects bundle missing required fields', () => { + const partial = Buffer.from(JSON.stringify({ api_url: 'x', region: 'y' })).toString('base64'); + expect(() => decodeBundle(partial)).toThrow(/missing or empty fields user_pool_id, client_id/); + }); + + test('decode rejects bundle with empty-string fields', () => { + const empty = Buffer.from(JSON.stringify({ + api_url: '', + region: 'us-east-1', + user_pool_id: 'pool', + client_id: 'client', + })).toString('base64'); + expect(() => decodeBundle(empty)).toThrow(/missing or empty fields api_url/); + }); +}); + +describe('generateTempPassword', () => { + // Cognito's default policy: min 12 chars, with at least one upper, lower, + // digit, and symbol. The CLI relies on satisfying this by construction — + // these tests guard against a regression that would silently produce + // passwords Cognito rejects with "InvalidPasswordException" only at + // `admin-create-user` time. + const upper = /[A-Z]/; + const lower = /[a-z]/; + const digit = /[0-9]/; + const symbol = /[!@#$%^&*()\-_=+\[\]{}<>?]/; + + test('produces a password ≥ 18 chars', () => { + const pwd = generateTempPassword(); + expect(pwd.length).toBeGreaterThanOrEqual(18); + }); + + test('contains at least one upper, lower, digit, and symbol', () => { + // Sample many passwords — the random shuffle should never strip a class. + for (let i = 0; i < 50; i += 1) { + const pwd = generateTempPassword(); + expect(pwd).toMatch(upper); + expect(pwd).toMatch(lower); + expect(pwd).toMatch(digit); + expect(pwd).toMatch(symbol); + } + }); + + test('produces distinct passwords on repeated calls', () => { + const seen = new Set(); + for (let i = 0; i < 20; i += 1) { + seen.add(generateTempPassword()); + } + // Allow at most one collision in 20 draws (effectively 0 with crypto rand). + expect(seen.size).toBeGreaterThanOrEqual(19); + }); +}); diff --git a/cli/test/commands/configure.test.ts b/cli/test/commands/configure.test.ts index b9319ec5..d0b5cf82 100644 --- a/cli/test/commands/configure.test.ts +++ b/cli/test/commands/configure.test.ts @@ -92,6 +92,45 @@ describe('configure command', () => { ).rejects.toThrow(/Missing required configuration/); }); + test('--from-bundle decodes a base64 bundle and writes config in one shot', async () => { + // Mirrors what `bgagent admin invite-user` would print: the four config + // fields encoded as base64 JSON. + const payload = { + api_url: 'https://api.example.com', + region: 'us-east-1', + user_pool_id: 'us-east-1_abc', + client_id: 'client-123', + }; + const bundle = Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64'); + + const cmd = makeConfigureCommand(); + await cmd.parseAsync(['node', 'test', '--from-bundle', bundle]); + + const config = JSON.parse(fs.readFileSync(path.join(tmpDir, 'config.json'), 'utf-8')); + expect(config).toEqual(payload); + expect(consoleSpy).toHaveBeenCalledWith('Configuration saved.'); + }); + + test('--from-bundle is mutually exclusive with individual flags', async () => { + const bundle = Buffer.from(JSON.stringify({ + api_url: 'https://x', region: 'us-east-1', user_pool_id: 'p', client_id: 'c', + }), 'utf-8').toString('base64'); + + const cmd = makeConfigureCommand(); + await expect(cmd.parseAsync([ + 'node', 'test', + '--from-bundle', bundle, + '--region', 'us-west-2', + ])).rejects.toThrow(/mutually exclusive/); + }); + + test('--from-bundle rejects malformed input', async () => { + const cmd = makeConfigureCommand(); + await expect( + cmd.parseAsync(['node', 'test', '--from-bundle', 'totally not base64']), + ).rejects.toThrow(); + }); + test('no flags with complete existing config → reports "No configuration changes" without re-saving', async () => { // Seed a complete config. const cmd1 = makeConfigureCommand(); diff --git a/cli/test/commands/linear.test.ts b/cli/test/commands/linear.test.ts index cd4aae22..bc52b936 100644 --- a/cli/test/commands/linear.test.ts +++ b/cli/test/commands/linear.test.ts @@ -18,7 +18,11 @@ */ import { PutCommand } from '@aws-sdk/lib-dynamodb'; -import { autoLinkTokenOwner } from '../../src/commands/linear'; +import { + autoLinkTokenOwner, + isWebhookSecretConfigured, + renderLinearAppTemplate, +} from '../../src/commands/linear'; import * as config from '../../src/config'; jest.mock('@aws-sdk/lib-dynamodb', () => { @@ -146,3 +150,78 @@ describe('autoLinkTokenOwner', () => { expect(msgs.some(m => m.includes('bgagent login'))).toBe(true); }); }); + +describe('renderLinearAppTemplate', () => { + test('uses sane defaults when no options are passed', () => { + const out = renderLinearAppTemplate(); + expect(out).toContain('bgagent[bot]'); + expect(out).toContain('Webhooks: ON'); + expect(out).toContain('REQUIRED for actor=app'); + }); + + test('includes the AWS callback URL placeholder when not provided', () => { + const out = renderLinearAppTemplate(); + expect(out).toContain(''); + }); + + test('substitutes the AWS callback URL when supplied', () => { + const url = 'https://bedrock-agentcore.us-east-1.amazonaws.com/identities/oauth2/callback/abc-123'; + const out = renderLinearAppTemplate({ awsCallbackUrl: url }); + expect(out).toContain(url); + expect(out).not.toContain(' { + const out = renderLinearAppTemplate({ + botName: 'acme-bot[bot]', + developerName: 'Acme Corp', + developerUrl: 'https://acme.com', + description: 'Internal coding agent', + }); + expect(out).toContain('acme-bot[bot]'); + expect(out).toContain('Acme Corp'); + expect(out).toContain('https://acme.com'); + expect(out).toContain('Internal coding agent'); + }); + + test('explains why each gating field matters (actor=app context)', () => { + const out = renderLinearAppTemplate(); + // The "why" explainer is the core differentiator of this command vs. raw + // docs — without it operators paste blindly and hit the cryptic Linear + // "Invalid redirect_uri" error documented in the 2.0b spike. + expect(out).toContain('Invalid redirect_uri'); + expect(out).toContain('Wildcard callback URLs are not accepted'); + }); +}); + +describe('isWebhookSecretConfigured', () => { + const mockSend = jest.fn(); + const mockClient = { send: mockSend } as unknown as Parameters[0]; + + beforeEach(() => { + mockSend.mockReset(); + }); + + test('returns true for a Linear-shaped lin_wh_ secret', async () => { + mockSend.mockResolvedValueOnce({ SecretString: 'lin_wh_AbCdEfGhIjKlMnOpQrStUvWxYz' }); + expect(await isWebhookSecretConfigured(mockClient, 'arn:secret')).toBe(true); + }); + + test('returns false for the CDK-autogenerated placeholder', async () => { + // CDK's default Secret value is a JSON-encoded random string — does + // NOT start with lin_wh_. The check is a heuristic, not authoritative, + // but good enough to avoid re-prompting on every setup re-run. + mockSend.mockResolvedValueOnce({ SecretString: '{"":"abcd"}' }); + expect(await isWebhookSecretConfigured(mockClient, 'arn:secret')).toBe(false); + }); + + test('returns false on Secrets Manager error (best-effort: re-prompt is harmless)', async () => { + mockSend.mockRejectedValueOnce(new Error('AccessDenied')); + expect(await isWebhookSecretConfigured(mockClient, 'arn:secret')).toBe(false); + }); + + test('returns false when SecretString is missing', async () => { + mockSend.mockResolvedValueOnce({}); + expect(await isWebhookSecretConfigured(mockClient, 'arn:secret')).toBe(false); + }); +}); diff --git a/cli/test/linear-oauth.test.ts b/cli/test/linear-oauth.test.ts new file mode 100644 index 00000000..8a8663b7 --- /dev/null +++ b/cli/test/linear-oauth.test.ts @@ -0,0 +1,283 @@ +/** + * 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 { CliError } from '../src/errors'; +import { + buildAuthorizationUrl, + computeExpiresAt, + exchangeAuthorizationCode, + generatePkce, + isAccessTokenExpiring, + LINEAR_AUTHORIZE_ENDPOINT, + LINEAR_OAUTH_SCOPES, + LINEAR_TOKEN_ENDPOINT, + linearOauthSecretName, + refreshAccessToken, +} from '../src/linear-oauth'; + +describe('linearOauthSecretName', () => { + test('prefixes with bgagent-linear-oauth-', () => { + expect(linearOauthSecretName('acme')).toBe('bgagent-linear-oauth-acme'); + expect(linearOauthSecretName('acme-corp')).toBe('bgagent-linear-oauth-acme-corp'); + }); +}); + +describe('LINEAR_OAUTH_SCOPES', () => { + test('matches the actor=app-compatible scope set verified in the spike', () => { + // Locked: removing app:assignable / app:mentionable breaks the Agent install + // (verified 2026-05-18); adding `admin` breaks actor=app entirely. + expect(LINEAR_OAUTH_SCOPES).toEqual(['read', 'write', 'app:assignable', 'app:mentionable']); + }); +}); + +describe('generatePkce', () => { + test('produces base64url-encoded verifier and SHA-256 challenge', () => { + const { codeVerifier, codeChallenge } = generatePkce(); + expect(codeVerifier).toMatch(/^[A-Za-z0-9_-]+$/); + expect(codeChallenge).toMatch(/^[A-Za-z0-9_-]+$/); + // base64url-encoded SHA-256 = 43 chars (256 bits / 6 bits per char, no padding) + expect(codeChallenge.length).toBe(43); + }); + + test('generates fresh values on each call', () => { + const a = generatePkce(); + const b = generatePkce(); + expect(a.codeVerifier).not.toBe(b.codeVerifier); + expect(a.codeChallenge).not.toBe(b.codeChallenge); + }); + + test('challenge is deterministic from the verifier', async () => { + const { codeVerifier, codeChallenge } = generatePkce(); + // Replay the verifier through SHA-256 and base64url-encode — must match. + const { createHash } = await import('crypto'); + const expected = createHash('sha256').update(codeVerifier).digest().toString('base64url'); + expect(codeChallenge).toBe(expected); + }); +}); + +describe('buildAuthorizationUrl', () => { + test('includes all required OAuth + PKCE params and actor=app by default', () => { + const url = buildAuthorizationUrl({ + clientId: 'cid', + redirectUri: 'https://localhost:8443/oauth/callback', + state: 'state-uuid', + codeChallenge: 'challenge-base64url', + }); + expect(url.startsWith(LINEAR_AUTHORIZE_ENDPOINT)).toBe(true); + const parsed = new URL(url); + expect(parsed.searchParams.get('client_id')).toBe('cid'); + expect(parsed.searchParams.get('redirect_uri')).toBe('https://localhost:8443/oauth/callback'); + expect(parsed.searchParams.get('response_type')).toBe('code'); + expect(parsed.searchParams.get('state')).toBe('state-uuid'); + expect(parsed.searchParams.get('code_challenge')).toBe('challenge-base64url'); + expect(parsed.searchParams.get('code_challenge_method')).toBe('S256'); + expect(parsed.searchParams.get('actor')).toBe('app'); + // Space-separated per RFC 6749 §3.3. Comma-separated triggers Linear's + // misleading "Invalid redirect_uri" — caught during smoke test 2026-05-19. + expect(parsed.searchParams.get('scope')).toBe('read write app:assignable app:mentionable'); + }); + + test('actorApp:false drops the actor param entirely (regression OAuth fallback)', () => { + const url = buildAuthorizationUrl({ + clientId: 'cid', + redirectUri: 'https://localhost:8443/oauth/callback', + state: 'state-uuid', + codeChallenge: 'challenge', + actorApp: false, + }); + const parsed = new URL(url); + expect(parsed.searchParams.has('actor')).toBe(false); + }); +}); + +describe('isAccessTokenExpiring', () => { + test('returns false for a token expiring well in the future', () => { + const future = new Date(Date.now() + 3600 * 1000).toISOString(); + expect(isAccessTokenExpiring(future)).toBe(false); + }); + + test('returns true within the 60s threshold', () => { + const soon = new Date(Date.now() + 30 * 1000).toISOString(); + expect(isAccessTokenExpiring(soon)).toBe(true); + }); + + test('returns true for a past expiry', () => { + const past = new Date(Date.now() - 60 * 1000).toISOString(); + expect(isAccessTokenExpiring(past)).toBe(true); + }); + + test('returns true for a malformed expires_at (defensive: prefer over-refresh)', () => { + expect(isAccessTokenExpiring('not a date')).toBe(true); + }); + + test('respects custom threshold', () => { + const fiveMinutesOut = new Date(Date.now() + 5 * 60 * 1000).toISOString(); + expect(isAccessTokenExpiring(fiveMinutesOut, 10)).toBe(false); + expect(isAccessTokenExpiring(fiveMinutesOut, 600)).toBe(true); + }); +}); + +describe('computeExpiresAt', () => { + test('adds expires_in seconds to the given now', () => { + const now = new Date('2026-05-19T12:00:00.000Z'); + expect(computeExpiresAt(86400, now)).toBe('2026-05-20T12:00:00.000Z'); + }); +}); + +// ─── Token endpoint round-trip tests ──────────────────────────────────────── + +function mockResponse(status: number, body: unknown): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + } as unknown as Response; +} + +describe('exchangeAuthorizationCode', () => { + test('happy path: parses Linear`s RFC-shaped response', async () => { + const fetchImpl = jest.fn().mockResolvedValueOnce(mockResponse(200, { + access_token: 'lin_oauth_aaaaaa', + token_type: 'Bearer', + expires_in: 86399, + refresh_token: 'lin_refresh_bbbbbb', + scope: 'read write app:assignable app:mentionable', + })); + + const result = await exchangeAuthorizationCode({ + code: 'authcode', + codeVerifier: 'verifier', + redirectUri: 'https://localhost:8443/oauth/callback', + clientId: 'cid', + clientSecret: 'csec', + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(result.access_token).toBe('lin_oauth_aaaaaa'); + expect(result.refresh_token).toBe('lin_refresh_bbbbbb'); + expect(result.expires_in).toBe(86399); + expect(result.scope).toBe('read write app:assignable app:mentionable'); + + // Verify the wire body is exactly what Linear expects (RFC 6749 §4.1.3). + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [url, init] = fetchImpl.mock.calls[0]; + expect(url).toBe(LINEAR_TOKEN_ENDPOINT); + expect(init.method).toBe('POST'); + expect(init.headers).toEqual({ 'Content-Type': 'application/x-www-form-urlencoded' }); + const sent = new URLSearchParams(init.body); + expect(sent.get('grant_type')).toBe('authorization_code'); + expect(sent.get('code')).toBe('authcode'); + expect(sent.get('code_verifier')).toBe('verifier'); + expect(sent.get('redirect_uri')).toBe('https://localhost:8443/oauth/callback'); + expect(sent.get('client_id')).toBe('cid'); + expect(sent.get('client_secret')).toBe('csec'); + }); + + test('translates Linear OAuth error responses to CliError with description', async () => { + const fetchImpl = jest.fn().mockResolvedValueOnce(mockResponse(400, { + error: 'invalid_grant', + error_description: 'authorization code has already been used', + })); + + await expect(exchangeAuthorizationCode({ + code: 'authcode', + codeVerifier: 'verifier', + redirectUri: 'https://localhost:8443/oauth/callback', + clientId: 'cid', + clientSecret: 'csec', + fetchImpl: fetchImpl as unknown as typeof fetch, + })).rejects.toThrow(/invalid_grant.*authorization code has already been used/); + }); + + test('rejects responses missing access_token (unexpected Linear shape)', async () => { + const fetchImpl = jest.fn().mockResolvedValueOnce(mockResponse(200, { + not_a_token: 'oops', + })); + + await expect(exchangeAuthorizationCode({ + code: 'authcode', + codeVerifier: 'verifier', + redirectUri: 'https://localhost:8443/oauth/callback', + clientId: 'cid', + clientSecret: 'csec', + fetchImpl: fetchImpl as unknown as typeof fetch, + })).rejects.toThrow(/unexpected shape/); + }); + + test('rejects non-JSON responses (Linear maintenance / proxy intercepts)', async () => { + const fetchImpl = jest.fn().mockResolvedValueOnce({ + ok: false, + status: 502, + json: async () => { throw new Error('not json'); }, + } as unknown as Response); + + await expect(exchangeAuthorizationCode({ + code: 'authcode', + codeVerifier: 'verifier', + redirectUri: 'https://localhost:8443/oauth/callback', + clientId: 'cid', + clientSecret: 'csec', + fetchImpl: fetchImpl as unknown as typeof fetch, + })).rejects.toThrow(/non-JSON.*HTTP 502/); + }); +}); + +describe('refreshAccessToken', () => { + test('happy path: posts refresh_token grant and returns new tokens', async () => { + const fetchImpl = jest.fn().mockResolvedValueOnce(mockResponse(200, { + access_token: 'lin_oauth_new', + token_type: 'Bearer', + expires_in: 86399, + refresh_token: 'lin_refresh_rotated', + scope: 'read write app:assignable app:mentionable', + })); + + const result = await refreshAccessToken({ + refreshToken: 'lin_refresh_old', + clientId: 'cid', + clientSecret: 'csec', + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(result.access_token).toBe('lin_oauth_new'); + expect(result.refresh_token).toBe('lin_refresh_rotated'); + + const [, init] = fetchImpl.mock.calls[0]; + const sent = new URLSearchParams(init.body); + expect(sent.get('grant_type')).toBe('refresh_token'); + expect(sent.get('refresh_token')).toBe('lin_refresh_old'); + // refresh grant does NOT send code/code_verifier/redirect_uri + expect(sent.get('code')).toBeNull(); + expect(sent.get('redirect_uri')).toBeNull(); + }); + + test('translates revoked-refresh-token error to CliError', async () => { + const fetchImpl = jest.fn().mockResolvedValueOnce(mockResponse(400, { + error: 'invalid_grant', + error_description: 'refresh token was revoked', + })); + + await expect(refreshAccessToken({ + refreshToken: 'lin_refresh_revoked', + clientId: 'cid', + clientSecret: 'csec', + fetchImpl: fetchImpl as unknown as typeof fetch, + })).rejects.toThrow(CliError); + }); +}); diff --git a/cli/test/oauth-callback-server.test.ts b/cli/test/oauth-callback-server.test.ts new file mode 100644 index 00000000..39f3fc0c --- /dev/null +++ b/cli/test/oauth-callback-server.test.ts @@ -0,0 +1,147 @@ +/** + * 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 * as http from 'http'; +import { + awaitOauthCallback, + CALLBACK_PORT, + CALLBACK_URL, +} from '../src/oauth-callback-server'; + +/** + * Make a plain HTTP GET request to localhost. Returns the response + * status + body. Closes the connection cleanly so the server can + * finish settling without hanging the test. + */ +function localGet(urlSuffix: string): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const req = http.get({ + host: 'localhost', + port: CALLBACK_PORT, + path: urlSuffix, + }, (res) => { + let body = ''; + res.on('data', (chunk) => { body += chunk; }); + res.on('end', () => resolve({ status: res.statusCode ?? 0, body })); + }); + req.on('error', reject); + }); +} + +describe('awaitOauthCallback', () => { + // The real OAuth flow waits on Linear — to avoid binding the callback port + // (8080) in CI when it might be in use, these tests run sequentially via + // Jest's default test isolation per file. If a developer has another + // bgagent setup running locally, expect EADDRINUSE. + + test('captures session_id from the first valid request and resolves', async () => { + // Fire the server + the request in parallel; the server resolves once it + // sees the request, then closes. + const expectedSessionId = 'urn:ietf:params:oauth:request_uri:test-uuid'; + const callbackPromise = awaitOauthCallback({ timeoutMs: 5_000 }); + // Tiny delay so the server has time to bind before we make the request. + await new Promise((r) => setTimeout(r, 100)); + const requestPromise = localGet(`/oauth/callback?session_id=${encodeURIComponent(expectedSessionId)}`); + + const [callbackResult, response] = await Promise.all([callbackPromise, requestPromise]); + expect(callbackResult.sessionId).toBe(expectedSessionId); + expect(response.status).toBe(200); + expect(response.body).toContain('Linear authorized'); + }); + + test('captures code+state from a direct Linear OAuth redirect', async () => { + // Phase 2.0b Option 2 path: Linear redirects with `code` + `state` + // (no AgentCore proxy in the middle). + const callbackPromise = awaitOauthCallback({ timeoutMs: 5_000 }); + await new Promise((r) => setTimeout(r, 100)); + const requestPromise = localGet( + '/oauth/callback?code=lin_authcode_abc&state=stateuuid', + ); + + const [callbackResult, response] = await Promise.all([callbackPromise, requestPromise]); + expect(callbackResult.code).toBe('lin_authcode_abc'); + expect(callbackResult.state).toBe('stateuuid'); + expect(callbackResult.sessionId).toBeNull(); + expect(response.status).toBe(200); + }); + + test('rejects with Linear`s error_description when redirect has ?error=', async () => { + // Linear surfaces `?error=access_denied` if the user clicks Cancel on + // the consent screen. Distinguish that from a missing-params failure + // so the caller can present a clearer message. + const callbackPromise = awaitOauthCallback({ timeoutMs: 5_000 }); + await new Promise((r) => setTimeout(r, 100)); + const responsePromise = localGet( + '/oauth/callback?error=access_denied&error_description=user+cancelled', + ); + + const [callbackOutcome, responseOutcome] = await Promise.allSettled([ + callbackPromise, + responsePromise, + ]); + expect(callbackOutcome.status).toBe('rejected'); + if (callbackOutcome.status === 'rejected') { + expect(String(callbackOutcome.reason.message)).toMatch(/access_denied.*user cancelled/); + } + if (responseOutcome.status === 'fulfilled') { + expect(responseOutcome.value.status).toBe(400); + } + }); + + test('rejects when the redirect has neither session_id nor code+state', async () => { + const callbackPromise = awaitOauthCallback({ timeoutMs: 5_000 }); + await new Promise((r) => setTimeout(r, 100)); + const responsePromise = localGet('/oauth/callback'); + + // Both promises settle together: the response carries the 400 + failure + // page, the callback promise rejects with the missing-params error. + // Capture both outcomes via allSettled so neither hangs the other. + const [callbackOutcome, responseOutcome] = await Promise.allSettled([ + callbackPromise, + responsePromise, + ]); + + expect(callbackOutcome.status).toBe('rejected'); + if (callbackOutcome.status === 'rejected') { + expect(String(callbackOutcome.reason.message)).toMatch(/without session_id or code\/state/); + } + expect(responseOutcome.status).toBe('fulfilled'); + if (responseOutcome.status === 'fulfilled') { + expect(responseOutcome.value.status).toBe(400); + expect(responseOutcome.value.body).toContain('Authorization not captured'); + } + }); + + test('rejects on timeout when no callback arrives', async () => { + // Short timeout so the test doesn't drag. + const startedAt = Date.now(); + await expect(awaitOauthCallback({ timeoutMs: 200 })).rejects.toThrow(/Timed out/); + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(180); + expect(Date.now() - startedAt).toBeLessThan(2000); + }); + + test('CALLBACK_URL constant matches the documented localhost URL', () => { + // Regression-lock: the URL is also baked into the CDK construct's + // allowlist (cdk/src/constructs/cli-workload-identity.ts default). + // Drift here = silent OAuth failure at runtime ("redirect_uri not allowlisted"). + // RFC 8252 §7.3: http://localhost is the right shape for native-app OAuth + // callbacks (no TLS required, no cert warnings). Port 8080 is conventional. + expect(CALLBACK_URL).toBe('http://localhost:8080/oauth/callback'); + }); +}); diff --git a/docs/guides/LINEAR_SETUP_GUIDE.md b/docs/guides/LINEAR_SETUP_GUIDE.md index 64d221bc..13631903 100644 --- a/docs/guides/LINEAR_SETUP_GUIDE.md +++ b/docs/guides/LINEAR_SETUP_GUIDE.md @@ -2,83 +2,108 @@ This guide walks through setting up the ABCA Linear integration. Once configured, applying the `bgagent` label to an issue in a mapped Linear project triggers an autonomous task. The agent posts progress comments back on the Linear issue as it works. +> **Phase 2.0b** — ABCA now authenticates to Linear via OAuth (`actor=app`) instead of a personal API key. One OAuth app per ABCA deployment, one credential provider per Linear workspace. Personal API keys are no longer supported (see [Migration from 2.0a (PAK) to 2.0b (OAuth)](#migration-from-20a-pak-to-20b-oauth) below). + ## Prerequisites - ABCA CDK stack deployed (see [Developer guide](./DEVELOPER_GUIDE.md)) - A Cognito user account configured (see [User guide](./USER_GUIDE.md)) -- A Linear workspace where you have admin access (to create API keys and webhooks) -- AWS CLI configured with credentials for your ABCA account +- A Linear workspace where you have **admin** access (you'll create an OAuth app and install it on the workspace) +- AWS CLI configured with credentials for your ABCA account, with `bedrock-agentcore-control:*` permissions on the deployment region +- The `bgagent` CLI installed and logged in (`bgagent configure` + `bgagent login`) ## How it works -1. A user adds the `bgagent` label (configurable per project) to a Linear issue. -2. Linear fires a webhook to `POST /v1/linear/webhook`. ABCA verifies the HMAC signature and dedups retries. -3. A processor Lambda resolves the Linear project → GitHub repo mapping and the Linear user → platform user mapping, then creates a task with `channel_source: 'linear'`. -4. The agent clones the repo, writes `.mcp.json` with Linear's hosted MCP server, and runs. It uses `mcp__linear-server__save_comment` / `mcp__linear-server__update_issue` to post updates to the originating issue. -5. The agent opens a PR on GitHub and adds a final comment to the Linear issue with the PR link. - -**Authentication for v1** is a Linear personal API key. A single key powers all agent-to-Linear calls for the whole stack. OAuth bot install + multi-workspace is a v3 follow-up. +1. A Linear-workspace admin creates a Linear OAuth app, registers it as an AgentCore Identity credential provider, and authorizes it on the workspace via `bgagent linear setup`. The workspace's OAuth token lives in the AgentCore Identity vault, keyed on `userId=linear-workspace-`. **One install per workspace, used by all teammates** — this matches the v1 personal-API-key semantics. +2. A user adds the `bgagent` label (configurable per project) to a Linear issue. +3. Linear fires a webhook to `POST /v1/linear/webhook`. ABCA verifies the HMAC signature and dedups retries. +4. A processor Lambda looks up the Linear `organizationId` in `LinearWorkspaceRegistryTable` to find the credential provider name, retrieves the workspace's OAuth token via AgentCore Identity, then resolves the project → repo mapping and creates a task with `channel_source: 'linear'`. +5. The agent clones the repo, writes `.mcp.json` with Linear's hosted MCP server, and runs. It uses `mcp__linear-server__save_comment` / `mcp__linear-server__update_issue` to post updates as `bgagent[bot]` (the OAuth app's identity). +6. The agent opens a PR on GitHub and adds a final comment to the Linear issue with the PR link. **Trigger**: only Linear issues with the configured label in a mapped project create tasks. Issues without the label, or in unmapped projects, are ignored. Label removal does not cancel a running task. -## Step-by-step setup - -### Step 1: Generate a Linear personal API key +**Multi-workspace**: a single ABCA deployment can serve multiple Linear workspaces. Each workspace gets its own AgentCore credential provider via `bgagent linear add-workspace`. -Open [Linear Settings → Security](https://linear.app/settings/account/security), scroll to **Personal API keys**, and create one. Copy the token — it starts with `lin_api_…`. You won't be able to see it again. +## Step-by-step setup -This key is used by the agent to post comments and update issue state. Personal API keys are full-workspace-scoped; document internally that you're handing that authority to ABCA. +### Step 1: Create the AgentCore credential provider -### Step 2: Run the setup wizard +The credential provider is an AWS-side OAuth2 client registration. It generates the **AWS-hosted callback URL** that Linear will redirect the browser to during consent — without this URL, you can't complete Step 2. ```bash -bgagent linear setup +bgagent linear oauth-register-workspace ``` -The wizard prints the exact webhook URL for your deployment, then waits at a **Webhook signing secret:** prompt. Leave it running; go create the webhook in the next step, then return and paste both values. +Where `` is the Linear `urlKey` of the workspace (e.g. `acme` from `https://linear.app/acme/...`). The command prompts for the Linear OAuth app's `clientId` and `clientSecret` — you don't have these yet, so first create the Linear OAuth app in Step 2 below, then come back and finish this step. Either order works; just pair them. -### Step 3: Create the Linear webhook +The command: +- Calls `aws bedrock-agentcore-control create-oauth2-credential-provider` with `credentialProviderVendor='CustomOauth2'` (Linear is not a built-in vendor, so the command supplies an explicit `authorizationServerMetadata` block — Linear has no `.well-known/openid-configuration`). +- Prints the AWS-hosted callback URL you'll paste into Linear's app form. +- Records the provider name (`linear-oauth-`) for `bgagent linear setup` to use later. -In [Linear Settings → API](https://linear.app/settings/api), under **Webhooks**, click **+**: +> **Why AWS hosts the callback.** Earlier ABCA designs (and most third-party docs at the time of writing) assumed the integrator hosted their own callback service. AgentCore Identity actually proxies the callback itself; the URL it surfaces in `create-oauth2-credential-provider` response (`callbackUrl`) is what Linear redirects to, **not** an URL you control. The `resourceOauth2ReturnUrl` you pass to `get_resource_oauth2_token` is just where AWS sends the **browser** after AWS finishes the code-exchange — typically a localhost URL that `bgagent linear setup` listens on for that one redirect. -- **URL**: paste the URL the wizard printed in Step 2. -- **Resource types**: check **Issues** only. -- **Team**: whichever team owns the projects you'll map to ABCA (or all teams). -- Save, then open the webhook's detail page and copy the **signing secret**. +### Step 2: Create the Linear OAuth app -### Step 4: Finish the wizard +Run: -Back in your terminal at the paused `bgagent linear setup` prompt: +```bash +bgagent linear app-template +``` + +This prints the exact field values to paste into Linear's OAuth app form. Open [Linear Settings → API → New application](https://linear.app/settings/api/applications/new) and fill in the fields the template lists. Critical fields (each gates the `actor=app` agent flow — without them Linear surfaces a misleading "Invalid redirect_uri" error): + +- **GitHub username**: must end with the literal `[bot]` suffix (e.g., `bgagent[bot]`) +- **Webhooks**: toggle ON (the URL value can be a placeholder; we don't subscribe to events for the OAuth flow itself) +- **Callback URLs**: paste the AWS-hosted URL from Step 1 on a single line. Wildcards are not accepted; if you have multiple environments, register each URL fully. -- Paste the **webhook signing secret** (from Step 3). -- Paste the **personal API key** (from Step 1). +If you ran Step 1 first, pass the AWS callback URL to the template so it's filled in: + +```bash +bgagent linear app-template --aws-callback-url "" +``` -Both are stored in Secrets Manager (`LinearWebhookSecret` and `LinearApiTokenSecret`). The wizard validates that the personal API key starts with `lin_api_`. Full authentication is verified the first time a webhook arrives or the agent calls the Linear MCP. +Click **Save**, then copy the **Client ID** and **Client Secret** from the app's detail page. -As a final step, `setup` calls the Linear API with the token you just stored, looks up the token owner, and auto-links that Linear identity to the Cognito user currently logged in to the CLI. This skips the code-exchange ceremony for the common case where one person installs ABCA for their own workspace. If the auto-link fails (token invalid, not logged in, etc.) setup prints a warning and continues. +### Step 3: Finish Step 1 — paste Linear secrets -**If auto-link fails persistently** (rare — usually transient Linear API hiccups, just re-run `bgagent linear setup`), an admin can insert the mapping directly into the `LinearUserMappingTable` DynamoDB table: +Return to the terminal where Step 1 is paused at the `Client ID:` prompt and paste the values you copied from Linear. The credential provider is now wired up. + +### Step 4: Authorize via OAuth ```bash -aws dynamodb put-item \ - --table-name -LinearIntegrationUserMappingTable... \ - --item '{ - "linear_identity": {"S": "#"}, - "platform_user_id": {"S": ""}, - "status": {"S": "active"}, - "linked_at": {"S": "2026-05-14T00:00:00Z"} - }' +bgagent linear setup ``` -To find the right values: +The wizard: + +1. Looks up the credential provider you registered in Step 1. +2. Starts an ephemeral HTTPS server on `localhost:8443` with a self-signed cert. **Your browser will warn about the cert** — click through, it's local-only. +3. Calls `get_resource_oauth2_token` with `customParameters={'actor': 'app'}` and opens the returned `authorizationUrl` in your default browser. +4. You authorize the OAuth app on the Linear consent screen. +5. AWS handles the code-exchange with Linear behind the scenes, then redirects your browser to `https://localhost:8443/oauth/callback?session_id=...`. +6. The wizard captures the `session_id`, polls for the access token (5s/600s timeout), then queries Linear's `viewer { id, organization { id, urlKey } }` to record workspace metadata in `LinearWorkspaceRegistryTable`. + +The OAuth token is stored in the AWS-managed token vault under `userId=linear-workspace-`. **All teammates' Linear-triggered tasks share this single token** — that's by design (matches the v1 PAK semantics, just with a revocable / scoped credential and audit trail). + +### Step 5: Configure the Linear webhook + +In [Linear Settings → API](https://linear.app/settings/api) → **Webhooks** → **+**: -- **`workspaceId`**: from Linear API `viewer { organization { id } }` or the URL `https://linear.app//...` -- **`viewerId`**: from Linear API `viewer { id }` -- **`platform_user_id`**: your Cognito `sub` claim — `cat ~/.bgagent/credentials.json | jq -r .id_token | cut -d. -f2 | base64 -d 2>/dev/null | jq -r .sub` +- **URL**: paste the URL `bgagent linear setup` printed at the end of Step 4 (looks like `https://.execute-api..amazonaws.com/v1/linear/webhook`) +- **Resource types**: check **Issues** only +- **Team**: whichever team owns the projects you'll map to ABCA (or all teams) -The CLI command `bgagent linear link ` exists in v1 but is **non-functional** without a Linear-side code generator (planned for v3 OAuth bot install). Do not rely on it. +Save, then open the webhook's detail page and copy the **signing secret**. Run: -### Step 5: Onboard a Linear project +```bash +bgagent linear setup --webhook-secret +``` + +This stores the secret in `LinearWebhookSecret`. (Webhook signing is independent of OAuth — it's how Linear authenticates inbound calls to your API Gateway, separate from how the agent authenticates outbound calls to Linear.) + +### Step 6: Onboard a Linear project Map a Linear project UUID to the GitHub repo you want tasks routed to: @@ -95,7 +120,7 @@ Optional flags: | `--region ` | AWS region | from `bgagent configure` | | `--stack-name ` | CloudFormation stack name | `backgroundagent-dev` | -**Finding the Linear project UUID.** Linear's project URL (`https://linear.app//project/-`) contains a *truncated* UUID at the end — that's not the full UUID the webhook sends. List the full UUIDs for all projects visible to the stored API token: +**Finding the Linear project UUID.** Linear's project URL (`https://linear.app//project/-`) contains a *truncated* UUID at the end — that's not the full UUID the webhook sends. List the full UUIDs for all projects visible to the OAuth token: ```bash bgagent linear list-projects @@ -103,26 +128,53 @@ bgagent linear list-projects Copy the `id` of the project you want to onboard. `onboard-project` validates the UUID format and will reject the truncated slug version with a pointer back to this command. -### Step 6: Link your Linear account +### Step 7: Link your Linear account (optional but recommended) + +ABCA needs to know which platform user a Linear actor maps to so triggered tasks are attributed correctly (concurrency caps, billing, `bgagent list`). + +**The admin who ran `bgagent linear setup` is auto-linked.** Setup queries Linear's `viewer { id }` with the new OAuth token and writes a row in `LinearUserMappingTable` for the Cognito user running the CLI. Look for `✓ Linked Linear user …` in the setup output. -ABCA needs to know which platform user a Linear actor maps to so tasks are attributed correctly. +**For other teammates**: Linear-triggered tasks they apply the label on will be **dropped** by the processor with `"Linear actor has no linked platform user — skipping task creation"` until their identity is mapped. Two paths: -**The token owner is linked automatically.** `bgagent linear setup` calls Linear's `viewer` query with the token you just pasted and writes the mapping for the Cognito user running the CLI. Look for `✓ Linked Linear user …` in the setup output — if you saw that, you're done. Skip to Step 7. +- **Manual (today):** the admin inserts a row into `LinearUserMappingTable`: -**Linking additional Linear users** (anyone other than the API-token owner) isn't supported in v1. A comment-triggered flow (`bgagent link` in a Linear comment → receive code → `bgagent linear link `) is a planned follow-up; the `bgagent linear link ` CLI command exists today but no Linear-side code generator ships with it yet. + ```bash + aws dynamodb put-item \ + --table-name -LinearIntegrationUserMappingTable... \ + --item '{ + "linear_identity": {"S": "#"}, + "platform_user_id": {"S": ""}, + "status": {"S": "active"}, + "linked_at": {"S": "2026-05-19T00:00:00Z"} + }' + ``` -For v1, design the flow around the API-token owner: that person installs ABCA, runs `bgagent linear setup`, and submits tasks on their own behalf. Tasks triggered by other Linear users in the workspace will be dropped by the processor with `"Linear actor has no linked platform user — skipping task creation"`. + Find the `viewerId` via Linear's API (`viewer { id }` while logged in as that teammate) and the Cognito sub via `bgagent admin invite-user` (printed when you create their user) or by decoding their cached id_token. -### Step 7: Test it +- **Self-service (planned, v2.x):** a comment-driven `@bgagent link` flow that exchanges a code for a row write — `bgagent linear link ` exists in v1 but is non-functional until the Linear-side code generator ships. + +### Step 8: Test it Add the `bgagent` label to a Linear issue in a mapped project. Within a few seconds: - The Linear webhook Lambda logs an `INFO` entry and invokes the processor. -- The processor creates a task in the `TaskTable` with `channel_source: 'linear'`. -- The agent container starts, clones the repo, and posts a `🤖 Starting on this issue…` comment to the Linear issue. +- The processor looks up `LinearWorkspaceRegistryTable` by the webhook's `organizationId`, retrieves the workspace's OAuth token via AgentCore Identity, and creates a task in `TaskTable` with `channel_source: 'linear'`. +- The agent container starts, clones the repo, and posts a `🤖 Starting on this issue…` comment as `bgagent[bot]`. - When the agent opens a PR, another comment appears with the PR link and the issue transitions to `In Review` (if that state exists). - On completion or failure, a final status comment is posted. +## Adding additional Linear workspaces + +A single ABCA deployment can serve multiple Linear workspaces. Each workspace gets its own credential provider and OAuth install: + +```bash +bgagent linear add-workspace +``` + +This re-runs Steps 1, 2, and 4 of the setup (asks for a new clientId/secret pair, creates a `linear-oauth-` provider, runs the OAuth dance against the new workspace). You'll need to create a separate Linear OAuth app for each workspace — Linear apps are workspace-scoped at install time even though the same OAuth credentials *could* technically install in multiple workspaces. Per-workspace apps give cleaner revocation and per-workspace branding. + +The 50-credential-provider-per-account quota in AgentCore is the practical ceiling for multi-tenant deployments. + ## Usage ### Trigger a task @@ -143,30 +195,51 @@ Use `bgagent cancel `. Removing the Linear label does not cancel a runn ### Webhook doesn't trigger a task 1. Is the project mapped? Run `aws dynamodb scan --table-name ` (look up the table name via `aws cloudformation describe-stacks`). -2. Is the label spelled exactly as configured? Match is case-insensitive but must be the same word. -3. Check CloudWatch logs for the `WebhookFn` and `WebhookProcessorFn` Lambdas for `Invalid Linear webhook signature`, `Linear project is not onboarded`, or `Linear actor has no linked platform user`. +2. Is the workspace registered? Scan `LinearWorkspaceRegistryTable` for the Linear `organizationId` from the webhook payload. +3. Is the label spelled exactly as configured? Match is case-insensitive but must be the same word. +4. Check CloudWatch logs for `WebhookFn` and `WebhookProcessorFn` for `Invalid Linear webhook signature`, `Linear workspace is not onboarded`, `Linear project is not onboarded`, or `Linear actor has no linked platform user`. ### "Linear actor has no linked platform user — skipping task creation" -The Linear user who applied the label hasn't linked their account. Run `bgagent linear link `. +The Linear user who applied the label hasn't been mapped to a Cognito user. See [Step 7](#step-7-link-your-linear-account-optional-but-recommended). + +### "Invalid redirect_uri parameter for the application" during Step 4 -### "Invalid or expired link code" +This is Linear's misleading error for `actor=app` flows where the OAuth app config is incomplete. Check, in your Linear app settings: -Link codes expire in 10 minutes. Generate a new one. +- **GitHub username** field is set to a value ending in `[bot]` (e.g. `bgagent[bot]`) +- **Webhooks** toggle is ON +- The AWS-hosted callback URL is on a **single line** in the Callback URLs textarea (line-wrapped URLs become two malformed entries that Linear silently rejects) + +Re-run `bgagent linear setup` after fixing. ### Agent doesn't post comments to Linear -1. Verify the API token is stored: `aws secretsmanager get-secret-value --secret-id ` (admin-only). -2. Check the agent container logs for `Linear MCP configured at …` — absence means `channel_source` wasn't set on the task. -3. Check for `${LINEAR_API_TOKEN}` in the MCP handshake — if unresolved, the token secret wasn't piped into the container. Re-deploy. +1. Verify the OAuth credential provider exists: `aws bedrock-agentcore-control list-oauth2-credential-providers --region ` — look for `linear-oauth-`. +2. Verify the workspace is registered: scan `LinearWorkspaceRegistryTable`. +3. Check the agent container logs for `Linear MCP configured at …` — absence means `channel_source` wasn't set on the task or the workspace lookup failed. +4. Check for `WARN linear_reactions: HTTP 401 from Linear` in CloudWatch — usually means the OAuth token in the vault has been revoked from the Linear side. Re-run `bgagent linear setup` to re-authorize. ### Webhook signature verification fails repeatedly -The signing secret in Secrets Manager doesn't match the webhook. Re-run `bgagent linear setup` and paste the secret from the webhook's detail page (not the API key page). +The signing secret in Secrets Manager doesn't match the webhook. Re-run `bgagent linear setup --webhook-secret ` and paste the secret from the webhook's detail page (not the OAuth app page). + +## Migration from 2.0a (PAK) to 2.0b (OAuth) + +If your deployment is on Phase 2.0a (personal API key), 2.0b is a **hard cutover** — there is no `--use-pak` fallback flag. Plan for a maintenance window: + +1. **Drain the queue.** Wait for in-flight tasks to finish. In-flight tasks at upgrade time will fail their final Linear comment because the OAuth token isn't yet authorized when the agent looks for it. +2. **Deploy 2.0b.** `mise //cdk:deploy`. This adds `LinearWorkspaceRegistryTable`, removes `LinearApiTokenSecret` IAM grants from the agent runtime + Lambdas, and removes the `linear-api-key` AgentCore credential provider's role in the runtime. +3. **For each Linear workspace, run Steps 1–4 above.** Each workspace needs a new Linear OAuth app, a new AgentCore credential provider (`linear-oauth-`), and a fresh OAuth authorize via `bgagent linear setup`. +4. **Verify with a test issue.** Apply the `bgagent` label in each onboarded workspace and confirm the agent posts as `bgagent[bot]` (not as the previous PAK owner's Linear identity). +5. **Decommission the PAK.** Once 2.0b is verified working, revoke the personal API key in Linear settings ([Linear Settings → Security](https://linear.app/settings/account/security) → Personal API keys → revoke). The PAK is no longer used by any code path; revoking it is a clean break. +6. **Clean up the old api-key credential provider:** `aws bedrock-agentcore-control delete-api-key-credential-provider --name linear-api-key`. + +User mappings in `LinearUserMappingTable` survive the migration — they're keyed on Linear identity, which is unchanged. Project mappings in `LinearProjectMappingTable` likewise survive. ## Limits and budgets -Linear's API rate limits (personal API key, per user): +Linear's API rate limits per OAuth-installed app, per workspace: | Metric | Limit / hour | |--------|--------------| @@ -175,11 +248,20 @@ Linear's API rate limits (personal API key, per user): A typical task makes ~10 Linear API calls (one starting comment, one PR comment, one state transition, one final comment), nowhere near the ceiling. Heavy users should monitor the `X-RateLimit-Requests-Remaining` header in agent logs. -## What's out of scope in v1 +AgentCore Identity quotas worth knowing: + +| Metric | Limit | +|--------|-------| +| OAuth2 credential providers per account-region | 50 | +| Workload identities per account-region | (check Service Quotas console) | -- **Attachments**: v1 tickets are text-only. Linear attachments (mockups, screenshots) are planned for v1.1 via S3 pre-fetch. -- **OAuth bot install**: v1 uses a single personal API key. OAuth + multi-workspace is v3. -- **Comment-driven triggers**: only labels trigger tasks. Comment commands are v2+. +Token refresh: Linear access tokens expire in 24h (since April 2026). AgentCore Identity auto-refreshes via the stored refresh token; the agent's `get_resource_oauth2_token` call returns a fresh token transparently. + +## What's out of scope in v1.x + +- **Comment-driven task triggers**: only labels trigger tasks. Comment commands (e.g. `@bgagent fix this`) are v2+. +- **Self-service user linking**: see Step 7 — admins must insert mapping rows manually until v2.x ships the `@bgagent link` comment flow. +- **Attachments**: tickets are text-only. Linear attachments (mockups, screenshots) are planned via S3 pre-fetch. - **Per-issue status polling**: use `bgagent status` or watch the Linear issue comments. ## Removing the integration @@ -187,7 +269,6 @@ A typical task makes ~10 Linear API calls (one starting comment, one PR comment, Deactivate a project mapping: ```bash -# manual DynamoDB update — no CLI for this yet aws dynamodb update-item \ --table-name \ --key '{"linear_project_id":{"S":""}}' \ @@ -196,6 +277,21 @@ aws dynamodb update-item \ --expression-attribute-values '{":removed":{"S":"removed"}}' ``` -Delete the Linear webhook from [Linear Settings → API](https://linear.app/settings/api). +Revoke a workspace install: + +```bash +aws bedrock-agentcore-control delete-oauth2-credential-provider \ + --name linear-oauth- \ + --region + +aws dynamodb update-item \ + --table-name \ + --key '{"linear_workspace_id":{"S":""}}' \ + --update-expression 'SET #s = :revoked' \ + --expression-attribute-names '{"#s":"status"}' \ + --expression-attribute-values '{":revoked":{"S":"revoked"}}' +``` + +Delete the Linear webhook from [Linear Settings → API](https://linear.app/settings/api) and uninstall the OAuth app from [Workspace Settings → Integrations](https://linear.app/settings/integrations) on the Linear side. -To remove the Linear integration from your ABCA deployment entirely, delete the webhook in Linear, delete the `LinearIntegration` construct from the stack, and redeploy. +To remove the Linear integration from your ABCA deployment entirely, delete the webhook in Linear, uninstall the OAuth app, run the `delete-oauth2-credential-provider` for each workspace, then delete the `LinearIntegration` construct from the stack and redeploy. diff --git a/docs/guides/USER_GUIDE.md b/docs/guides/USER_GUIDE.md index d07fb952..77098a99 100644 --- a/docs/guides/USER_GUIDE.md +++ b/docs/guides/USER_GUIDE.md @@ -14,6 +14,23 @@ There are five ways to interact with the platform. You can use them independentl For example, a team might use the **CLI** for ad-hoc tasks, **webhooks** to auto-trigger `pr_review` on every new PR via GitHub Actions, **Slack** for quick team-wide requests, **Linear** for tickets that already live in the PM tool, and the **REST API** to build a dashboard that tracks task status across repositories. +## Roles + +ABCA is a **shared-stack-per-organization** platform — one CDK deployment, used by everyone on the team. Like a self-hosted GitLab or Linear instance: one company → one stack → many users. You generally do **not** run your own deployment to use someone else's; you join theirs as a Cognito user. + +There are four lifecycle roles. They are often the same person early on, but the operations they perform are distinct: + +| Role | What they do | Frequency | +|------|--------------|-----------| +| **Stack admin** | `cdk deploy` the stack; rotates platform-level secrets; runs `bgagent admin invite-user` to onboard teammates | Once + occasional | +| **Linear / Slack workspace admin** | Runs `bgagent linear setup` (or `bgagent slack setup`) once per workspace to install the OAuth app | One-time per workspace | +| **Repo onboarder** | Runs `bgagent linear onboard-project` (or registers a Blueprint via CDK) to wire a repo into the platform | As needed; any authenticated user | +| **Teammate** | Runs `bgagent configure` once + `bgagent submit` / Linear-label / Slack mention from then on | Daily user | + +If you're a teammate joining an existing deployment, jump to [Joining an existing deployment](#joining-an-existing-deployment) below. + +If you're standing up a new deployment from scratch, see the [Developer guide](./DEVELOPER_GUIDE.md) first, then come back here for the [admin onboarding flow](#get-stack-outputs). + ## Prerequisites - The CDK stack deployed (see [Developer guide](./DEVELOPER_GUIDE.md)) @@ -64,6 +81,49 @@ flowchart TB 3. **Verify signature** - The handler fetches the webhook's shared secret from AWS Secrets Manager, computes `HMAC-SHA256(secret, raw_request_body)`, and compares it to the provided signature using constant-time comparison (`crypto.timingSafeEqual`). Mismatches are rejected with `403`. 4. **Extract identity** - The `user_id` is the Cognito user who originally created the webhook integration. Tasks created via webhook are owned by that user. +### Joining an existing deployment + +If your team already has ABCA deployed and someone (the "stack admin") has invited you, this is your path. You will **not** run `cdk deploy`, will **not** run `bgagent linear setup`, and will not need AWS credentials. You're a tenant on a shared deployment. + +Three steps: + +1. **Get a config bundle from your admin.** They run `bgagent admin invite-user your-email@example.com` and send you the output via Slack / 1Password / email. The output looks like: + + ``` + ✓ Created Cognito user your-email@example.com + ✓ Set permanent password (no first-login change required) + + Share with the new teammate: + ──────────────────────────────────────────────────────────────── + email: your-email@example.com + password: K9$mPq2nL!vXf3Hb + bundle: eyJhcGlfdXJsIjoiaHR0cHM6Ly9hYmMxMjM… + ──────────────────────────────────────────────────────────────── + ``` + + The `bundle` is a base64 blob carrying the four config fields (API URL, region, user pool ID, app client ID) so you don't have to type them as separate flags. + +2. **Configure your CLI from the bundle:** + + ```bash + bgagent configure --from-bundle + ``` + +3. **Log in with the temp password:** + + ```bash + bgagent login --username your-email@example.com + # paste the temp password + ``` + + The CLI caches your tokens in `~/.bgagent/credentials.json` and auto-refreshes them. + +You're in. `bgagent submit`, `bgagent list`, `bgagent status` work against the shared stack. Tasks you submit are attributed to your Cognito user; concurrency caps and budgets are scoped to you. + +**You do not run** `bgagent linear setup` or `bgagent slack setup` — those are workspace-level operations performed once by the stack/workspace admin. If you want Linear-triggered tasks to be attributed to *you* (not auto-dropped), the admin needs to map your Linear identity to your Cognito user; ask them about [Linear user linking](./LINEAR_SETUP_GUIDE.md#step-6-link-your-linear-account). + +If something looks broken (commands fail with `Not configured` or `401 Unauthorized`), re-paste the bundle and re-run `bgagent login`. The bundle holds no secrets — your password (separate) is the credential. + ### Get stack outputs After deployment, retrieve the API URL and Cognito identifiers. Set `REGION` to the AWS region where you deployed the stack (for example `us-east-1`). Use the same value for all `aws` and `bgagent configure` commands below - a mismatch often surfaces as a confusing Cognito “app client does not exist” error. @@ -82,7 +142,26 @@ APP_CLIENT_ID=$(aws cloudformation describe-stacks --stack-name backgroundagent- --query 'Stacks[0].Outputs[?OutputKey==`AppClientId`].OutputValue' --output text) ``` -### Create a user (admin) +### Invite a teammate (admin) + +```bash +bgagent admin invite-user teammate@example.com +``` + +This wraps Cognito `admin-create-user` + `admin-set-user-password` with the right defaults (email-verified, password set as permanent so the teammate doesn't hit a password-change flow on first login, suppress-email so SES isn't required) and prints a shareable config bundle plus an auto-generated strong temp password. Send the bundle + password to the teammate; they paste them into `bgagent configure --from-bundle ` + `bgagent login --username ` and they're in. + +The CLI command requires the running shell to have AWS credentials with `cognito-idp:AdminCreateUser` and `cognito-idp:AdminSetUserPassword` on the configured user pool — i.e. you're acting as the stack admin, not as a Cognito-authenticated end-user. + +**Pool constraints** (enforced server-side; the CLI handles them, but useful to know if you ever need to bypass it with raw AWS CLI): + +- **Username MUST be an email address.** The pool is configured with email as the sign-in alias. +- **Password policy**: minimum 12 characters, with at least one uppercase, lowercase, digit, and symbol. +- **`email_verified=true` attribute is required**, otherwise the account stays in `FORCE_CHANGE_PASSWORD` state and `initiate-auth` fails with `User is not confirmed`. +- **`--message-action SUPPRESS`** stops Cognito from trying to email the temp password — required unless you've set up SES verified identities. + +#### Raw AWS CLI fallback + +If you can't run `bgagent admin invite-user` (e.g., you're scripting this from CI without the CLI installed), the underlying calls are: ```bash aws cognito-idp admin-create-user \ @@ -101,14 +180,7 @@ aws cognito-idp admin-set-user-password \ --permanent ``` -**Pool constraints** (enforced server-side; ignoring them yields cryptic Cognito errors at login): - -- **Username MUST be an email address.** The pool is configured with email as the sign-in alias, so `--username` has to be a valid email — short handles like `alice` are rejected at create time. -- **Password policy**: minimum 12 characters, with at least one uppercase letter, one lowercase letter, one digit, and one symbol. -- **`email_verified=true` attribute is required** for the account to log in. Creating a user without it leaves the account in `FORCE_CHANGE_PASSWORD` state and subsequent `initiate-auth` calls fail with `User is not confirmed`. -- **`--message-action SUPPRESS`** stops Cognito from trying to email the temporary password. If SES isn't configured on the account, omitting this flag causes `admin-create-user` to fail with `NotAuthorizedException`. Safe for non-prod; omit only if you have a working SES sender identity. - -The first command creates the user with a temporary password and pre-verifies the email. The second sets a permanent password so you do not have to go through a password change flow on first login. +The first command creates the user with a temporary password and pre-verifies the email. The second sets a permanent password so the teammate does not have to go through a password change flow on first login. After running these, hand the teammate the four config fields manually (or build the bundle: `echo '{"api_url":"…","region":"…","user_pool_id":"…","client_id":"…"}' | base64`). ### Obtain a JWT token diff --git a/docs/src/content/docs/using/Authentication.md b/docs/src/content/docs/using/Authentication.md index 4806ffda..3d80fd4e 100644 --- a/docs/src/content/docs/using/Authentication.md +++ b/docs/src/content/docs/using/Authentication.md @@ -43,6 +43,49 @@ flowchart TB 3. **Verify signature** - The handler fetches the webhook's shared secret from AWS Secrets Manager, computes `HMAC-SHA256(secret, raw_request_body)`, and compares it to the provided signature using constant-time comparison (`crypto.timingSafeEqual`). Mismatches are rejected with `403`. 4. **Extract identity** - The `user_id` is the Cognito user who originally created the webhook integration. Tasks created via webhook are owned by that user. +### Joining an existing deployment + +If your team already has ABCA deployed and someone (the "stack admin") has invited you, this is your path. You will **not** run `cdk deploy`, will **not** run `bgagent linear setup`, and will not need AWS credentials. You're a tenant on a shared deployment. + +Three steps: + +1. **Get a config bundle from your admin.** They run `bgagent admin invite-user your-email@example.com` and send you the output via Slack / 1Password / email. The output looks like: + + ``` + ✓ Created Cognito user your-email@example.com + ✓ Set permanent password (no first-login change required) + + Share with the new teammate: + ──────────────────────────────────────────────────────────────── + email: your-email@example.com + password: K9$mPq2nL!vXf3Hb + bundle: eyJhcGlfdXJsIjoiaHR0cHM6Ly9hYmMxMjM… + ──────────────────────────────────────────────────────────────── + ``` + + The `bundle` is a base64 blob carrying the four config fields (API URL, region, user pool ID, app client ID) so you don't have to type them as separate flags. + +2. **Configure your CLI from the bundle:** + + ```bash + bgagent configure --from-bundle + ``` + +3. **Log in with the temp password:** + + ```bash + bgagent login --username your-email@example.com + # paste the temp password + ``` + + The CLI caches your tokens in `~/.bgagent/credentials.json` and auto-refreshes them. + +You're in. `bgagent submit`, `bgagent list`, `bgagent status` work against the shared stack. Tasks you submit are attributed to your Cognito user; concurrency caps and budgets are scoped to you. + +**You do not run** `bgagent linear setup` or `bgagent slack setup` — those are workspace-level operations performed once by the stack/workspace admin. If you want Linear-triggered tasks to be attributed to *you* (not auto-dropped), the admin needs to map your Linear identity to your Cognito user; ask them about [Linear user linking](/using/linear-setup-guide#step-6-link-your-linear-account). + +If something looks broken (commands fail with `Not configured` or `401 Unauthorized`), re-paste the bundle and re-run `bgagent login`. The bundle holds no secrets — your password (separate) is the credential. + ### Get stack outputs After deployment, retrieve the API URL and Cognito identifiers. Set `REGION` to the AWS region where you deployed the stack (for example `us-east-1`). Use the same value for all `aws` and `bgagent configure` commands below - a mismatch often surfaces as a confusing Cognito “app client does not exist” error. @@ -61,7 +104,26 @@ APP_CLIENT_ID=$(aws cloudformation describe-stacks --stack-name backgroundagent- --query 'Stacks[0].Outputs[?OutputKey==`AppClientId`].OutputValue' --output text) ``` -### Create a user (admin) +### Invite a teammate (admin) + +```bash +bgagent admin invite-user teammate@example.com +``` + +This wraps Cognito `admin-create-user` + `admin-set-user-password` with the right defaults (email-verified, password set as permanent so the teammate doesn't hit a password-change flow on first login, suppress-email so SES isn't required) and prints a shareable config bundle plus an auto-generated strong temp password. Send the bundle + password to the teammate; they paste them into `bgagent configure --from-bundle ` + `bgagent login --username ` and they're in. + +The CLI command requires the running shell to have AWS credentials with `cognito-idp:AdminCreateUser` and `cognito-idp:AdminSetUserPassword` on the configured user pool — i.e. you're acting as the stack admin, not as a Cognito-authenticated end-user. + +**Pool constraints** (enforced server-side; the CLI handles them, but useful to know if you ever need to bypass it with raw AWS CLI): + +- **Username MUST be an email address.** The pool is configured with email as the sign-in alias. +- **Password policy**: minimum 12 characters, with at least one uppercase, lowercase, digit, and symbol. +- **`email_verified=true` attribute is required**, otherwise the account stays in `FORCE_CHANGE_PASSWORD` state and `initiate-auth` fails with `User is not confirmed`. +- **`--message-action SUPPRESS`** stops Cognito from trying to email the temp password — required unless you've set up SES verified identities. + +#### Raw AWS CLI fallback + +If you can't run `bgagent admin invite-user` (e.g., you're scripting this from CI without the CLI installed), the underlying calls are: ```bash aws cognito-idp admin-create-user \ @@ -80,14 +142,7 @@ aws cognito-idp admin-set-user-password \ --permanent ``` -**Pool constraints** (enforced server-side; ignoring them yields cryptic Cognito errors at login): - -- **Username MUST be an email address.** The pool is configured with email as the sign-in alias, so `--username` has to be a valid email — short handles like `alice` are rejected at create time. -- **Password policy**: minimum 12 characters, with at least one uppercase letter, one lowercase letter, one digit, and one symbol. -- **`email_verified=true` attribute is required** for the account to log in. Creating a user without it leaves the account in `FORCE_CHANGE_PASSWORD` state and subsequent `initiate-auth` calls fail with `User is not confirmed`. -- **`--message-action SUPPRESS`** stops Cognito from trying to email the temporary password. If SES isn't configured on the account, omitting this flag causes `admin-create-user` to fail with `NotAuthorizedException`. Safe for non-prod; omit only if you have a working SES sender identity. - -The first command creates the user with a temporary password and pre-verifies the email. The second sets a permanent password so you do not have to go through a password change flow on first login. +The first command creates the user with a temporary password and pre-verifies the email. The second sets a permanent password so the teammate does not have to go through a password change flow on first login. After running these, hand the teammate the four config fields manually (or build the bundle: `echo '{"api_url":"…","region":"…","user_pool_id":"…","client_id":"…"}' | base64`). ### Obtain a JWT token diff --git a/docs/src/content/docs/using/Linear-setup-guide.md b/docs/src/content/docs/using/Linear-setup-guide.md index eedc9c8e..4eec9900 100644 --- a/docs/src/content/docs/using/Linear-setup-guide.md +++ b/docs/src/content/docs/using/Linear-setup-guide.md @@ -6,83 +6,108 @@ title: Linear setup guide This guide walks through setting up the ABCA Linear integration. Once configured, applying the `bgagent` label to an issue in a mapped Linear project triggers an autonomous task. The agent posts progress comments back on the Linear issue as it works. +> **Phase 2.0b** — ABCA now authenticates to Linear via OAuth (`actor=app`) instead of a personal API key. One OAuth app per ABCA deployment, one credential provider per Linear workspace. Personal API keys are no longer supported (see [Migration from 2.0a (PAK) to 2.0b (OAuth)](#migration-from-20a-pak-to-20b-oauth) below). + ## Prerequisites - ABCA CDK stack deployed (see [Developer guide](/developer-guide/introduction)) - A Cognito user account configured (see [User guide](/using/overview)) -- A Linear workspace where you have admin access (to create API keys and webhooks) -- AWS CLI configured with credentials for your ABCA account +- A Linear workspace where you have **admin** access (you'll create an OAuth app and install it on the workspace) +- AWS CLI configured with credentials for your ABCA account, with `bedrock-agentcore-control:*` permissions on the deployment region +- The `bgagent` CLI installed and logged in (`bgagent configure` + `bgagent login`) ## How it works -1. A user adds the `bgagent` label (configurable per project) to a Linear issue. -2. Linear fires a webhook to `POST /v1/linear/webhook`. ABCA verifies the HMAC signature and dedups retries. -3. A processor Lambda resolves the Linear project → GitHub repo mapping and the Linear user → platform user mapping, then creates a task with `channel_source: 'linear'`. -4. The agent clones the repo, writes `.mcp.json` with Linear's hosted MCP server, and runs. It uses `mcp__linear-server__save_comment` / `mcp__linear-server__update_issue` to post updates to the originating issue. -5. The agent opens a PR on GitHub and adds a final comment to the Linear issue with the PR link. - -**Authentication for v1** is a Linear personal API key. A single key powers all agent-to-Linear calls for the whole stack. OAuth bot install + multi-workspace is a v3 follow-up. +1. A Linear-workspace admin creates a Linear OAuth app, registers it as an AgentCore Identity credential provider, and authorizes it on the workspace via `bgagent linear setup`. The workspace's OAuth token lives in the AgentCore Identity vault, keyed on `userId=linear-workspace-`. **One install per workspace, used by all teammates** — this matches the v1 personal-API-key semantics. +2. A user adds the `bgagent` label (configurable per project) to a Linear issue. +3. Linear fires a webhook to `POST /v1/linear/webhook`. ABCA verifies the HMAC signature and dedups retries. +4. A processor Lambda looks up the Linear `organizationId` in `LinearWorkspaceRegistryTable` to find the credential provider name, retrieves the workspace's OAuth token via AgentCore Identity, then resolves the project → repo mapping and creates a task with `channel_source: 'linear'`. +5. The agent clones the repo, writes `.mcp.json` with Linear's hosted MCP server, and runs. It uses `mcp__linear-server__save_comment` / `mcp__linear-server__update_issue` to post updates as `bgagent[bot]` (the OAuth app's identity). +6. The agent opens a PR on GitHub and adds a final comment to the Linear issue with the PR link. **Trigger**: only Linear issues with the configured label in a mapped project create tasks. Issues without the label, or in unmapped projects, are ignored. Label removal does not cancel a running task. -## Step-by-step setup - -### Step 1: Generate a Linear personal API key +**Multi-workspace**: a single ABCA deployment can serve multiple Linear workspaces. Each workspace gets its own AgentCore credential provider via `bgagent linear add-workspace`. -Open [Linear Settings → Security](https://linear.app/settings/account/security), scroll to **Personal API keys**, and create one. Copy the token — it starts with `lin_api_…`. You won't be able to see it again. +## Step-by-step setup -This key is used by the agent to post comments and update issue state. Personal API keys are full-workspace-scoped; document internally that you're handing that authority to ABCA. +### Step 1: Create the AgentCore credential provider -### Step 2: Run the setup wizard +The credential provider is an AWS-side OAuth2 client registration. It generates the **AWS-hosted callback URL** that Linear will redirect the browser to during consent — without this URL, you can't complete Step 2. ```bash -bgagent linear setup +bgagent linear oauth-register-workspace ``` -The wizard prints the exact webhook URL for your deployment, then waits at a **Webhook signing secret:** prompt. Leave it running; go create the webhook in the next step, then return and paste both values. +Where `` is the Linear `urlKey` of the workspace (e.g. `acme` from `https://linear.app/acme/...`). The command prompts for the Linear OAuth app's `clientId` and `clientSecret` — you don't have these yet, so first create the Linear OAuth app in Step 2 below, then come back and finish this step. Either order works; just pair them. -### Step 3: Create the Linear webhook +The command: +- Calls `aws bedrock-agentcore-control create-oauth2-credential-provider` with `credentialProviderVendor='CustomOauth2'` (Linear is not a built-in vendor, so the command supplies an explicit `authorizationServerMetadata` block — Linear has no `.well-known/openid-configuration`). +- Prints the AWS-hosted callback URL you'll paste into Linear's app form. +- Records the provider name (`linear-oauth-`) for `bgagent linear setup` to use later. -In [Linear Settings → API](https://linear.app/settings/api), under **Webhooks**, click **+**: +> **Why AWS hosts the callback.** Earlier ABCA designs (and most third-party docs at the time of writing) assumed the integrator hosted their own callback service. AgentCore Identity actually proxies the callback itself; the URL it surfaces in `create-oauth2-credential-provider` response (`callbackUrl`) is what Linear redirects to, **not** an URL you control. The `resourceOauth2ReturnUrl` you pass to `get_resource_oauth2_token` is just where AWS sends the **browser** after AWS finishes the code-exchange — typically a localhost URL that `bgagent linear setup` listens on for that one redirect. -- **URL**: paste the URL the wizard printed in Step 2. -- **Resource types**: check **Issues** only. -- **Team**: whichever team owns the projects you'll map to ABCA (or all teams). -- Save, then open the webhook's detail page and copy the **signing secret**. +### Step 2: Create the Linear OAuth app -### Step 4: Finish the wizard +Run: -Back in your terminal at the paused `bgagent linear setup` prompt: +```bash +bgagent linear app-template +``` + +This prints the exact field values to paste into Linear's OAuth app form. Open [Linear Settings → API → New application](https://linear.app/settings/api/applications/new) and fill in the fields the template lists. Critical fields (each gates the `actor=app` agent flow — without them Linear surfaces a misleading "Invalid redirect_uri" error): + +- **GitHub username**: must end with the literal `[bot]` suffix (e.g., `bgagent[bot]`) +- **Webhooks**: toggle ON (the URL value can be a placeholder; we don't subscribe to events for the OAuth flow itself) +- **Callback URLs**: paste the AWS-hosted URL from Step 1 on a single line. Wildcards are not accepted; if you have multiple environments, register each URL fully. -- Paste the **webhook signing secret** (from Step 3). -- Paste the **personal API key** (from Step 1). +If you ran Step 1 first, pass the AWS callback URL to the template so it's filled in: + +```bash +bgagent linear app-template --aws-callback-url "" +``` -Both are stored in Secrets Manager (`LinearWebhookSecret` and `LinearApiTokenSecret`). The wizard validates that the personal API key starts with `lin_api_`. Full authentication is verified the first time a webhook arrives or the agent calls the Linear MCP. +Click **Save**, then copy the **Client ID** and **Client Secret** from the app's detail page. -As a final step, `setup` calls the Linear API with the token you just stored, looks up the token owner, and auto-links that Linear identity to the Cognito user currently logged in to the CLI. This skips the code-exchange ceremony for the common case where one person installs ABCA for their own workspace. If the auto-link fails (token invalid, not logged in, etc.) setup prints a warning and continues. +### Step 3: Finish Step 1 — paste Linear secrets -**If auto-link fails persistently** (rare — usually transient Linear API hiccups, just re-run `bgagent linear setup`), an admin can insert the mapping directly into the `LinearUserMappingTable` DynamoDB table: +Return to the terminal where Step 1 is paused at the `Client ID:` prompt and paste the values you copied from Linear. The credential provider is now wired up. + +### Step 4: Authorize via OAuth ```bash -aws dynamodb put-item \ - --table-name -LinearIntegrationUserMappingTable... \ - --item '{ - "linear_identity": {"S": "#"}, - "platform_user_id": {"S": ""}, - "status": {"S": "active"}, - "linked_at": {"S": "2026-05-14T00:00:00Z"} - }' +bgagent linear setup ``` -To find the right values: +The wizard: + +1. Looks up the credential provider you registered in Step 1. +2. Starts an ephemeral HTTPS server on `localhost:8443` with a self-signed cert. **Your browser will warn about the cert** — click through, it's local-only. +3. Calls `get_resource_oauth2_token` with `customParameters={'actor': 'app'}` and opens the returned `authorizationUrl` in your default browser. +4. You authorize the OAuth app on the Linear consent screen. +5. AWS handles the code-exchange with Linear behind the scenes, then redirects your browser to `https://localhost:8443/oauth/callback?session_id=...`. +6. The wizard captures the `session_id`, polls for the access token (5s/600s timeout), then queries Linear's `viewer { id, organization { id, urlKey } }` to record workspace metadata in `LinearWorkspaceRegistryTable`. + +The OAuth token is stored in the AWS-managed token vault under `userId=linear-workspace-`. **All teammates' Linear-triggered tasks share this single token** — that's by design (matches the v1 PAK semantics, just with a revocable / scoped credential and audit trail). + +### Step 5: Configure the Linear webhook + +In [Linear Settings → API](https://linear.app/settings/api) → **Webhooks** → **+**: -- **`workspaceId`**: from Linear API `viewer { organization { id } }` or the URL `https://linear.app//...` -- **`viewerId`**: from Linear API `viewer { id }` -- **`platform_user_id`**: your Cognito `sub` claim — `cat ~/.bgagent/credentials.json | jq -r .id_token | cut -d. -f2 | base64 -d 2>/dev/null | jq -r .sub` +- **URL**: paste the URL `bgagent linear setup` printed at the end of Step 4 (looks like `https://.execute-api..amazonaws.com/v1/linear/webhook`) +- **Resource types**: check **Issues** only +- **Team**: whichever team owns the projects you'll map to ABCA (or all teams) -The CLI command `bgagent linear link ` exists in v1 but is **non-functional** without a Linear-side code generator (planned for v3 OAuth bot install). Do not rely on it. +Save, then open the webhook's detail page and copy the **signing secret**. Run: -### Step 5: Onboard a Linear project +```bash +bgagent linear setup --webhook-secret +``` + +This stores the secret in `LinearWebhookSecret`. (Webhook signing is independent of OAuth — it's how Linear authenticates inbound calls to your API Gateway, separate from how the agent authenticates outbound calls to Linear.) + +### Step 6: Onboard a Linear project Map a Linear project UUID to the GitHub repo you want tasks routed to: @@ -99,7 +124,7 @@ Optional flags: | `--region ` | AWS region | from `bgagent configure` | | `--stack-name ` | CloudFormation stack name | `backgroundagent-dev` | -**Finding the Linear project UUID.** Linear's project URL (`https://linear.app//project/-`) contains a *truncated* UUID at the end — that's not the full UUID the webhook sends. List the full UUIDs for all projects visible to the stored API token: +**Finding the Linear project UUID.** Linear's project URL (`https://linear.app//project/-`) contains a *truncated* UUID at the end — that's not the full UUID the webhook sends. List the full UUIDs for all projects visible to the OAuth token: ```bash bgagent linear list-projects @@ -107,26 +132,53 @@ bgagent linear list-projects Copy the `id` of the project you want to onboard. `onboard-project` validates the UUID format and will reject the truncated slug version with a pointer back to this command. -### Step 6: Link your Linear account +### Step 7: Link your Linear account (optional but recommended) + +ABCA needs to know which platform user a Linear actor maps to so triggered tasks are attributed correctly (concurrency caps, billing, `bgagent list`). + +**The admin who ran `bgagent linear setup` is auto-linked.** Setup queries Linear's `viewer { id }` with the new OAuth token and writes a row in `LinearUserMappingTable` for the Cognito user running the CLI. Look for `✓ Linked Linear user …` in the setup output. -ABCA needs to know which platform user a Linear actor maps to so tasks are attributed correctly. +**For other teammates**: Linear-triggered tasks they apply the label on will be **dropped** by the processor with `"Linear actor has no linked platform user — skipping task creation"` until their identity is mapped. Two paths: -**The token owner is linked automatically.** `bgagent linear setup` calls Linear's `viewer` query with the token you just pasted and writes the mapping for the Cognito user running the CLI. Look for `✓ Linked Linear user …` in the setup output — if you saw that, you're done. Skip to Step 7. +- **Manual (today):** the admin inserts a row into `LinearUserMappingTable`: -**Linking additional Linear users** (anyone other than the API-token owner) isn't supported in v1. A comment-triggered flow (`bgagent link` in a Linear comment → receive code → `bgagent linear link `) is a planned follow-up; the `bgagent linear link ` CLI command exists today but no Linear-side code generator ships with it yet. + ```bash + aws dynamodb put-item \ + --table-name -LinearIntegrationUserMappingTable... \ + --item '{ + "linear_identity": {"S": "#"}, + "platform_user_id": {"S": ""}, + "status": {"S": "active"}, + "linked_at": {"S": "2026-05-19T00:00:00Z"} + }' + ``` -For v1, design the flow around the API-token owner: that person installs ABCA, runs `bgagent linear setup`, and submits tasks on their own behalf. Tasks triggered by other Linear users in the workspace will be dropped by the processor with `"Linear actor has no linked platform user — skipping task creation"`. + Find the `viewerId` via Linear's API (`viewer { id }` while logged in as that teammate) and the Cognito sub via `bgagent admin invite-user` (printed when you create their user) or by decoding their cached id_token. -### Step 7: Test it +- **Self-service (planned, v2.x):** a comment-driven `@bgagent link` flow that exchanges a code for a row write — `bgagent linear link ` exists in v1 but is non-functional until the Linear-side code generator ships. + +### Step 8: Test it Add the `bgagent` label to a Linear issue in a mapped project. Within a few seconds: - The Linear webhook Lambda logs an `INFO` entry and invokes the processor. -- The processor creates a task in the `TaskTable` with `channel_source: 'linear'`. -- The agent container starts, clones the repo, and posts a `🤖 Starting on this issue…` comment to the Linear issue. +- The processor looks up `LinearWorkspaceRegistryTable` by the webhook's `organizationId`, retrieves the workspace's OAuth token via AgentCore Identity, and creates a task in `TaskTable` with `channel_source: 'linear'`. +- The agent container starts, clones the repo, and posts a `🤖 Starting on this issue…` comment as `bgagent[bot]`. - When the agent opens a PR, another comment appears with the PR link and the issue transitions to `In Review` (if that state exists). - On completion or failure, a final status comment is posted. +## Adding additional Linear workspaces + +A single ABCA deployment can serve multiple Linear workspaces. Each workspace gets its own credential provider and OAuth install: + +```bash +bgagent linear add-workspace +``` + +This re-runs Steps 1, 2, and 4 of the setup (asks for a new clientId/secret pair, creates a `linear-oauth-` provider, runs the OAuth dance against the new workspace). You'll need to create a separate Linear OAuth app for each workspace — Linear apps are workspace-scoped at install time even though the same OAuth credentials *could* technically install in multiple workspaces. Per-workspace apps give cleaner revocation and per-workspace branding. + +The 50-credential-provider-per-account quota in AgentCore is the practical ceiling for multi-tenant deployments. + ## Usage ### Trigger a task @@ -147,30 +199,51 @@ Use `bgagent cancel `. Removing the Linear label does not cancel a runn ### Webhook doesn't trigger a task 1. Is the project mapped? Run `aws dynamodb scan --table-name ` (look up the table name via `aws cloudformation describe-stacks`). -2. Is the label spelled exactly as configured? Match is case-insensitive but must be the same word. -3. Check CloudWatch logs for the `WebhookFn` and `WebhookProcessorFn` Lambdas for `Invalid Linear webhook signature`, `Linear project is not onboarded`, or `Linear actor has no linked platform user`. +2. Is the workspace registered? Scan `LinearWorkspaceRegistryTable` for the Linear `organizationId` from the webhook payload. +3. Is the label spelled exactly as configured? Match is case-insensitive but must be the same word. +4. Check CloudWatch logs for `WebhookFn` and `WebhookProcessorFn` for `Invalid Linear webhook signature`, `Linear workspace is not onboarded`, `Linear project is not onboarded`, or `Linear actor has no linked platform user`. ### "Linear actor has no linked platform user — skipping task creation" -The Linear user who applied the label hasn't linked their account. Run `bgagent linear link `. +The Linear user who applied the label hasn't been mapped to a Cognito user. See [Step 7](#step-7-link-your-linear-account-optional-but-recommended). + +### "Invalid redirect_uri parameter for the application" during Step 4 -### "Invalid or expired link code" +This is Linear's misleading error for `actor=app` flows where the OAuth app config is incomplete. Check, in your Linear app settings: -Link codes expire in 10 minutes. Generate a new one. +- **GitHub username** field is set to a value ending in `[bot]` (e.g. `bgagent[bot]`) +- **Webhooks** toggle is ON +- The AWS-hosted callback URL is on a **single line** in the Callback URLs textarea (line-wrapped URLs become two malformed entries that Linear silently rejects) + +Re-run `bgagent linear setup` after fixing. ### Agent doesn't post comments to Linear -1. Verify the API token is stored: `aws secretsmanager get-secret-value --secret-id ` (admin-only). -2. Check the agent container logs for `Linear MCP configured at …` — absence means `channel_source` wasn't set on the task. -3. Check for `${LINEAR_API_TOKEN}` in the MCP handshake — if unresolved, the token secret wasn't piped into the container. Re-deploy. +1. Verify the OAuth credential provider exists: `aws bedrock-agentcore-control list-oauth2-credential-providers --region ` — look for `linear-oauth-`. +2. Verify the workspace is registered: scan `LinearWorkspaceRegistryTable`. +3. Check the agent container logs for `Linear MCP configured at …` — absence means `channel_source` wasn't set on the task or the workspace lookup failed. +4. Check for `WARN linear_reactions: HTTP 401 from Linear` in CloudWatch — usually means the OAuth token in the vault has been revoked from the Linear side. Re-run `bgagent linear setup` to re-authorize. ### Webhook signature verification fails repeatedly -The signing secret in Secrets Manager doesn't match the webhook. Re-run `bgagent linear setup` and paste the secret from the webhook's detail page (not the API key page). +The signing secret in Secrets Manager doesn't match the webhook. Re-run `bgagent linear setup --webhook-secret ` and paste the secret from the webhook's detail page (not the OAuth app page). + +## Migration from 2.0a (PAK) to 2.0b (OAuth) + +If your deployment is on Phase 2.0a (personal API key), 2.0b is a **hard cutover** — there is no `--use-pak` fallback flag. Plan for a maintenance window: + +1. **Drain the queue.** Wait for in-flight tasks to finish. In-flight tasks at upgrade time will fail their final Linear comment because the OAuth token isn't yet authorized when the agent looks for it. +2. **Deploy 2.0b.** `mise //cdk:deploy`. This adds `LinearWorkspaceRegistryTable`, removes `LinearApiTokenSecret` IAM grants from the agent runtime + Lambdas, and removes the `linear-api-key` AgentCore credential provider's role in the runtime. +3. **For each Linear workspace, run Steps 1–4 above.** Each workspace needs a new Linear OAuth app, a new AgentCore credential provider (`linear-oauth-`), and a fresh OAuth authorize via `bgagent linear setup`. +4. **Verify with a test issue.** Apply the `bgagent` label in each onboarded workspace and confirm the agent posts as `bgagent[bot]` (not as the previous PAK owner's Linear identity). +5. **Decommission the PAK.** Once 2.0b is verified working, revoke the personal API key in Linear settings ([Linear Settings → Security](https://linear.app/settings/account/security) → Personal API keys → revoke). The PAK is no longer used by any code path; revoking it is a clean break. +6. **Clean up the old api-key credential provider:** `aws bedrock-agentcore-control delete-api-key-credential-provider --name linear-api-key`. + +User mappings in `LinearUserMappingTable` survive the migration — they're keyed on Linear identity, which is unchanged. Project mappings in `LinearProjectMappingTable` likewise survive. ## Limits and budgets -Linear's API rate limits (personal API key, per user): +Linear's API rate limits per OAuth-installed app, per workspace: | Metric | Limit / hour | |--------|--------------| @@ -179,11 +252,20 @@ Linear's API rate limits (personal API key, per user): A typical task makes ~10 Linear API calls (one starting comment, one PR comment, one state transition, one final comment), nowhere near the ceiling. Heavy users should monitor the `X-RateLimit-Requests-Remaining` header in agent logs. -## What's out of scope in v1 +AgentCore Identity quotas worth knowing: + +| Metric | Limit | +|--------|-------| +| OAuth2 credential providers per account-region | 50 | +| Workload identities per account-region | (check Service Quotas console) | -- **Attachments**: v1 tickets are text-only. Linear attachments (mockups, screenshots) are planned for v1.1 via S3 pre-fetch. -- **OAuth bot install**: v1 uses a single personal API key. OAuth + multi-workspace is v3. -- **Comment-driven triggers**: only labels trigger tasks. Comment commands are v2+. +Token refresh: Linear access tokens expire in 24h (since April 2026). AgentCore Identity auto-refreshes via the stored refresh token; the agent's `get_resource_oauth2_token` call returns a fresh token transparently. + +## What's out of scope in v1.x + +- **Comment-driven task triggers**: only labels trigger tasks. Comment commands (e.g. `@bgagent fix this`) are v2+. +- **Self-service user linking**: see Step 7 — admins must insert mapping rows manually until v2.x ships the `@bgagent link` comment flow. +- **Attachments**: tickets are text-only. Linear attachments (mockups, screenshots) are planned via S3 pre-fetch. - **Per-issue status polling**: use `bgagent status` or watch the Linear issue comments. ## Removing the integration @@ -191,7 +273,6 @@ A typical task makes ~10 Linear API calls (one starting comment, one PR comment, Deactivate a project mapping: ```bash -# manual DynamoDB update — no CLI for this yet aws dynamodb update-item \ --table-name \ --key '{"linear_project_id":{"S":""}}' \ @@ -200,6 +281,21 @@ aws dynamodb update-item \ --expression-attribute-values '{":removed":{"S":"removed"}}' ``` -Delete the Linear webhook from [Linear Settings → API](https://linear.app/settings/api). +Revoke a workspace install: + +```bash +aws bedrock-agentcore-control delete-oauth2-credential-provider \ + --name linear-oauth- \ + --region + +aws dynamodb update-item \ + --table-name \ + --key '{"linear_workspace_id":{"S":""}}' \ + --update-expression 'SET #s = :revoked' \ + --expression-attribute-names '{"#s":"status"}' \ + --expression-attribute-values '{":revoked":{"S":"revoked"}}' +``` + +Delete the Linear webhook from [Linear Settings → API](https://linear.app/settings/api) and uninstall the OAuth app from [Workspace Settings → Integrations](https://linear.app/settings/integrations) on the Linear side. -To remove the Linear integration from your ABCA deployment entirely, delete the webhook in Linear, delete the `LinearIntegration` construct from the stack, and redeploy. +To remove the Linear integration from your ABCA deployment entirely, delete the webhook in Linear, uninstall the OAuth app, run the `delete-oauth2-credential-provider` for each workspace, then delete the `LinearIntegration` construct from the stack and redeploy. diff --git a/docs/src/content/docs/using/Roles.md b/docs/src/content/docs/using/Roles.md new file mode 100644 index 00000000..9471b8d0 --- /dev/null +++ b/docs/src/content/docs/using/Roles.md @@ -0,0 +1,18 @@ +--- +title: Roles +--- + +ABCA is a **shared-stack-per-organization** platform — one CDK deployment, used by everyone on the team. Like a self-hosted GitLab or Linear instance: one company → one stack → many users. You generally do **not** run your own deployment to use someone else's; you join theirs as a Cognito user. + +There are four lifecycle roles. They are often the same person early on, but the operations they perform are distinct: + +| Role | What they do | Frequency | +|------|--------------|-----------| +| **Stack admin** | `cdk deploy` the stack; rotates platform-level secrets; runs `bgagent admin invite-user` to onboard teammates | Once + occasional | +| **Linear / Slack workspace admin** | Runs `bgagent linear setup` (or `bgagent slack setup`) once per workspace to install the OAuth app | One-time per workspace | +| **Repo onboarder** | Runs `bgagent linear onboard-project` (or registers a Blueprint via CDK) to wire a repo into the platform | As needed; any authenticated user | +| **Teammate** | Runs `bgagent configure` once + `bgagent submit` / Linear-label / Slack mention from then on | Daily user | + +If you're a teammate joining an existing deployment, jump to [Joining an existing deployment](#joining-an-existing-deployment) below. + +If you're standing up a new deployment from scratch, see the [Developer guide](/developer-guide/introduction) first, then come back here for the [admin onboarding flow](#get-stack-outputs). \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 814102fe..62992cfb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -313,6 +313,101 @@ "@smithy/util-utf8" "^2.0.0" tslib "^2.6.2" +"@aws-sdk/client-bedrock-agentcore-control@3.1024.0": + version "3.1024.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-bedrock-agentcore-control/-/client-bedrock-agentcore-control-3.1024.0.tgz#7be5af704b906174c423f26a789a20138c70ae75" + integrity sha512-gpLZoS7pKWqvPGGvrR14VpZX10BVTSRPkIrIahYuZ1tZrPx0k+zZoDzcrOh6KyGgDPi9bIAA1LXgmkLSo9B53g== + dependencies: + "@aws-crypto/sha256-browser" "5.2.0" + "@aws-crypto/sha256-js" "5.2.0" + "@aws-sdk/core" "^3.973.26" + "@aws-sdk/credential-provider-node" "^3.972.29" + "@aws-sdk/middleware-host-header" "^3.972.8" + "@aws-sdk/middleware-logger" "^3.972.8" + "@aws-sdk/middleware-recursion-detection" "^3.972.9" + "@aws-sdk/middleware-user-agent" "^3.972.28" + "@aws-sdk/region-config-resolver" "^3.972.10" + "@aws-sdk/types" "^3.973.6" + "@aws-sdk/util-endpoints" "^3.996.5" + "@aws-sdk/util-user-agent-browser" "^3.972.8" + "@aws-sdk/util-user-agent-node" "^3.973.14" + "@smithy/config-resolver" "^4.4.13" + "@smithy/core" "^3.23.13" + "@smithy/fetch-http-handler" "^5.3.15" + "@smithy/hash-node" "^4.2.12" + "@smithy/invalid-dependency" "^4.2.12" + "@smithy/middleware-content-length" "^4.2.12" + "@smithy/middleware-endpoint" "^4.4.28" + "@smithy/middleware-retry" "^4.4.46" + "@smithy/middleware-serde" "^4.2.16" + "@smithy/middleware-stack" "^4.2.12" + "@smithy/node-config-provider" "^4.3.12" + "@smithy/node-http-handler" "^4.5.1" + "@smithy/protocol-http" "^5.3.12" + "@smithy/smithy-client" "^4.12.8" + "@smithy/types" "^4.13.1" + "@smithy/url-parser" "^4.2.12" + "@smithy/util-base64" "^4.3.2" + "@smithy/util-body-length-browser" "^4.2.2" + "@smithy/util-body-length-node" "^4.2.3" + "@smithy/util-defaults-mode-browser" "^4.3.44" + "@smithy/util-defaults-mode-node" "^4.2.48" + "@smithy/util-endpoints" "^3.3.3" + "@smithy/util-middleware" "^4.2.12" + "@smithy/util-retry" "^4.2.13" + "@smithy/util-utf8" "^4.2.2" + "@smithy/util-waiter" "^4.2.14" + tslib "^2.6.2" + +"@aws-sdk/client-bedrock-agentcore@3.1024.0": + version "3.1024.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-bedrock-agentcore/-/client-bedrock-agentcore-3.1024.0.tgz#e906821d52c75fccbe9d33331861c7e73dec318c" + integrity sha512-vcC8SrXYHurvk15ahOiEZpgBj4ncRO4M6GCx+BtdK1CU9kHq5C9daoR6BHc7ZOGfuCAYr/I6J6qWXnKzzxMIpw== + dependencies: + "@aws-crypto/sha256-browser" "5.2.0" + "@aws-crypto/sha256-js" "5.2.0" + "@aws-sdk/core" "^3.973.26" + "@aws-sdk/credential-provider-node" "^3.972.29" + "@aws-sdk/middleware-host-header" "^3.972.8" + "@aws-sdk/middleware-logger" "^3.972.8" + "@aws-sdk/middleware-recursion-detection" "^3.972.9" + "@aws-sdk/middleware-user-agent" "^3.972.28" + "@aws-sdk/region-config-resolver" "^3.972.10" + "@aws-sdk/types" "^3.973.6" + "@aws-sdk/util-endpoints" "^3.996.5" + "@aws-sdk/util-user-agent-browser" "^3.972.8" + "@aws-sdk/util-user-agent-node" "^3.973.14" + "@smithy/config-resolver" "^4.4.13" + "@smithy/core" "^3.23.13" + "@smithy/eventstream-serde-browser" "^4.2.12" + "@smithy/eventstream-serde-config-resolver" "^4.3.12" + "@smithy/eventstream-serde-node" "^4.2.12" + "@smithy/fetch-http-handler" "^5.3.15" + "@smithy/hash-node" "^4.2.12" + "@smithy/invalid-dependency" "^4.2.12" + "@smithy/middleware-content-length" "^4.2.12" + "@smithy/middleware-endpoint" "^4.4.28" + "@smithy/middleware-retry" "^4.4.46" + "@smithy/middleware-serde" "^4.2.16" + "@smithy/middleware-stack" "^4.2.12" + "@smithy/node-config-provider" "^4.3.12" + "@smithy/node-http-handler" "^4.5.1" + "@smithy/protocol-http" "^5.3.12" + "@smithy/smithy-client" "^4.12.8" + "@smithy/types" "^4.13.1" + "@smithy/url-parser" "^4.2.12" + "@smithy/util-base64" "^4.3.2" + "@smithy/util-body-length-browser" "^4.2.2" + "@smithy/util-body-length-node" "^4.2.3" + "@smithy/util-defaults-mode-browser" "^4.3.44" + "@smithy/util-defaults-mode-node" "^4.2.48" + "@smithy/util-endpoints" "^3.3.3" + "@smithy/util-middleware" "^4.2.12" + "@smithy/util-retry" "^4.2.13" + "@smithy/util-stream" "^4.5.21" + "@smithy/util-utf8" "^4.2.2" + tslib "^2.6.2" + "@aws-sdk/client-bedrock-agentcore@^3.1046.0": version "3.1047.0" resolved "https://registry.yarnpkg.com/@aws-sdk/client-bedrock-agentcore/-/client-bedrock-agentcore-3.1047.0.tgz#c39bb3c9185d538d6f2e955e061bff4104031b19" @@ -436,7 +531,7 @@ "@smithy/util-waiter" "^4.2.14" tslib "^2.6.2" -"@aws-sdk/client-cognito-identity-provider@^3.1021.0": +"@aws-sdk/client-cognito-identity-provider@3.1024.0": version "3.1024.0" resolved "https://registry.yarnpkg.com/@aws-sdk/client-cognito-identity-provider/-/client-cognito-identity-provider-3.1024.0.tgz#9a9c02214d8483e7585daff0eabcb2bb5f0babe0" integrity sha512-rWDcqb3Z5x8704l4/zmSIsYtjcws5ugxt8e9/3uZLW5c/MkYZxNuFgRSbvsmdGzl4ZGqQdPFBIhMjGjV5g7noQ== @@ -10564,7 +10659,16 @@ string-length@^4.0.2: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -10622,7 +10726,14 @@ stringify-entities@^4.0.0: character-entities-html4 "^2.0.0" character-entities-legacy "^3.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -11429,7 +11540,16 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==