Skip to content

Commit 1ad937d

Browse files
committed
Merge remote-tracking branch 'upstream/main' into HEAD
2 parents 85e141d + 3af6cc3 commit 1ad937d

18 files changed

Lines changed: 1127 additions & 109 deletions

File tree

.github/workflows/integ.yml

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@ on:
2929
types: [completed]
3030
workflow_dispatch: {}
3131

32-
# Only one integ run at a time against the shared account — overlapping deploys
33-
# would collide on the single hardcoded `backgroundagent-integ` stack name.
32+
# Only one integ run at a time against the shared account. Stack names are now
33+
# run-unique (`int-<sha>`, see the integ job) so this is no longer a correctness
34+
# requirement — it bounds shared-account cost/parallelism rather than preventing
35+
# a name collision.
3436
concurrency:
3537
group: cdk-integ
3638
cancel-in-progress: false
@@ -217,30 +219,36 @@ jobs:
217219
- name: Install dependencies
218220
run: yarn install --immutable
219221

220-
- name: Run integ tests (deploy → assert → destroy)
221-
run: mise //cdk:integ
222-
223-
# Safety net: integ-runner forces teardown on success and failure, but if
224-
# the run is cancelled or crashes mid-deploy the stack can be stranded in
225-
# the shared account. Delete it directly via CloudFormation so we never
226-
# leak billable resources.
222+
# Teardown: integ-runner forces `cdk destroy` on success and failure (via
223+
# the CDK bootstrap roles, which hold the CloudFormation permissions). No
224+
# raw-CLI safety net here: the GitHub OIDC role has no
225+
# cloudformation:DeleteStack/DescribeStacks, so such a step can only fail
226+
# (#566). If a cancelled/crashed run strands the stack, reclaim is the
227+
# stranded-stack cleanup owned by #400/#72 — and the run-unique name below
228+
# means a stranded stack never blocks a later run.
227229
#
228-
# NOTE: `cdk destroy backgroundagent-integ` would NOT work here — it
229-
# synthesizes the main app (src/main.ts), which does not contain the integ
230-
# stack, so it exits 0 having deleted nothing. Target the stack by its
231-
# literal CloudFormation name instead. delete-stack is idempotent (no-op if
232-
# already gone), so `|| true` only guards transient API errors.
233-
- name: Ensure stack torn down
234-
if: always()
230+
# INTEG_STACK_NAME maps the stack to the commit under test plus the run
231+
# number (`int-<sha>-<run>`), replacing the single fixed
232+
# `backgroundagent-integ` name. The run number makes same-commit re-runs
233+
# distinct; the `int-` prefix marks it ephemeral for #72's sweep. HEAD_SHA
234+
# is the resolved PR head (or dispatched ref) and RUN_NUMBER is a positive
235+
# integer, so both are already lowercase-alphanumeric — no sanitizing
236+
# needed to stay CloudFormation-valid.
237+
- name: Run integ tests (deploy → assert → destroy)
235238
env:
236-
AWS_REGION: ${{ vars.AWS_REGION || 'us-east-1' }}
237-
AWS_DEFAULT_REGION: ${{ vars.AWS_REGION || 'us-east-1' }}
239+
HEAD_SHA: ${{ needs.resolve.outputs.head_sha }}
240+
RUN_NUMBER: ${{ github.run_number }}
238241
run: |
239242
set -euo pipefail
240-
aws cloudformation delete-stack --stack-name backgroundagent-integ || true
241-
# No `|| true` on the wait: a DELETE_FAILED must surface loudly so we
242-
# never silently leak billable resources in the shared account.
243-
aws cloudformation wait stack-delete-complete --stack-name backgroundagent-integ
243+
export INTEG_STACK_NAME="int-${HEAD_SHA:0:12}-${RUN_NUMBER}"
244+
echo "Integ stack name: $INTEG_STACK_NAME"
245+
mise //cdk:integ -- --all --require-approval never \
246+
-c stackName="integ-${{ github.run_id }}-${{ github.run_attempt }}" \
247+
-c github:sha="$(git rev-parse HEAD)" \
248+
-c github:ref="$(git branch --show-current || echo "detached")" \
249+
-c github:actor="${{ github.triggering_actor }}" \
250+
-c github:repository="$(gh repo view --json nameWithOwner --jq .nameWithOwner)" \
251+
-c github:clean="$([ -z "$(git status --porcelain)" ] && echo true || echo false)"
244252
245253
# Post the final integ-smoke status back to the PR head so the check flips from
246254
# pending to success/failure. Skipped for workflow_dispatch (no PR to gate).

