Skip to content

Commit 6681645

Browse files
author
bgagent
committed
chore(project): address findings
1 parent d53675b commit 6681645

25 files changed

Lines changed: 570 additions & 185 deletions

agent/src/context.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@ def fetch_github_issue(repo_url: str, issue_number: str, token: str) -> GitHubIs
6161
comments = [
6262
IssueComment(
6363
id=int(c["id"]),
64-
author=c["user"]["login"],
64+
# GitHub returns "user": null for comments whose author
65+
# account was deleted ("ghost" comments) — an unguarded
66+
# c["user"]["login"] would abort the whole hydration.
67+
author=(c.get("user") or {}).get("login", "(deleted user)"),
6568
body=c["body"] or "",
6669
)
6770
for c in comments_resp.json()

agent/src/post_hooks.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,9 +164,15 @@ def _note_unpushed_commits(repo_dir: str, branch: str, config: TaskConfig) -> No
164164
latest work, so the reviewer must be told. Failure to post the comment is
165165
logged but not fatal — the WARN log line emitted by the caller is the
166166
fallback signal.
167+
168+
``check=False`` means ``run_cmd`` does NOT raise on a non-zero ``gh``
169+
exit, so the returncode is inspected explicitly — otherwise a failed
170+
``gh pr comment`` (missing scope, rate limit, not-a-PR) is a silent
171+
no-op and the reviewer never learns the PR is stale. The ``except``
172+
below only covers OS-level failures (gh missing, timeout).
167173
"""
168174
try:
169-
run_cmd(
175+
result = run_cmd(
170176
[
171177
"gh",
172178
"pr",
@@ -181,6 +187,15 @@ def _note_unpushed_commits(repo_dir: str, branch: str, config: TaskConfig) -> No
181187
cwd=repo_dir,
182188
check=False,
183189
)
190+
if result.returncode != 0:
191+
stderr_msg = result.stderr.strip()[:200] if result.stderr else "(no stderr)"
192+
log(
193+
"WARN",
194+
"Failed to post un-pushed-commits note "
195+
f"(gh exit {result.returncode}): {stderr_msg} — the PR does not "
196+
"reflect the agent's latest commits and the reviewer has NOT "
197+
"been notified.",
198+
)
184199
except Exception as e:
185200
log("WARN", f"Failed to post un-pushed-commits note: {type(e).__name__}: {e}")
186201

agent/tests/test_context.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,23 @@ def test_null_body_becomes_empty_string(self, monkeypatch):
106106

107107
assert issue.body == ""
108108

109+
def test_comment_by_deleted_account_does_not_crash(self, monkeypatch):
110+
# GitHub returns "user": null for comments whose author account was
111+
# deleted ("ghost" comments). An unguarded c["user"]["login"] raised
112+
# TypeError and aborted the whole issue hydration.
113+
payload = {"title": "Title", "body": "body", "number": 12, "comments": 2}
114+
comments = [
115+
{"id": 1, "user": None, "body": "comment from a deleted account"},
116+
{"id": 2, "user": {"login": "alice"}, "body": "still here"},
117+
]
118+
monkeypatch.setattr(context, "requests", _fake_requests(payload, comments))
119+
120+
issue = fetch_github_issue("owner/repo", "12", "tok")
121+
122+
assert issue.comments[0].author == "(deleted user)"
123+
assert issue.comments[0].body == "comment from a deleted account"
124+
assert issue.comments[1].author == "alice"
125+
109126

110127
def _fetched_issue(monkeypatch, *, title, body, number, comments=None):
111128
"""Fetch an issue from raw payloads, exercising the source sanitizer.

agent/tests/test_post_hooks.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,30 @@ def test_push_failure_posts_unpushed_note_and_returns_url(self, monkeypatch):
123123
note_cmd = run_cmd.cmd_for("note-unpushed-commits")
124124
assert "comment" in note_cmd
125125

126+
def test_failed_note_post_warns_loudly(self, monkeypatch):
127+
# check=False means run_cmd never raises on a non-zero gh exit, so
128+
# _note_unpushed_commits must inspect the returncode itself — a
129+
# failed `gh pr comment` (missing scope, rate limit) was previously
130+
# a silent no-op while the PR quietly went stale.
131+
monkeypatch.setattr(post_hooks, "ensure_pushed", lambda d, b: False)
132+
sub = _pr_view("https://github.com/o/r/pull/9")
133+
run_cmd = _RunCmdRecorder(returncodes={"note-unpushed-commits": 1})
134+
monkeypatch.setattr(post_hooks.subprocess, "run", sub)
135+
monkeypatch.setattr(post_hooks, "run_cmd", run_cmd)
136+
warns: list[str] = []
137+
monkeypatch.setattr(
138+
post_hooks, "log", lambda lvl, msg: warns.append(msg) if lvl == "WARN" else None
139+
)
140+
141+
url = post_hooks.ensure_pr(
142+
_config(), _setup(), build_passed=True, lint_passed=True, strategy="push_resolve"
143+
)
144+
145+
# The URL is still returned (PR exists), but the failure to notify
146+
# the reviewer is surfaced as a WARN naming the consequence.
147+
assert url == "https://github.com/o/r/pull/9"
148+
assert any("reviewer has NOT been notified" in w for w in warns)
149+
126150
def test_push_success_does_not_post_note(self, monkeypatch):
127151
monkeypatch.setattr(post_hooks, "ensure_pushed", lambda d, b: True)
128152
sub = _pr_view("https://github.com/o/r/pull/9")

cdk/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
"@typescript-eslint/eslint-plugin": "^8",
5656
"@typescript-eslint/parser": "^8",
5757
"aws-cdk": "^2",
58-
"esbuild": "^0.27.4",
58+
"esbuild": "^0.28.1",
5959
"eslint": "^10",
6060
"eslint-import-resolver-typescript": "^4",
6161
"eslint-plugin-import-x": "^4",

cdk/src/constructs/github-screenshot-integration.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import * as path from 'path';
2121
import { ArnFormat, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib';
2222
import * as apigw from 'aws-cdk-lib/aws-apigateway';
23+
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
2324
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
2425
import * as iam from 'aws-cdk-lib/aws-iam';
2526
import { Architecture, Runtime } from 'aws-cdk-lib/aws-lambda';
@@ -110,6 +111,11 @@ export class GitHubScreenshotIntegration extends Construct {
110111
/** Async processor Lambda (browser + S3 + PR comment). */
111112
public readonly webhookProcessorFn: lambda.NodejsFunction;
112113

114+
/** Fires when a failed async invocation lands in the processor DLQ —
115+
* mirrors ``FanOutConsumer.dlqDepthAlarm``: without it the queue is
116+
* "for operator inspection" that no operator is ever told to make. */
117+
public readonly processorDlqDepthAlarm: cloudwatch.Alarm;
118+
113119
constructor(scope: Construct, id: string, props: GitHubScreenshotIntegrationProps) {
114120
super(scope, id);
115121

@@ -185,6 +191,22 @@ export class GitHubScreenshotIntegration extends Construct {
185191
},
186192
]);
187193

194+
// Alarm on any record landing in the DLQ. The processor handler
195+
// swallows its own errors, so only init-time crashes reach this queue
196+
// — rare, but each one is a screenshot pipeline silently down. Same
197+
// threshold-1 shape as FanOutConsumer.dlqDepthAlarm.
198+
this.processorDlqDepthAlarm = new cloudwatch.Alarm(this, 'WebhookProcessorDlqDepthAlarm', {
199+
metric: processorDlq.metricApproximateNumberOfMessagesVisible({
200+
period: Duration.minutes(5),
201+
statistic: 'Maximum',
202+
}),
203+
threshold: 1,
204+
evaluationPeriods: 1,
205+
alarmDescription:
206+
'Screenshot webhook processor DLQ has failed async invocations — the screenshot pipeline is crashing before handling events',
207+
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
208+
});
209+
188210
this.screenshotBucket.bucket.grantPut(this.webhookProcessorFn);
189211
props.githubTokenSecret.grantRead(this.webhookProcessorFn);
190212

cdk/src/handlers/shared/validation.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@ export const MAX_MAX_TURNS = 500;
3939
/** Maximum allowed length for task_description. */
4040
export const MAX_TASK_DESCRIPTION_LENGTH = 10_000;
4141

42-
const REPO_PATTERN = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/;
42+
// Dots are legal inside segments (`vercel/next.js`) but a segment of ONLY
43+
// dots (`owner/..`, `./repo`) is a path token, not a repo name — the
44+
// lookaheads reject those so URL/key interpolation never sees `.`/`..`.
45+
const REPO_PATTERN = /^(?!\.+\/)[a-zA-Z0-9._-]+\/(?!\.+$)[a-zA-Z0-9._-]+$/;
4346
const IDEMPOTENCY_KEY_PATTERN = /^[a-zA-Z0-9_-]{1,128}$/;
4447
const WEBHOOK_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9 _-]{0,62}[a-zA-Z0-9]$/;
4548
// ULID format: 26 chars, Crockford Base32 alphabet (0-9, A-Z excluding I, L, O, U).

cdk/test/constructs/github-screenshot-integration.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,31 @@ describe('GitHubScreenshotIntegration construct', () => {
6565
});
6666
});
6767

