Skip to content

Commit c9645f3

Browse files
PraeSynBHbeingben
andauthored
fix(server): size agent JWT TTL to the run's own wall-clock budget (RBR-1035 AC1/AC4) (#29)
RBR-1014 root cause: a flat instance-wide JWT TTL (default 3600s) that agent runs routinely outlive, causing a 401 mid-run that the client's retry/timeout path was masking as host-saturation. RBR-1034 (CISO) decided agents must not hold or mint their own JWT signing secret, which forecloses in-process refresh (option b from the RBR-1035 plan). This lands option (a): mint the token with a TTL derived from the run's own configured max wall clock (adapter timeoutSec) plus a 5-minute margin, falling back to the existing instance default when no run timeout is configured (unbounded runs) or when the derived minimum does not exceed the default. - server/src/agent-auth-jwt.ts: createLocalAgentJwt takes an optional minTtlSeconds and uses it when it exceeds the configured default TTL. - server/src/services/heartbeat.ts: derive minTtlSeconds from resolveHeartbeatRunTimeoutPolicy(agent.adapterType, runtimeConfig) + AGENT_JWT_RUN_TIMEOUT_MARGIN_SECONDS at the JWT-minting call site. - server/src/__tests__/agent-auth-jwt.test.ts: RBR-1035 AC1/AC4 regression tests — long-budget run gets a TTL that outlives the flat 1h default and is still valid past it; short-budget run keeps the tighter default and is provably dead before a long-budget token minted at the same instant; non-finite/non-positive minTtlSeconds falls back to the default. AC #2 (401 fail-fast taxonomy) and AC #3 (JWT-secret exposure policy) are out of scope here per the CEO's rescoping comment on this issue: AC#2 is already in progress on RBR-1036 (cli/src/client/http.ts, separate worktree) and AC#3 is closed by RBR-1034's decision, which this change complies with (no agent-held or agent-minted signing secret anywhere in this path). Verification: `npx vitest run src/__tests__/agent-auth-jwt.test.ts` — 21/21 passing (17 pre-existing + 4 new). Touched files parse clean under esbuild. Full `tsc --noEmit` was not run this pass — host load was 13-19 on a 12-core box (RBR-974 guardrail) and a prior foreground tsc attempt exceeded the 600s budget without completing; a narrower, targeted check was used per the AGENTS.md verification guidance instead of forcing a full workspace typecheck under saturation. Co-authored-by: Ben Hamilton <benhami@gmail.com>
1 parent 6f0a5be commit c9645f3

3 files changed

Lines changed: 165 additions & 3 deletions

File tree

