From d0f06d5c5ea7006b39ac8d4c8ff8a4260e64eace Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Sat, 8 Aug 2026 16:20:52 +0000 Subject: [PATCH] fix(webapp,dashboard-agent): address a branch environment by name and branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent could not read anything on a preview or dev branch. Its environment name is derived from the environment's type, and every branch shares its parent's type — so the name identifies a family, not a row. The API's env routes are name-addressed, so a bare "preview" or "dev" resolved to the parent, and the delegated token's `environmentId` claim then correctly refused it. The guard was the detector, not the defect: the exchange was never given enough identity to resolve the environment the dashboard had selected. Name and branch are now resolved together into one address, so no caller can take the name without the branch, and both mint sites share the one type map instead of keeping a copy each. The address travels to the JWT exchange and to the three delegated-token reads that resolve by name (list_tasks, correlate_version, the repo snapshot). The env-JWT reads address the environment by id and are untouched. Second, and why nobody saw the first: the exchange reported the same "no environment" for a genuine absence and for any failure, and cached the failure for the whole turn. Following the queue live-read precedent, only a missing environment is stated as one; anything else says the read didn't land and carries its status. --- ...aram.env.$envParam.dashboard-agent.in.$.ts | 8 +- ...jectParam.env.$envParam.dashboard-agent.ts | 15 +- .../app/services/dashboardAgent.server.ts | 13 -- ...dashboardAgentEnvironmentAddress.server.ts | 37 ++++ .../test/dashboardAgentClientMetadata.test.ts | 40 +++- .../dashboardAgentInProxyMintFailure.test.ts | 1 - apps/webapp/test/uatEnvironmentClaim.test.ts | 157 ++++++++++++- .../dashboard-agent/src/agent-runtime.ts | 1 + .../src/tool-api-branch.test.ts | 207 ++++++++++++++++++ .../dashboard-agent/src/tool-api-client.ts | 126 +++++++---- .../dashboard-agent/src/tool-api.ts | 57 +++-- .../dashboard-agent/src/tool-context.ts | 3 + .../dashboard-agent/src/tool-source-ledger.ts | 6 +- .../dashboard-agent/src/tools.ts | 1 + 14 files changed, 577 insertions(+), 95 deletions(-) create mode 100644 apps/webapp/app/services/dashboardAgentEnvironmentAddress.server.ts create mode 100644 internal-packages/dashboard-agent/src/tool-api-branch.test.ts diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts index d2b180738..5939e836d 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts @@ -11,10 +11,10 @@ import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { dashboardAgentApiOrigin, - dashboardAgentEnvironmentName, mintDashboardAgentUserActorToken, resolveDashboardAgentRepoSnapshot, } from "~/services/dashboardAgent.server"; +import { dashboardAgentEnvironmentAddress } from "~/services/dashboardAgentEnvironmentAddress.server"; import { logger } from "~/services/logger.server"; import { requireUser } from "~/services/session.server"; import { readBoundedBodyText } from "~/utils/boundedRequestBody.server"; @@ -88,7 +88,7 @@ export async function action({ request, params }: ActionFunctionArgs) { // dev row, and a token must never be minted for someone else's environment — or for none. const runtimeEnv = await findEnvironmentBySlug(project.id, envParam, user.id); if (!runtimeEnv) return json({ error: "Environment not found" }, { status: 404 }); - const environmentName = dashboardAgentEnvironmentName(runtimeEnv.type); + const environmentAddress = dashboardAgentEnvironmentAddress(runtimeEnv); // Null without a connected GitHub repo, and the agent stays in assistant mode. const repoSnapshot = await resolveDashboardAgentRepoSnapshot(project.id); @@ -147,9 +147,9 @@ export async function action({ request, params }: ActionFunctionArgs) { userId: user.id, projectId: project.id, // `(projectId, slug)` isn't unique (dev is per-member), so anything addressing - // one environment row uses this id. `environmentName` is for name-addressed tools. + // one environment row uses this id. The address is for name-addressed tools. environmentId: runtimeEnv.id, - environmentName, + ...environmentAddress, ...(repoSnapshot ? { repoSnapshot } : {}), }; body = JSON.stringify(parsed); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts index 1feb45ce5..d34519604 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts @@ -34,6 +34,7 @@ import { resolveDashboardAgentRepoSnapshot, startDashboardAgentSession, } from "~/services/dashboardAgent.server"; +import { dashboardAgentEnvironmentAddress } from "~/services/dashboardAgentEnvironmentAddress.server"; import { startDashboardAgentHeadStart } from "~/services/dashboardAgentHeadStart.server"; import { dashboardAgentDb } from "~/services/dashboardAgentDb.server"; import { logger } from "~/services/logger.server"; @@ -45,14 +46,6 @@ import { canAccessDashboardAgent } from "~/v3/canAccessDashboardAgent.server"; // drift apart. import { pickAgentClientMetadata } from "./resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$"; -// The agent's tools address the canonical env name, not the dashboard URL slug. -const ENV_NAME_BY_TYPE: Record = { - DEVELOPMENT: "dev", - STAGING: "staging", - PRODUCTION: "prod", - PREVIEW: "preview", -}; - const ActionBody = z.object({ intent: z.enum([ "start", @@ -224,7 +217,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { // someone else's environment — or, when nothing resolves, for no environment at all. const runtimeEnv = await findEnvironmentBySlug(project.id, envParam, userId); if (!runtimeEnv) return json({ error: "Environment not found" }, { status: 404 }); - const environmentName = ENV_NAME_BY_TYPE[runtimeEnv.type]; + const environmentAddress = dashboardAgentEnvironmentAddress(runtimeEnv); const chatId = generateFriendlyId("chat"); try { @@ -250,7 +243,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { projectId: project.id, // Same environment identity the `in` proxy injects. environmentId: runtimeEnv.id, - environmentName, + ...environmentAddress, ...(repoSnapshot ? { repoSnapshot } : {}), } : undefined; @@ -284,7 +277,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { userId, projectId: project.id, environmentId: runtimeEnv.id, - environmentName, + ...environmentAddress, }, }); } diff --git a/apps/webapp/app/services/dashboardAgent.server.ts b/apps/webapp/app/services/dashboardAgent.server.ts index 88eec884d..5ae9eb257 100644 --- a/apps/webapp/app/services/dashboardAgent.server.ts +++ b/apps/webapp/app/services/dashboardAgent.server.ts @@ -53,19 +53,6 @@ export function mintDashboardAgentUserActorToken( }); } -// The API's env routes key on the canonical env name, not the dashboard URL slug -// (staging's slug is "stg"). Anything handing the agent an environment maps through here. -const ENV_NAME_BY_TYPE: Record = { - DEVELOPMENT: "dev", - STAGING: "staging", - PRODUCTION: "prod", - PREVIEW: "preview", -}; - -export function dashboardAgentEnvironmentName(type: string | undefined): string | undefined { - return type ? ENV_NAME_BY_TYPE[type] : undefined; -} - // The session is created in whatever env DASHBOARD_AGENT_SECRET_KEY belongs to. // baseURL is the Trigger instance this webapp runs against (its own API origin). function dashboardAgentConfig() { diff --git a/apps/webapp/app/services/dashboardAgentEnvironmentAddress.server.ts b/apps/webapp/app/services/dashboardAgentEnvironmentAddress.server.ts new file mode 100644 index 000000000..7f29602da --- /dev/null +++ b/apps/webapp/app/services/dashboardAgentEnvironmentAddress.server.ts @@ -0,0 +1,37 @@ +/** + * How the dashboard agent addresses one environment on the name-addressed API routes. + * + * The name is derived from the environment's type, and every branch shares its parent's type — so + * the name alone does not identify an environment, it identifies a family. The branch is the rest + * of the address, and the API's resolver needs both to land on the row the dashboard selected. + * + * Returned as a pair so no caller can take the name without it. Handing the JWT exchange a bare + * "preview" resolves the parent, and the delegated token's `environmentId` claim then correctly + * refuses it — the guard is the detector, not the defect. + * + * Kept free of heavy imports so both mint sites and their tests can use the real thing. + */ + +// The API's env routes key on the canonical env name, not the dashboard URL slug +// (staging's slug is "stg"). +const ENV_NAME_BY_TYPE: Record = { + DEVELOPMENT: "dev", + STAGING: "staging", + PRODUCTION: "prod", + PREVIEW: "preview", +}; + +export type DashboardAgentEnvironmentAddress = { + environmentName?: string; + environmentBranch?: string; +}; + +export function dashboardAgentEnvironmentAddress( + environment: { type: string; branchName?: string | null } | undefined +): DashboardAgentEnvironmentAddress { + if (!environment) return {}; + return { + environmentName: ENV_NAME_BY_TYPE[environment.type], + ...(environment.branchName ? { environmentBranch: environment.branchName } : {}), + }; +} diff --git a/apps/webapp/test/dashboardAgentClientMetadata.test.ts b/apps/webapp/test/dashboardAgentClientMetadata.test.ts index 95cdeb7d0..eb75621fc 100644 --- a/apps/webapp/test/dashboardAgentClientMetadata.test.ts +++ b/apps/webapp/test/dashboardAgentClientMetadata.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ fetch: vi.fn(), + findEnvironmentBySlug: vi.fn<(...args: any[]) => Promise>(), })); vi.mock("~/db.server", () => ({ $replica: {} })); @@ -20,11 +21,10 @@ vi.mock("~/models/project.server", () => ({ }), })); vi.mock("~/models/runtimeEnvironment.server", () => ({ - findEnvironmentBySlug: async () => ({ id: "env_real", type: "DEVELOPMENT" }), + findEnvironmentBySlug: mocks.findEnvironmentBySlug, })); vi.mock("~/services/dashboardAgent.server", () => ({ dashboardAgentApiOrigin: () => "https://api.trigger.dev", - dashboardAgentEnvironmentName: () => "dev", mintDashboardAgentUserActorToken: async () => "tr_uat_real", resolveDashboardAgentRepoSnapshot: async () => null, })); @@ -66,6 +66,12 @@ async function appendTurn(metadata: Record): Promise { beforeEach(() => { + mocks.findEnvironmentBySlug.mockReset(); + mocks.findEnvironmentBySlug.mockResolvedValue({ + id: "env_real", + type: "DEVELOPMENT", + branchName: null, + }); mocks.fetch.mockReset(); mocks.fetch.mockResolvedValue( new Response(JSON.stringify({ ok: true }), { @@ -95,6 +101,7 @@ describe("dashboard agent `in` proxy — client metadata", () => { projectRef: "proj_ref_evil", environmentId: "env_evil", environmentName: "prod", + environmentBranch: "evil-branch", apiOrigin: "https://evil.example.com", userActorToken: "tr_uat_evil", repoSnapshot: { tarballUrl: "https://evil.example.com/x.tar.gz" }, @@ -106,11 +113,40 @@ describe("dashboard agent `in` proxy — client metadata", () => { expect(metadata.projectRef).toBe("proj_ref_real"); expect(metadata.environmentId).toBe("env_real"); expect(metadata.environmentName).toBe("dev"); + expect(metadata.environmentBranch).toBeUndefined(); expect(metadata.apiOrigin).toBe("https://api.trigger.dev"); expect(metadata.userActorToken).toBe("tr_uat_real"); expect(metadata.repoSnapshot).toBeUndefined(); }); + // The whole address the proxy hands the agent, for each of the four environment shapes. The + // name is shared by a parent and all its branches, so a branch is only addressable when its + // branch travels with the name. + it.each([ + ["production", { id: "env_prod", type: "PRODUCTION", branchName: null }, "prod", undefined], + ["staging", { id: "env_stg", type: "STAGING", branchName: null }, "staging", undefined], + [ + "a preview branch", + { id: "env_preview_branch", type: "PREVIEW", branchName: "feat/checkout" }, + "preview", + "feat/checkout", + ], + [ + "a development branch", + { id: "env_dev_branch", type: "DEVELOPMENT", branchName: "katia/spike" }, + "dev", + "katia/spike", + ], + ])("addresses %s by the environment it resolved", async (_name, env, expectedName, branch) => { + mocks.findEnvironmentBySlug.mockResolvedValue(env); + + const metadata = await appendTurn({ currentPage: "/runs" }); + + expect(metadata.environmentId).toBe(env.id); + expect(metadata.environmentName).toBe(expectedName); + expect(metadata.environmentBranch).toBe(branch); + }); + it("drops any field the server doesn't own", async () => { const metadata = await appendTurn({ currentPage: "/runs", diff --git a/apps/webapp/test/dashboardAgentInProxyMintFailure.test.ts b/apps/webapp/test/dashboardAgentInProxyMintFailure.test.ts index 3c32b1fa8..fb24ef87b 100644 --- a/apps/webapp/test/dashboardAgentInProxyMintFailure.test.ts +++ b/apps/webapp/test/dashboardAgentInProxyMintFailure.test.ts @@ -25,7 +25,6 @@ vi.mock("~/models/runtimeEnvironment.server", () => ({ })); vi.mock("~/services/dashboardAgent.server", () => ({ dashboardAgentApiOrigin: () => "https://api.trigger.dev", - dashboardAgentEnvironmentName: () => "dev", mintDashboardAgentUserActorToken: mocks.mint, resolveDashboardAgentRepoSnapshot: async () => null, })); diff --git a/apps/webapp/test/uatEnvironmentClaim.test.ts b/apps/webapp/test/uatEnvironmentClaim.test.ts index 5c4327d5d..46e6f2431 100644 --- a/apps/webapp/test/uatEnvironmentClaim.test.ts +++ b/apps/webapp/test/uatEnvironmentClaim.test.ts @@ -69,9 +69,20 @@ vi.mock("~/db.server", () => ({ MEMBER_USER_IDS.includes(where.id) ? { id: where.id } : null, }, runtimeEnvironment: { + // Enough of the where-clause to tell the rows apart the way Prisma would: the branchless + // lookup keys on slug, the branch lookup on type + branchName, and dev of either kind is + // additionally scoped to the calling member. findFirst: async ({ where }: any) => - ENVIRONMENTS.find((env) => env.projectId === where.projectId && env.slug === where.slug) ?? - null, + ENVIRONMENTS.find((env) => { + if (env.projectId !== where.projectId) return false; + if (where.slug !== undefined && env.slug !== where.slug) return false; + if (where.type !== undefined && env.type !== where.type) return false; + if (where.branchName !== undefined && env.branchName !== where.branchName) return false; + if (where.archivedAt !== undefined && env.archivedAt !== where.archivedAt) return false; + if (where.orgMember?.userId && env.orgMemberUserId !== where.orgMember.userId) + return false; + return true; + }) ?? null, }, workerDeployment: { findFirst: async () => null }, backgroundWorkerTask: { findMany: async () => [] }, @@ -98,11 +109,20 @@ const PROJECT = { id: "proj_1234", externalRef: "proj_ref_1234", slug: "test-pro const USER_ID = "usr_member"; const MEMBER_USER_IDS = [USER_ID]; -function environment(id: string, slug: string, type: "PRODUCTION" | "STAGING") { +function environment( + id: string, + slug: string, + type: "PRODUCTION" | "STAGING" | "PREVIEW" | "DEVELOPMENT", + branchName: string | null = null +) { return { id, slug, type, + branchName, + archivedAt: null, + // Dev rows are per-member; the others aren't scoped to one. + orgMemberUserId: type === "DEVELOPMENT" ? USER_ID : null, apiKey: `tr_${slug}_abcdefghijklmnop`, organizationId: ORGANIZATION.id, organization: ORGANIZATION, @@ -114,7 +134,23 @@ function environment(id: string, slug: string, type: "PRODUCTION" | "STAGING") { // Two environments of the same project, both reachable by the same member. const ENV_A = environment("env_aaaa", "prod", "PRODUCTION"); const ENV_B = environment("env_bbbb", "stg", "STAGING"); -const ENVIRONMENTS = [ENV_A, ENV_B]; + +// The two branchable families: a parent and one of its branch children each. `upsertBranch` gives +// the child its own slug, but both rows answer to the same API env name — "preview", "dev". +const PREVIEW_BRANCH_NAME = "feat/checkout"; +const DEV_BRANCH_NAME = "katia/spike"; +const PREVIEW_PARENT = environment("env_preview", "preview", "PREVIEW"); +const PREVIEW_BRANCH = { + ...environment("env_preview_branch", "preview-feat-checkout", "PREVIEW", PREVIEW_BRANCH_NAME), + // The resolver reads the parent to override the branch's api key. + parentEnvironment: PREVIEW_PARENT, +}; +const DEV_PARENT = environment("env_dev", "dev", "DEVELOPMENT"); +const DEV_BRANCH = { + ...environment("env_dev_branch", "dev-katia-spike", "DEVELOPMENT", DEV_BRANCH_NAME), + parentEnvironment: DEV_PARENT, +}; +const ENVIRONMENTS = [ENV_A, ENV_B, PREVIEW_PARENT, PREVIEW_BRANCH, DEV_PARENT, DEV_BRANCH]; function mintToken(opts: { environmentId?: string; client?: string } = {}) { return signUserActorToken(SESSION_SECRET, { @@ -135,13 +171,30 @@ async function respond(call: () => Promise): Promise { } } +// `branch` rides the same header the SDK and the agent's client send. +let branchHeader: string | undefined; + function requestFor(token: string, url: string, init?: RequestInit) { return new Request(`https://example.com${url}`, { - headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + ...(branchHeader ? { "x-trigger-branch": branchHeader } : {}), + }, ...init, }); } +/** Runs a route case with the branch header set for the duration of the call. */ +async function withBranch(branch: string | undefined, call: () => Promise): Promise { + branchHeader = branch; + try { + return await call(); + } finally { + branchHeader = undefined; + } +} + type RouteCase = { name: string; /** `env` is the URL slug the route resolves from, not the environment id. */ @@ -292,6 +345,100 @@ describe("user-actor token environment scope", () => { }); }); +/** + * Every environment shape the agent can be opened on, against the real routes. `preview` and `dev` + * name a family rather than a row, so a branch is only addressable when `x-trigger-branch` travels + * with the name — and a token minted for the branch is refused against the parent it otherwise + * resolves to. Production and staging have no branch and must be unchanged by any of it. + */ +type EnvironmentCase = { + name: string; + env: string; + branch?: string; + expected: { id: string }; + /** The row a request without the branch lands on instead, for the branchable families. */ + fallsBackTo?: { id: string }; +}; + +const ENVIRONMENT_CASES: EnvironmentCase[] = [ + { name: "production", env: "prod", expected: ENV_A }, + { name: "staging", env: "staging", expected: ENV_B }, + { + name: "a preview branch", + env: "preview", + branch: PREVIEW_BRANCH_NAME, + expected: PREVIEW_BRANCH, + fallsBackTo: PREVIEW_PARENT, + }, + { + name: "a development branch", + env: "dev", + branch: DEV_BRANCH_NAME, + expected: DEV_BRANCH, + fallsBackTo: DEV_PARENT, + }, +]; + +describe.each(ENVIRONMENT_CASES)( + "user-actor token on $name", + ({ env, branch, expected, fallsBackTo }) => { + beforeEach(() => { + mocks.can.mockReset(); + mocks.can.mockReturnValue(true); + mocks.findCurrentWorkerFromEnvironment.mockReset(); + mocks.findCurrentWorkerFromEnvironment.mockResolvedValue({ + id: "worker_1", + friendlyId: "worker_1234", + version: "20240101.1", + engine: "V2", + sdkVersion: "4.0.0", + cliVersion: "4.0.0", + }); + }); + + it("mints for that exact environment", async () => { + const token = await mintToken({ environmentId: expected.id }); + + const response = await withBranch(branch, () => ROUTE_CASES[0].call(token, env)); + + expect(response.status).toBe(200); + const { token: jwt } = (await response.json()) as { token: string }; + const payload = JSON.parse(Buffer.from(jwt.split(".")[1]!, "base64url").toString()); + expect(payload.sub).toBe(expected.id); + }); + + it("resolves that exact environment on the delegated-token reads too", async () => { + const token = await mintToken({ environmentId: expected.id }); + + const response = await withBranch(branch, () => ROUTE_CASES[2].call(token, env)); + + expect(response.status).toBe(200); + expect(mocks.findCurrentWorkerFromEnvironment.mock.calls[0][0]).toMatchObject({ + id: expected.id, + }); + }); + + if (fallsBackTo) { + it("403s the branch's token when the branch didn't travel with it", async () => { + const token = await mintToken({ environmentId: expected.id }); + + const response = await ROUTE_CASES[0].call(token, env); + + expect(response.status).toBe(403); + expect(await response.json()).toMatchObject({ code: "forbidden_environment" }); + }); + + it("403s the parent's token when a branch did", async () => { + const token = await mintToken({ environmentId: fallsBackTo.id }); + + const response = await withBranch(branch, () => ROUTE_CASES[0].call(token, env)); + + expect(response.status).toBe(403); + }); + } + } +); + /** * The exchange's own ceiling: a delegated token names the scopes it wants, and its `cap` is * what it may have. Without the intersection a read-only agent token mints a write JWT diff --git a/internal-packages/dashboard-agent/src/agent-runtime.ts b/internal-packages/dashboard-agent/src/agent-runtime.ts index f9176a940..94e259009 100644 --- a/internal-packages/dashboard-agent/src/agent-runtime.ts +++ b/internal-packages/dashboard-agent/src/agent-runtime.ts @@ -300,6 +300,7 @@ export const clientDataSchema = z.object({ apiOrigin: z.string().optional(), projectRef: z.string().optional(), environmentName: z.string().optional(), + environmentBranch: z.string().optional(), // Injected only when the current project has a connected GitHub repo: a signed, // short-lived archive pointer the code-mode source tools read from. repoSnapshot: z diff --git a/internal-packages/dashboard-agent/src/tool-api-branch.test.ts b/internal-packages/dashboard-agent/src/tool-api-branch.test.ts new file mode 100644 index 000000000..6b1252569 --- /dev/null +++ b/internal-packages/dashboard-agent/src/tool-api-branch.test.ts @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { buildApiTools } from "./tool-api"; +import { createApiClient } from "./tool-api-client"; + +/** + * The agent's tools, on each environment it can be opened on. The API's env routes are + * name-addressed and a branch shares its parent's name ("preview", "dev"), so the name identifies + * a family and `x-trigger-branch` is what picks the row out of it. Without the branch every + * name-addressed call resolves to the parent, and the token minted for the branch is refused + * there — which the agent then reported to the model as "no current environment". + * + * Real tool path, reconstructed route: the tools, the client, the exchange and its cache are the + * shipping code, driven through `execute` exactly as the model drives them. The server is a stub + * that answers the way the real routes do — it resolves name + branch to a row and refuses a token + * minted for a different one, mirroring `assertUserActorEnvironment`. The real handlers are driven + * separately by `apps/webapp/test/uatEnvironmentClaim.test.ts` over the same wire format, which is + * the closest the two packages can be joined: `internal-packages/dashboard-agent/src/index.ts` + * requires webapp imports to stay type-only, so no single test can hold both halves. + */ + +const ORIGIN = "https://api.example.com"; +const PREVIEW_BRANCH_NAME = "feat/checkout"; +const DEV_BRANCH_NAME = "katia/spike"; + +// What the server resolves a (name, branch) address to. A branchless name lands on the parent. +const ENVIRONMENTS: Record = { + "prod|": "env_prod", + "staging|": "env_staging", + "preview|": "env_preview_parent", + [`preview|${PREVIEW_BRANCH_NAME}`]: "env_preview_branch", + "dev|": "env_dev_parent", + [`dev|${DEV_BRANCH_NAME}`]: "env_dev_branch", +}; + +type Call = { url: string; branch: string | null }; +let calls: Call[] = []; + +function resolveEnvironment(url: string, branch: string | null): string | undefined { + const name = url.match(/\/api\/v1\/projects\/[^/]+\/([^/]+)/)?.[1]; + return ENVIRONMENTS[`${name}|${branch ?? ""}`]; +} + +/** + * `tokenEnvironmentId` is the environment the delegated token was minted for. A request resolving + * to another one is refused, the way `assertUserActorEnvironment` refuses it. + */ +function stubFetch(opts: { tokenEnvironmentId: string; jwtStatus?: () => number | undefined }) { + return vi.fn(async (input: any, init: any = {}) => { + const url = typeof input === "string" ? input : input.url; + const branch = new Headers(init.headers ?? {}).get("x-trigger-branch"); + calls.push({ url, branch }); + + // The env-JWT reads carry the minted JWT, not the delegated token: they address the + // environment by id, so no branch is involved. + if (!url.includes("/api/v1/projects/")) { + return Response.json({ data: [] }); + } + + const resolved = resolveEnvironment(url, branch); + if (resolved !== opts.tokenEnvironmentId) { + return Response.json( + { error: "This token isn't scoped to that environment.", code: "forbidden_environment" }, + { status: 403 } + ); + } + if (url.endsWith("/jwt")) { + const forced = opts.jwtStatus?.(); + if (forced) return new Response("nope", { status: forced }); + return Response.json({ token: `env-jwt:${resolved}` }); + } + return Response.json({ environmentId: resolved }); + }); +} + +function tools(overrides: Record = {}) { + const ctx = { + userActorToken: "uat", + apiOrigin: ORIGIN, + projectRef: "proj_ref", + ...overrides, + }; + return buildApiTools({ + ctx, + client: createApiClient(ctx), + renderInvestigations: (() => []) as any, + }); +} + +const run = (t: ReturnType, name: string, input: any = {}) => + (t[name] as any).execute(input, {} as any) as Promise>; + +/** The four shapes an agent session can be opened on. */ +const ENVIRONMENT_CASES = [ + { name: "production", environmentName: "prod", branch: undefined, id: "env_prod" }, + { name: "staging", environmentName: "staging", branch: undefined, id: "env_staging" }, + { + name: "a preview branch", + environmentName: "preview", + branch: PREVIEW_BRANCH_NAME, + id: "env_preview_branch", + parentId: "env_preview_parent", + }, + { + name: "a development branch", + environmentName: "dev", + branch: DEV_BRANCH_NAME, + id: "env_dev_branch", + parentId: "env_dev_parent", + }, +]; + +describe.each(ENVIRONMENT_CASES)( + "the agent's tools on $name", + ({ environmentName, branch, id, parentId }) => { + beforeEach(() => { + calls = []; + vi.stubGlobal("fetch", stubFetch({ tokenEnvironmentId: id })); + }); + afterEach(() => vi.unstubAllGlobals()); + + const ctx = () => ({ environmentName, environmentBranch: branch }); + + it("reaches that exact environment", async () => { + const result = await run(tools(ctx()), "list_runs"); + + expect(result.error).toBeUndefined(); + expect(calls.find((call) => call.url.endsWith("/jwt"))!.branch).toBe(branch ?? null); + }); + + it("reaches it on the delegated-token reads too", async () => { + const t = tools(ctx()); + + const tasks = await run(t, "list_tasks"); + const commit = await run(t, "correlate_version", { runId: "run_1234" }); + + expect(tasks.error).toBeUndefined(); + expect(commit.error).toBeUndefined(); + const named = calls.filter((call) => call.url.includes("/api/v1/projects/")); + expect(named.length).toBeGreaterThan(0); + expect(named.every((call) => call.branch === (branch ?? null))).toBe(true); + }); + + if (parentId) { + it("is refused, not silently served the parent, when the branch is dropped", async () => { + const result = await run(tools({ environmentName }), "list_runs"); + + expect(result.error).toBe( + "Couldn't reach the current environment to read runs from (status 403)." + ); + }); + } + } +); + +/** + * The failure that hid the one above: an exchange that was refused is not an environment that + * isn't there, and the model has to be able to tell them apart. Same three-state shape the queue's + * live read uses — only the definite case is stated definitely. + */ +describe("an environment that couldn't be reached", () => { + beforeEach(() => { + calls = []; + vi.stubGlobal("fetch", stubFetch({ tokenEnvironmentId: "env_preview_branch" })); + }); + afterEach(() => vi.unstubAllGlobals()); + + const branchCtx = { environmentName: "preview", environmentBranch: PREVIEW_BRANCH_NAME }; + + it("says there is no environment only when none was named", async () => { + const result = await run(tools(), "list_runs"); + + expect(result.error).toBe("No current environment is available to read runs from."); + expect(calls).toEqual([]); + }); + + it.each([ + ["read errors from", "list_errors"], + ["read deployments from", "list_deploys"], + ["query", "get_query_schema"], + ])("distinguishes the two for %s", async (action, toolName) => { + const refused = await run(tools({ environmentName: "preview" }), toolName); + const absent = await run(tools(), toolName); + + expect(refused.error).toBe(`Couldn't reach the current environment to ${action} (status 403).`); + expect(absent.error).toBe(`No current environment is available to ${action}.`); + }); + + it("retries a failed exchange rather than pinning the turn to it", async () => { + let attempts = 0; + vi.stubGlobal( + "fetch", + stubFetch({ + tokenEnvironmentId: "env_preview_branch", + jwtStatus: () => (attempts++ === 0 ? 500 : undefined), + }) + ); + const t = tools(branchCtx); + + const first = await run(t, "list_runs"); + const second = await run(t, "list_runs"); + + expect(first.error).toBe( + "Couldn't reach the current environment to read runs from (status 500)." + ); + expect(second.error).toBeUndefined(); + }); +}); diff --git a/internal-packages/dashboard-agent/src/tool-api-client.ts b/internal-packages/dashboard-agent/src/tool-api-client.ts index ccb892cae..eb31b2488 100644 --- a/internal-packages/dashboard-agent/src/tool-api-client.ts +++ b/internal-packages/dashboard-agent/src/tool-api-client.ts @@ -7,6 +7,22 @@ import { logger } from "@trigger.dev/sdk"; export type FetchResult = { ok: true; data: unknown } | { ok: false; status: number }; +/** + * Why an environment-scoped call was never made. Only `"missing"` says there is no current + * environment; `"unknown"` is an exchange that failed, which is not evidence of absence. + */ +export type EnvUnavailable = + | { ok: false; envUnavailable: "missing" } + | { ok: false; envUnavailable: "unknown"; status?: number }; + +export type EnvFetchResult = FetchResult | EnvUnavailable; + +export function isEnvUnavailable(result: object): result is EnvUnavailable { + return "envUnavailable" in result; +} + +const MISSING_ENV: EnvUnavailable = { ok: false, envUnavailable: "missing" }; + // "query" is the server rejecting the TRQL, "transport" is the request breaking. Chart // validation only fails a render on "query". export type QueryPostResult = @@ -15,32 +31,54 @@ export type QueryPostResult = export const NO_AUTH = { error: "No delegated access is available for this turn." } as const; -export async function apiGet(origin: string, path: string, token: string): Promise { - const res = await fetch(`${origin}${path}`, { - headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, - }); +// `branch` is needed on the name-addressed routes: `preview`/`dev` resolve to the parent +// environment unless the branch travels with them, and a branch-scoped token then 403s. +export async function apiGet( + origin: string, + path: string, + token: string, + branch?: string +): Promise { + const headers: Record = { + Authorization: `Bearer ${token}`, + Accept: "application/json", + }; + if (branch) headers["x-trigger-branch"] = branch; + const res = await fetch(`${origin}${path}`, { headers }); if (!res.ok) return { ok: false, status: res.status }; return { ok: true, data: await res.json() }; } // The exchange ceilings these scopes to the delegated token's read-only cap, so the -// JWT can never widen the grant. Null when there is no current env, or on a denial. +// JWT can never widen the grant. async function exchangeEnvJwt( origin: string, userActorToken: string, projectRef: string, - environmentName: string -): Promise { - const res = await fetch(`${origin}/api/v1/projects/${projectRef}/${environmentName}/jwt`, { - method: "POST", - headers: { Authorization: `Bearer ${userActorToken}`, "Content-Type": "application/json" }, - body: JSON.stringify({ - claims: { scopes: ["read:runs", "read:deployments", "read:errors", "read:query"] }, - }), - }); - if (!res.ok) return null; - const data = (await res.json()) as { token?: string }; - return data.token ?? null; + environmentName: string, + branch?: string +): Promise<{ ok: true; token: string } | EnvUnavailable> { + const headers: Record = { + Authorization: `Bearer ${userActorToken}`, + "Content-Type": "application/json", + }; + if (branch) headers["x-trigger-branch"] = branch; + let res: Response; + try { + res = await fetch(`${origin}/api/v1/projects/${projectRef}/${environmentName}/jwt`, { + method: "POST", + headers, + body: JSON.stringify({ + claims: { scopes: ["read:runs", "read:deployments", "read:errors", "read:query"] }, + }), + }); + } catch { + return { ok: false, envUnavailable: "unknown" }; + } + if (!res.ok) return { ok: false, envUnavailable: "unknown", status: res.status }; + const data = (await res.json().catch(() => ({}))) as { token?: string }; + if (!data.token) return { ok: false, envUnavailable: "unknown" }; + return { ok: true, token: data.token }; } export type DashboardAgentApiClient = { @@ -48,9 +86,9 @@ export type DashboardAgentApiClient = { origin: string; /** Whether this turn has both a delegated token and an origin to spend it on. */ hasAuth: boolean; - /** A GET as the environment JWT. `null` means there is no current environment. */ - envApiGet(path: string): Promise; - postQuery(query: string, period: string | undefined): Promise; + /** A GET as the environment JWT, or why no environment JWT could be made. */ + envApiGet(path: string): Promise; + postQuery(query: string, period: string | undefined): Promise; validateChartQuery(query: string, period: string | undefined): Promise; }; @@ -59,48 +97,60 @@ export type ApiClientContext = { apiOrigin?: string; projectRef?: string; environmentName?: string; + environmentBranch?: string; }; export function createApiClient(ctx: ApiClientContext): DashboardAgentApiClient { - const { userActorToken, apiOrigin, projectRef, environmentName } = ctx; + const { userActorToken, apiOrigin, projectRef, environmentName, environmentBranch } = ctx; const origin = apiOrigin ? apiOrigin.replace(/\/$/, "") : ""; const hasAuth = Boolean(userActorToken && origin); // Turn-scoped, since the tool set is rebuilt per turn, and keyed by project + // environment. Caching the promise makes concurrent calls share one exchange. - const envJwts = new Map>(); - function getEnvJwt(refresh = false): Promise { - if (!hasAuth || !projectRef || !environmentName) return Promise.resolve(null); - const key = `${projectRef}/${environmentName}`; + type EnvJwt = { ok: true; token: string } | EnvUnavailable; + const envJwts = new Map>(); + function getEnvJwt(refresh = false): Promise { + if (!hasAuth || !projectRef || !environmentName) return Promise.resolve(MISSING_ENV); + const key = `${projectRef}/${environmentName}/${environmentBranch ?? ""}`; if (refresh) envJwts.delete(key); let pending = envJwts.get(key); if (!pending) { - pending = exchangeEnvJwt(origin, userActorToken!, projectRef, environmentName); + // A failed exchange is not cached: a 403 or a 5xx would otherwise pin the whole turn. + pending = exchangeEnvJwt( + origin, + userActorToken!, + projectRef, + environmentName, + environmentBranch + ).then((result) => { + if (!result.ok) envJwts.delete(key); + return result; + }); envJwts.set(key, pending); } return pending; } /** - * `null` means there is no current environment. On an unauthorized result the cache - * entry is dropped and the call is retried once, since a token can be minted stale. + * On an unauthorized result the cache entry is dropped and the call is retried once, + * since a token can be minted stale. */ - async function withEnvJwt( + async function withEnvJwt( call: (jwt: string) => Promise, isUnauthorized: (result: T) => boolean - ): Promise { + ): Promise { const jwt = await getEnvJwt(); - if (!jwt) return null; - const first = await call(jwt); + if (!jwt.ok) return jwt; + const first = await call(jwt.token); if (!isUnauthorized(first)) return first; const fresh = await getEnvJwt(true); - if (!fresh) return first; - return call(fresh); + if (!fresh.ok) return first; + return call(fresh.token); } const unauthorizedGet = (result: FetchResult) => !result.ok && result.status === 401; - function envApiGet(path: string): Promise { + function envApiGet(path: string): Promise { return withEnvJwt((jwt) => apiGet(origin, path, jwt), unauthorizedGet); } @@ -109,7 +159,7 @@ export function createApiClient(ctx: ApiClientContext): DashboardAgentApiClient async function postQuery( query: string, period: string | undefined - ): Promise { + ): Promise { const attempt = await withEnvJwt<{ res: Response } | { error: string }>( async (jwt) => { try { @@ -130,7 +180,7 @@ export function createApiClient(ctx: ApiClientContext): DashboardAgentApiClient }, (result) => "res" in result && result.res.status === 401 ); - if (!attempt) return null; + if (isEnvUnavailable(attempt)) return attempt; if ("error" in attempt) return { ok: false, kind: "transport", error: attempt.error }; const res = attempt.res; // The route returns 400 with { error } for invalid TRQL. @@ -154,7 +204,7 @@ export function createApiClient(ctx: ApiClientContext): DashboardAgentApiClient period: string | undefined ): Promise { const result = await postQuery(query, period); - if (!result || result.ok) return null; + if (isEnvUnavailable(result) || result.ok) return null; if (result.kind === "transport") { logger.warn("Skipped chart query validation", { error: result.error }); return null; diff --git a/internal-packages/dashboard-agent/src/tool-api.ts b/internal-packages/dashboard-agent/src/tool-api.ts index bff7b077d..d7f195875 100644 --- a/internal-packages/dashboard-agent/src/tool-api.ts +++ b/internal-packages/dashboard-agent/src/tool-api.ts @@ -20,7 +20,13 @@ import { runQuerySchema, searchDocsSchema, } from "./tool-schemas"; -import { apiGet, NO_AUTH, type DashboardAgentApiClient } from "./tool-api-client"; +import { + apiGet, + isEnvUnavailable, + NO_AUTH, + type DashboardAgentApiClient, + type EnvUnavailable, +} from "./tool-api-client"; import type { DashboardAgentToolContext } from "./tool-context"; import { clampPeriod, @@ -40,6 +46,19 @@ import { import { searchTriggerDocs } from "./tool-docs"; import type { InvestigationRenderer } from "./tool-investigations"; +/** + * What to tell the model when a read never reached an environment. Only a missing + * environment is stated as one; a failed exchange says the read didn't land, and carries + * its status, so an authorization failure is never reported as an absent environment. + */ +export function envUnavailableError(result: EnvUnavailable, action: string): { error: string } { + if (result.envUnavailable === "missing") { + return { error: `No current environment is available to ${action}.` }; + } + const status = result.status ? ` (status ${result.status})` : ""; + return { error: `Couldn't reach the current environment to ${action}${status}.` }; +} + /** * The API read tools, in the frozen key order `dashboardAgentToolSchemas` declares: * a different order is a different cached prompt prefix. @@ -50,7 +69,7 @@ export function buildApiTools(args: { renderInvestigations: InvestigationRenderer; }): ToolSet { const { ctx, client, renderInvestigations } = args; - const { userActorToken, projectRef, environmentName } = ctx; + const { userActorToken, projectRef, environmentName, environmentBranch } = ctx; const { origin, hasAuth, envApiGet, postQuery, validateChartQuery } = client; return { @@ -91,7 +110,8 @@ export function buildApiTools(args: { const result = await apiGet( origin, `/api/v1/projects/${projectRef}/${environmentName}/workers/current`, - userActorToken! + userActorToken!, + environmentBranch ); if (!result.ok) return { error: `Couldn't list tasks (status ${result.status}).` }; return curateTasks(result.data); @@ -109,7 +129,7 @@ export function buildApiTools(args: { if (effectivePeriod) sp.append("filter[createdAt][period]", effectivePeriod); sp.append("page[size]", String(Math.min(limit ?? 10, 50))); const result = await envApiGet(`/api/v1/runs?${sp.toString()}`); - if (!result) return { error: "No current environment is available to read runs from." }; + if (isEnvUnavailable(result)) return envUnavailableError(result, "read runs from"); if (!result.ok) return { error: `Couldn't list runs (status ${result.status}).` }; return { ...curateRuns(result.data), period: effectivePeriod }; }, @@ -122,7 +142,7 @@ export function buildApiTools(args: { ...getRunSchema, execute: async ({ runId }) => { const result = await envApiGet(`/api/v3/runs/${encodeURIComponent(runId)}`); - if (!result) return { error: "No current environment is available to read runs from." }; + if (isEnvUnavailable(result)) return envUnavailableError(result, "read runs from"); if (!result.ok) return { error: `Couldn't get run ${runId} (status ${result.status}).` }; return curateRun(result.data); }, @@ -132,7 +152,7 @@ export function buildApiTools(args: { ...getRunTraceSchema, execute: async ({ runId }) => { const result = await envApiGet(`/api/v1/runs/${encodeURIComponent(runId)}/trace`); - if (!result) return { error: "No current environment is available to read runs from." }; + if (isEnvUnavailable(result)) return envUnavailableError(result, "read runs from"); if (!result.ok) return { error: `Couldn't get the trace for ${runId} (status ${result.status}).` }; return curateTrace(result.data); @@ -149,7 +169,7 @@ export function buildApiTools(args: { if (period) sp.append("filter[period]", period); sp.append("page[size]", String(Math.min(limit ?? 20, 100))); const result = await envApiGet(`/api/v1/errors?${sp.toString()}`); - if (!result) return { error: "No current environment is available to read errors from." }; + if (isEnvUnavailable(result)) return envUnavailableError(result, "read errors from"); if (!result.ok) return { error: `Couldn't list errors (status ${result.status}).` }; return curateErrors(result.data); }, @@ -159,7 +179,7 @@ export function buildApiTools(args: { ...getErrorSchema, execute: async ({ errorId }) => { const result = await envApiGet(`/api/v1/errors/${encodeURIComponent(errorId)}`); - if (!result) return { error: "No current environment is available to read errors from." }; + if (isEnvUnavailable(result)) return envUnavailableError(result, "read errors from"); if (!result.ok) return { error: `Couldn't get error ${errorId} (status ${result.status}).` }; return curateError(result.data); @@ -170,7 +190,7 @@ export function buildApiTools(args: { ...getQuerySchemaSchema, execute: async ({ table }) => { const result = await envApiGet("/api/v1/query/schema"); - if (!result) return { error: "No current environment is available to query." }; + if (isEnvUnavailable(result)) return envUnavailableError(result, "query"); if (!result.ok) return { error: `Couldn't load the query schema (status ${result.status}).` }; const tables = ((result.data as { tables?: any[] })?.tables ?? []) as any[]; @@ -208,7 +228,7 @@ export function buildApiTools(args: { ...runQuerySchema, execute: async ({ query, period }) => { const result = await postQuery(query, period); - if (!result) return { error: "No current environment is available to query." }; + if (isEnvUnavailable(result)) return envUnavailableError(result, "query"); if (!result.ok) return { error: result.error }; const cap = 200; const rows = result.rows; @@ -294,7 +314,7 @@ export function buildApiTools(args: { const result = await envApiGet( `/api/v1/reports/${encodeURIComponent(reportKey)}?${sp.toString()}` ); - if (!result) return { error: "No current environment is available to report on." }; + if (isEnvUnavailable(result)) return envUnavailableError(result, "report on"); if (!result.ok) { return { error: `Couldn't get the ${reportKey} report (status ${result.status}).` }; } @@ -324,7 +344,7 @@ export function buildApiTools(args: { const result = await envApiGet( `/api/v1/queues/${encodeURIComponent(queue)}/metrics?${sp.toString()}` ); - if (!result) return { error: "No current environment is available to read queues from." }; + if (isEnvUnavailable(result)) return envUnavailableError(result, "read queues from"); if (!result.ok) { return { error: `Couldn't get metrics for the ${queue} queue (status ${result.status}).`, @@ -343,9 +363,7 @@ export function buildApiTools(args: { if (effectivePeriod) sp.append("period", effectivePeriod); sp.append("page[size]", String(Math.min(limit ?? 10, 50))); const result = await envApiGet(`/api/v1/deployments?${sp.toString()}`); - if (!result) { - return { error: "No current environment is available to read deployments from." }; - } + if (isEnvUnavailable(result)) return envUnavailableError(result, "read deployments from"); if (!result.ok) return { error: `Couldn't list deployments (status ${result.status}).` }; const rows = ((result.data as any)?.data ?? []) as any[]; return { @@ -359,11 +377,11 @@ export function buildApiTools(args: { get_deploy: tool({ ...getDeploySchema, execute: async ({ version }) => { - const noEnv = { error: "No current environment is available to read deployments from." }; + const noEnv = (r: EnvUnavailable) => envUnavailableError(r, "read deployments from"); // No version: the promoted deployment, which is what new runs use. if (!version) { const result = await envApiGet("/api/v1/deployments/current"); - if (!result) return noEnv; + if (isEnvUnavailable(result)) return noEnv(result); if (!result.ok) { return { error: `Couldn't get the current deployment (status ${result.status}).` }; } @@ -372,7 +390,7 @@ export function buildApiTools(args: { // The public retrieve route is API-key-only, so find the version in the // JWT-reachable list instead. const result = await envApiGet("/api/v1/deployments?page[size]=100"); - if (!result) return noEnv; + if (isEnvUnavailable(result)) return noEnv(result); if (!result.ok) return { error: `Couldn't look up deployments (status ${result.status}).` }; const rows = ((result.data as any)?.data ?? []) as any[]; const match = (Array.isArray(rows) ? rows : []).find( @@ -398,7 +416,8 @@ export function buildApiTools(args: { const result = await apiGet( origin, `/api/v1/projects/${projectRef}/${environmentName}/runs/${encodeURIComponent(runId)}/commit`, - userActorToken! + userActorToken!, + environmentBranch ); if (!result.ok) { if (result.status === 404) { diff --git a/internal-packages/dashboard-agent/src/tool-context.ts b/internal-packages/dashboard-agent/src/tool-context.ts index 8c1a1dcbf..f5bf07903 100644 --- a/internal-packages/dashboard-agent/src/tool-context.ts +++ b/internal-packages/dashboard-agent/src/tool-context.ts @@ -15,6 +15,9 @@ export type DashboardAgentToolContext = { projectRef?: string; // Canonical API env name (dev/staging/prod/preview), resolved by the proxy. environmentName?: string; + // Set when the current environment is a branch. The name-addressed routes resolve to the + // parent without it, so a branch-scoped token would be refused there. + environmentBranch?: string; // RuntimeEnvironment id: the `{env}` component of every trigger:// URI this turn // emits. Names and slugs must never appear in a URI. environmentId?: string; diff --git a/internal-packages/dashboard-agent/src/tool-source-ledger.ts b/internal-packages/dashboard-agent/src/tool-source-ledger.ts index 0716dcb17..178027a30 100644 --- a/internal-packages/dashboard-agent/src/tool-source-ledger.ts +++ b/internal-packages/dashboard-agent/src/tool-source-ledger.ts @@ -26,11 +26,12 @@ export type SourceLedgerContext = { userActorToken?: string; projectRef?: string; environmentName?: string; + environmentBranch?: string; repoSnapshot?: RepoSnapshot; }; export function createSourceReadLedger(ctx: SourceLedgerContext): SourceReadLedger { - const { origin, hasAuth, userActorToken, projectRef, environmentName } = ctx; + const { origin, hasAuth, userActorToken, projectRef, environmentName, environmentBranch } = ctx; // Null means the file tools fall back to the default tracked-branch snapshot. const fetchRunSnapshot = async (runId: string): Promise => { @@ -38,7 +39,8 @@ export function createSourceReadLedger(ctx: SourceLedgerContext): SourceReadLedg const result = await apiGet( origin, `/api/v1/projects/${projectRef}/${environmentName}/repo/snapshot?runId=${encodeURIComponent(runId)}`, - userActorToken! + userActorToken!, + environmentBranch ); if (!result.ok) return null; const d = result.data as Partial | undefined; diff --git a/internal-packages/dashboard-agent/src/tools.ts b/internal-packages/dashboard-agent/src/tools.ts index 41d996f68..b0b61b2e3 100644 --- a/internal-packages/dashboard-agent/src/tools.ts +++ b/internal-packages/dashboard-agent/src/tools.ts @@ -28,6 +28,7 @@ export function buildDashboardAgentTools(ctx: DashboardAgentToolContext): ToolSe userActorToken: ctx.userActorToken, projectRef: ctx.projectRef, environmentName: ctx.environmentName, + environmentBranch: ctx.environmentBranch, repoSnapshot: ctx.repoSnapshot, }); const renderInvestigations = createInvestigationRenderer({