Skip to content

Commit aa9de9d

Browse files
carderneTrigger.dev RepoOps
authored andcommitted
feat(run-tokens): refresh TRIGGER_RUN_TOKEN for long-running and restored runs
Deployed tasks keep a valid short-lived run token for their whole run. The token is refreshed during long runs and before a run resumes from a checkpoint. A warm-started task no longer keeps the previous run's token when the next run has none, and run credentials are left out of runner debug logs. Mono-RevId: 84ad2da92b5ca2f92c1bdb493236592027a235fb
1 parent 790d604 commit aa9de9d

33 files changed

Lines changed: 1222 additions & 90 deletions

‎.changeset/refresh-run-tokens.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@trigger.dev/core": patch
3+
"trigger.dev": patch
4+
---
5+
6+
Deployed tasks now keep a valid short-lived run token for their whole run: the token is refreshed during long runs and before a run resumes from a checkpoint. A warm-started task no longer keeps the previous run's token when the next run has none, and run credentials are left out of runner debug logs.

‎.server-changes/runtime-api-run-tokens.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ area: webapp
33
type: feature
44
---
55

6-
Expand short-lived run-token support to task APIs, with permission checks for packets, metadata, streams and waits. Run tokens last 24 hours independently of root-key rotation and cannot be individually revoked. They can mint public access tokens for up to 30 days; those tokens may outlive the run token that minted them.
6+
Expand short-lived run-token support to task APIs, with permission checks for packets, metadata, streams and waits. Run tokens last one hour independently of root-key rotation and cannot be individually revoked. They can mint public access tokens for up to 30 days; those tokens may outlive the run token that minted them.

‎apps/supervisor/src/workloadServer/index.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -528,7 +528,8 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
528528
params.runFriendlyId,
529529
params.snapshotFriendlyId,
530530
body,
531-
this.runnerIdFromRequest(req)
531+
this.runnerIdFromRequest(req),
532+
auth.environmentId
532533
);
533534

534535
if (!heartbeatResponse.success) {
@@ -540,9 +541,8 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
540541
return;
541542
}
542543

543-
reply.json({
544-
ok: true,
545-
} satisfies WorkloadHeartbeatResponseBody);
544+
// Carries any replacement run token through to the runner. Never log this body.
545+
reply.json(heartbeatResponse.data satisfies WorkloadHeartbeatResponseBody);
546546
},
547547
{ highFrequency: true }
548548
),
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { WORKLOAD_HEADERS } from "@trigger.dev/core/v3/workers";
2+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
3+
4+
vi.mock("std-env", () => ({
5+
env: {
6+
TRIGGER_API_URL: "http://localhost:3030",
7+
TRIGGER_WORKER_TOKEN: "test-token",
8+
MANAGED_WORKER_SECRET: "test-secret",
9+
OTEL_EXPORTER_OTLP_ENDPOINT: "http://localhost:4318",
10+
},
11+
}));
12+
13+
const { WorkloadServer } = await import("./index.js");
14+
15+
const PORT = 18736;
16+
const BASE = `http://127.0.0.1:${PORT}`;
17+
18+
const heartbeatRun = vi.fn(async () => ({
19+
success: true as const,
20+
data: { ok: true as const, runToken: "replacement-token" },
21+
}));
22+
23+
let server: InstanceType<typeof WorkloadServer>;
24+
25+
beforeAll(async () => {
26+
server = new WorkloadServer({
27+
port: PORT,
28+
workerClient: { heartbeatRun } as any,
29+
wideEventOpts: { service: "supervisor", env: { nodeId: "test" }, enabled: false },
30+
wideEventsNoisyRoutes: false,
31+
});
32+
await server.start();
33+
});
34+
35+
afterAll(async () => {
36+
await server.stop();
37+
});
38+
39+
describe("WorkloadServer run heartbeat", () => {
40+
it("forwards the runner's run-token report and returns the replacement token", async () => {
41+
const runToken = {
42+
expiresAt: 1_800_000_000,
43+
trigger: "restore",
44+
installation: { result: "failure", reason: "ack_timeout", trigger: "heartbeat" },
45+
};
46+
47+
const res = await fetch(
48+
`${BASE}/api/v1/workload-actions/runs/run_1/snapshots/snap_1/heartbeat`,
49+
{
50+
method: "POST",
51+
headers: { "content-type": "application/json", [WORKLOAD_HEADERS.RUNNER_ID]: "runner_1" },
52+
body: JSON.stringify({ runToken }),
53+
}
54+
);
55+
56+
expect(res.status).toBe(200);
57+
expect(await res.json()).toEqual({ ok: true, runToken: "replacement-token" });
58+
expect(heartbeatRun).toHaveBeenLastCalledWith(
59+
"run_1",
60+
"snap_1",
61+
{ runToken },
62+
"runner_1",
63+
undefined
64+
);
65+
});
66+
});

