diff --git a/doc/idle-gateway-credential-expiry.md b/doc/idle-gateway-credential-expiry.md new file mode 100644 index 00000000000..427adcdc325 --- /dev/null +++ b/doc/idle-gateway-credential-expiry.md @@ -0,0 +1,74 @@ +# Gateway credential expiry and idle behavior + +Gateway sessions and named gateway tokens already persist their expiry times. +`cleanupExpiredSessions` remains the owner of physical deletion. Authentication +continues to check expiry, revocation, company scope, run ownership, and active +run state on use. No table, authorization rule, or external alarm is added. + +## Producer inventory + +- `createSession` registers intent before inserting a run-bound session in a + transaction. Its existing audit and returned credential remain unchanged. +- `createNamedGatewayToken` registers intent before inserting an expiring token + in a transaction. A token without an expiry owes no expiry cleanup and does + not notify this worker. +- Last-use updates do not change expiry. Token/session revocation, including + heartbeat completion and connection removal, also leaves the saved expiry + intact. Revoked rows retain the same physical deletion deadline. +- Gateway/company deletion removes dependent rows and can leave one harmless + stale deadline. It cannot create overdue work. No supported service changes + a saved credential expiry after creation. + +The database signal layer owns outer commit, rollback, savepoints, and uncertain +outcomes. The worker supports producers sharing that signal owner. Independent +SQL writers and another process need a separate live ingress contract. Startup +reconstructs existing rows but does not provide continuous replica discovery. + +## Worker contract + +`nextCredentialExpiryAt` reads the earliest non-null expiry across both tables. +An empty credential store or retained non-expiring tokens returns no deadline. +`registerGatewayCredentialExpiryWork` supplies cleanup and deadline selection to +the shared `registerDeadlineWork` helper. Provider and authentication behavior +stay in the gateway service; startup only registers the worker. + +The worker starts where the old startup cleanup ran. With heartbeat scheduling +enabled, committed changes and actual expiry times replace the recurring cleanup +call. With heartbeat scheduling disabled, the existing startup-only cleanup is +preserved. Tool action-review recovery is a separate obligation and is unchanged. + +Existing token deletion is bounded to 500 rows per pass. A remaining overdue +expiry schedules another pass after 30 seconds. Failed reconciliation also +retries at that bound. No unfinished reconciliation or future expiry means no +recurring SQL. A generation retains writes arriving during reconciliation. +Startup restores deadlines; drain retains first writes without passive SQL; +resume completes overdue cleanup. The coordinator owns shutdown and waits for +in-flight work. + +## Evidence and limits + +The new PostgreSQL suite exercises real session/token creation, configured idle +state, non-expiring history, expiry ordering, revoked-token cleanup, outer commit +and rollback, startup reconstruction, a backlog larger than one cleanup batch, +and drain/resume. It tests expired authentication before deferred physical +cleanup, through both session and named-gateway entry points. Existing gateway +acceptance and server-startup tests cover the changed integration boundaries. + +Use only disposable migrated databases for the test URL overrides. Their names +must end in `_test`; the suites delete their fixtures. Ordinary CI uses embedded +PostgreSQL. Test credentials are synthetic and no external provider is contacted +by the expiry suite. + +The migration proves worker behavior, not host suspension. Existing Cloud idle +proof remains conservative. An in-process deadline cannot wake stopped compute; +external wake coverage must be verified before changing sleep eligibility. + +## Related unmerged token-lifetime work + +At implementation time, PRs #17221 and #17274 change run-scoped token lifetime +and cleanup eligibility. This slice preserves the current master policy and +changes scheduling only. When combining those changes, the deadline query and +producer signals must use the same token eligibility as cleanup: run-token idle +markers must not schedule the operator-token expiry worker. Keep the run-liveness +reaper with its own owner and retain both regression suites. A conflict-free +text merge alone does not prove this integration. diff --git a/doc/plans/2026-10-08-instance-sleep-and-scheduler-inventory.md b/doc/plans/2026-10-08-instance-sleep-and-scheduler-inventory.md index 9141878caa8..38dec000a0e 100644 --- a/doc/plans/2026-10-08-instance-sleep-and-scheduler-inventory.md +++ b/doc/plans/2026-10-08-instance-sleep-and-scheduler-inventory.md @@ -649,8 +649,10 @@ Longer polling intervals alone do not complete an item. - [ ] **JOB-21 — Secret proposals and expired action tokens (partially migrated).** Secret-proposal expiry now uses transaction signals and saved expiry deadlines; - see [the lifecycle contract](../idle-secret-proposal-expiry.md). Tool review - recovery and gateway token cleanup still use **30s** queries. Change: enforce expiry synchronously on use and + see [the lifecycle contract](../idle-secret-proposal-expiry.md). Gateway token + and session expiry now also uses saved deadlines and producer signals; see + [its lifecycle contract](../idle-gateway-credential-expiry.md). Tool review + recovery still uses **30s** queries. Change: enforce expiry synchronously on use and register required scrub/cleanup deadlines. **Complete when:** idle cleanup does not extend authorization or secret-retention periods. Sources: [secret-proposals.ts](../../server/src/services/secret-proposals.ts), diff --git a/doc/plans/2026-10-24-recurring-server-sleep-stack.md b/doc/plans/2026-10-24-recurring-server-sleep-stack.md index a7126d42071..21605f826b0 100644 --- a/doc/plans/2026-10-24-recurring-server-sleep-stack.md +++ b/doc/plans/2026-10-24-recurring-server-sleep-stack.md @@ -18,7 +18,7 @@ Do not mark an item done from a timer removal or a green check alone. | 04 | Decision retention and notifications | Retention deadlines and notification delivery are separate reasons | Pending | | 05 | Decision expiry and interrupted effects | Expiry, effect recovery, continuation recovery | Pending | | 06 | Tool connection health | Configuration/use activation and defined proactive health deadlines | Pending | -| 07 | Secret proposals, tool reviews, gateway tokens | Separate expiry/scrub/recovery owners | Secret-proposal expiry in progress; tool reviews and gateway tokens pending | +| 07 | Secret proposals, tool reviews, gateway tokens | Separate expiry/scrub/recovery owners | Secret proposals: [#17446](https://github.com/paperclipai/paperclip/pull/17446), CI/review running; gateway credential expiry in progress; tool reviews pending | | 08 | GitHub connection events and continuity | Event discovery/replay and credential continuity separately | Pending | | 09 | Merged-PR confirmations | Candidate writes, webhook activation where supported, outstanding-candidate fallback | Pending | | 10 | External-object refresh | Configuration changes, refresh deadlines, source events | Pending | diff --git a/server/src/__tests__/gateway-credential-expiry.test.ts b/server/src/__tests__/gateway-credential-expiry.test.ts new file mode 100644 index 00000000000..af16c68659c --- /dev/null +++ b/server/src/__tests__/gateway-credential-expiry.test.ts @@ -0,0 +1,164 @@ +import { randomUUID } from "node:crypto"; +import { sql } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { activityLog, agents, companies, createDb, heartbeatRuns, toolAccessAuditEvents, toolGatewaySessions, + toolMcpGateways, toolMcpGatewayTokens, toolProfiles } from "@paperclipai/db"; +import { createToolGatewayService } from "../services/tool-gateway.js"; +import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js"; +import { registerGatewayCredentialExpiryWork } from "../services/gateway-credential-expiry-work.js"; +import { idleWorkSnapshot } from "../services/task-admission.js"; +import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; + +const external = resolveExternalTestDatabaseUrl("PAPERCLIP_GATEWAY_EXPIRY_TEST_DATABASE_URL"); +const supported = external ? true : (await getEmbeddedPostgresTestSupport()).supported; + +describe.skipIf(!supported)("gateway credential expiry", () => { + let db: ReturnType; + let temporary: Awaited> | undefined; + let gateway: ReturnType; + let coordinator: ReturnType | undefined; + let companyId: string, agentId: string, runId: string, namedId: string; + let draining: boolean; + const errors: unknown[] = []; + + beforeAll(async () => { + if (!external) temporary = await startEmbeddedPostgresTestDatabase("gateway-expiry-"); + db = createDb(external ?? temporary!.connectionString, { maxConnections: 6, idleTimeoutSeconds: 0, maxLifetimeSeconds: 0 }); + }, 90_000); + afterAll(async () => { await db?.$client.end({ timeout: 1 }); await temporary?.cleanup(); }); + beforeEach(async () => { + await Promise.all(Array.from({ length: 6 }, () => db.execute(sql`select pg_sleep(0.01)`))); + companyId = randomUUID(); agentId = randomUUID(); runId = randomUUID(); draining = false; errors.length = 0; + await db.insert(companies).values({ id: companyId, name: "Credential expiry", issuePrefix: "EXP" }); + await db.insert(agents).values({ id: agentId, companyId, name: "Agent", role: "engineer", adapterType: "process" }); + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "assignment", status: "running" }); + const [profile] = await db.insert(toolProfiles).values({ companyId, profileKey: "expiry", name: "Expiry", defaultAction: "deny" }).returning(); + gateway = createToolGatewayService(db, { toolActionSigningSecret: "expiry-test-only" }); + namedId = (await gateway.createNamedGateway({ companyId, body: { name: "Expiry", profileId: profile.id } })).id; + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"], shouldAdvanceTime: true }); + }); + afterEach(async () => { + await coordinator?.stop(); coordinator = undefined; + vi.useRealTimers(); vi.restoreAllMocks(); + await db.delete(activityLog); + await db.delete(toolAccessAuditEvents); + await db.delete(toolGatewaySessions); + await db.delete(toolMcpGatewayTokens); + await db.delete(toolMcpGateways); + await db.delete(toolProfiles); + await db.delete(heartbeatRuns); + await db.delete(agents); + await db.delete(companies); + expect(errors).toEqual([]); + }); + async function start() { + coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => !draining, + canReconcile: () => true, onError: error => errors.push(error) }); + await registerGatewayCredentialExpiryWork(coordinator, gateway, () => !draining).ready; + } + async function settle(ms = 60_000) { + await vi.advanceTimersByTimeAsync(ms); + if (!draining) await vi.waitFor(() => expect(idleWorkSnapshot().active).toBe(0), { timeout: 5000 }); + } + async function quiet() { + await settle(); expect(coordinator!.nextWakeAt()).toBeNull(); + const reads = vi.spyOn(db, "execute"), selects = vi.spyOn(db, "select"), deletes = vi.spyOn(db, "delete"); + await settle(60 * 60_000); + expect(reads).not.toHaveBeenCalled(); expect(selects).not.toHaveBeenCalled(); expect(deletes).not.toHaveBeenCalled(); + reads.mockRestore(); selects.mockRestore(); deletes.mockRestore(); + } + const token = (expiresAt?: Date) => gateway.createNamedGatewayToken({ companyId, gatewayId: namedId, body: { name: "Expiry", expiresAt } }); + const session = (service = gateway) => service.createSession({ companyId, agentId, runId, ttlMs: 5 * 60_000 }); + + it("stays quiet with configuration and a nonexpiring token, then expires real tokens and sessions by deadline", async () => { + await start(); await quiet(); + const retained = await token(); await quiet(); + const active = await session(); + const due = new Date(Date.now() + 3 * 60_000); + const expiring = await token(due); + await settle(); + expect(coordinator!.nextWakeAt()).toBe(due.getTime()); + const reads = vi.spyOn(db, "execute"), selects = vi.spyOn(db, "select"); + await settle(30_000); + expect(reads).not.toHaveBeenCalled(); expect(selects).not.toHaveBeenCalled(); + reads.mockRestore(); selects.mockRestore(); + await gateway.revokeNamedGatewayToken({ companyId, tokenId: expiring.id }); + await settle(2 * 60_000); + expect((await db.select().from(toolMcpGatewayTokens)).map(row => row.id)).toEqual([retained.id]); + expect(coordinator!.nextWakeAt()).toBe(active.expiresAt.getTime()); + await settle(2 * 60_000); + expect(await db.select().from(toolGatewaySessions)).toEqual([]); + await quiet(); + }); + + it.each([ + { kind: "session", rollback: false }, { kind: "session", rollback: true }, + { kind: "token", rollback: false }, { kind: "token", rollback: true }, + ])("respects $kind outer transaction settlement (rollback $rollback)", async ({ kind, rollback }) => { + await start(); await quiet(); + let release!: () => void, written!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const staged = new Promise(resolve => { written = resolve; }); + const rollbackError = new Error("rollback fixture"); + const writing = db.transaction(async tx => { + const scoped = createToolGatewayService(tx as unknown as typeof db, { toolActionSigningSecret: "expiry-test-only" }); + if (kind === "session") await session(scoped); + else await scoped.createNamedGatewayToken({ companyId, gatewayId: namedId, + body: { name: "Outer transaction", expiresAt: new Date(Date.now() + 5 * 60_000) } }); + written(); await gate; if (rollback) throw rollbackError; + }).catch(error => { if (error !== rollbackError) throw error; }); + await Promise.race([staged, writing]); + try { + await vi.advanceTimersByTimeAsync(60_000); + expect(await db.select().from(toolGatewaySessions)).toEqual([]); + expect(await db.select().from(toolMcpGatewayTokens)).toEqual([]); + } finally { release(); } + await writing; await settle(); + if (rollback) await quiet(); + else { + expect(coordinator!.nextWakeAt()).toBe(await gateway.nextCredentialExpiryAt()); + await settle(5 * 60_000); await quiet(); + } + }); + + it("reconstructs saved token expiry on startup", async () => { + const due = new Date(Date.now() + 5 * 60_000); + await token(due); + await start(); await settle(); + expect(coordinator!.nextWakeAt()).toBe(due.getTime()); + await settle(5 * 60_000); + expect(await db.select().from(toolMcpGatewayTokens)).toEqual([]); + await quiet(); + }); + + it("drains an overdue persisted backlog across bounded batches", async () => { + await db.insert(toolMcpGatewayTokens).values(Array.from({ length: 501 }, (_, index) => ({ + companyId, gatewayId: namedId, name: `Expired ${index}`, tokenHash: randomUUID(), + tokenPrefix: "expired", expiresAt: new Date(Date.now() - 1), + }))); + await start(); + expect(coordinator!.nextWakeAt()).not.toBeNull(); + await quiet(); + expect(await db.select().from(toolMcpGatewayTokens)).toEqual([]); + }); + + it("retains cleanup through drain without permitting expired authentication", async () => { + await start(); await quiet(); draining = true; + const active = await session(); + const expiredToken = await token(new Date(Date.now() + 3 * 60_000)); + await settle(); + const reads = vi.spyOn(db, "execute"), selects = vi.spyOn(db, "select"); + await settle(10 * 60_000); + expect(reads).not.toHaveBeenCalled(); expect(selects).not.toHaveBeenCalled(); + reads.mockRestore(); selects.mockRestore(); + expect(await db.select().from(toolGatewaySessions)).toHaveLength(1); + await expect(gateway.listToolsForSession(active.token)).rejects.toMatchObject({ status: 401, reasonCode: "session_expired" }); + await expect(gateway.initializeNamedGatewayProtocol({ gatewayId: namedId, bearerToken: expiredToken.token })) + .rejects.toMatchObject({ status: 401 }); + draining = false; await settle(); + expect(await db.select().from(toolGatewaySessions)).toEqual([]); + expect(await db.select().from(toolMcpGatewayTokens)).toEqual([]); + await quiet(); + }); +}); diff --git a/server/src/__tests__/server-startup-feedback-export.test.ts b/server/src/__tests__/server-startup-feedback-export.test.ts index e3d29b1435f..13307ed8aad 100644 --- a/server/src/__tests__/server-startup-feedback-export.test.ts +++ b/server/src/__tests__/server-startup-feedback-export.test.ts @@ -68,6 +68,7 @@ const { toolGateway: { sweepActionReviews: vi.fn(async () => ({ scanned: 0 })), cleanupExpiredSessions: vi.fn(async () => ({ deletedCount: 0 })), + nextCredentialExpiryAt: vi.fn(async () => null), }, toolActionDeliveries: { hasPending: vi.fn(async () => false), sweepPending: vi.fn(async () => ({ scanned: 0, delivered: 0 })) }, }, diff --git a/server/src/__tests__/tool-gateway.test.ts b/server/src/__tests__/tool-gateway.test.ts index 32dcd49bef7..dc9b7699690 100644 --- a/server/src/__tests__/tool-gateway.test.ts +++ b/server/src/__tests__/tool-gateway.test.ts @@ -1,3 +1,4 @@ +import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js"; import { createHash, randomUUID } from "node:crypto"; import { createServer, type IncomingMessage } from "node:http"; import express from "express"; @@ -65,7 +66,8 @@ import { startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; -const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const externalTestDatabase = resolveExternalTestDatabaseUrl("PAPERCLIP_TOOL_GATEWAY_TEST_DATABASE_URL"); +const embeddedPostgresSupport = externalTestDatabase ? { supported: true } : await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; const testToolActionSigningSecret = "test-tool-action-signing-secret"; @@ -553,6 +555,7 @@ describeEmbeddedPostgres("tool gateway acceptance", () => { let tempDb: Awaited> | null = null; beforeAll(async () => { + if (externalTestDatabase) { db = createDb(externalTestDatabase); return; } tempDb = await startEmbeddedPostgresTestDatabase("paperclip-tool-gateway-"); db = createDb(tempDb.connectionString); }, 90_000); @@ -592,6 +595,7 @@ describeEmbeddedPostgres("tool gateway acceptance", () => { }); afterAll(async () => { + await db?.$client.end({ timeout: 1 }); await tempDb?.cleanup(); }); diff --git a/server/src/index.ts b/server/src/index.ts index 2666d05c814..97d545d888a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,3 +1,4 @@ +import { registerGatewayCredentialExpiryWork } from "./services/gateway-credential-expiry-work.js"; import { registerSecretProposalExpiryWork } from "./services/secret-proposal-expiry-work.js"; import { registerCustomImageCleanupWork } from "./services/custom-image-cleanup-work.js"; import { registerEnvironmentCleanupWork } from "./services/environment-cleanup-work.js"; @@ -1436,7 +1437,10 @@ async function startServerWithDatabaseTeardown( }; await timeStartupRecovery("tool_reviews", () => app.locals.toolGateway.sweepActionReviews()).catch((err: unknown) => logger.error({ err }, "startup tool review recovery failed")); - await timeStartupRecovery("gateway_sessions", () => app.locals.toolGateway.cleanupExpiredSessions()).catch((err: unknown) => logger.error({ err }, "startup gateway token cleanup failed")); + await timeStartupRecovery("gateway_sessions", () => config.heartbeatSchedulerEnabled + ? registerGatewayCredentialExpiryWork(deliveryWork, app.locals.toolGateway, + () => !isIdleTaskDrainActive() && !heartbeatSchedulerStopped && !isWarmStandby()).ready + : app.locals.toolGateway.cleanupExpiredSessions()).catch((err: unknown) => logger.error({ err }, "startup gateway token cleanup failed")); await timeStartupRecovery("delivery_workers", () => Promise.all([budgetEnforcementWorker, accountingWorker, fastResponseWorker, ...deliveryWorkers].map(worker => worker.ready))); scheduleGitHubConnectionEventPoll(); scheduleGitHubConnectionContinuitySweep(); @@ -1857,7 +1861,6 @@ async function startServerWithDatabaseTeardown( })); trackHeartbeatSchedulerWork(app.locals.toolGateway.sweepActionReviews().catch((err: unknown) => logger.error({ err }, "tool review recovery failed"))); - trackHeartbeatSchedulerWork(app.locals.toolGateway.cleanupExpiredSessions().catch((err: unknown) => logger.error({ err }, "gateway token cleanup failed"))); if (isIdleTaskDrainActive() || heartbeatSchedulerStopped) return; if (!(await heartbeat.resolveSchedulingSuppression()).suppressed) { diff --git a/server/src/services/delivery-work-notifications.ts b/server/src/services/delivery-work-notifications.ts index 98ab73c8de2..fd2192e1d4f 100644 --- a/server/src/services/delivery-work-notifications.ts +++ b/server/src/services/delivery-work-notifications.ts @@ -8,6 +8,7 @@ export const DELIVERY_QUEUES = { executionStatus: "execution-status", executionDisposition: "execution-disposition", environmentCleanup: "environment-cleanup", + gatewayCredentialExpiry: "gateway-credential-expiry", secretProposalExpiry: "secret-proposal-expiry", customImageExpiry: "custom-image-expiry", historicalCleanup: "historical-cleanup", diff --git a/server/src/services/gateway-credential-expiry-work.ts b/server/src/services/gateway-credential-expiry-work.ts new file mode 100644 index 00000000000..48ddeedfa4b --- /dev/null +++ b/server/src/services/gateway-credential-expiry-work.ts @@ -0,0 +1,17 @@ +import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js"; +import { DELIVERY_QUEUES } from "./delivery-work-notifications.js"; +import { registerDeadlineWork } from "./deadline-work.js"; +import type { createToolGatewayService } from "./tool-gateway.js"; + +export function registerGatewayCredentialExpiryWork( + coordinator: ReturnType, + gateway: Pick, "cleanupExpiredSessions" | "nextCredentialExpiryAt">, + canRun: () => boolean, +) { + return registerDeadlineWork(coordinator, DELIVERY_QUEUES.gatewayCredentialExpiry, { + retryMs: 30_000, + canRun, + run: () => gateway.cleanupExpiredSessions(), + next: () => gateway.nextCredentialExpiryAt(), + }); +} diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index f5971156148..890ea64ace5 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -9020,44 +9020,47 @@ export function createToolGatewayService( const token = generateNamedGatewayToken(tokenId); const tokenPrefix = `pcgw_${tokenId.slice(0, 8)}`; const now = new Date(); - const [row] = await db - .insert(toolMcpGatewayTokens) - .values({ - id: tokenId, - companyId: input.companyId, - gatewayId: input.gatewayId, - name: input.body.name, - tokenHash: hashGatewayToken(token), - tokenPrefix, - subjectType: input.body.subjectType ?? "gateway_client", - subjectId: input.body.subjectId ?? null, - clientLabel: input.body.clientLabel, - ownerNote: input.body.ownerNote, - allowedActions: input.body.allowedActions ?? [ - "tools/list", - "tools/call", - "resources/list", - "resources/read", - "prompts/list", - "prompts/get", - ], - expiresAt: input.body.expiresAt ?? null, - expiryOverrideReason: input.body.expiryOverrideReason ?? null, - expiryOverrideByAgentId: - input.actor?.agentId && input.body.expiryOverrideReason - ? input.actor.agentId - : null, - expiryOverrideByUserId: - input.actor?.userId && input.body.expiryOverrideReason - ? input.actor.userId - : null, - expiryOverrideAt: input.body.expiryOverrideReason ? now : null, - createdByAgentId: input.actor?.agentId ?? null, - createdByUserId: input.actor?.userId ?? null, - createdAt: now, - updatedAt: now, - }) - .returning(); + const [row] = await db.transaction(async tx => { + if (input.body.expiresAt) await notifyDeliveryWork(tx, DELIVERY_QUEUES.gatewayCredentialExpiry); + return tx + .insert(toolMcpGatewayTokens) + .values({ + id: tokenId, + companyId: input.companyId, + gatewayId: input.gatewayId, + name: input.body.name, + tokenHash: hashGatewayToken(token), + tokenPrefix, + subjectType: input.body.subjectType ?? "gateway_client", + subjectId: input.body.subjectId ?? null, + clientLabel: input.body.clientLabel, + ownerNote: input.body.ownerNote, + allowedActions: input.body.allowedActions ?? [ + "tools/list", + "tools/call", + "resources/list", + "resources/read", + "prompts/list", + "prompts/get", + ], + expiresAt: input.body.expiresAt ?? null, + expiryOverrideReason: input.body.expiryOverrideReason ?? null, + expiryOverrideByAgentId: + input.actor?.agentId && input.body.expiryOverrideReason + ? input.actor.agentId + : null, + expiryOverrideByUserId: + input.actor?.userId && input.body.expiryOverrideReason + ? input.actor.userId + : null, + expiryOverrideAt: input.body.expiryOverrideReason ? now : null, + createdByAgentId: input.actor?.agentId ?? null, + createdByUserId: input.actor?.userId ?? null, + createdAt: now, + updatedAt: now, + }) + .returning(); + }); return { ...toGatewayToken(row), token }; }, @@ -9174,18 +9177,21 @@ export function createToolGatewayService( expiresAt: new Date(now.getTime() + sessionTtlMs(input.ttlMs)), }; - await db.insert(toolGatewaySessions).values({ - id: session.id, - companyId: session.companyId, - agentId: input.agentId, - runId: input.runId, - issueId: session.issueId, - projectId: session.projectId, - tokenHash: hashGatewayToken(token), - expiresAt: session.expiresAt, - createdAt: session.createdAt, - updatedAt: session.createdAt, - } as any); + await db.transaction(async tx => { + await notifyDeliveryWork(tx, DELIVERY_QUEUES.gatewayCredentialExpiry); + await tx.insert(toolGatewaySessions).values({ + id: session.id, + companyId: session.companyId, + agentId: input.agentId, + runId: input.runId, + issueId: session.issueId, + projectId: session.projectId, + tokenHash: hashGatewayToken(token), + expiresAt: session.expiresAt, + createdAt: session.createdAt, + updatedAt: session.createdAt, + } as any); + }); await writeAudit({ session, @@ -11599,6 +11605,17 @@ export function createToolGatewayService( return { ...sessionView, revokedAt: session!.revokedAt ?? now }; }, + async nextCredentialExpiryAt(): Promise { + const [row] = await db.execute<{ at: string | Date | null }>(sql` + select min(expires_at) as at from ( + select ${toolMcpGatewayTokens.expiresAt} as expires_at from ${toolMcpGatewayTokens} + union all + select ${toolGatewaySessions.expiresAt} as expires_at from ${toolGatewaySessions} + ) expiries + `); + return row?.at == null ? null : new Date(row.at).getTime(); + }, + async cleanupExpiredSessions(input: { now?: Date } = {}) { const now = input.now ?? new Date(); const expiredTokens = await db.select({ id: toolMcpGatewayTokens.id })