agent/pyproject.toml

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ version = "0.1.0"
44
description = "Background coding agent — runs tasks in isolated cloud environments and produces pull requests"
55
requires-python = ">=3.13"
66
dependencies = [
7-
"boto3==1.43.38", #https://pypi.org/project/boto3/
7+
"boto3==1.43.40", #https://pypi.org/project/boto3/
88
# Vestigial from the parked AgentCore Identity flow (Phase 2.0a).
99
# Phase 2.0b reads per-workspace Linear OAuth tokens directly from
1010
# Secrets Manager because AgentCore Identity's USER_FEDERATION
@@ -15,11 +15,11 @@ dependencies = [
1515
# try/except (ImportError, AttributeError), so removing this dep
1616
# would degrade gracefully — but for now we keep the dep to
1717
# preserve the clean code path.
18-
"bedrock-agentcore==1.16.0", #https://pypi.org/project/bedrock-agentcore/
18+
"bedrock-agentcore==1.17.0", #https://pypi.org/project/bedrock-agentcore/
1919
"claude-agent-sdk==0.2.110", #https://github.com/anthropics/claude-agent-sdk-python/releases/tag/v0.2.110 (bundles claude CLI 2.1.191; kept in lockstep with the npm CLI pin in the Dockerfile, #215)
2020
"requests==2.34.2", #https://pypi.org/project/requests/
2121
"fastapi==0.139.0", #https://pypi.org/project/fastapi/
22-
"uvicorn==0.49.0", #https://pypi.org/project/uvicorn/
22+
"uvicorn==0.50.0", #https://pypi.org/project/uvicorn/
2323
"aws-opentelemetry-distro==0.18.0", #https://pypi.org/project/aws-opentelemetry-distro/
2424
"mcp==1.28.1", #https://pypi.org/project/mcp/
2525
# CEDAR ENGINE PARITY — DO NOT BUMP IN ISOLATION.
@@ -49,6 +49,14 @@ dependencies = [
4949
"jsonschema==4.26.0", #https://pypi.org/project/jsonschema/
5050
]
5151

52+
# The uv-missing-dependency-cooldown rule wants a global exclude-newer window.
53+
# We intentionally do NOT set one: deps are exact-pinned (==) and lockfile-managed
54+
# via upgrade-main.yml, and a global cooldown conflicts with fresh exact pins
55+
# (fastapi/bedrock-agentcore/boto3 were published within the last week) — it makes
56+
# `uv sync` unsatisfiable and re-breaks on every pin bump. The rule's threat model
57+
# (surprise malicious/unstable version) is already covered by exact pins + reviewed
58+
# lock upgrades. See #532.
59+
# nosemgrep: uv-missing-dependency-cooldown
5260
[tool.uv]
5361
constraint-dependencies = [
5462
"pyjwt>=2.13.0", # PYSEC-2026-175/177/178/179 — transitive via mcp; remove when mcp bumps floor (#267)

agent/uv.lock

Lines changed: 12 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cdk/src/handlers/get-task-events.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,16 @@ export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayPr
118118
);
119119
}
120120

121-
const afterValid = typeof afterRaw === 'string' && afterRaw.length === ULID_LENGTH ? afterRaw : undefined;
121+
// Normalize the cursor to upper-case before it reaches the DynamoDB key
122+
// condition. ``isValidUlid`` accepts lower-case callers (it uppercases
123+
// before matching), but stored ``event_id``s are upper-case Crockford
124+
// Base32. DynamoDB compares raw bytes, and lower-case ASCII sorts *after*
125+
// upper-case, so a lower-case cursor would be "greater than" every stored
126+
// id and ``event_id > :after`` would return zero rows — silently dropping
127+
// the rest of the event stream for a contract-valid input.
128+
const afterValid = typeof afterRaw === 'string' && afterRaw.length === ULID_LENGTH
129+
? afterRaw.toUpperCase()
130+
: undefined;
122131

123132
// 3b. ``desc`` combined with ``after`` makes no semantic sense: ``after``
124133
// is a forward-walking ULID cursor. Reject the combination rather than

cdk/src/handlers/shared/create-task-core.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,11 @@ export async function createTaskCore(
339339
parseResult.scope.startsWith('bash_pattern:')
340340
|| parseResult.scope.startsWith('write_path:')
341341
) {
342-
const value = parseResult.scope.split(':', 2)[1] ?? '';
342+
// Take everything after the first colon — the value itself may
343+
// contain colons (e.g. ``bash_pattern:git log --format=%h:%s``), so a
344+
// ``split(':', 2)`` would truncate it and could turn a legitimate
345+
// pattern into a degenerate-looking fragment, producing a spurious 400.
346+
const value = parseResult.scope.slice(parseResult.scope.indexOf(':') + 1);
343347
if (isDegeneratePattern(value)) {
344348
return errorResponse(
345349
400,

cdk/test/handlers/get-task-events.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,22 @@ describe('get-task-events handler', () => {
269269
expect(queryInput.ExclusiveStartKey).toBeUndefined();
270270
});
271271

272+
test('lower-case ?after is normalized to upper-case before the DDB query', async () => {
273+
// ``isValidUlid`` accepts lower-case callers, but stored event_ids are
274+
// upper-case Crockford Base32 and DDB compares raw bytes (lower-case sorts
275+
// after upper-case). Without normalization a lower-case cursor would be
276+
// "greater than" every stored id and silently return zero events.
277+
const event = makeEvent({ queryStringParameters: { after: VALID_AFTER.toLowerCase() } });
278+
await handler(event);
279+
280+
const queryInput = MockQueryCommand.mock.calls[0][0];
281+
expect(queryInput.KeyConditionExpression).toBe('task_id = :tid AND event_id > :after');
282+
expect(queryInput.ExpressionAttributeValues).toEqual({
283+
':tid': 'task-1',
284+
':after': VALID_AFTER, // upper-cased, not the lower-case input
285+
});
286+
});
287+
272288
test('both after and next_token → after wins + WARN logged', async () => {
273289
const stdoutWrite = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
274290
try {

cdk/test/handlers/shared/create-task-core.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,38 @@ describe('createTaskCore', () => {
114114
expect(mockLambdaSend).toHaveBeenCalledTimes(1);
115115
});
116116

117+
test('accepts an initial_approvals pattern whose value contains a colon', async () => {
118+
// Regression: the degenerate-pattern guard used split(':', 2)[1], which
119+
// truncated the value at the next colon. For "ab:cdefgh" that yields the
120+
// 2-char fragment "ab", which isDegeneratePattern flags as degenerate —
121+
// a spurious 400. The full value "ab:cdefgh" is not degenerate, so the
122+
// scope must be accepted.
123+
const result = await createTaskCore(
124+
{
125+
repo: 'org/repo',
126+
task_description: 'Fix the bug',
127+
initial_approvals: ['bash_pattern:ab:cdefgh'],
128+
} as any,
129+
makeContext(),
130+
'req-1',
131+
);
132+
expect(result.statusCode).toBe(201);
133+
});
134+
135+
test('still rejects a genuinely degenerate initial_approvals pattern', async () => {
136+
const result = await createTaskCore(
137+
{
138+
repo: 'org/repo',
139+
task_description: 'Fix the bug',
140+
initial_approvals: ['bash_pattern:*'],
141+
} as any,
142+
makeContext(),
143+
'req-1',
144+
);
145+
expect(result.statusCode).toBe(400);
146+
expect(JSON.parse(result.body).error.code).toBe('VALIDATION_ERROR');
147+
});
148+
117149
test('returns 400 for invalid repo', async () => {
118150
const result = await createTaskCore({ repo: 'invalid' } as any, makeContext(), 'req-1');
119151
expect(result.statusCode).toBe(400);

cdk/test/integ/integ.task-api-smoke.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,15 @@ class TaskApiSmokeStack extends Stack {
8989
}
9090
}
9191

92+
// Stack name is run-unique in CI (`int-<short-sha>-<run-number>`, set by
93+
// integ.yml) so a stranded stack from a cancelled/crashed run never collides
94+
// with — or blocks — a later run under one fixed name. Falls back to a stable
95+
// local name so the `mise //cdk:integ` dev path is unchanged. The `int-` prefix
96+
// keeps it letter-led and the rest is lowercase-alphanumeric + hyphens, so the
97+
// name is CloudFormation-valid.
9298
const app = new App();
93-
const stack = new TaskApiSmokeStack(app, 'backgroundagent-integ');
99+
const stackName = process.env.INTEG_STACK_NAME || 'backgroundagent-integ-local';
100+
const stack = new TaskApiSmokeStack(app, stackName);
94101

95102
const integ = new IntegTest(app, 'TaskApiSmoke', {
96103
testCases: [stack],

cli/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,25 @@ With no flags, writes to the platform default `GitHubTokenSecretArn` stack outpu
302302

303303
Configure the preview-deploy screenshot pipeline webhook. See [Deploy preview screenshots guide](../docs/guides/DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md).
304304

305+
### `bgagent jira setup` / `map` / `invite-user` / `link`
306+
307+
Manage the Jira Cloud integration. `setup` authorizes a tenant via OAuth (3LO) and stores the token in Secrets Manager; `map` routes a Jira project to a GitHub repo; the two-step `invite-user``link` handshake links a teammate's Jira identity to their platform user. See the [Jira setup guide](../docs/guides/JIRA_SETUP_GUIDE.md) for the full walkthrough.
308+
309+
```
310+
bgagent jira setup \
311+
--stack-name backgroundagent-dev
312+
313+
bgagent jira map <cloud-id> <PROJECT-KEY> --repo owner/repo
314+
315+
bgagent jira invite-user <cloud-id> <account-id-or-email> \
316+
--region <region> AWS region (defaults to configured region) \
317+
--stack-name <name> CloudFormation stack name (default: backgroundagent-dev)
318+
319+
bgagent jira link <code>
320+
```
321+
322+
`invite-user` resolves the teammate's Jira identity through the tenant OAuth token, then writes a `pending#<code>` row (24h TTL) and prints the `bgagent jira link <code>` the teammate runs from their own machine. The teammate previews the Jira identity before confirming, so a wrong pick can be aborted rather than misattributed. If the identity is already linked, the command warns but still issues the code.
323+
305324
### `bgagent admin invite-user` / `list-users` / `delete-user` / `reset-password`
306325

307326
Manage Cognito users with operator AWS credentials (`cognito-idp:Admin*` on the deployment user pool). Works **before** `bgagent configure` when `--stack-name` is passed (reads `UserPoolId` from CloudFormation).

0 commit comments

Comments
 (0)