‎apps/webapp/app/routes/engine.v1.worker-actions.runs.$runFriendlyId.snapshots.$snapshotFriendlyId.attempts.start.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export const action = createActionWorkerApiRoute(
2828
isWarmStart: body.isWarmStart,
2929
workerCell: body.workerCell,
3030
workerLane: body.workerLane,
31+
supportsRunTokenRefresh: body.supportsRunTokenRefresh,
3132
runnerId,
3233
environmentId,
3334
});
Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,40 @@
11
import type { TypedResponse } from "@remix-run/server-runtime";
22
import { json } from "@remix-run/server-runtime";
3-
import type { WorkloadHeartbeatResponseBody } from "@trigger.dev/core/v3/workers";
3+
import {
4+
WorkerApiRunHeartbeatRequestBody,
5+
type WorkerApiRunHeartbeatResponseBody,
6+
} from "@trigger.dev/core/v3/workers";
47
import { z } from "zod";
58
import { createActionWorkerApiRoute } from "~/services/routeBuilders/apiBuilder.server";
69

10+
// Older supervisors send no body fields this route reads; anything unparseable is ignored.
11+
const HeartbeatBody = WorkerApiRunHeartbeatRequestBody.catch({});
12+
713
export const action = createActionWorkerApiRoute(
814
{
15+
body: HeartbeatBody,
916
params: z.object({
1017
runFriendlyId: z.string(),
1118
snapshotFriendlyId: z.string(),
1219
}),
1320
},
1421
async ({
1522
authenticatedWorker,
23+
body,
1624
params,
1725
runnerId,
18-
}): Promise<TypedResponse<WorkloadHeartbeatResponseBody>> => {
26+
environmentId,
27+
}): Promise<TypedResponse<WorkerApiRunHeartbeatResponseBody>> => {
1928
const { runFriendlyId, snapshotFriendlyId } = params;
2029

21-
await authenticatedWorker.heartbeatRun({
30+
const { runToken } = await authenticatedWorker.heartbeatRun({
2231
runFriendlyId,
2332
snapshotFriendlyId,
2433
runnerId,
34+
environmentId,
35+
runTokenReport: body.runToken,
2536
});
2637

27-
return json({ ok: true });
38+
return json({ ok: true, runToken });
2839
}
2940
);

‎apps/webapp/app/services/oidc/runTokenAuth.server.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ async function verifyRunTokenWithActiveKeys(token: string): Promise<RunTokenClai
2929
/**
3030
* Verifies a run token locally against the cached internal keys, then loads its environment with
3131
* one lookup. Rejects archived environments and any org or project mismatch. Does not check that
32-
* the run is still executing. Tokens have an independent 24-hour lifetime: root-key rotation
32+
* the run is still executing. Tokens have an independent one-hour lifetime: root-key rotation
3333
* does not revoke them. There is no per-token emergency revocation.
3434
*/
3535
export async function resolveRunToken(

‎apps/webapp/app/services/oidc/runTokenClaims.ts‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
import { OIDC_MAX_SUPPORTED_JWT_TTL_SECONDS } from "./constants";
2-
31
// Run tokens use their own key set and issuer. The distinct audience and `typ` are a second check.
42
export const RUN_TOKEN_TYPE = "run+jwt";
53
export const RUN_TOKEN_AUDIENCE = "trigger:api";
64
export const RUN_TOKEN_USE = "run";
7-
export const RUN_TOKEN_TTL_SECONDS = OIDC_MAX_SUPPORTED_JWT_TTL_SECONDS;
5+
export const RUN_TOKEN_TTL_SECONDS = 60 * 60;
86

97
// Environment tenancy is enforced when authenticating and resolving each route's target.
108
export function runTokenScopes(): string[] {
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import { getMeter } from "@internal/tracing";
2+
import type { ExecutionResult, TaskRunExecutionStatus } from "@trigger.dev/core/v3";
3+
import {
4+
RUN_TOKEN_INSTALL_FAILURE_REASONS,
5+
RUN_TOKEN_REFRESH_BEFORE_EXPIRY_SECONDS,
6+
RUN_TOKEN_REFRESH_TRIGGERS,
7+
type RunTokenHeartbeatReport,
8+
type RunTokenRefreshTrigger,
9+
} from "@trigger.dev/core/v3/workers";
10+
import type { PrismaClientOrTransaction } from "~/db.server";
11+
import { env } from "~/env.server";
12+
import { logger } from "~/services/logger.server";
13+
import { mintRunToken, runTokenInjectionEnabled } from "~/services/oidc/runToken.server";
14+
import { singleton } from "~/utils/singleton";
15+
16+
// Statuses in which the heartbeating runner still owns a live attempt, including the restore
17+
// window before it continues.
18+
const LIVE_ATTEMPT_STATUSES: ReadonlySet<TaskRunExecutionStatus> = new Set<TaskRunExecutionStatus>([
19+
"PENDING_EXECUTING",
20+
"EXECUTING",
21+
"EXECUTING_WITH_WAITPOINTS",
22+
"QUEUED_EXECUTING",
23+
]);
24+
25+
const REMAINING_LIFETIME_BUCKETS_SECONDS = [0, 60, 300, 600, 900, 1800, 2700, 3600];
26+
27+
const ISSUE_ERROR_REASONS = ["lookup_error", "sign_error"] as const;
28+
29+
type IssueOutcome =
30+
| { result: "success"; reason: "none"; token: string }
31+
| {
32+
result: "skipped";
33+
reason: "injection_disabled" | "stale_snapshot" | "not_executing" | "identity_mismatch";
34+
}
35+
| { result: "error"; reason: (typeof ISSUE_ERROR_REASONS)[number] };
36+
37+
// Names and label values are relied on by internal dashboards and monitors. Labels stay bounded.
38+
const telemetry = singleton("runTokenRefreshTelemetry", () => {
39+
const meter = getMeter("run-token-refresh");
40+
const issued = meter.createCounter("run_token.refresh.issued", {
41+
description: "Due run-token refresh requests handled by the webapp. Not delivery success",
42+
});
43+
const installations = meter.createCounter("run_token.refresh.installations", {
44+
description: "Runner-reported installs of refreshed run tokens into the task process",
45+
});
46+
47+
// Export the rare failure series from zero, so a rate query sees a process's first failure.
48+
for (const trigger of RUN_TOKEN_REFRESH_TRIGGERS) {
49+
for (const reason of ISSUE_ERROR_REASONS) issued.add(0, { trigger, result: "error", reason });
50+
for (const reason of RUN_TOKEN_INSTALL_FAILURE_REASONS) {
51+
installations.add(0, { trigger, result: "failure", reason });
52+
}
53+
}
54+
55+
return {
56+
issued,
57+
installations,
58+
remainingLifetime: meter.createHistogram("run_token.refresh.remaining_lifetime_seconds", {
59+
description:
60+
"Remaining lifetime of the run token installed in the task process, per heartbeat",
61+
unit: "s",
62+
advice: { explicitBucketBoundaries: REMAINING_LIFETIME_BUCKETS_SECONDS },
63+
}),
64+
};
65+
});
66+
67+
export type FindRunForRefresh = (runId: string) => Promise<{
68+
taskIdentifier: string;
69+
runtimeEnvironmentId: string;
70+
lockedById: string | null;
71+
} | null>;
72+
73+
export type RunTokenRefreshRequest = {
74+
report: RunTokenHeartbeatReport | undefined;
75+
/** The engine's heartbeat result, i.e. the run's latest snapshot. */
76+
heartbeat: ExecutionResult;
77+
snapshotFriendlyId: string;
78+
/** Verified workload environment, when the supervisor forwarded one. */
79+
environmentId?: string;
80+
findRun: FindRunForRefresh;
81+
replica: PrismaClientOrTransaction;
82+
now?: Date;
83+
};
84+
85+
/**
86+
* Records the runner's installed-token telemetry and, only when its token is due, mints a
87+
* replacement. Identity comes from the run's server-side state, never from the runner's token.
88+
*/
89+
export async function refreshRunTokenOnHeartbeat(
90+
request: RunTokenRefreshRequest
91+
): Promise<string | undefined> {
92+
const { report } = request;
93+
if (!report) return undefined;
94+
95+
const { trigger } = report;
96+
const remainingSeconds = report.expiresAt - (request.now ?? new Date()).getTime() / 1_000;
97+
// OTel JS drops negative histogram values; zero stands for expired.
98+
telemetry.remainingLifetime.record(Math.max(0, remainingSeconds), { trigger });
99+
100+
if (report.installation) {
101+
const { installation } = report;
102+
telemetry.installations.add(1, {
103+
trigger: installation.trigger,
104+
result: installation.result,
105+
reason: installation.result === "success" ? "none" : installation.reason,
106+
});
107+
}
108+
109+
if (remainingSeconds > RUN_TOKEN_REFRESH_BEFORE_EXPIRY_SECONDS) return undefined;
110+
111+
const outcome = await issue(request, trigger);
112+
telemetry.issued.add(1, { trigger, result: outcome.result, reason: outcome.reason });
113+
return outcome.result === "success" ? outcome.token : undefined;
114+
}
115+
116+
async function issue(
117+
{ heartbeat, snapshotFriendlyId, environmentId, findRun, replica }: RunTokenRefreshRequest,
118+
trigger: RunTokenRefreshTrigger
119+
): Promise<IssueOutcome> {
120+
if (!env.RUN_TOKEN_ISSUER_URL) return { result: "skipped", reason: "injection_disabled" };
121+
// Legacy workload calls carry no verified tenant scope, even in enforcement mode.
122+
if (!environmentId) return { result: "skipped", reason: "identity_mismatch" };
123+
124+
const { snapshot, run } = heartbeat;
125+
if (snapshot.friendlyId !== snapshotFriendlyId) {
126+
return { result: "skipped", reason: "stale_snapshot" };
127+
}
128+
if (!LIVE_ATTEMPT_STATUSES.has(snapshot.executionStatus)) {
129+
return { result: "skipped", reason: "not_executing" };
130+
}
131+
if (!run.attemptNumber) return { result: "skipped", reason: "identity_mismatch" };
132+
133+
let identity;
134+
try {
135+
identity = await loadIdentity(run.id, findRun, replica);
136+
} catch (error) {
137+
logger.warn("Run token refresh lookup failed", {
138+
runId: run.friendlyId,
139+
trigger,
140+
error: error instanceof Error ? error.message : String(error),
141+
});
142+
return { result: "error", reason: "lookup_error" };
143+
}
144+
145+
if (!identity || identity.environment.id !== environmentId) {
146+
return { result: "skipped", reason: "identity_mismatch" };
147+
}
148+
149+
const { environment, taskIdentifier, deploymentFriendlyId } = identity;
150+
if (!runTokenInjectionEnabled(environment.organization.featureFlags)) {
151+
return { result: "skipped", reason: "injection_disabled" };
152+
}
153+
154+
try {
155+
const token = await mintRunToken({
156+
organizationId: environment.organizationId,
157+
projectId: environment.projectId,
158+
environmentId: environment.id,
159+
environmentType: environment.type,
160+
runFriendlyId: run.friendlyId,
161+
attemptNumber: run.attemptNumber,
162+
taskIdentifier,
163+
deploymentFriendlyId,
164+
});
165+
return { result: "success", reason: "none", token };
166+
} catch (error) {
167+
logger.warn("Run token refresh signing failed", {
168+
runId: run.friendlyId,
169+
trigger,
170+
error: error instanceof Error ? error.message : String(error),
171+
});
172+
return { result: "error", reason: "sign_error" };
173+
}
174+
}
175+
176+
// Runs once per due refresh (roughly once per token lifetime per live attempt), not per heartbeat.
177+
async function loadIdentity(
178+
runId: string,
179+
findRun: FindRunForRefresh,
180+
replica: PrismaClientOrTransaction
181+
) {
182+
const run = await findRun(runId);
183+
if (!run) return undefined;
184+
185+
const [environment, lockedTask] = await Promise.all([
186+
replica.runtimeEnvironment.findFirst({
187+
where: { id: run.runtimeEnvironmentId },
188+
select: {
189+
id: true,
190+
type: true,
191+
organizationId: true,
192+
projectId: true,
193+
organization: { select: { featureFlags: true } },
194+
},
195+
}),
196+
run.lockedById
197+
? replica.backgroundWorkerTask.findFirst({
198+
where: { id: run.lockedById },
199+
select: { worker: { select: { deployment: { select: { friendlyId: true } } } } },
200+
})
201+
: undefined,
202+
]);
203+
if (!environment) return undefined;
204+
205+
return {
206+
environment,
207+
taskIdentifier: run.taskIdentifier,
208+
deploymentFriendlyId: lockedTask?.worker.deployment?.friendlyId,
209+
};
210+
}

0 commit comments

Comments
 (0)