Skip to content

Commit 41cf322

Browse files
authored
Merge branch 'main' into fix/353-agentcore-supported-azs
2 parents 09e782e + 86fda64 commit 41cf322

53 files changed

Lines changed: 7719 additions & 49 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/auto-approve.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,5 @@ jobs:
4545
steps:
4646
- uses: hmarr/auto-approve-action@f0939ea97e9205ef24d872e76833fa908a770363 # v4.0.0
4747
with:
48-
github-token: ${{ secrets.GITHUB_TOKEN }}
48+
github-token: ${{ secrets.AUTOMATION_GITHUB_TOKEN }}
4949
review-message: Auto approved automated PR

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
## What is ABCA
2121

22-
**ABCA (Autonomous Background Coding Agents on AWS)** is a sample of what a self-hosted background coding agents platform might look like on AWS. You submit a coding task (via Slack, Linear, CLI, or webhook), walk away, and come back to a ready-to-review PR. The agent clones the repo, writes code, runs tests, and opens the PR autonomously in an isolated cloud environment. No babysitting, no IDE sessions, no back-and-forth.
22+
**ABCA (Autonomous Background Coding Agents on AWS)** is a sample of what a self-hosted background coding agents platform might look like on AWS. You submit a coding task (via Slack, Linear, Jira, CLI, or webhook), walk away, and come back to a ready-to-review PR. The agent clones the repo, writes code, runs tests, and opens the PR autonomously in an isolated cloud environment. No babysitting, no IDE sessions, no back-and-forth.
2323

2424
## Why it matters
2525

@@ -31,7 +31,7 @@
3131

3232
## The Use Case
3333

34-
Users submit tasks through webhooks, CLI, Slack,... For each task, the orchestrator executes the blueprint: an isolated environment is provisioned, an agent clones the target GitHub repository, creates a branch, works on the task, and opens a pull request.
34+
Users submit tasks through webhooks, CLI, Slack, Linear, Jira,... For each task, the orchestrator executes the blueprint: an isolated environment is provisioned, an agent clones the target GitHub repository, creates a branch, works on the task, and opens a pull request.
3535

3636
Key characteristics:
3737

agent/src/channel_mcp.py

Lines changed: 96 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,46 @@
11
"""Channel-specific MCP configuration for the agent container.
22
3-
For Linear-origin tasks we write (or merge into) ``.mcp.json`` in the cloned
4-
repo ``cwd`` so the Claude Agent SDK — configured with
5-
``setting_sources=["project"]`` — picks up the Linear MCP at session start
6-
and exposes ``mcp__linear-server__*`` tools.
3+
For inbound channel sources that have a hosted MCP we write (or merge into)
4+
``.mcp.json`` in the cloned repo ``cwd`` so the Claude Agent SDK — configured
5+
with ``setting_sources=["project"]`` — picks up the channel MCP at session
6+
start and exposes the server's tools.
7+
8+
Currently wired channels:
9+
- ``linear`` → Linear hosted MCP (``mcp__linear-server__*`` tools) — functional.
10+
- ``jira`` → Atlassian Remote MCP entry — a NON-FUNCTIONAL placeholder. It
11+
is written for forward-compatibility but cannot connect from a headless
12+
agent (interactive OAuth 2.1 only); live outbound Jira comments go through
13+
the REST shim in ``jira_reactions.py``. See ``JIRA_MCP_URL`` below + ADR-015.
714
815
For all other channel sources this is a no-op: no MCP is written, and the
9-
SDK sees no Linear tools. That's the gate keeping Slack/API/webhook tasks
10-
from touching Linear.
16+
SDK sees no channel-specific tools.
1117
12-
See: cdk/src/handlers/linear-webhook-processor.ts (inbound), runner.py
13-
(SDK invocation), plans at ~/.claude/plans/linear-mcp-findings.md.
18+
See: cdk/src/handlers/{linear,jira}-webhook-processor.ts (inbound),
19+
runner.py (SDK invocation).
1420
"""
1521

1622
from __future__ import annotations
1723

1824
import json
1925
import os
20-
from typing import Any
26+
from typing import TYPE_CHECKING, Any
2127

2228
from shell import log
2329

30+
if TYPE_CHECKING:
31+
from collections.abc import Callable
32+
33+
# ─── Linear ──────────────────────────────────────────────────────────────────
34+
2435
#: Linear MCP endpoint — hosted by Linear, Streamable HTTP transport.
2536
LINEAR_MCP_URL = "https://mcp.linear.app/mcp"
2637

