From f401fd32da3508a9a407acf0b7c210c188c12d01 Mon Sep 17 00:00:00 2001 From: Alexander Richey Date: Fri, 4 Sep 2026 21:37:43 +0000 Subject: [PATCH] fix(project): refuse status outside the project's deployed region MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare `project status` links each deployed row to its detail page with ?region=, but the actions those pages open in turn (endpoints, events, detail JSON, …) run in the ambient region, so a project deployed anywhere else breaks one step past the tree (#2239 tried to thread the region through every flow instead). The headless report has the same problem one step later: the ids it prints only work with the same --region. Compare the resolved target's region with the context's before rendering. The CLI throws a ProjectStateError naming both regions; the screen shows the same message in place of the tree and keeps esc as the way out. Rerun with --region to see the report. The headless tests pin AWS_REGION to the default target's region, since withRegion would otherwise read the developer's shell, and the screen tests pin RegionKey to the target's so the tree still renders; each suite gains a mismatch case of its own. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016KnHdxcPYTnQiDY7Y1PY6s --- src/handlers/project/status/index.test.ts | 32 +++++++++++++- src/handlers/project/status/index.ts | 11 +++++ src/handlers/project/status/screen.tsx | 42 +++++++++++++------ .../project/status/status.screen.test.tsx | 33 ++++++++++++--- 4 files changed, 99 insertions(+), 19 deletions(-) diff --git a/src/handlers/project/status/index.test.ts b/src/handlers/project/status/index.test.ts index c91ccccb7..d71d4702f 100644 --- a/src/handlers/project/status/index.test.ts +++ b/src/handlers/project/status/index.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -12,6 +12,7 @@ import { waitFor, } from "../../../testing"; import type { ProjectBackend } from "../../../core/project"; +import { ProjectStateError } from "../../../errors"; import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets"; import type { ResolvedProjectResource } from "../types"; @@ -74,6 +75,18 @@ afterEach(async () => { ); }); +// The report is refused when the ambient region is not the target's, so pin +// the ambient region to the default target's rather than leave it to the +// developer's shell (see withRegion for the fallback chain). +const SAVED_AWS_REGION = process.env.AWS_REGION; +beforeEach(() => { + process.env.AWS_REGION = DEFAULT_TARGET.region; +}); +afterEach(() => { + if (SAVED_AWS_REGION === undefined) delete process.env.AWS_REGION; + else process.env.AWS_REGION = SAVED_AWS_REGION; +}); + async function inProject( subject: ReturnType, spec: Record = {}, @@ -223,7 +236,7 @@ describe("project status handler", () => { const subject = testStatusCommand([]); await inProject(subject); - await subject.run(["--target", "staging"]); + await subject.run(["--region", STAGING_TARGET.region, "--target", "staging"]); expect(subject.targets).toEqual([STAGING_TARGET]); expect(subject.json()).toMatchObject({ target: "staging", region: "eu-west-1" }); @@ -232,6 +245,21 @@ describe("project status handler", () => { /has no deployment target named 'typo'/, ); }); + + test("refuses a target deployed outside the ambient region", async () => { + const subject = testStatusCommand([HARNESS_ROW]); + await inProject(subject); + + // The ambient region is the default target's (pinned above); staging's is not. + const outcome = subject.run(["--target", "staging"]); + await expect(outcome).rejects.toBeInstanceOf(ProjectStateError); + await expect(outcome).rejects.toThrow("This project is deployed to eu-west-1, not us-east-1"); + // An explicit --region takes part in the same comparison. + await expect(subject.run(["--region", "us-west-2"])).rejects.toThrow( + "This project is deployed to us-east-1, not us-west-2", + ); + expect(subject.io.stdout()).toBe(""); + }); }); describe("project status dispatch", () => { diff --git a/src/handlers/project/status/index.ts b/src/handlers/project/status/index.ts index e91179b45..8ad24d1eb 100644 --- a/src/handlers/project/status/index.ts +++ b/src/handlers/project/status/index.ts @@ -3,6 +3,8 @@ import { DEFAULT_TARGET_NAME } from "../../../projectSchemas/aws-targets"; import { createHandler, flag, ProjectKey } from "../../../router"; import { JsonRendererKey } from "../../../tui"; import type { ProjectManager, ResolvedProjectResource } from "../types"; +import { RegionKey } from "../../keys"; +import { ProjectStateError } from "../../../errors"; type StatusProjectHandlerConfig = { projectManager: ProjectManager; @@ -38,6 +40,15 @@ export const createStatusProjectHandler = (config: StatusProjectHandlerConfig) = region: resolved.target.region, resources: resolved.resources, }; + + // Every follow-up command runs in the ambient region, so a report for a + // project deployed elsewhere would print ids the user cannot act on as-is. + // Refuse it and name the region to rerun with. + const region = ctx.require(RegionKey); + if (status.region !== region) { + throw new ProjectStateError(`This project is deployed to ${status.region}, not ${region}`); + } + ctx.require(JsonRendererKey).renderJson(status); }, }); diff --git a/src/handlers/project/status/screen.tsx b/src/handlers/project/status/screen.tsx index 00c0230db..b40c73fb4 100644 --- a/src/handlers/project/status/screen.tsx +++ b/src/handlers/project/status/screen.tsx @@ -15,6 +15,7 @@ import { ProjectKey } from "../../../router"; import type { ScreenProps } from "../../types"; import type { DeployableResource, Project, ResolvedProjectResource } from "../types"; import { LoadingFrame, ProjectGate } from "../ProjectGate"; +import { RegionKey } from "../../keys"; const theme = darkTheme; @@ -168,15 +169,16 @@ export function ProjectStatusScreen({ ctx, core }: ScreenProps) { seed={ctx.value(ProjectKey)} onBack={() => navigate(PROJECT_MENU)} > - {(project) => } + {(project) => } ); } function ProjectStatusView({ core, + ctx, project, -}: Pick & { +}: ScreenProps & { project: Project; }) { const navigate = useNavigate(); @@ -224,6 +226,12 @@ function ProjectStatusView({ setHint(node.data?.hint); }; + // A linked detail page fetches in the target's region, but everything it + // opens in turn runs in the ambient one, so a project deployed elsewhere is + // reported rather than listed: the user reopens with --region. + const region = ctx.require(RegionKey); + const isWrongRegion = status.data.target.region !== region; + return ( - resources - - {nodes.length === 0 ? ( - - No resources are declared in this project. Run `agentcore project add` to declare one. - - ) : ( - - )} - + {isWrongRegion && ( + + This project is deployed to {status.data.target.region}, not {region} + + )} + {!isWrongRegion && ( + <> + resources + + {nodes.length === 0 ? ( + + No resources are declared in this project. Run `agentcore project add` to declare + one. + + ) : ( + + )} + + + )} {hint !== undefined && ( {hint} diff --git a/src/handlers/project/status/status.screen.test.tsx b/src/handlers/project/status/status.screen.test.tsx index 7685189b5..a2515ab82 100644 --- a/src/handlers/project/status/status.screen.test.tsx +++ b/src/handlers/project/status/status.screen.test.tsx @@ -9,6 +9,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { ProjectSpecSchema } from "../../../projectSchemas/project"; import { ProjectKey } from "../../../router"; +import { RegionKey } from "../../keys"; import { cleanupScreens, flatFrame, @@ -30,9 +31,10 @@ afterEach(async () => { ); }); -// The target region differs from the base context's us-east-1 on purpose: the -// detail screens must fetch where the project deployed, not in the ambient -// region. +// The target region differs from the base context's us-east-1 on purpose, so +// the detail screens are linked with the region the project deployed in. +// renderStatus pins the ambient region to the target's, since the screen only +// lists a project deployed there; the mismatch case has a test of its own. const TARGET = { name: "default", account: "111122223333", region: "eu-west-1" } as const; const ARN = `arn:aws:bedrock-agentcore:${TARGET.region}:${TARGET.account}`; const RUNTIME_ID = "checkout-AbCdEf1234"; @@ -100,10 +102,14 @@ function core(resources: ResolvedProjectResource[] = RUNTIME_RESOURCES): TestCor return value; } -function renderStatus(value: TestCoreClient, seed: Project = RUNTIME_PROJECT) { +function renderStatus( + value: TestCoreClient, + seed: Project = RUNTIME_PROJECT, + region: string = TARGET.region, +) { return renderScreen("/agentcore/project/status", { core: value, - withContext: (ctx) => ctx.withValue(ProjectKey, seed), + withContext: (ctx) => ctx.withValue(ProjectKey, seed).withValue(RegionKey, region), }); } @@ -277,6 +283,23 @@ describe("project status screen", () => { await waitForText(screen.lastFrame, "No resources are declared in this project."); }); + test("reports a project deployed outside the ambient region instead of listing it", async () => { + const screen = renderStatus(core(), RUNTIME_PROJECT, "us-east-1"); + + await waitForFlatText( + screen.lastFrame, + `This project is deployed to ${TARGET.region}, not us-east-1`, + ); + const frame = flatFrame(screen.lastFrame); + expect(frame).not.toContain("checkout agent"); + expect(frame).not.toMatch(/runtime\s+checkout/); + // Nothing is focusable, so enter goes nowhere and escape still leaves. + await screen.press("return"); + expect(screen.lastFrame()).toContain("agentcore → project → status"); + await screen.press("escape"); + await waitForText(screen.lastFrame, "manage an AgentCore project"); + }); + test("reports the CLI's own guidance outside a project", async () => { const directory = await mkdtemp(join(tmpdir(), "agentcore-status-no-project-")); tempDirectories.push(directory);