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({