Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
9af796d
feat(linear): resolve API token via AgentCore Identity (Phase 2.0a)
May 15, 2026
f1e3724
docs(linear): use aws CLI for credential provider, not the agentcore …
May 18, 2026
1c6eed2
fix(linear): pass runtimeUserId so AgentCore injects WorkloadAccessToken
May 18, 2026
ed9d24b
fix(linear): two undocumented gotchas to make AgentCore Identity actu…
May 18, 2026
ef11b0c
feat(2.0b): foundation — workspace registry, admin invite-user, Linea…
May 19, 2026
9cfe807
docs(linear): rewrite setup guide for OAuth (2.0b)
May 19, 2026
677f323
feat(cli): workload-token retrieval helper for AgentCore Identity (2.…
May 19, 2026
f5fbb33
feat(cli): bgagent linear oauth-register-workspace (2.0b B2)
May 19, 2026
a8f6b24
feat(2.0b): CLI workload identity + localhost OAuth callback server (A3)
May 19, 2026
b40411f
feat(cli): bgagent linear setup OAuth dance orchestration (2.0b C2/C3)
May 19, 2026
0f75a25
fix(cdk): use full SDK v3 package name for AgentCore Identity custom …
May 19, 2026
e1acb7a
chore(2.0b): park diagnostic flags + tokenEndpointAuthMethods + force…
May 19, 2026
fb8ee7c
feat(2.0b-O2): direct Linear OAuth + per-workspace Secrets Manager
May 20, 2026
629fe14
fix(2.0b-O2): space-separated OAuth scopes + --no-actor-app diagnostic
May 20, 2026
8d3470d
feat(2.0b-O2): Wave C — migrate Lambdas + agent runtime to per-worksp…
May 20, 2026
6bc41e7
fix(orchestrator): bundle import.meta.url shim for durable-execution SDK
May 20, 2026
e259da1
fix(cli): switch OAuth callback to plain HTTP localhost
May 20, 2026
21ad80d
Merge branch 'main' into feat/agentcore-oauth-2-0b
isadeks May 20, 2026
13e18c8
fix(agent): ruff lint + format for OAuth refresh path
May 20, 2026
cd4ab8e
test(linear-feedback): rewrite tests against OAuth context signature
May 20, 2026
1e4788f
fix(cdk): align yarn.lock with upstream main + bump table count for O…
May 20, 2026
0b95e23
style: apply eslint --fix from CI's self-mutation guard
May 20, 2026
87718b2
Merge branch 'main' into feat/agentcore-oauth-2-0b
isadeks May 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions agent/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
160 changes: 127 additions & 33 deletions agent/src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import os
import sys
import uuid
from datetime import UTC

from models import TaskConfig, TaskType
from shell import log
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion agent/src/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
54 changes: 54 additions & 0 deletions agent/src/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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}",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}


Expand Down
Loading