diff --git a/server/src/__tests__/agent-auth-jwt.test.ts b/server/src/__tests__/agent-auth-jwt.test.ts index 7d809ce3d3..9ad27d6e6b 100644 --- a/server/src/__tests__/agent-auth-jwt.test.ts +++ b/server/src/__tests__/agent-auth-jwt.test.ts @@ -253,13 +253,17 @@ describe("agent local JWT", () => { expect(verifyLocalAgentJwt(legacyToken)).toBeNull(); }); - it("defaults TTL to 1h when PAPERCLIP_AGENT_JWT_TTL_SECONDS is unset", () => { + it("defaults TTL to 48h when PAPERCLIP_AGENT_JWT_TTL_SECONDS is unset", () => { + // Must match DEFAULT_AGENT_JWT_TTL_SECONDS in cli/src/commands/env.ts. Run + // tokens are minted once at adapter spawn, and a suspended host (laptop lid + // closed) can delay first execution past a short TTL, making the injected + // PAPERCLIP_API_KEY dead on arrival. delete process.env[ttlEnv]; vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1"); const claims = verifyLocalAgentJwt(token!); expect(claims).not.toBeNull(); - expect(claims!.exp - claims!.iat).toBe(60 * 60); + expect(claims!.exp - claims!.iat).toBe(60 * 60 * 48); }); // Helper: hand-craft a token signed with the raw master secret (legacy path). diff --git a/server/src/agent-auth-jwt.ts b/server/src/agent-auth-jwt.ts index 140bcfafe4..a4f42b9d77 100644 --- a/server/src/agent-auth-jwt.ts +++ b/server/src/agent-auth-jwt.ts @@ -42,7 +42,13 @@ function jwtConfig() { return { secret, - ttlSeconds: parseNumber(process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS, 60 * 60), + // 48h default, matching DEFAULT_AGENT_JWT_TTL_SECONDS in cli/src/commands/env.ts + // and the agent-authentication design doc. Run tokens are minted once at + // adapter spawn and injected as env, so the TTL must cover the entire run — + // including host-suspension gaps: heartbeats scheduled while a laptop lid is + // closed fire during ~2s dark wakes, and the spawned session can then sit + // frozen for over an hour before it first executes. + ttlSeconds: parseNumber(process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS, 60 * 60 * 48), issuer: process.env.PAPERCLIP_AGENT_JWT_ISSUER ?? "paperclip", audience: process.env.PAPERCLIP_AGENT_JWT_AUDIENCE ?? "paperclip-api", // The control-plane instance this process belongs to. The live plane runs as @@ -183,7 +189,7 @@ export function verifyLocalAgentJwt(token: string): LocalAgentJwtClaims | null { // bounds the legacy window naturally). // // Operators should set `PAPERCLIP_AGENT_JWT_DISABLE_LEGACY_FALLBACK=true` - // approximately one JWT TTL (~1h by default, see PAPERCLIP_AGENT_JWT_TTL_SECONDS) + // approximately one JWT TTL (~48h by default, see PAPERCLIP_AGENT_JWT_TTL_SECONDS) // after deploying per-company signing. Once set, the master-secret fallback // is disabled and only tokens validating under the per-instance/per-company // derived key are accepted — closing the window in which a leaked master