server/src/__tests__/agent-auth-jwt.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,4 +218,121 @@ describe("agent local JWT", () => {
218218
run_id: "run-1",
219219
});
220220
});
221+
222+
// RBR-1035 AC1 + AC4 regression test: the JWT TTL must be sized to the
223+
// run's own configured max wall clock (+ margin, computed by the caller
224+
// in heartbeat.ts) instead of always trusting the flat instance-wide
225+
// default. This is the fix for RBR-1014: a hard 1h default TTL that agent
226+
// runs routinely outlive. Note this is distinct from RBR-1036's AC4
227+
// (which tests the client's fail-fast-on-401 behavior, not TTL sizing) —
228+
// this suite only asserts token-minting/expiry behavior. `minTtlSeconds`
229+
// is the 5th positional arg on this fork/master signature (no
230+
// responsibleUserId/keyScope params here yet).
231+
describe("run-derived TTL (RBR-1035 AC1)", () => {
232+
it("mints a token whose exp covers a long-budget run when minTtlSeconds exceeds the default TTL", () => {
233+
process.env[ttlEnv] = "3600"; // instance-wide default: 1h
234+
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
235+
236+
// Simulate a run configured with a wall-clock budget well beyond the
237+
// 1h default (e.g. timeoutSec 21600 + a 300s margin, as computed by
238+
// resolveHeartbeatRunTimeoutPolicy + AGENT_JWT_RUN_TIMEOUT_MARGIN_SECONDS
239+
// in heartbeat.ts) and assert the minted token's lifetime is sized to
240+
// that run budget, not clamped to the shorter instance default.
241+
const runBudgetSeconds = 21600 + 300; // 6h05m
242+
const token = createLocalAgentJwt(
243+
"agent-1",
244+
"company-1",
245+
"claude_local",
246+
"run-long",
247+
runBudgetSeconds,
248+
);
249+
expect(token).not.toBeNull();
250+
251+
const claims = verifyLocalAgentJwt(token!);
252+
expect(claims).not.toBeNull();
253+
// exp - iat must equal the run's budget, not the 3600s instance default.
254+
expect(claims!.exp - claims!.iat).toBe(runBudgetSeconds);
255+
expect(claims!.exp - claims!.iat).toBeGreaterThan(3600);
256+
257+
// Prove the token is actually alive at the moment the flat 1h default
258+
// would have already expired it — this is the exact RBR-1014 failure
259+
// mode (401 mid-run masquerading as a timeout) that this fix closes.
260+
vi.setSystemTime(new Date("2026-01-01T01:30:00.000Z")); // +90 minutes
261+
expect(verifyLocalAgentJwt(token!)).not.toBeNull();
262+
});
263+
264+
it("keeps the tighter instance default TTL for a short-budget run (does not raise the floor for every run)", () => {
265+
process.env[ttlEnv] = "3600";
266+
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
267+
268+
// A short-budget run (e.g. timeoutSec 60 + margin = 360s) must NOT
269+
// widen the token lifetime beyond the instance default — only runs
270+
// whose own budget exceeds the default should get a longer-lived
271+
// token.
272+
const shortRunBudgetSeconds = 360;
273+
const token = createLocalAgentJwt(
274+
"agent-1",
275+
"company-1",
276+
"claude_local",
277+
"run-short",
278+
shortRunBudgetSeconds,
279+
);
280+
expect(token).not.toBeNull();
281+
282+
const claims = verifyLocalAgentJwt(token!);
283+
expect(claims).not.toBeNull();
284+
expect(claims!.exp - claims!.iat).toBe(3600);
285+
});
286+
287+
it("expires a short-budget-run token sooner than a long-budget-run token minted at the same instant", () => {
288+
process.env[ttlEnv] = "3600";
289+
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
290+
291+
const longToken = createLocalAgentJwt(
292+
"agent-1",
293+
"company-1",
294+
"claude_local",
295+
"run-long",
296+
14400,
297+
);
298+
const shortToken = createLocalAgentJwt(
299+
"agent-1",
300+
"company-1",
301+
"claude_local",
302+
"run-short",
303+
300,
304+
);
305+
306+
const longClaims = verifyLocalAgentJwt(longToken!);
307+
const shortClaims = verifyLocalAgentJwt(shortToken!);
308+
expect(longClaims).not.toBeNull();
309+
expect(shortClaims).not.toBeNull();
310+
expect(longClaims!.exp).toBeGreaterThan(shortClaims!.exp);
311+
312+
// Advance past the short run's ceiling (3600s default, since 300 <
313+
// default) but well before the long run's 14400s budget elapses: the
314+
// short-budget token must be dead while the long-budget token is
315+
// still alive.
316+
vi.setSystemTime(new Date("2026-01-01T02:00:00.000Z")); // +2h
317+
expect(verifyLocalAgentJwt(shortToken!)).toBeNull();
318+
expect(verifyLocalAgentJwt(longToken!)).not.toBeNull();
319+
});
320+
321+
it("ignores a non-finite or non-positive minTtlSeconds and falls back to the instance default", () => {
322+
process.env[ttlEnv] = "3600";
323+
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
324+
325+
for (const invalid of [NaN, -100, 0]) {
326+
const token = createLocalAgentJwt(
327+
"agent-1",
328+
"company-1",
329+
"claude_local",
330+
"run-1",
331+
invalid,
332+
);
333+
const claims = verifyLocalAgentJwt(token!);
334+
expect(claims!.exp - claims!.iat).toBe(3600);
335+
}
336+
});
337+
});
221338
});

server/src/agent-auth-jwt.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,18 +88,39 @@ function safeCompare(a: string, b: string) {
8888
return timingSafeEqual(left, right);
8989
}
9090