2738
#: Key name inside ``mcpServers``. Tools surface as
28-
#: ``mcp__linear-server__*`` in the Agent SDK (verified in findings).
39+
#: ``mcp__linear-server__*`` in the Agent SDK.
2940
LINEAR_MCP_SERVER_KEY = "linear-server"
3041

3142
#: Env var name the MCP server entry reads via ``${LINEAR_API_TOKEN}``
32-
#: placeholder expansion. Populated from ``LinearApiTokenSecret`` by run.sh.
43+
#: placeholder expansion. Populated from the OAuth secret by config.py.
3344
LINEAR_API_TOKEN_ENV = "LINEAR_API_TOKEN" # noqa: S105 — env var *name*, not a secret value
3445

3546

@@ -44,11 +55,62 @@ def _linear_server_entry() -> dict[str, Any]:
4455
}
4556

4657

58+
# ─── Jira (Atlassian Remote MCP — NON-FUNCTIONAL PLACEHOLDER) ────────────────
59+
60+
#: Atlassian Remote MCP endpoint — Streamable HTTP transport.
61+
#:
62+
#: IMPORTANT: this entry does NOT work from a headless agent and is retained
63+
#: only as a forward-looking placeholder. The hosted Atlassian MCP requires an
64+
#: interactive, browser-based OAuth 2.1 flow with dynamic client registration
65+
#: and will NOT accept the stored REST OAuth token as a Bearer header, so it
66+
#: fails to connect in the runtime (``claude mcp list`` → "Failed to connect").
67+
#:
68+
#: The LIVE outbound path is the REST shim in ``agent/src/jira_reactions.py``
69+
#: (the "Plan B" that became Plan A), which posts comments via the Jira REST
70+
#: v3 API using the same stored OAuth token. See ADR-015 and
71+
#: ``agent/src/prompt_builder.py``. If Atlassian ever ships a token-compatible
72+
#: MCP, this entry can be promoted and the REST shim retired.
73+
JIRA_MCP_URL = "https://mcp.atlassian.com/v1/sse"
74+
75+
#: Key name inside ``mcpServers``. Tools surface as ``mcp__jira-server__*``
76+
#: in the Agent SDK. If this changes the agent prompt's channel addendum
77+
#: must be updated in lockstep.
78+
JIRA_MCP_SERVER_KEY = "jira-server"
79+
80+
#: Env var name the Jira MCP server entry reads via ``${JIRA_API_TOKEN}``
81+
#: placeholder expansion. Populated from the per-tenant OAuth secret by
82+
#: config.resolve_jira_oauth_token.
83+
JIRA_API_TOKEN_ENV = "JIRA_API_TOKEN" # noqa: S105 — env var *name*, not a secret value
84+
85+
86+
def _jira_server_entry() -> dict[str, Any]:
87+
"""Build the `mcpServers` entry for Atlassian's Remote MCP."""
88+
return {
89+
"type": "http",
90+
"url": JIRA_MCP_URL,
91+
"headers": {
92+
"Authorization": f"Bearer ${{{JIRA_API_TOKEN_ENV}}}",
93+
},
94+
}
95+
96+
97+
# ─── Dispatch ────────────────────────────────────────────────────────────────
98+
99+
#: Per-channel ``mcpServers`` entry builder. The channel_source values mirror
100+
#: ``ChannelSource`` in cdk/src/handlers/shared/types.ts. Sources that don't
101+
#: have a hosted MCP (api, webhook, slack) intentionally have no entry here —
102+
#: the gate in ``configure_channel_mcp`` short-circuits on missing keys.
103+
CHANNEL_MCP_BUILDERS: dict[str, tuple[str, Callable[[], dict[str, Any]]]] = {
104+
"linear": (LINEAR_MCP_SERVER_KEY, _linear_server_entry),
105+
"jira": (JIRA_MCP_SERVER_KEY, _jira_server_entry),
106+
}
107+
108+
47109
def _read_existing_mcp_config(path: str) -> dict[str, Any]:
48110
"""Return the parsed .mcp.json at ``path``, or an empty dict if absent/invalid.
49111
50112
Malformed JSON is logged and treated as absent — we prefer to overlay a
51-
valid Linear entry than to crash the agent because a user committed a
113+
valid channel entry than to crash the agent because a user committed a
52114
broken .mcp.json to their repo.
53115
"""
54116
if not os.path.isfile(path):
@@ -67,23 +129,26 @@ def _read_existing_mcp_config(path: str) -> dict[str, Any]:
67129
def configure_channel_mcp(repo_dir: str, channel_source: str) -> bool:
68130
"""Write or merge a channel-specific ``.mcp.json`` into ``repo_dir``.
69131
70-
Gated on ``channel_source``:
71-
* ``'linear'`` → ensure the ``linear-server`` entry is present in
132+
Looks up ``channel_source`` in :data:`CHANNEL_MCP_BUILDERS`:
133+
* present → ensure the corresponding ``mcpServers`` entry is in
72134
``.mcp.json`` (merges into any existing config without clobbering
73135
other servers). Returns True.
74-
* anything else → no-op. Returns False.
136+
* absent → no-op. Returns False.
75137
76138
Args:
77139
repo_dir: the cloned-repo working directory the SDK will use as ``cwd``.
78140
channel_source: inbound channel (``TaskConfig.channel_source``).
79141
80142
Returns:
81-
True if a Linear MCP entry was (re)written into ``repo_dir/.mcp.json``,
82-
False otherwise (including any non-Linear channel or missing repo_dir).
143+
True if a channel MCP entry was (re)written, False otherwise (channel
144+
unmapped, missing repo_dir, or write failure).
83145
"""
84-
if channel_source != "linear":
146+
builder_entry = CHANNEL_MCP_BUILDERS.get(channel_source)
147+
if builder_entry is None:
85148
return False
86149