68+
test('alarms on the first record landing in the processor DLQ', () => {
69+
// The processor handler swallows its own errors, so only init-time
70+
// crashes reach the queue — each one means the screenshot pipeline is
71+
// silently down. Without the alarm the queue is "for operator
72+
// inspection" that no operator is ever told to make.
73+
template.resourceCountIs('AWS::CloudWatch::Alarm', 1);
74+
template.hasResourceProperties('AWS::CloudWatch::Alarm', {
75+
MetricName: 'ApproximateNumberOfMessagesVisible',
76+
Namespace: 'AWS/SQS',
77+
Statistic: 'Maximum',
78+
Period: 300,
79+
Threshold: 1,
80+
EvaluationPeriods: 1,
81+
TreatMissingData: 'notBreaching',
82+
Dimensions: Match.arrayWith([
83+
Match.objectLike({
84+
Name: 'QueueName',
85+
Value: Match.objectLike({
86+
'Fn::GetAtt': Match.arrayWith([Match.stringLikeRegexp('WebhookProcessorDlq')]),
87+
}),
88+
}),
89+
]),
90+
});
91+
});
92+
6893
test('wires the DLQ as the processor Lambda async-invoke dead-letter target', () => {
6994
// The queue existing is not enough — it must be bound to the
7095
// processor function's DeadLetterConfig or failed async invokes

cdk/test/handlers/shared/validation.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,19 @@ describe('isValidRepo', () => {
7777
expect(isValidRepo('trailing-slash/')).toBe(false);
7878
expect(isValidRepo('has spaces/repo')).toBe(false);
7979
});
80+
81+
test('rejects pure-dot path segments while keeping dotted names', () => {
82+
// `owner/..` is a path token, not a repo name — the char class allows
83+
// dots so the multi-slash rule alone never caught the single-segment
84+
// traversal shapes. Dotted REAL names (next.js) must keep working.
85+
expect(isValidRepo('owner/..')).toBe(false);
86+
expect(isValidRepo('owner/.')).toBe(false);
87+
expect(isValidRepo('../repo')).toBe(false);
88+
expect(isValidRepo('./repo')).toBe(false);
89+
expect(isValidRepo('vercel/next.js')).toBe(true);
90+
expect(isValidRepo('owner/.github')).toBe(true);
91+
expect(isValidRepo('owner/repo.')).toBe(true);
92+
});
8093
});
8194

8295
describe('hasTaskSpec', () => {

cli/src/auth.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,12 @@ async function ensureFreshCredentials(): Promise<Credentials> {
115115

116116
function isExpired(creds: Credentials): boolean {
117117
const expiryMs = new Date(creds.token_expiry).getTime();
118+
// A corrupt token_expiry parses to NaN, and every comparison against NaN
119+
// is false — the token would be classified as never-expiring and surface
120+
// as an opaque 401 instead of a refresh. Treat unparseable as expired.
121+
if (!Number.isFinite(expiryMs)) {
122+
return true;
123+
}
118124
return Date.now() >= expiryMs - TOKEN_REFRESH_BUFFER_MS;
119125
}
120126

0 commit comments

Comments
 (0)