Skip to content

Commit 67db4e8

Browse files
authored
Merge branch 'main' into feat/eslint-10-upgrade
2 parents 0382d47 + 08b91bf commit 67db4e8

47 files changed

Lines changed: 4800 additions & 456 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/security.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@ jobs:
6262

6363
- name: Run security suite
6464
id: scan
65-
run: mise run security 2>&1 | tee security-log.txt
65+
run: |
66+
set -o pipefail
67+
mise run security 2>&1 | tee security-log.txt
6668
continue-on-error: true
6769

6870
- name: Open issue on failure

agent/pyproject.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ description = "Background coding agent — runs tasks in isolated cloud environm
55
requires-python = ">=3.13"
66
dependencies = [
77
"boto3==1.43.9", #https://pypi.org/project/boto3/
8+
# Vestigial from the parked AgentCore Identity flow (Phase 2.0a).
9+
# Phase 2.0b reads per-workspace Linear OAuth tokens directly from
10+
# Secrets Manager because AgentCore Identity's USER_FEDERATION
11+
# flow has an open service-side bug (see memory/project_oauth_2_0b.md).
12+
# Kept here so the workload-token bridge in `server.py` still
13+
# imports cleanly when Phase 2.0c eventually resumes the
14+
# AgentCore Identity path. The bridge is now wrapped in
15+
# try/except (ImportError, AttributeError), so removing this dep
16+
# would degrade gracefully — but for now we keep the dep to
17+
# preserve the clean code path.
18+
"bedrock-agentcore==1.9.1", #https://pypi.org/project/bedrock-agentcore/
819
"claude-agent-sdk==0.2.82", #https://github.com/anthropics/claude-agent-sdk-python/releases/tag/v0.2.82
920
"requests==2.34.2", #https://pypi.org/project/requests/
1021
"fastapi==0.136.1", #https://pypi.org/project/fastapi/

agent/src/config.py

Lines changed: 254 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import os
44
import sys
55
import uuid
6+
from datetime import UTC
67

78
from models import AttachmentConfig, TaskConfig, TaskType
89
from shell import log
@@ -38,58 +39,279 @@ def resolve_github_token() -> str:
3839
return ""
3940

4041

41-
def resolve_linear_api_token() -> str:
42-
"""Resolve the Linear personal API token from Secrets Manager or env.
42+
def resolve_linear_api_token(channel_metadata: dict[str, str] | None = None) -> str:
43+
"""Resolve the Linear OAuth access token from Secrets Manager.
4344
44-
Mirrors ``resolve_github_token``: in deployed mode
45-
``LINEAR_API_TOKEN_SECRET_ARN`` is set and the token is fetched once
46-
and cached in ``LINEAR_API_TOKEN``. For local development, falls back
47-
to ``LINEAR_API_TOKEN`` directly.
45+
Phase 2.0b-O2: the orchestrator stamps ``linear_oauth_secret_arn``
46+
into the task record's ``channel_metadata`` at task-creation time.
47+
Pass that dict in via ``channel_metadata`` (the pipeline does this
48+
automatically). We fetch the per-workspace secret, parse the token
49+
JSON, refresh if expiring, and cache the access_token in
50+
``LINEAR_API_TOKEN`` so downstream consumers (the Linear MCP's
51+
``${LINEAR_API_TOKEN}`` placeholder in ``.mcp.json`` and
52+
``linear_reactions.py``'s GraphQL Authorization header) keep working
53+
unchanged.
4854
49-
Returns an empty string if the secret is absent or empty — the agent-side
50-
MCP config then renders with an unresolved ``${LINEAR_API_TOKEN}`` env
51-
placeholder, and the Linear MCP will reject the request (fail-closed).
52-
This function is only called when ``channel_source == 'linear'``.
55+
For local development, a pre-set ``LINEAR_API_TOKEN`` env var
56+
short-circuits the lookup so the agent can run outside the runtime.
57+
58+
Returns an empty string when the credential is absent — the agent-side
59+
MCP config then renders with an unresolved ``${LINEAR_API_TOKEN}``
60+
placeholder and the Linear MCP fails closed. This function is only
61+
called when ``channel_source == 'linear'``.
62+
63+
Phase 2.0a (parked) used AgentCore Identity. Phase 2.0b-O2 reads
64+
Secrets Manager directly because AgentCore Identity's USER_FEDERATION
65+
flow has an open service-side bug (see memory/project_oauth_2_0b.md).
5366
"""
5467
cached = os.environ.get("LINEAR_API_TOKEN", "")
5568
if cached:
5669
return cached
57-
secret_arn = os.environ.get("LINEAR_API_TOKEN_SECRET_ARN")
70+
71+
# Prefer the per-task channel_metadata; fall back to env var so the
72+
# function can be called early (e.g. before pipeline construction)
73+
# via LINEAR_OAUTH_SECRET_ARN if the orchestrator set it that way.
74+
secret_arn = ""
75+
if channel_metadata:
76+
secret_arn = channel_metadata.get("linear_oauth_secret_arn", "")
77+
if not secret_arn:
78+
secret_arn = os.environ.get("LINEAR_OAUTH_SECRET_ARN", "")
5879
if not secret_arn:
5980
return ""
81+
82+
region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
83+
if not region:
84+
log("WARN", "resolve_linear_api_token: AWS_REGION not set; cannot resolve token")
85+
return ""
86+
6087
try:
88+
import json
89+
from datetime import datetime, timedelta
90+
6191
import boto3
6292
from botocore.exceptions import BotoCoreError, ClientError
6393
except ImportError as e:
64-
# boto3 missing from the container image — degrade gracefully rather
65-
# than hard-crashing the agent. The Linear MCP will fail on first
66-
# call with a clear auth error.
6794
log("WARN", f"resolve_linear_api_token: boto3 unavailable ({e}); skipping")
6895
return ""
6996

97+
sm = boto3.client("secretsmanager", region_name=region)
98+
99+
def _fetch_token() -> dict | None:
100+
"""Fetch + parse the per-workspace OAuth secret.
101+
102+
Returns the parsed dict, or None if the SM payload can't be
103+
decoded as JSON (corrupted byte, missing SecretString key,
104+
etc.). The caller treats None like a missing secret — agent
105+
proceeds without Linear MCP rather than crashing the task
106+
pipeline thread on a raw traceback.
107+
"""
108+
resp = sm.get_secret_value(SecretId=secret_arn)
109+
try:
110+
return json.loads(resp["SecretString"])
111+
except (json.JSONDecodeError, KeyError, TypeError) as e:
112+
log(
113+
"ERROR",
114+
f"resolve_linear_api_token: secret '{secret_arn}' is not valid JSON "
115+
f"({type(e).__name__}: {e}); workspace requires re-onboarding",
116+
)
117+
return None
118+
119+
def _is_expiring(expires_at_iso: str, threshold_seconds: int = 60) -> bool:
120+
try:
121+
expiry = datetime.fromisoformat(expires_at_iso.replace("Z", "+00:00"))
122+
except ValueError:
123+
# Malformed timestamp: treat as expiring so the refresh path runs.
124+
# Log so a bad write earlier in the chain doesn't silently trigger
125+
# a refresh on every single task with no diagnostic trace.
126+
log(
127+
"WARN",
128+
f"_is_expiring: malformed expires_at '{expires_at_iso}'; treating as expiring",
129+
)
130+
return True
131+
return (expiry - datetime.now(UTC)).total_seconds() < threshold_seconds
132+
133+
def _try_refresh_once(current: dict) -> tuple[str, dict | None]:
134+
"""Single Linear /oauth/token POST.
135+
136+
Returns one of:
137+
- ("success", new_token_dict)
138+
- ("invalid_grant", None) — Linear rejected the refresh_token,
139+
usually because another caller rotated it first
140+
- ("failure", None) — any other error (network, 5xx, missing
141+
fields). No retry; surface upward.
142+
"""
143+
try:
144+
import urllib.error
145+
import urllib.parse
146+
import urllib.request
147+
except ImportError:
148+
return ("failure", None)
149+
150+
body = urllib.parse.urlencode(
151+
{
152+
"grant_type": "refresh_token",
153+
"refresh_token": current["refresh_token"],
154+
"client_id": current["client_id"],
155+
"client_secret": current["client_secret"],
156+
}
157+
).encode("utf-8")
158+
req = urllib.request.Request(
159+
"https://api.linear.app/oauth/token",
160+
data=body,
161+
headers={"Content-Type": "application/x-www-form-urlencoded"},
162+
method="POST",
163+
)
164+
try:
165+
with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310
166+
payload = json.loads(resp.read().decode("utf-8"))
167+
except urllib.error.HTTPError as e:
168+
# Body may carry `{"error": "invalid_grant", ...}` even on 400.
169+
err_code = None
170+
try:
171+
err_payload = json.loads(e.read().decode("utf-8"))
172+
err_code = err_payload.get("error")
173+
except (json.JSONDecodeError, UnicodeDecodeError, AttributeError):
174+
# Body wasn't JSON or wasn't readable — caller will see
175+
# status code only, no error code.
176+
pass
177+
log(
178+
"WARN",
179+
f"resolve_linear_api_token refresh rejected: status={e.code} error={err_code}",
180+
)
181+
if err_code == "invalid_grant":
182+
return ("invalid_grant", None)
183+
return ("failure", None)
184+
except (urllib.error.URLError, OSError) as e:
185+
# Genuine network failures (DNS, timeout, TCP reset). Other
186+
# exceptions (KeyError on missing field, TypeError on bad
187+
# JSON shape) are programmer errors and should propagate
188+
# with a clear stack trace rather than being swallowed.
189+
log("WARN", f"resolve_linear_api_token refresh failed: {type(e).__name__}: {e}")
190+
return ("failure", None)
191+
192+
if "access_token" not in payload:
193+
return ("failure", None)
194+
195+
now = datetime.now(UTC)
196+
# Linear's `expires_in` is documented and reliably sent; if it's
197+
# missing we assume the access token is already valid for as long
198+
# as the refresh-token call took to round-trip — set expiry to now.
199+
if "expires_in" in payload:
200+
future = now + timedelta(seconds=int(payload["expires_in"]))
201+
expires_at_iso = future.replace(microsecond=0).isoformat().replace("+00:00", "Z")
202+
else:
203+
expires_at_iso = now.replace(microsecond=0).isoformat().replace("+00:00", "Z")
204+
next_token = {
205+
**current,
206+
"access_token": payload["access_token"],
207+
"refresh_token": payload.get("refresh_token", current["refresh_token"]),
208+
"expires_at": expires_at_iso,
209+
"scope": payload.get("scope", current["scope"]),
210+
"updated_at": now.isoformat().replace("+00:00", "Z"),
211+
}
212+
213+
# Phase 2.0b-O2 review item S1: agent runtime no longer has
214+
# `secretsmanager:PutSecretValue` on the OAuth secret prefix —
215+
# the agent executes untrusted repo code, and writing tokens
216+
# back means a compromised agent could overwrite any
217+
# workspace's token. Lambdas (trusted code) handle persistence.
218+
# The freshly-refreshed in-memory token still works for THIS
219+
# task; the rotated refresh_token is lost when the agent exits,
220+
# but Linear's grace window (~30 min on replays) absorbs that
221+
# for the rare case where this agent refreshed strictly before
222+
# any Lambda did.
223+
224+
# Positive-path log so operators diagnosing intermittent 401s have
225+
# a breadcrumb showing which workspace refreshed and to what expiry.
226+
ws_id = next_token.get("workspace_id", "?")
227+
ws_slug = next_token.get("workspace_slug", "?")
228+
log(
229+
"INFO",
230+
f"linear_oauth_refresh_ok workspace_id={ws_id} "
231+
f"workspace_slug={ws_slug} new_expires_at={expires_at_iso}",
232+
)
233+
return ("success", next_token)
234+
235+
def _refresh(current: dict) -> dict | None:
236+
"""Refresh with one retry on invalid_grant after re-reading the secret.
237+
238+
Linear rotates refresh_tokens on every use. Concurrent callers
239+
(Lambda + agent + CLI) racing the same secret will see one
240+
succeed and the rest get `invalid_grant`. On invalid_grant,
241+
re-read SM (bypassing the just-failed token) and retry once if
242+
the refresh_token actually changed.
243+
"""
244+
kind, refreshed = _try_refresh_once(current)
245+
if kind == "success":
246+
return refreshed
247+
if kind == "failure":
248+
return None
249+
250+
# invalid_grant: maybe a concurrent caller refreshed first.
251+
log(
252+
"WARN",
253+
"resolve_linear_api_token: invalid_grant — re-reading secret to check "
254+
"for concurrent refresh",
255+
)
256+
try:
257+
fresh = _fetch_token()
258+
except (ClientError, BotoCoreError) as e:
259+
log("WARN", f"resolve_linear_api_token: re-read after invalid_grant failed: {e}")
260+
return None
261+
if fresh is None:
262+
# Secret is unreadable (corrupted JSON). Already logged inside
263+
# _fetch_token; no point retrying refresh against bad data.
264+
return None
265+
266+
if fresh.get("refresh_token") == current.get("refresh_token"):
267+
# No race — Linear truly rejected this refresh_token.
268+
log(
269+
"ERROR",
270+
"resolve_linear_api_token: refresh_token permanently rejected; re-onboard required",
271+
)
272+
return None
273+
274+
# Concurrent caller rotated the token. If the freshly-read value
275+
# is itself usable, just take it.
276+
if not _is_expiring(fresh.get("expires_at", "")):
277+
log(
278+
"INFO",
279+
"resolve_linear_api_token: concurrent refresh detected; using freshly-read token",
280+
)
281+
return fresh
282+
283+
# Concurrent refresh produced a token that's also already
284+
# expiring (rare). Retry once with the new refresh_token.
285+
kind2, refreshed2 = _try_refresh_once(fresh)
286+
if kind2 == "success":
287+
return refreshed2
288+
return None
289+
70290
try:
71-
region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
72-
client = boto3.client("secretsmanager", region_name=region)
73-
resp = client.get_secret_value(SecretId=secret_arn)
74-
token = resp.get("SecretString", "") or ""
75-
if token:
76-
os.environ["LINEAR_API_TOKEN"] = token
77-
return token
78-
except ClientError as e:
79-
# Narrowed from a broader `except` per #63 review — broader catches
80-
# hid genuine bugs in the Secrets Manager call shape. AccessDenied
81-
# is logged at ERROR because it's a persistent IAM misconfig that
82-
# should page someone, not a transient blip.
83-
code = e.response.get("Error", {}).get("Code", "")
84-
severity = "ERROR" if code == "AccessDeniedException" else "WARN"
291+
token_obj = _fetch_token()
292+
except (ClientError, BotoCoreError) as e:
293+
code = ""
294+
if hasattr(e, "response"):
295+
code = getattr(e, "response", {}).get("Error", {}).get("Code", "") or ""
296+
is_hard_failure = code in ("AccessDeniedException", "ResourceNotFoundException")
297+
severity = "ERROR" if is_hard_failure else "WARN"
85298
log(severity, f"resolve_linear_api_token failed: {type(e).__name__}: {e}")
86299
return ""
87-
except BotoCoreError as e:
88-
# Never let a Secrets Manager outage crash the agent. The Linear MCP
89-
# will simply fail on first call with a clear auth error.
90-
log("WARN", f"resolve_linear_api_token failed: {type(e).__name__}: {e}")
300+
if token_obj is None:
301+
# Corrupted secret JSON; already logged inside _fetch_token.
302+
# Fail closed — Linear MCP renders with unresolved placeholder.
91303
return ""
92304

305+
if _is_expiring(token_obj.get("expires_at", "")):
306+
refreshed = _refresh(token_obj)
307+
if refreshed:
308+
token_obj = refreshed
309+
310+
access = token_obj.get("access_token", "")
311+
if access:
312+
os.environ["LINEAR_API_TOKEN"] = access
313+
return access
314+
93315

94316
def build_config(
95317
repo_url: str,

agent/src/pipeline.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None:
456456
# writing .mcp.json so the child SDK process inherits the env var
457457
# that the MCP server entry references via ${LINEAR_API_TOKEN}.
458458
if config.channel_source == "linear":
459-
resolve_linear_api_token()
459+
resolve_linear_api_token(config.channel_metadata)
460460
configure_channel_mcp(setup.repo_dir, config.channel_source)
461461

462462
# 👀 on the Linear issue — acknowledges the task is picked up.

0 commit comments

Comments
 (0)