150+
server_key, build_entry = builder_entry
151+
87152
if not repo_dir or not os.path.isdir(repo_dir):
88153
log("WARN", f"configure_channel_mcp: repo_dir missing or not a directory: {repo_dir!r}")
89154
return False
@@ -94,19 +159,29 @@ def configure_channel_mcp(repo_dir: str, channel_source: str) -> bool:
94159
servers = config.get("mcpServers")
95160
if not isinstance(servers, dict):
96161
servers = {}
97-
servers[LINEAR_MCP_SERVER_KEY] = _linear_server_entry()
162+
servers[server_key] = build_entry()
98163
config["mcpServers"] = servers
99164

100165
try:
101166
with open(mcp_path, "w", encoding="utf-8") as f:
102167
json.dump(config, f, indent=2)
103168
f.write("\n")
104169
except OSError as e:
105-
log("ERROR", f"Failed to write Linear MCP config to {mcp_path}: {e}")
170+
log("ERROR", f"Failed to write {channel_source} MCP config to {mcp_path}: {e}")
106171
return False
107172

108173
log(
109174
"TASK",
110-
f"Linear MCP configured at {mcp_path} (server key: {LINEAR_MCP_SERVER_KEY})",
175+
f"{channel_source} MCP configured at {mcp_path} (server key: {server_key})",
111176
)
177+
if channel_source == "jira":
178+
# The Jira MCP entry is a non-functional placeholder (see JIRA_MCP_URL
179+
# docstring + ADR-015). Log it in-band so a "Failed to connect" line in
180+
# the agent logs isn't mistaken for the cause of a missing comment —
181+
# the live outbound path is the REST shim in jira_reactions.py.
182+
log(
183+
"TASK",
184+
"jira MCP entry is a placeholder and is EXPECTED to fail to connect; "
185+
"outbound Jira comments use the REST shim (jira_reactions.py), not MCP",
186+
)
112187
return True

agent/src/config.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,123 @@ def _refresh(current: dict) -> dict | None:
330330
return access
331331

332332

