feat(webapp): run the dashboard agent through AWS Bedrock behind an env switch (#4609)

## What & why

The dashboard agent can now run its model calls through AWS Bedrock
instead of the direct Anthropic API, chosen by a single env switch. It's
**off by default** (`DASHBOARD_AGENT_MODEL_PROVIDER` unset ⇒
`anthropic`), so merging changes nothing at runtime — the Bedrock path
is a dormant branch until an operator sets the switch and AWS config.
The default Anthropic path is byte-for-byte unchanged.

This also carries a related tenant-isolation hardening for the agent's
delegated token (kept together deliberately — both land the agent on
Bedrock for HIPAA readiness). Refs: TRI-13251, TRI-11032.

## What's inside

**Provider seam** —
`internal-packages/dashboard-agent/src/model-provider.ts`: the registry
now holds both `anthropic` and `bedrock`; `resolveDashboardAgentModel()`
maps the canonical `"anthropic:<id>"` strings the managed prompts carry
to the active provider, and the cache-breakpoint helpers emit the active
provider's shape — Anthropic `cacheControl` vs Bedrock `cachePoint`.
Managed prompt strings stay canonical, so stored prompts don't change
meaning. Unmapped model ids throw rather than shipping a guaranteed-404
profile. All agent, watch, compaction and title callsites route through
the resolver; the `dashboardAgentModelKey` locals override (test mock
injection) is preserved.

**Cache telemetry** — `step-cache.ts`: cache token usage is read from
the active provider (Anthropic reports it on provider metadata; Bedrock
reports the write on metadata and the read via standard usage), so
`gen_ai.usage.cache_*` is populated on both. This also fixes a latent
ordering bug where step attributes could null-overwrite the prompt-cache
read count.

**Webapp callsites** — `dashboardAgentHeadStart.server.ts` and the
head-start route resolve the model and the cache breakpoint through the
same seam, so the warm-up prefix and the following turn share one
provider. The head-start firing gate is provider-aware: on Bedrock it
gates on `AWS_REGION` and lets the SDK resolve credentials (IAM role /
static keys / session token / bearer), so a role-based deploy still
warms; on Anthropic it stays `Boolean(ANTHROPIC_API_KEY)`.
`app/env.server.ts` gains the optional AWS vars and validates
`DASHBOARD_AGENT_MODEL_PROVIDER`. `ANTHROPIC_API_KEY` is untouched and
not required on a Bedrock deploy.

**Tenant-isolation hardening** —
`internal-packages/rbac/src/fallback.ts`: for a **scoped** context, the
OSS `authenticateUserActor` now applies the same membership floor as the
session path — a delegated user-actor token whose user is not a member
of the scoped org/project is denied (403). Unscoped tokens keep their
prior behavior (no tenant claim, no lookup). The user lookup falls back
replica→primary so replication lag can't spuriously 401 a just-joined
member. Members and admins are unaffected. Previously this invariant
held only through per-route discipline; this makes it structural.

## Enabling Bedrock (later, ops)

- Set `DASHBOARD_AGENT_MODEL_PROVIDER=bedrock` **identically** in both
the webapp and the agent task container — the webapp warms the cache
prefix and the task reads it, so a split would silently miss the cache.
- Set `AWS_REGION` and provide credentials the Bedrock SDK can resolve
(IAM role preferred). For v1 this runs **without** an Anthropic API key.
Note: with no Anthropic key set, rollback is "turn the agent off", not
"unset the switch" (unsetting falls back to the Anthropic provider,
which then has no key).
- Two things to confirm before rollout: the Sonnet inference-profile id
is validated against the SDK's own model-id union but still warrants a
live smoke test; and Bedrock prompt caching for Sonnet is a 5-minute
window (not Anthropic's 1h), so input-token cost rises when flipped.

## Testing

Unit tests cover both provider paths: the provider switch and
per-provider cache shapes, a structural regex asserting Bedrock ids are
real inference profiles (not an echo of the table), the split-metadata
cache telemetry, and real-Postgres RBAC tests — member allowed, scoped
non-member denied (org-only and project-only), missing user → 401, admin
non-member exempt, unscoped success. `typecheck --filter webapp` and the
dashboard-agent + rbac suites pass.
This commit is contained in:
Katia Bulatova
2026-08-18 13:14:01 +02:00
committed by GitHub
parent b33197691b
commit e768d0a724
21 changed files with 761 additions and 94 deletions
+22
View File
@@ -209,6 +209,28 @@ const EnvironmentSchema = z
// uses its own key on the Trigger side. When unset, Head Start is disabled
// and the first turn falls back to the normal cold-start path.
ANTHROPIC_API_KEY: z.string().optional(),
// Selects the dashboard agent's LLM provider (default anthropic). The internal
// seam reads process.env directly; this entry validates the value webapp-side.
DASHBOARD_AGENT_MODEL_PROVIDER: z.preprocess(
(v) => (typeof v === "string" && v.trim() === "" ? undefined : v),
z.enum(["anthropic", "bedrock"]).default("anthropic")
),
// AWS credentials for the dashboard agent's Bedrock provider (only used when
// DASHBOARD_AGENT_MODEL_PROVIDER=bedrock; default path stays Anthropic). The
// provider resolves credentials itself, so only the region is read here.
AWS_REGION: z.string().optional(),
AWS_DEFAULT_REGION: z.string().optional(),
AWS_ACCESS_KEY_ID: z.string().optional(),
AWS_SECRET_ACCESS_KEY: z.string().optional(),
AWS_SESSION_TOKEN: z.string().optional(),
AWS_BEARER_TOKEN_BEDROCK: z.string().optional(),
// Dedicated, non-global credentials for the dashboard agent's Bedrock calls (a
// Bedrock-invoke-only IAM user). Kept separate from AWS_ACCESS_KEY_ID/etc so
// injecting them can't hijack the default credential chain the ECR/STS deploy
// clients rely on.
DASHBOARD_AGENT_AWS_ACCESS_KEY_ID: z.string().optional(),
DASHBOARD_AGENT_AWS_SECRET_ACCESS_KEY: z.string().optional(),
DASHBOARD_AGENT_AWS_REGION: z.string().optional(),
DIRECT_URL: z
.string()
.refine(
@@ -17,6 +17,7 @@ import {
softDeleteChat,
} from "@internal/dashboard-agent-db";
import { watchDraftSchema, type WatchDraft } from "@internal/dashboard-agent-contracts";
import { dashboardAgentProvider } from "@internal/dashboard-agent/model-provider";
import { generateFriendlyId } from "@trigger.dev/core/v3/isomorphic";
import type { UIMessage } from "ai";
import { z } from "zod";
@@ -329,7 +330,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
const chatId = generateFriendlyId("chat");
try {
const repoSnapshot = await resolveDashboardAgentRepoSnapshot(project.id);
const headStarted = Boolean(env.ANTHROPIC_API_KEY);
const headStarted =
dashboardAgentProvider() === "bedrock"
? Boolean(env.DASHBOARD_AGENT_AWS_REGION || env.AWS_REGION || env.AWS_DEFAULT_REGION)
: Boolean(env.ANTHROPIC_API_KEY);
// The lookups and the mint all run before the chat row exists, so a failure here can't
// leave an empty chat behind in the user's history.
@@ -1,4 +1,3 @@
import { createAnthropic } from "@ai-sdk/anthropic";
import {
DASHBOARD_AGENT_CODE_SYSTEM_PROMPT,
DASHBOARD_AGENT_MODEL,
@@ -8,9 +7,12 @@ import {
} from "@internal/dashboard-agent/tool-schemas";
import {
describePromptPrefix,
PROMPT_CACHE_CONTROL,
promptCacheAttributes,
} from "@internal/dashboard-agent/prompt-prefix";
import {
resolveDashboardAgentModel,
withCacheBreakpoint,
} from "@internal/dashboard-agent/model-provider";
import { ApiClient, SessionStreamInstance, writeTurnCompleteRecord } from "@trigger.dev/core/v3";
import { chat as chatServer } from "@trigger.dev/sdk/chat-server";
import { streamText, type UIMessage, type UIMessageChunk } from "ai";
@@ -23,8 +25,6 @@ import { logger } from "~/services/logger.server";
const TASK_ID = "dashboard-agent";
const anthropic = createAnthropic({ apiKey: env.ANTHROPIC_API_KEY });
/** Shown when the warm first turn produced nothing. The provider error is only logged. */
export const HEAD_START_FAILURE_ERROR_TEXT =
"The assistant couldn't start this response. Please send your message again.";
@@ -113,16 +113,16 @@ export async function startDashboardAgentHeadStart(params: {
run: async ({ chat: helper }) =>
streamText({
...helper.toStreamTextOptions({ tools }),
model: anthropic(DASHBOARD_AGENT_MODEL),
model: resolveDashboardAgentModel(DASHBOARD_AGENT_MODEL),
// A structured system message, not a bare string: without provider options
// Anthropic neither writes nor reads the cache, so this call paid full price
// the provider neither writes nor reads the cache, so this call paid full price
// for the prefix and the agent's step 2 then paid for a fresh write. The tool
// key order is frozen (see `tool-schemas.ts`) so both prefixes are identical
// — the logged fingerprint is how a drift becomes visible.
system: {
role: "system",
content: system,
providerOptions: { anthropic: { cacheControl: PROMPT_CACHE_CONTROL } },
providerOptions: withCacheBreakpoint(undefined, "prefix"),
},
onStepFinish: (step) => {
logger.info(
@@ -9,9 +9,11 @@
".": "./src/index.ts",
"./tool-curation": "./src/tool-curation.ts",
"./tool-schemas": "./src/tool-schemas.ts",
"./prompt-prefix": "./src/prompt-prefix.ts"
"./prompt-prefix": "./src/prompt-prefix.ts",
"./model-provider": "./src/model-provider.ts"
},
"dependencies": {
"@ai-sdk/amazon-bedrock": "4.0.117",
"@ai-sdk/anthropic": "^3.0.0",
"@internal/dashboard-agent-contracts": "workspace:*",
"@internal/dashboard-agent-db": "workspace:*",
@@ -1,4 +1,3 @@
import { anthropic } from "@ai-sdk/anthropic";
import {
appendChatMessageOnce,
createDashboardAgentDb,
@@ -18,13 +17,7 @@ import {
type UpsertInvestigationResult,
} from "@internal/dashboard-agent-db";
import { locals, logger } from "@trigger.dev/sdk";
import {
createProviderRegistry,
type LanguageModel,
type ModelMessage,
type ToolSet,
type UIMessage,
} from "ai";
import { type LanguageModel, type ModelMessage, type ToolSet, type UIMessage } from "ai";
import { z } from "zod";
import {
agentPageContextSchema,
@@ -32,8 +25,8 @@ import {
investigationStateSchema,
type InvestigationState,
} from "@internal/dashboard-agent-contracts";
import { withCacheBreakpoint } from "./model-provider";
import { codeSystemPrompt, systemPrompt } from "./prompts";
import { PROMPT_CACHE_CONTROL } from "./prompt-prefix";
import { buildDashboardAgentTools } from "./tools";
/**
@@ -63,8 +56,8 @@ function getDb(): DashboardAgentDbClient {
}
// Resolves the `"provider:model-id"` strings on our managed prompts to AI SDK
// models. Add another @ai-sdk/* provider here to allow it on a prompt.
export const registry = createProviderRegistry({ anthropic });
// models, against whichever provider is switched on.
export { registry, resolveDashboardAgentModel } from "./model-provider";
// The agent's persistence, behind an interface so tests can inject a fake via
// `locals` and never need a real database.
@@ -354,7 +347,7 @@ export function sanitizeReplayedToolInputs(messages: ModelMessage[]): ModelMessa
}) as ModelMessage[];
}
// Same Anthropic breakpoint `prepareMessages` rolls onto a turn's last message.
// Same breakpoint `prepareMessages` rolls onto a turn's last message.
export function withCacheBreakpointOnLast(messages: ModelMessage[]): ModelMessage[] {
if (messages.length === 0) return messages;
const last = messages[messages.length - 1]!;
@@ -362,12 +355,9 @@ export function withCacheBreakpointOnLast(messages: ModelMessage[]): ModelMessag
...messages.slice(0, -1),
{
...last,
providerOptions: {
...last.providerOptions,
// Merged, not replaced: the breakpoint is one Anthropic option among any
// others the message already carries.
anthropic: { ...last.providerOptions?.anthropic, cacheControl: PROMPT_CACHE_CONTROL },
},
// Merged, not replaced: the breakpoint is one provider option among any
// others the message already carries.
providerOptions: withCacheBreakpoint(last.providerOptions, "prefix"),
},
];
}
@@ -35,6 +35,7 @@ describe("withCacheBreakpointOnLast", () => {
const prepared = withCacheBreakpointOnLast(lastMessageWithAnthropicOptions());
expect(prepared[1]!.providerOptions).toEqual({
__cacheBreakpoint: { kind: "prefix" },
anthropic: { cacheControl: PROMPT_CACHE_CONTROL, thinking: { budget: 1024 } },
openai: { store: false },
});
@@ -54,6 +55,7 @@ describe("prepareTurnMessages", () => {
});
expect(prepared[1]!.providerOptions).toEqual({
__cacheBreakpoint: { kind: "prefix" },
anthropic: { cacheControl: PROMPT_CACHE_CONTROL, thinking: { budget: 1024 } },
openai: { store: false },
});
@@ -5,7 +5,7 @@ import { generateText, type ModelMessage, type UIMessage } from "ai";
import {
dashboardAgentModelKey,
latestCards,
registry,
resolveDashboardAgentModel,
sanitizeReplayedToolInputs,
} from "./agent-runtime";
@@ -271,7 +271,7 @@ export function renderTranscriptForSummary(messages: ModelMessage[]): string {
async function summarizeConversation(event: SummarizeEvent): Promise<string> {
const { text } = await generateText({
model: locals.get(dashboardAgentModelKey) ?? registry.languageModel(SUMMARY_MODEL),
model: locals.get(dashboardAgentModelKey) ?? resolveDashboardAgentModel(SUMMARY_MODEL),
system: SUMMARY_INSTRUCTION,
prompt: renderTranscriptForSummary(event.messages),
maxOutputTokens: SUMMARY_MAX_OUTPUT_TOKENS,
@@ -16,7 +16,7 @@ import {
getStore,
getSystemPrompt,
modeFor,
registry,
resolveDashboardAgentModel,
sanitizeReplayedToolInputs,
settlementCardMessages,
clearOpenInvestigations,
@@ -25,7 +25,7 @@ import {
type DashboardAgentStore,
} from "./agent-runtime";
import { titlePrompt } from "./prompts";
import { PROMPT_CACHE_CONTROL } from "./prompt-prefix";
import { withCacheBreakpoint } from "./model-provider";
import { recordPromptCacheUsage, stepCachePrepareStep } from "./step-cache";
import { dashboardAgentActionSchema, handleWatchAction } from "./watch-actions";
import { dashboardAgentCompaction, withDurableState } from "./compaction";
@@ -309,9 +309,7 @@ async function generateAndSaveTitle(
const { text } = await generateText({
model:
locals.get(dashboardAgentModelKey) ??
registry.languageModel(
(resolved.model ?? "anthropic:claude-haiku-4-5") as `anthropic:${string}`
),
resolveDashboardAgentModel(resolved.model ?? "anthropic:claude-haiku-4-5"),
system: resolved.text,
prompt: userText,
...resolved.toAISDKTelemetry(),
@@ -428,7 +426,7 @@ export const dashboardAgent = chat.agent({
// prompt; the resolve is cached per process. The cache breakpoint on the system
// block carries through toStreamTextOptions() and survives suspend/resume.
chat.prompt.set(await getSystemPrompt(modeFor(clientData)), {
providerOptions: { anthropic: { cacheControl: PROMPT_CACHE_CONTROL } },
providerOptions: withCacheBreakpoint(undefined, "prefix"),
});
},
@@ -581,9 +579,7 @@ export const dashboardAgent = chat.agent({
...options,
model:
locals.get(dashboardAgentModelKey) ??
registry.languageModel(
(resolved.model ?? "anthropic:claude-sonnet-4-6") as `anthropic:${string}`
),
resolveDashboardAgentModel(resolved.model ?? "anthropic:claude-sonnet-4-6"),
messages,
abortSignal: signal,
prepareStep: stepCachePrepareStep(options) as never,
@@ -1,4 +1,3 @@
import { anthropic } from "@ai-sdk/anthropic";
import {
createDashboardAgentDb,
insertTurnEval,
@@ -6,6 +5,7 @@ import {
} from "@internal/dashboard-agent-db";
import { logger, task } from "@trigger.dev/sdk";
import { EVAL_ERROR_CATEGORIES, redactedEvalOutputErrored } from "./eval-policy";
import { resolveDashboardAgentModel } from "./model-provider";
import { generateObject } from "ai";
import { z } from "zod";
@@ -164,7 +164,7 @@ export const evalTurn = task({
id: "dashboard-agent-eval-turn",
run: async (payload: EvalTurnPayload, { ctx }) => {
const { object } = await generateObject({
model: anthropic(JUDGE_MODEL),
model: resolveDashboardAgentModel(`anthropic:${JUDGE_MODEL}`),
schema: TurnEval,
system: JUDGE_SYSTEM,
prompt: [
@@ -0,0 +1,184 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { PROMPT_CACHE_CONTROL } from "./prompt-prefix";
import {
BEDROCK_MODEL_IDS,
bedrockProviderSettings,
bedrockRegion,
isLongLivedCacheBreakpoint,
isStepCacheBreakpoint,
resolveDashboardAgentModel,
STEP_CACHE_CONTROL,
withCacheBreakpoint,
withoutCacheBreakpoint,
} from "./model-provider";
function useBedrock() {
process.env.DASHBOARD_AGENT_MODEL_PROVIDER = "bedrock";
}
const AWS_ENV_VARS = [
"DASHBOARD_AGENT_MODEL_PROVIDER",
"DASHBOARD_AGENT_AWS_ACCESS_KEY_ID",
"DASHBOARD_AGENT_AWS_SECRET_ACCESS_KEY",
"DASHBOARD_AGENT_AWS_REGION",
"AWS_REGION",
"AWS_DEFAULT_REGION",
] as const;
let priorEnv: Record<string, string | undefined>;
beforeEach(() => {
priorEnv = Object.fromEntries(AWS_ENV_VARS.map((key) => [key, process.env[key]]));
for (const key of AWS_ENV_VARS) delete process.env[key];
});
afterEach(() => {
for (const key of AWS_ENV_VARS) {
if (priorEnv[key] === undefined) delete process.env[key];
else process.env[key] = priorEnv[key];
}
});
describe("resolveDashboardAgentModel", () => {
it("resolves a canonical prompt string against Anthropic by default", () => {
expect(resolveDashboardAgentModel("anthropic:claude-sonnet-4-6").modelId).toBe(
"claude-sonnet-4-6"
);
});
it("maps the same canonical string to a Bedrock inference profile", () => {
useBedrock();
expect(resolveDashboardAgentModel("anthropic:claude-sonnet-4-6").modelId).toBe(
"us.anthropic.claude-sonnet-4-6"
);
expect(resolveDashboardAgentModel("anthropic:claude-haiku-4-5").modelId).toBe(
"us.anthropic.claude-haiku-4-5-20251001-v1:0"
);
});
it("throws rather than guessing a profile for an unmapped id", () => {
useBedrock();
expect(() => resolveDashboardAgentModel("anthropic:claude-made-up-9-9")).toThrow(
/No Bedrock model mapping/
);
});
// Pinned to Anthropic's official Bedrock model table, not a shape regex — there is
// no shared suffix convention across models, so a well-formed id can still be wrong.
it("maps every model to its exact documented Bedrock id", () => {
expect(BEDROCK_MODEL_IDS).toEqual({
"claude-sonnet-4-6": "us.anthropic.claude-sonnet-4-6",
"claude-haiku-4-5": "us.anthropic.claude-haiku-4-5-20251001-v1:0",
});
});
});
describe("cache breakpoints", () => {
it("keeps the Anthropic cacheControl ttls intact, tagged with the discriminator", () => {
expect(withCacheBreakpoint({ openai: { store: false } }, "prefix")).toEqual({
__cacheBreakpoint: { kind: "prefix" },
openai: { store: false },
anthropic: { cacheControl: PROMPT_CACHE_CONTROL },
});
expect(withCacheBreakpoint(undefined, "step")).toEqual({
__cacheBreakpoint: { kind: "step" },
anthropic: { cacheControl: STEP_CACHE_CONTROL },
});
});
it("emits a plain Bedrock cachePoint with no ttl for either marker", () => {
useBedrock();
for (const breakpoint of ["prefix", "step"] as const) {
const options = withCacheBreakpoint(undefined, breakpoint);
// The only thing the SDK serialises to AWS is bedrock.cachePoint — it must be plain.
expect(options.bedrock.cachePoint).toEqual({ type: "default" });
expect(options.bedrock.cachePoint).not.toHaveProperty("ttl");
expect(options.__cacheBreakpoint).toEqual({ kind: breakpoint });
}
});
it("classifies and strips the active provider's breakpoint via the discriminator", () => {
const anthropicStep = withCacheBreakpoint({ anthropic: { keep: true } }, "step");
expect(isStepCacheBreakpoint(anthropicStep)).toBe(true);
expect(isLongLivedCacheBreakpoint(withCacheBreakpoint(undefined, "prefix"))).toBe(true);
// The strip removes both the provider field and the top-level discriminator.
expect(withoutCacheBreakpoint(anthropicStep)).toEqual({ anthropic: { keep: true } });
useBedrock();
const bedrockStep = withCacheBreakpoint(undefined, "step");
const bedrockPrefix = withCacheBreakpoint(undefined, "prefix");
// The two Bedrock markers are byte-identical on the wire — only the tag tells them apart.
expect(bedrockStep.bedrock).toEqual(bedrockPrefix.bedrock);
expect(isStepCacheBreakpoint(bedrockStep)).toBe(true);
expect(isLongLivedCacheBreakpoint(bedrockStep)).toBe(false);
expect(isLongLivedCacheBreakpoint(bedrockPrefix)).toBe(true);
expect(withoutCacheBreakpoint(bedrockStep)).toEqual({});
});
// Conversations persisted before the __cacheBreakpoint discriminator existed carry
// a bare anthropic.cacheControl. Detection must fall back to classifying its ttl.
it("classifies a legacy Anthropic cacheControl with no discriminator by its ttl", () => {
const legacyPrefix = { anthropic: { cacheControl: PROMPT_CACHE_CONTROL } };
const legacyStepWithTtl = { anthropic: { cacheControl: STEP_CACHE_CONTROL } };
const legacyStepNoTtl = { anthropic: { cacheControl: { type: "ephemeral" } } };
expect(isLongLivedCacheBreakpoint(legacyPrefix)).toBe(true);
expect(isStepCacheBreakpoint(legacyPrefix)).toBe(false);
expect(isStepCacheBreakpoint(legacyStepWithTtl)).toBe(true);
expect(isLongLivedCacheBreakpoint(legacyStepWithTtl)).toBe(false);
expect(isStepCacheBreakpoint(legacyStepNoTtl)).toBe(true);
});
it("strips a legacy Anthropic cacheControl even while Bedrock is active", () => {
useBedrock();
const legacyStep = { anthropic: { cacheControl: STEP_CACHE_CONTROL, keep: true } };
expect(withoutCacheBreakpoint(legacyStep)).toEqual({ anthropic: { keep: true } });
});
});
describe("Bedrock region and credential resolution", () => {
it("prefers DASHBOARD_AGENT_AWS_REGION over the global AWS region vars", () => {
process.env.AWS_REGION = "us-east-1";
process.env.AWS_DEFAULT_REGION = "us-west-2";
process.env.DASHBOARD_AGENT_AWS_REGION = "eu-west-1";
expect(bedrockRegion()).toBe("eu-west-1");
});
it("falls back to AWS_REGION, then AWS_DEFAULT_REGION", () => {
process.env.AWS_DEFAULT_REGION = "us-west-2";
expect(bedrockRegion()).toBe("us-west-2");
process.env.AWS_REGION = "us-east-1";
expect(bedrockRegion()).toBe("us-east-1");
});
it("treats an empty region as unset at every tier", () => {
process.env.DASHBOARD_AGENT_AWS_REGION = "";
process.env.AWS_REGION = "";
process.env.AWS_DEFAULT_REGION = "";
expect(bedrockRegion()).toBeUndefined();
});
it("passes explicit credentials when the dedicated pair is set", () => {
process.env.DASHBOARD_AGENT_AWS_ACCESS_KEY_ID = "AKIA_DASHBOARD_AGENT";
process.env.DASHBOARD_AGENT_AWS_SECRET_ACCESS_KEY = "secret";
process.env.DASHBOARD_AGENT_AWS_REGION = "eu-west-1";
expect(bedrockProviderSettings()).toEqual({
region: "eu-west-1",
accessKeyId: "AKIA_DASHBOARD_AGENT",
secretAccessKey: "secret",
});
});
it("keeps the default credential chain when the dedicated pair is unset", () => {
process.env.AWS_REGION = "us-east-1";
expect(bedrockProviderSettings()).toEqual({ region: "us-east-1" });
});
it("keeps the default chain when only one half of the dedicated pair is set", () => {
process.env.DASHBOARD_AGENT_AWS_ACCESS_KEY_ID = "AKIA_DASHBOARD_AGENT";
expect(bedrockProviderSettings()).toEqual({ region: undefined });
});
});
@@ -0,0 +1,181 @@
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock";
import { anthropic } from "@ai-sdk/anthropic";
import { createProviderRegistry } from "ai";
import { PROMPT_CACHE_CONTROL } from "./prompt-prefix";
/**
* Which provider the agent's model calls go through, and the two things that
* differ between them: the model id, and the shape of the prompt-cache options.
*
* Managed prompts stay canonical `"anthropic:<model-id>"` strings whichever
* provider is active, so a stored or dashboard-overridden prompt keeps meaning
* the same model.
*
* Kept free of the SDK runtime so the webapp's head-start path can import it.
*/
export type DashboardAgentProvider = "anthropic" | "bedrock";
/** Global switch, read per call so it can be set per environment. */
export function dashboardAgentProvider(): DashboardAgentProvider {
return process.env.DASHBOARD_AGENT_MODEL_PROVIDER === "bedrock" ? "bedrock" : "anthropic";
}
// Region passed explicitly since the SDK reads only AWS_REGION. `||` treats an empty
// region as unset. DASHBOARD_AGENT_AWS_REGION takes priority over the global vars.
export function bedrockRegion(): string | undefined {
return (
process.env.DASHBOARD_AGENT_AWS_REGION ||
process.env.AWS_REGION ||
process.env.AWS_DEFAULT_REGION ||
undefined
);
}
// Dedicated, non-global credentials only — the default chain (and the global
// AWS_ACCESS_KEY_ID/etc, if ever set) stays untouched for the ECR/STS deploy clients.
function bedrockCredentials(): { accessKeyId: string; secretAccessKey: string } | undefined {
const accessKeyId = process.env.DASHBOARD_AGENT_AWS_ACCESS_KEY_ID;
const secretAccessKey = process.env.DASHBOARD_AGENT_AWS_SECRET_ACCESS_KEY;
return accessKeyId && secretAccessKey ? { accessKeyId, secretAccessKey } : undefined;
}
export function bedrockProviderSettings(): {
region?: string;
accessKeyId?: string;
secretAccessKey?: string;
} {
return { region: bedrockRegion(), ...bedrockCredentials() };
}
const bedrock = createAmazonBedrock(bedrockProviderSettings());
export const registry = createProviderRegistry({ anthropic, bedrock });
/**
* Canonical model id -> Bedrock us cross-region inference profile, verbatim from
* Anthropic's official Bedrock model table. No shared suffix convention across
* models — copy each id exactly rather than deriving it.
*/
export const BEDROCK_MODEL_IDS: Record<string, string> = {
"claude-sonnet-4-6": "us.anthropic.claude-sonnet-4-6",
"claude-haiku-4-5": "us.anthropic.claude-haiku-4-5-20251001-v1:0",
};
/** Resolve a canonical `"anthropic:<model-id>"` string against the active provider. */
export function resolveDashboardAgentModel(model: string) {
const id = model.startsWith("anthropic:") ? model.slice("anthropic:".length) : model;
if (dashboardAgentProvider() === "anthropic") {
return registry.languageModel(`anthropic:${id}` as `anthropic:${string}`);
}
const bedrockId = BEDROCK_MODEL_IDS[id];
if (!bedrockId) {
// No Bedrock profile can be guessed from the canonical id — a made-up one is a
// guaranteed 404, so fail loudly instead.
throw new Error(`No Bedrock model mapping for "${id}"`);
}
return registry.languageModel(`bedrock:${bedrockId}` as `bedrock:${string}`);
}
/**
* The two breakpoints a turn sets: the prefix one that spans the turn, and the
* rolling per-step one.
*/
export type CacheBreakpoint = "prefix" | "step";
export const STEP_CACHE_CONTROL = { type: "ephemeral", ttl: "5m" } as const;
type ProviderOptions = Record<string, any> | undefined;
// Breakpoint discriminator under a top-level key no provider serialises. Value is an
// object because the AI SDK validates providerOptions as records, rejecting a bare string.
const CACHE_BREAKPOINT_KEY = "__cacheBreakpoint";
function breakpointKind(providerOptions: ProviderOptions): CacheBreakpoint | undefined {
const discriminated = providerOptions?.[CACHE_BREAKPOINT_KEY]?.kind;
if (discriminated) return discriminated;
// Conversations persisted before the discriminator existed carry a bare Anthropic
// cacheControl. Classify it by ttl: "1h" is the turn-wide prefix, anything else the step.
const legacyCacheControl = providerOptions?.anthropic?.cacheControl;
if (!legacyCacheControl) return undefined;
return legacyCacheControl.ttl === "1h" ? "prefix" : "step";
}
function cacheOptions(breakpoint: CacheBreakpoint): Record<string, any> {
if (dashboardAgentProvider() === "anthropic") {
return {
anthropic: {
cacheControl: breakpoint === "prefix" ? PROMPT_CACHE_CONTROL : STEP_CACHE_CONTROL,
},
};
}
// Plain, documented cachePoint for both markers — nothing undocumented reaches AWS.
return { bedrock: { cachePoint: { type: "default" } } };
}
/** Merge the active provider's breakpoint into a message's provider options. */
export function withCacheBreakpoint(
providerOptions: ProviderOptions,
breakpoint: CacheBreakpoint
): Record<string, any> {
const [key, options] = Object.entries(cacheOptions(breakpoint))[0]!;
return {
...providerOptions,
[CACHE_BREAKPOINT_KEY]: { kind: breakpoint },
[key]: { ...providerOptions?.[key], ...options },
};
}
/**
* Whether these options carry the rolling step breakpoint — the one the step-strip
* pass rolls off.
*/
export function isStepCacheBreakpoint(providerOptions: ProviderOptions): boolean {
return breakpointKind(providerOptions) === "step";
}
/** Whether these options carry a breakpoint that outlives a step (the turn-wide prefix). */
export function isLongLivedCacheBreakpoint(providerOptions: ProviderOptions): boolean {
return breakpointKind(providerOptions) === "prefix";
}
/**
* The cache token counts the active provider reports on a call's metadata.
* Bedrock puts only the write there; its read count reaches the call's usage.
*/
export function cacheUsageFromProviderMetadata(providerMetadata: unknown): {
write?: number;
read?: number;
} {
const metadata = providerMetadata as Record<string, any> | undefined;
const count = (value: unknown) => (typeof value === "number" ? value : undefined);
if (dashboardAgentProvider() === "anthropic") {
return {
write: count(metadata?.anthropic?.cacheCreationInputTokens),
read: count(metadata?.anthropic?.cacheReadInputTokens),
};
}
return { write: count(metadata?.bedrock?.usage?.cacheWriteInputTokens) };
}
/** The same options with the active provider's breakpoint and its discriminator removed. */
export function withoutCacheBreakpoint(providerOptions: ProviderOptions): Record<string, any> {
const hasDiscriminator = providerOptions?.[CACHE_BREAKPOINT_KEY] !== undefined;
// A legacy message keeps its native anthropic.cacheControl shape no matter which
// provider is active now, so strip that key rather than the current provider's.
const isLegacy = !hasDiscriminator && providerOptions?.anthropic?.cacheControl !== undefined;
const key = isLegacy
? "anthropic"
: dashboardAgentProvider() === "anthropic"
? "anthropic"
: "bedrock";
const field = key === "anthropic" ? "cacheControl" : "cachePoint";
const {
[key]: provider,
[CACHE_BREAKPOINT_KEY]: _tag,
...rest
} = (providerOptions ?? {}) as Record<string, any>;
const { [field]: _dropped, ...providerRest } = (provider ?? {}) as Record<string, any>;
// An empty provider entry is not the same as no options for it, so drop the key.
return Object.keys(providerRest).length > 0 ? { ...rest, [key]: providerRest } : rest;
}
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
markStepCacheBreakpoint,
MIN_STEP_CACHE_CHARS,
@@ -7,6 +7,7 @@ import {
withStepCacheBreakpoint,
} from "./step-cache";
import { PROMPT_CACHE_CONTROL } from "./prompt-prefix";
import { withCacheBreakpoint } from "./model-provider";
type Message = {
role: string;
@@ -22,7 +23,10 @@ function turnHistory(): Message {
return {
role: "user",
content: "why did run_1 fail?",
providerOptions: { anthropic: { cacheControl: PROMPT_CACHE_CONTROL } },
providerOptions: {
__cacheBreakpoint: { kind: "prefix" },
anthropic: { cacheControl: PROMPT_CACHE_CONTROL },
},
};
}
@@ -31,6 +35,7 @@ function stepBreakpointWith(otherAnthropicOptions: Record<string, unknown>): Mes
role: "tool",
content: "ok",
providerOptions: {
__cacheBreakpoint: { kind: "step" },
anthropic: { cacheControl: STEP_CACHE_CONTROL, ...otherAnthropicOptions },
openai: { store: false },
},
@@ -107,6 +112,7 @@ describe("the step cache breakpoint", () => {
]);
expect(marked.at(-1)!.providerOptions).toEqual({
__cacheBreakpoint: { kind: "step" },
anthropic: { anotherOption: "keep", cacheControl: STEP_CACHE_CONTROL },
openai: { store: false },
});
@@ -125,6 +131,83 @@ describe("the step cache breakpoint", () => {
const empty: Message[] = [];
expect(markStepCacheBreakpoint(empty)).toBe(empty);
});
// A conversation resumed after this branch shipped can still carry a step
// breakpoint in the pre-discriminator shape. It must be stripped like any other.
it("strips a legacy step breakpoint on resume", () => {
const legacyStep: Message = {
role: "tool",
content: "ok",
providerOptions: { anthropic: { cacheControl: STEP_CACHE_CONTROL, keep: true } },
};
const marked = markStepCacheBreakpoint([turnHistory(), legacyStep, toolResult(20)]);
expect(ttlOf(marked[1])).toBeUndefined();
expect(marked[1]!.providerOptions).toEqual({ anthropic: { keep: true } });
expect(ttlOf(marked[0])).toBe("1h");
});
});
describe("the step cache breakpoint on Bedrock", () => {
let priorProvider: string | undefined;
beforeEach(() => {
priorProvider = process.env.DASHBOARD_AGENT_MODEL_PROVIDER;
process.env.DASHBOARD_AGENT_MODEL_PROVIDER = "bedrock";
});
afterEach(() => {
if (priorProvider === undefined) delete process.env.DASHBOARD_AGENT_MODEL_PROVIDER;
else process.env.DASHBOARD_AGENT_MODEL_PROVIDER = priorProvider;
});
function bedrockCachePoint(message: Message | undefined): { ttl?: unknown } | undefined {
return (message?.providerOptions?.bedrock as { cachePoint?: { ttl?: unknown } } | undefined)
?.cachePoint;
}
function breakpointTag(message: Message | undefined): unknown {
return (message?.providerOptions?.__cacheBreakpoint as { kind?: unknown } | undefined)?.kind;
}
function prefixMarker(): Message {
return {
role: "user",
content: "why did run_1 fail?",
providerOptions: withCacheBreakpoint(undefined, "prefix"),
};
}
// Nothing undocumented reaches AWS: the wire cachePoint is a plain `{type:"default"}`
// for both markers. The prefix/step distinction lives only in the `__cacheBreakpoint` tag.
it("emits a plain cachePoint with no ttl for either marker", () => {
expect(bedrockCachePoint(prefixMarker())).toEqual({ type: "default" });
const step: Message = {
role: "tool",
content: "ok",
providerOptions: withCacheBreakpoint(undefined, "step"),
};
expect(bedrockCachePoint(step)).toEqual({ type: "default" });
expect(bedrockCachePoint(step)).not.toHaveProperty("ttl");
expect(breakpointTag(prefixMarker())).toBe("prefix");
expect(breakpointTag(step)).toBe("step");
});
// The turn-wide prefix marker sits on the last message; a short conversation never
// earns a step marker, so stripping the prefix would leave the history uncached.
it("keeps the turn-wide prefix cachePoint on a short conversation", () => {
const marked = markStepCacheBreakpoint([prefixMarker()]);
expect(bedrockCachePoint(marked.at(-1))).toEqual({ type: "default" });
expect(breakpointTag(marked.at(-1))).toBe("prefix");
});
it("rolls the per-step cachePoint onto the tail once it is worth caching", () => {
const marked = markStepCacheBreakpoint([prefixMarker(), toolResult(MIN_STEP_CACHE_CHARS)]);
expect(bedrockCachePoint(marked[0])).toEqual({ type: "default" });
expect(breakpointTag(marked[0])).toBe("prefix");
expect(bedrockCachePoint(marked.at(-1))).toEqual({ type: "default" });
expect(breakpointTag(marked.at(-1))).toBe("step");
});
});
describe("wrapping the SDK's prepareStep", () => {
@@ -162,6 +245,7 @@ describe("wrapping the SDK's prepareStep", () => {
const prepared = await withStepCacheBreakpoint(inner as never)({ messages: [] } as never);
expect((prepared!.messages!.at(-1) as Message).providerOptions).toEqual({
__cacheBreakpoint: { kind: "step" },
anthropic: { anotherOption: "keep", cacheControl: STEP_CACHE_CONTROL },
});
});
@@ -189,6 +273,27 @@ describe("per-step cache telemetry", () => {
});
});
it("reports Bedrock's write from its metadata and its read from the call's usage", () => {
const prior = process.env.DASHBOARD_AGENT_MODEL_PROVIDER;
process.env.DASHBOARD_AGENT_MODEL_PROVIDER = "bedrock";
try {
expect(
stepCacheAttributes(
2,
{ bedrock: { usage: { cacheWriteInputTokens: 8_000 } } },
{ inputTokenDetails: { cacheReadTokens: 12_000 } }
)
).toEqual({
"dashboard_agent.step": 2,
"gen_ai.usage.cache_creation_input_tokens": 8_000,
"gen_ai.usage.cache_read_input_tokens": 12_000,
});
} finally {
if (prior === undefined) delete process.env.DASHBOARD_AGENT_MODEL_PROVIDER;
else process.env.DASHBOARD_AGENT_MODEL_PROVIDER = prior;
}
});
it("reports null rather than zero when the provider said nothing", () => {
expect(stepCacheAttributes(0, undefined)).toEqual({
"dashboard_agent.step": 0,
@@ -1,5 +1,12 @@
import { logger } from "@trigger.dev/sdk";
import type { ModelMessage, ToolSet } from "ai";
import {
cacheUsageFromProviderMetadata,
isLongLivedCacheBreakpoint,
isStepCacheBreakpoint,
withCacheBreakpoint,
withoutCacheBreakpoint,
} from "./model-provider";
import {
describePromptPrefix,
promptCacheAttributes,
@@ -15,36 +22,16 @@ import {
* its accumulated tool outputs uncached on every step.
*/
export const STEP_CACHE_CONTROL = { type: "ephemeral", ttl: "5m" } as const;
export { STEP_CACHE_CONTROL } from "./model-provider";
// Anthropic silently refuses to cache a prefix shorter than roughly 1024 tokens.
export const MIN_STEP_CACHE_CHARS = 4_096;
type MaybeCached = { providerOptions?: Record<string, unknown> };
function cacheControlTtl(message: MaybeCached): string | undefined {
const anthropic = message.providerOptions?.anthropic as
| { cacheControl?: { ttl?: unknown } }
| undefined;
const ttl = anthropic?.cacheControl?.ttl;
return typeof ttl === "string" ? ttl : undefined;
}
function anthropicOptions(message: MaybeCached): Record<string, unknown> {
const anthropic = message.providerOptions?.anthropic;
return typeof anthropic === "object" && anthropic !== null
? (anthropic as Record<string, unknown>)
: {};
}
function withoutStepBreakpoint<T extends MaybeCached>(message: T): T {
if (cacheControlTtl(message) !== STEP_CACHE_CONTROL.ttl) return message;
const { anthropic, ...rest } = message.providerOptions as Record<string, unknown>;
const { cacheControl: _dropped, ...anthropicRest } = anthropic as Record<string, unknown>;
// An empty `anthropic` is not the same as no Anthropic options, so drop the key.
const providerOptions =
Object.keys(anthropicRest).length > 0 ? { ...rest, anthropic: anthropicRest } : rest;
return { ...message, providerOptions };
if (!isStepCacheBreakpoint(message.providerOptions)) return message;
return { ...message, providerOptions: withoutCacheBreakpoint(message.providerOptions) };
}
// Only ever one step breakpoint at a time: Anthropic allows four in total, and the
@@ -54,8 +41,7 @@ export function markStepCacheBreakpoint<T extends MaybeCached>(messages: T[]): T
let lastLongLived = -1;
messages.forEach((message, index) => {
const ttl = cacheControlTtl(message);
if (ttl !== undefined && ttl !== STEP_CACHE_CONTROL.ttl) lastLongLived = index;
if (isLongLivedCacheBreakpoint(message.providerOptions)) lastLongLived = index;
});
const tail = messages.slice(lastLongLived + 1);
if ((JSON.stringify(tail)?.length ?? 0) < MIN_STEP_CACHE_CHARS) {
@@ -66,13 +52,7 @@ export function markStepCacheBreakpoint<T extends MaybeCached>(messages: T[]): T
const last = stripped[stripped.length - 1]!;
return [
...stripped.slice(0, -1),
{
...last,
providerOptions: {
...last.providerOptions,
anthropic: { ...anthropicOptions(last), cacheControl: STEP_CACHE_CONTROL },
},
},
{ ...last, providerOptions: withCacheBreakpoint(last.providerOptions, "step") },
];
}
@@ -97,16 +77,16 @@ export function stepCachePrepareStep(options: unknown): PrepareStepFn {
export function stepCacheAttributes(
step: number | undefined,
providerMetadata: unknown
providerMetadata: unknown,
usage?: PromptCacheUsage
): Record<string, unknown> {
const anthropic = (providerMetadata as { anthropic?: Record<string, unknown> } | undefined)
?.anthropic;
const write = anthropic?.cacheCreationInputTokens;
const read = anthropic?.cacheReadInputTokens;
const { write, read } = cacheUsageFromProviderMetadata(providerMetadata);
return {
"dashboard_agent.step": step ?? null,
"gen_ai.usage.cache_creation_input_tokens": typeof write === "number" ? write : null,
"gen_ai.usage.cache_read_input_tokens": typeof read === "number" ? read : null,
"gen_ai.usage.cache_creation_input_tokens":
write ?? usage?.inputTokenDetails?.cacheWriteTokens ?? null,
"gen_ai.usage.cache_read_input_tokens":
read ?? usage?.inputTokenDetails?.cacheReadTokens ?? null,
};
}
@@ -131,7 +111,7 @@ export function recordPromptCacheUsage(args: {
usage: args.usage,
prefix: describePromptPrefix({ system: args.system, tools: args.tools }),
}),
...stepCacheAttributes(args.step, args.providerMetadata),
...stepCacheAttributes(args.step, args.providerMetadata, args.usage),
});
} catch (error) {
// Measurement must never fail a turn.
@@ -28,7 +28,7 @@ import {
getStore,
getSystemPrompt,
modeFor,
registry,
resolveDashboardAgentModel,
latestCards,
sanitizeReplayedToolInputs,
clearOpenInvestigations,
@@ -428,7 +428,7 @@ async function narrateWithPlan(input: {
? streamText({
model:
locals.get(dashboardAgentModelKey) ??
registry.languageModel("anthropic:claude-haiku-4-5"),
resolveDashboardAgentModel("anthropic:claude-haiku-4-5"),
system: HAIKU_WAKE_BRIEF,
// Bounded on purpose: the wake alone, no conversation and no tools.
messages: [
@@ -442,9 +442,7 @@ async function narrateWithPlan(input: {
: streamText({
model:
locals.get(dashboardAgentModelKey) ??
registry.languageModel(
(resolved.model ?? "anthropic:claude-sonnet-4-6") as `anthropic:${string}`
),
resolveDashboardAgentModel(resolved.model ?? "anthropic:claude-sonnet-4-6"),
system: resolved.text,
// No tools: a wake reports what the check already established, and carries no
// delegated token to read with. The breakpoint goes on the last message of the
@@ -782,9 +780,7 @@ async function conductWatchInvestigation(args: {
const result = streamText({
model:
locals.get(dashboardAgentModelKey) ??
registry.languageModel(
(resolved.model ?? "anthropic:claude-sonnet-4-6") as `anthropic:${string}`
),
resolveDashboardAgentModel(resolved.model ?? "anthropic:claude-sonnet-4-6"),
system: resolved.text,
tools,
// Ten steps of accumulating tool output is exactly what the rolling breakpoint
+2 -1
View File
@@ -9,13 +9,14 @@
"@trigger.dev/plugins": "workspace:*"
},
"devDependencies": {
"@internal/testcontainers": "workspace:*",
"@trigger.dev/database": "workspace:*",
"@types/node": "^24.13.3",
"rimraf": "6.0.1"
},
"scripts": {
"clean": "rimraf dist",
"typecheck": "tsc --noEmit",
"typecheck": "tsc --noEmit -p tsconfig.src.json && tsc --noEmit -p tsconfig.test.json",
"build": "pnpm run clean && tsc -p tsconfig.build.json",
"dev": "tsc --noEmit false --outDir dist --declaration --watch",
"test": "vitest run",
+23
View File
@@ -260,6 +260,29 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController {
if (!claims) {
return { ok: false, status: 401, error: "Invalid user-actor token" };
}
// Same tenant floor as authenticateSession: in a scoped context a non-member's
// delegated token is denied here, not handed a usable ability (even for reads).
// Admins are exempt. An unscoped context is not a tenant claim — skip the lookup
// entirely and keep the prior behavior (no user query, no denial).
if (context.organizationId || context.projectId) {
const where = { id: claims.userId };
const user =
(await this.replica.user.findFirst({ where, select: { id: true, admin: true } })) ??
(await this.prisma.user.findFirst({ where, select: { id: true, admin: true } }));
if (!user) {
return { ok: false, status: 401, error: "Invalid user-actor token" };
}
if (!user.admin) {
const denied = await this.deniedByMembership(
context.organizationId,
context.projectId,
user.id
);
if (denied) return { ok: false, status: 403, error: "Unauthorized" };
}
}
return {
ok: true,
userId: claims.userId,
@@ -0,0 +1,102 @@
import type { PrismaClient } from "@trigger.dev/database";
import { signUserActorToken } from "@trigger.dev/plugins";
import { postgresTest } from "@internal/testcontainers";
import { expect } from "vitest";
import { RoleBaseAccessFallback } from "./fallback.js";
const SECRET = "test-user-actor-secret";
function uatRequest(token: string): Request {
return new Request("https://example.test", {
headers: { Authorization: `Bearer ${token}` },
});
}
async function seedUser(prisma: PrismaClient, email: string, admin = false) {
return prisma.user.create({
data: { email, authenticationMethod: "MAGIC_LINK", admin },
});
}
async function uat(userId: string) {
return signUserActorToken(SECRET, { userId, client: "test" });
}
postgresTest(
"authenticateUserActor: scoped membership floor",
async ({ prisma }) => {
const p = prisma as PrismaClient;
const org = await p.organization.create({
data: { slug: `org-${Date.now()}`, title: "Org" },
});
const project = await p.project.create({
data: {
slug: `proj-${Date.now()}`,
name: "Project",
externalRef: `ref-${Date.now()}`,
organizationId: org.id,
},
});
const member = await seedUser(p, "member@example.test");
const stranger = await seedUser(p, "stranger@example.test");
const admin = await seedUser(p, "admin@example.test", true);
await p.orgMember.create({ data: { organizationId: org.id, userId: member.id } });
const controller = new RoleBaseAccessFallback(p, { userActorSecret: SECRET }).create();
// Member with a capless token keeps the read:all default.
const memberResult = await controller.authenticateUserActor(uatRequest(await uat(member.id)), {
organizationId: org.id,
});
expect(memberResult.ok).toBe(true);
if (memberResult.ok) {
expect(memberResult.ability.can("read", { type: "runs", id: "run_x" })).toBe(true);
}
// Non-member is denied at the ability layer, not handed a usable ability.
const strangerResult = await controller.authenticateUserActor(
uatRequest(await uat(stranger.id)),
{ organizationId: org.id }
);
expect(strangerResult.ok).toBe(false);
if (!strangerResult.ok) expect(strangerResult.status).toBe(403);
// A token for a user that no longer exists fails closed.
const ghostResult = await controller.authenticateUserActor(uatRequest(await uat("usr_ghost")), {
organizationId: org.id,
});
expect(ghostResult.ok).toBe(false);
if (!ghostResult.ok) expect(ghostResult.status).toBe(401);
// A platform admin is exempt from the membership floor.
const adminResult = await controller.authenticateUserActor(uatRequest(await uat(admin.id)), {
organizationId: org.id,
});
expect(adminResult.ok).toBe(true);
// A project-only scope resolves through the project's org: non-member denied.
const projectResult = await controller.authenticateUserActor(
uatRequest(await uat(stranger.id)),
{ projectId: project.id }
);
expect(projectResult.ok).toBe(false);
if (!projectResult.ok) expect(projectResult.status).toBe(403);
},
120_000
);
postgresTest(
"authenticateUserActor: unscoped context skips the floor and never queries the user",
async ({ prisma }) => {
const p = prisma as PrismaClient;
const controller = new RoleBaseAccessFallback(p, { userActorSecret: SECRET }).create();
// A user that doesn't exist: if the unscoped path ran the lookup this would 401.
const result = await controller.authenticateUserActor(uatRequest(await uat("usr_ghost")), {});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.ability.can("read", { type: "runs", id: "run_x" })).toBe(true);
}
},
120_000
);
+3 -1
View File
@@ -13,5 +13,7 @@
"strict": true,
"customConditions": ["@triggerdotdev/source"]
},
"exclude": ["node_modules", "dist"]
// Excluded from this IDE-default project: needs ES2022 lib to type-check
// (see tsconfig.test.json). typecheck script still checks it.
"exclude": ["node_modules", "dist", "src/fallback.userActor.test.ts"]
}
+19
View File
@@ -0,0 +1,19 @@
{
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"],
"compilerOptions": {
"target": "ES2019",
"lib": ["ES2019", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"preserveWatchOutput": true,
"skipLibCheck": true,
"noEmit": true,
"strict": true,
"types": ["node"],
"customConditions": ["@triggerdotdev/source"]
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"include": ["src/**/*.test.ts"],
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"preserveWatchOutput": true,
"skipLibCheck": true,
"noEmit": true,
"strict": true,
"types": ["vitest/globals", "node"],
"customConditions": ["@triggerdotdev/source"]
}
}
+42 -2
View File
@@ -958,6 +958,9 @@ importers:
internal-packages/dashboard-agent:
dependencies:
'@ai-sdk/amazon-bedrock':
specifier: 4.0.117
version: 4.0.117(zod@3.25.76)
'@ai-sdk/anthropic':
specifier: ^3.0.0
version: 3.0.84(zod@3.25.76)
@@ -1163,6 +1166,9 @@ importers:
specifier: workspace:*
version: link:../../packages/plugins
devDependencies:
'@internal/testcontainers':
specifier: workspace:*
version: link:../testcontainers
'@trigger.dev/database':
specifier: workspace:*
version: link:../database
@@ -2113,6 +2119,12 @@ importers:
packages:
'@ai-sdk/amazon-bedrock@4.0.117':
resolution: {integrity: sha512-MebXAEsdvNdzKZCbVxFK0668KhYrs6W3mMH5fWT5Yc0TMSSTbwPI9fIw0sOzxLWd9TE9cJz73vVPjQjtG3vj/Q==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/anthropic@3.0.84':
resolution: {integrity: sha512-BIDaHmCHs6Sr5VUsEkTbbVlAN4GWjg97X9x/IfXyviLtzsXvffui9XIcZugkAi1Ri6FnvI5T5qDGh5YLnSuzRg==}
engines: {node: '>=18'}
@@ -2143,6 +2155,12 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/openai@3.0.71':
resolution: {integrity: sha512-j6eBAa5oHFZ4U5CxpIV3T4zXNM/BviodNCZCL1qHkA4aqkwK9iQ18TWYz2DZcXpw4BO5pikKzqpXORxb1EnZGA==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/otel@1.0.0-beta.6':
resolution: {integrity: sha512-K5VikyO3EKQkNk77ew9oMjM8FInKF+WWar599LmP8rQ0x0iB+P/DVS+h6zQvmecxMNPtQOOyt0uDQFx/AA0DGw==}
engines: {node: '>=18'}
@@ -8546,6 +8564,9 @@ packages:
aws4fetch@1.0.18:
resolution: {integrity: sha512-3Cf+YaUl07p24MoQ46rFwulAmiyCwH2+1zw1ZyPAX5OtJ34Hh185DwB8y/qRLb6cYYYtSFJ9pthyLc0MD4e8sQ==}
aws4fetch@1.0.20:
resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==}
axios@1.19.0:
resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==}
@@ -15076,6 +15097,17 @@ packages:
snapshots:
'@ai-sdk/amazon-bedrock@4.0.117(zod@3.25.76)':
dependencies:
'@ai-sdk/anthropic': 3.0.84(zod@3.25.76)
'@ai-sdk/openai': 3.0.71(zod@3.25.76)
'@ai-sdk/provider': 3.0.10
'@ai-sdk/provider-utils': 4.0.29(zod@3.25.76)
'@smithy/eventstream-codec': 4.2.5
'@smithy/util-utf8': 4.2.0
aws4fetch: 1.0.20
zod: 3.25.76
'@ai-sdk/anthropic@3.0.84(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.10
@@ -15109,6 +15141,12 @@ snapshots:
'@ai-sdk/provider-utils': 4.0.29(zod@3.25.76)
zod: 3.25.76
'@ai-sdk/openai@3.0.71(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.10
'@ai-sdk/provider-utils': 4.0.29(zod@3.25.76)
zod: 3.25.76
'@ai-sdk/otel@1.0.0-beta.6(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 4.0.0-beta.5
@@ -21471,7 +21509,7 @@ snapshots:
'@smithy/node-http-handler': 4.4.5
'@smithy/types': 4.9.0
'@smithy/util-base64': 4.3.0
'@smithy/util-buffer-from': 4.0.0
'@smithy/util-buffer-from': 4.2.0
'@smithy/util-hex-encoding': 4.2.0
'@smithy/util-utf8': 4.2.0
tslib: 2.8.1
@@ -22750,6 +22788,8 @@ snapshots:
aws4fetch@1.0.18: {}
aws4fetch@1.0.20: {}
axios@1.19.0:
dependencies:
follow-redirects: 1.16.0
@@ -27006,7 +27046,7 @@ snapshots:
node-abi@3.89.0:
dependencies:
semver: 7.8.5
semver: 7.8.1
optional: true
node-abort-controller@3.1.1: {}