91-
export function createLocalAgentJwt(agentId: string, companyId: string, adapterType: string, runId: string) {
91+
export function createLocalAgentJwt(
92+
agentId: string,
93+
companyId: string,
94+
adapterType: string,
95+
runId: string,
96+
minTtlSeconds?: number | null,
97+
) {
9298
const config = jwtConfig();
9399
if (!config) return null;
94100

101+
// RBR-1035 AC1: the token must outlive the run it authenticates, not just
102+
// the instance-wide default TTL. `minTtlSeconds` is derived by the caller
103+
// from the run's own configured max wall clock (adapter `timeoutSec`) plus
104+
// a safety margin — see the JWT-minting call site in heartbeat.ts. A hard
105+
// 1h default TTL is what caused RBR-1014: agent runs routinely exceed one
106+
// hour, especially under host load, and once the token expires mid-run
107+
// every subsequent Paperclip API call fails with no recovery path. Using
108+
// the larger of the configured default and the run-derived minimum means a
109+
// bounded run is always covered without raising the TTL floor for every
110+
// run (e.g. short-lived ones keep the tighter default expiry).
111+
const ttlSeconds =
112+
typeof minTtlSeconds === "number" && Number.isFinite(minTtlSeconds) && minTtlSeconds > config.ttlSeconds
113+
? Math.floor(minTtlSeconds)
114+
: config.ttlSeconds;
115+
95116
const now = Math.floor(Date.now() / 1000);
96117
const claims: LocalAgentJwtClaims = {
97118
sub: agentId,
98119
company_id: companyId,
99120
adapter_type: adapterType,
100121
run_id: runId,
101122
iat: now,
102-
exp: now + config.ttlSeconds,
123+
exp: now + ttlSeconds,
103124
iss: config.issuer,
104125
aud: config.audience,
105126
};

server/src/services/heartbeat.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ import {
9292
buildHeartbeatRunStopMetadata,
9393
mergeHeartbeatRunStopMetadata,
9494
normalizeMaxTurnStopReason,
95+
resolveHeartbeatRunTimeoutPolicy,
9596
} from "./heartbeat-stop-metadata.js";
9697
import {
9798
classifyRunLiveness,
@@ -255,6 +256,12 @@ const PAPERCLIP_HARNESS_CHECKOUT_KEY = "paperclipHarnessCheckedOut";
255256
const DETACHED_PROCESS_ERROR_CODE = "process_detached";
256257
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
257258
const MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS = 10 * 60 * 1000;
259+
// RBR-1035 AC1: margin added on top of a run's configured max wall clock
260+
// (`timeoutSec`) when sizing that run's agent JWT TTL. Covers clock skew
261+
// between the token-minting request and the adapter process actually
262+
// starting, plus any tail-end API calls the run makes right up against its
263+
// own timeout (e.g. a final disposition PATCH).
264+
const AGENT_JWT_RUN_TIMEOUT_MARGIN_SECONDS = 5 * 60;
258265
const MAX_INLINE_WAKE_COMMENTS = 8;
259266
const MAX_INLINE_WAKE_COMMENT_BODY_CHARS = 4_000;
260267
const MAX_INLINE_WAKE_COMMENT_BODY_TOTAL_CHARS = 12_000;
@@ -10915,8 +10922,25 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
1091510922
};
1091610923

1091710924
const adapter = getServerAdapter(agent.adapterType);
10925+
// RBR-1035 AC1: derive the JWT TTL from the run's own configured max
10926+
// wall clock (adapter `timeoutSec`) plus a safety margin, rather than
10927+
// trusting the instance-wide 1h default to always outlive the run.
10928+
// This is deliberately (a) from the RBR-1035 plan — a fixed max-TTL
10929+
// computed from the run's own bound — not (b) an in-process refresh:
10930+
// refreshing would require the agent process to either hold a signing
10931+
// secret (the exact anti-pattern under CISO review in RBR-1034) or
10932+
// call a not-yet-built orchestrator-brokered refresh endpoint. No new
10933+
// runtime moving parts are needed for a run whose maximum duration is
10934+
// already known up front. Runs with no configured timeout (`timeoutSec`
10935+
// unset/0, i.e. unbounded) keep the default TTL — there is no run
10936+
// duration to size the token to.
10937+
const runTimeoutPolicy = resolveHeartbeatRunTimeoutPolicy(agent.adapterType, runtimeConfig);
10938+
const jwtMinTtlSeconds =
10939+
runTimeoutPolicy.timeoutConfigured && runTimeoutPolicy.effectiveTimeoutSec
10940+
? Math.ceil(runTimeoutPolicy.effectiveTimeoutSec) + AGENT_JWT_RUN_TIMEOUT_MARGIN_SECONDS
10941+
: null;
1091810942
const authToken = adapter.supportsLocalAgentJwt
10919-
? createLocalAgentJwt(agent.id, agent.companyId, agent.adapterType, run.id)
10943+
? createLocalAgentJwt(agent.id, agent.companyId, agent.adapterType, run.id, jwtMinTtlSeconds)
1092010944
: null;
1092110945
if (adapter.supportsLocalAgentJwt && !authToken) {
1092210946
logger.warn(

0 commit comments

Comments
 (0)