333+
def resolve_jira_oauth_token(channel_metadata: dict[str, str] | None = None) -> str:
334+
"""Resolve the Jira Cloud OAuth access token from Secrets Manager.
335+
336+
The orchestrator stamps ``jira_oauth_secret_arn`` into the task
337+
record's ``channel_metadata`` at task-creation time. We fetch the
338+
per-tenant secret, parse the token JSON, and cache the access_token in
339+
``JIRA_API_TOKEN`` so the agent-side Jira REST calls
340+
(``jira_reactions``) can authorize.
341+
342+
**The agent never refreshes the token.** Unlike Linear, Atlassian
343+
*rotates the refresh_token on every use* — a successful refresh
344+
invalidates the stored refresh_token and returns a new one. The agent
345+
runtime has ``secretsmanager:GetSecretValue`` ONLY (no ``PutSecretValue``;
346+
a compromised agent must not be able to overwrite any tenant's OAuth
347+
bundle), so it cannot persist the rotated token. If the agent refreshed,
348+
it would consume the stored refresh_token, keep the replacement only in
349+
memory for this one task, and leave Secrets Manager holding a dead
350+
refresh_token — the next Lambda/agent resolve would get ``invalid_grant``
351+
and the tenant would require re-onboarding. So we deliberately do NOT
352+
refresh here: the trusted Lambda path (``jira-oauth-resolver.ts``, which
353+
has ``PutSecretValue``) owns all refreshes, and the agent uses whatever
354+
access_token the Lambdas have most-recently written.
355+
356+
If the stored token is already expiring/expired, we fail closed — return
357+
an empty string and let the advisory Jira comments no-op. The
358+
orchestrator resolves (and refreshes) the token just before starting the
359+
session, so in practice the agent reads a freshly-written token with a
360+
full lifetime ahead of it.
361+
362+
For local development, a pre-set ``JIRA_API_TOKEN`` env var
363+
short-circuits the lookup so the agent can run outside the runtime.
364+
365+
This function is only called when ``channel_source == 'jira'``.
366+
"""
367+
cached = os.environ.get("JIRA_API_TOKEN", "")
368+
if cached:
369+
return cached
370+
371+
secret_arn = ""
372+
if channel_metadata:
373+
secret_arn = channel_metadata.get("jira_oauth_secret_arn", "")
374+
if not secret_arn:
375+
secret_arn = os.environ.get("JIRA_OAUTH_SECRET_ARN", "")
376+
if not secret_arn:
377+
return ""
378+
379+
region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
380+
if not region:
381+
log("WARN", "resolve_jira_oauth_token: AWS_REGION not set; cannot resolve token")
382+
return ""
383+
384+
try:
385+
import json
386+
from datetime import datetime
387+
388+
import boto3
389+
from botocore.exceptions import BotoCoreError, ClientError
390+
except ImportError as e:
391+
log("WARN", f"resolve_jira_oauth_token: boto3 unavailable ({e}); skipping")
392+
return ""
393+
394+
sm = boto3.client("secretsmanager", region_name=region)
395+
396+
def _fetch_token() -> dict | None:
397+
resp = sm.get_secret_value(SecretId=secret_arn)
398+
try:
399+
return json.loads(resp["SecretString"])
400+
except (json.JSONDecodeError, KeyError, TypeError) as e:
401+
log(
402+
"ERROR",
403+
f"resolve_jira_oauth_token: secret '{secret_arn}' is not valid JSON "
404+
f"({type(e).__name__}: {e}); tenant requires re-onboarding",
405+
)
406+
return None
407+
408+
def _is_expiring(expires_at_iso: str, threshold_seconds: int = 60) -> bool:
409+
try:
410+
expiry = datetime.fromisoformat(expires_at_iso.replace("Z", "+00:00"))
411+
except ValueError:
412+
log(
413+
"WARN",
414+
f"_is_expiring: malformed expires_at '{expires_at_iso}'; treating as expiring",
415+
)
416+
return True
417+
return (expiry - datetime.now(UTC)).total_seconds() < threshold_seconds
418+
419+
try:
420+
token_obj = _fetch_token()
421+
except (ClientError, BotoCoreError) as e:
422+
code = ""
423+
if hasattr(e, "response"):
424+
code = getattr(e, "response", {}).get("Error", {}).get("Code", "") or ""
425+
is_hard_failure = code in ("AccessDeniedException", "ResourceNotFoundException")
426+
severity = "ERROR" if is_hard_failure else "WARN"
427+
log(severity, f"resolve_jira_oauth_token failed: {type(e).__name__}: {e}")
428+
return ""
429+
if token_obj is None:
430+
return ""
431+
432+
# Fail closed if the stored token is expiring — the agent cannot refresh
433+
# without burning Atlassian's rotating refresh_token (see docstring). The
434+
# Lambda path owns refresh; advisory Jira comments simply no-op here.
435+
if _is_expiring(token_obj.get("expires_at", "")):
436+
log(
437+
"WARN",
438+
"resolve_jira_oauth_token: stored token is expiring and the agent does not "
439+
"refresh (Atlassian rotates refresh_tokens; agent lacks PutSecretValue). "
440+
"Failing closed — Jira comments will be skipped for this task.",
441+
)
442+
return ""
443+
444+
access = token_obj.get("access_token", "")
445+
if access:
446+
os.environ["JIRA_API_TOKEN"] = access
447+
return access
448+
449+
333450
def build_config(
334451
repo_url: str = "",
335452
task_description: str = "",

0 commit comments

Comments
 (0)