Files
triggerdotdev--trigger.dev/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts
2026-07-13 13:32:57 +08:00

126 lines
5.0 KiB
TypeScript

import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { $replica } from "~/db.server";
import { findProjectBySlug } from "~/models/project.server";
import {
dashboardAgentApiOrigin,
mintDashboardAgentUserActorToken,
resolveDashboardAgentRepoSnapshot,
} from "~/services/dashboardAgent.server";
import { logger } from "~/services/logger.server";
import { requireUser } from "~/services/session.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import { canAccessDashboardAgent } from "~/v3/canAccessDashboardAgent.server";
// Same-origin proxy for the chat "in"/append request. The transport routes the
// `in` endpoint here (and the `out` SSE stream direct to the Trigger API), so
// every turn passes through the dashboard's own session before reaching the
// agent. We use that hop to mint a fresh read-only delegated token for the
// signed-in user and inject it into the turn's metadata server-side. The token
// reaches the agent without ever touching the browser, and minting stays tied
// to the user's own session (no shared-secret backdoor).
//
// The append body is `{ kind, payload: { metadata, ... } }`; we add the token
// (plus the API origin and the server-vouched project ref + env) to
// `payload.metadata`. Only `kind === "message"` turns carry metadata — stop
// chunks pass through untouched. We forward only the headers the API needs and
// deliberately drop the dashboard session cookie.
const FORWARDED_HEADERS = [
"authorization",
"content-type",
"x-part-id",
"x-trigger-source",
"x-trigger-branch",
];
// The API's env routes key on the canonical env name (dev/staging/prod/preview),
// not the dashboard URL slug (e.g. staging's slug is "stg"). Map from the env
// type so the agent's tools address the right environment. Preview branches
// aren't threaded yet (they'd need the branch on every tool call) — a follow-up.
const ENV_NAME_BY_TYPE: Record<string, string> = {
DEVELOPMENT: "dev",
STAGING: "staging",
PRODUCTION: "prod",
PREVIEW: "preview",
};
export async function action({ request, params }: ActionFunctionArgs) {
const user = await requireUser(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
if (
!(await canAccessDashboardAgent({
userId: user.id,
isAdmin: user.admin,
isImpersonating: user.isImpersonating,
organizationSlug,
}))
) {
return json({ error: "Not found" }, { status: 404 });
}
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) return json({ error: "Project not found" }, { status: 404 });
// The SDK builds the upstream path (`realtime/v1/sessions/{chatId}/in/append`);
// it arrives here as the splat. Forward it verbatim to the Trigger API.
const upstreamPath = params["*"];
if (!upstreamPath) return json({ error: "Not found" }, { status: 404 });
const apiOrigin = dashboardAgentApiOrigin();
const url = new URL(request.url);
const upstreamUrl = `${apiOrigin.replace(/\/$/, "")}/${upstreamPath}${url.search}`;
// Resolve the dashboard env slug to the canonical API env name its tools use.
const runtimeEnv = await $replica.runtimeEnvironment.findFirst({
where: { projectId: project.id, slug: envParam },
select: { type: true },
});
const environmentName = runtimeEnv ? ENV_NAME_BY_TYPE[runtimeEnv.type] : undefined;
// When the project has a connected GitHub repo, resolve a signed source-archive
// pointer (code mode). Null otherwise -> the agent stays in assistant mode.
const repoSnapshot = await resolveDashboardAgentRepoSnapshot(project.id);
// Inject the delegated token + context into the turn's metadata.
const raw = await request.text();
let body = raw;
try {
const parsed = JSON.parse(raw) as {
kind?: string;
payload?: { metadata?: Record<string, unknown> };
};
if (parsed.kind === "message" && parsed.payload) {
parsed.payload.metadata = {
...(parsed.payload.metadata ?? {}),
userActorToken: await mintDashboardAgentUserActorToken(user.id),
apiOrigin,
projectRef: project.externalRef,
environmentName,
...(repoSnapshot ? { repoSnapshot } : {}),
};
body = JSON.stringify(parsed);
}
} catch {
// Non-JSON or unexpected shape — forward unchanged rather than break the turn.
}
const headers = new Headers();
for (const name of FORWARDED_HEADERS) {
const value = request.headers.get(name);
if (value) headers.set(name, value);
}
try {
const upstream = await fetch(upstreamUrl, { method: "POST", headers, body });
const text = await upstream.text();
return new Response(text, {
status: upstream.status,
headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" },
});
} catch (error) {
logger.error("Dashboard agent in-proxy failed", { error, upstreamPath });
return json({ error: "The dashboard agent couldn't reach the run." }, { status: 502 });
}
}