From 644d017f2e3018bb75883549c6148b8b3ab41763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 27 Aug 2026 20:35:25 +0200 Subject: [PATCH 1/5] fix(daemon): enforce provider session artifact ownership --- .../src/runtime-session.test.ts | 27 ++ .../provider-webdriver/src/runtime-session.ts | 23 +- src/__tests__/daemon-entrypoint.test.ts | 30 +- src/daemon/__tests__/lease-lifecycle.test.ts | 35 +++ .../__tests__/lease-artifacts.test.ts | 265 ++++++++++++++++++ src/daemon/handlers/lease.ts | 54 +++- src/daemon/lease-lifecycle.ts | 5 + src/daemon/lease-registry.ts | 31 ++ src/daemon/provider-session-ownership.ts | 89 ++++++ 9 files changed, 525 insertions(+), 34 deletions(-) create mode 100644 src/daemon/handlers/__tests__/lease-artifacts.test.ts create mode 100644 src/daemon/provider-session-ownership.ts diff --git a/packages/provider-webdriver/src/runtime-session.test.ts b/packages/provider-webdriver/src/runtime-session.test.ts index f07ad5c183..e435e5571b 100644 --- a/packages/provider-webdriver/src/runtime-session.test.ts +++ b/packages/provider-webdriver/src/runtime-session.test.ts @@ -72,6 +72,33 @@ test('a create-timeout surfaces provider evidence for the maybe-leaked session', } }); +test('cloud artifact lookup does not accept a provider session id without its lease', async () => { + let listCalls = 0; + const runtime = makeRuntime({ + listArtifacts: async () => { + listCalls += 1; + return { + provider: 'webdriver-test', + status: 'ready', + cloudArtifacts: [], + }; + }, + }); + + try { + const cloudArtifacts = runtime.cloudArtifacts; + assert.ok(cloudArtifacts); + const result = await cloudArtifacts.listCloudArtifacts?.({ + provider: 'webdriver-test', + providerSessionId: 'never-authorized', + }); + assert.equal(result, undefined); + assert.equal(listCalls, 0); + } finally { + await runtime.shutdown(); + } +}); + function makeRuntime(overrides: Partial = {}) { return createCloudWebDriverRuntime({ clientVersion: 'test', diff --git a/packages/provider-webdriver/src/runtime-session.ts b/packages/provider-webdriver/src/runtime-session.ts index 9bf27156ae..00611a975b 100644 --- a/packages/provider-webdriver/src/runtime-session.ts +++ b/packages/provider-webdriver/src/runtime-session.ts @@ -146,12 +146,25 @@ export class WebDriverSessionManager { async listCloudArtifacts(query: CloudArtifactsQuery): Promise { if (query.provider !== this.options.provider) return undefined; - const session = query.leaseId ? this.sessionsByLeaseId.get(query.leaseId) : undefined; - if (session) return await this.safeListArtifacts(session); - const providerSessionId = - query.providerSessionId ?? - (query.leaseId ? this.releasedProviderSessionIdsByLeaseId.get(query.leaseId) : undefined); + const session = this.sessionsByLeaseId.get(query.leaseId ?? ''); + if (session) return await this.listActiveCloudArtifacts(session, query.providerSessionId); + return await this.listReleasedCloudArtifacts(query); + } + + private async listActiveCloudArtifacts( + session: WebDriverProviderSession, + providerSessionId: string | undefined, + ): Promise { + if (providerSessionId && providerSessionId !== session.providerSessionId) return undefined; + return await this.safeListArtifacts(session); + } + + private async listReleasedCloudArtifacts( + query: CloudArtifactsQuery, + ): Promise { + const providerSessionId = this.releasedProviderSessionIdsByLeaseId.get(query.leaseId ?? ''); if (!providerSessionId || !this.options.listArtifacts) return undefined; + if (query.providerSessionId && query.providerSessionId !== providerSessionId) return undefined; return await this.options.listArtifacts({ provider: this.options.provider, providerSessionId, diff --git a/src/__tests__/daemon-entrypoint.test.ts b/src/__tests__/daemon-entrypoint.test.ts index d88c62a617..d2c1a470ac 100644 --- a/src/__tests__/daemon-entrypoint.test.ts +++ b/src/__tests__/daemon-entrypoint.test.ts @@ -171,11 +171,10 @@ test('daemon runtime publishes dual transport metadata', async () => { } }); -test('daemon default provider composition serves cloud artifacts over RPC', async () => { +test('daemon rejects unowned cloud artifacts over RPC', async () => { const stateDir = mkdtempForTestSync('agent-device-daemon-provider-'); const providerRequests: string[] = []; - const providerServer = http.createServer((req, res) => { - providerRequests.push(req.url ?? ''); + const providerServer = http.createServer((_req, res) => { res.setHeader('content-type', 'application/json'); res.end( JSON.stringify({ @@ -220,27 +219,14 @@ test('daemon default provider composition serves cloud artifacts over RPC', asyn }), }); const body = (await response.json()) as { - result?: { ok?: boolean; data?: Record }; + error?: { code?: number; message?: string; data?: { code?: string; details?: unknown } }; }; - assert.equal(response.status, 200); - assert.equal(body.result?.ok, true); - assert.deepEqual(body.result?.data, { - provider: 'browserstack', - providerSessionId: 'wd-1', - status: 'ready', - cloudArtifacts: [ - { - provider: 'browserstack', - providerSessionId: 'wd-1', - kind: 'video', - name: 'Session video', - url: 'https://browserstack.example/video.mp4', - availability: 'ready', - }, - ], - }); - assert.deepEqual(providerRequests, ['/sessions/wd-1.json']); + assert.equal(response.status, 401); + assert.equal(body.error?.code, -32000); + assert.equal(body.error?.data?.code, 'UNAUTHORIZED'); + assert.deepEqual(body.error?.data?.details, { reason: 'PROVIDER_SESSION_NOT_OWNED' }); + assert.deepEqual(providerRequests, []); } finally { await runtime?.shutdown(); await closeLoopbackServer(providerServer); diff --git a/src/daemon/__tests__/lease-lifecycle.test.ts b/src/daemon/__tests__/lease-lifecycle.test.ts index 7c17dac510..84eea2f987 100644 --- a/src/daemon/__tests__/lease-lifecycle.test.ts +++ b/src/daemon/__tests__/lease-lifecycle.test.ts @@ -120,6 +120,41 @@ test('releaseSessionLease releases with the stored session owner scope', async ( expect(provider).toEqual({ provider: 'proxy' }); }); +test('releaseSessionLease retains provider session ownership for artifact lookup', async () => { + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseBackend: 'android-instance', + leaseProvider: 'browserstack', + }); + const session = makeIosSession('default', { + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + leaseProvider: lease.leaseProvider, + }, + }); + + await releaseSessionLease({ + session, + leaseRegistry, + leaseLifecycleProvider: { + release: async () => ({ providerSessionId: 'bs-session-1' }), + }, + }); + + expect( + leaseRegistry.resolveProviderSession({ + provider: 'browserstack', + providerSessionId: 'bs-session-1', + tenantId: 'tenant-a', + }), + ).toMatchObject({ leaseId: lease.leaseId, tenantId: 'tenant-a' }); +}); + test('releaseExpiredProviderLease releases a provider-owned lease without a session', async () => { const lease = new LeaseRegistry().allocateLease({ tenantId: 'tenant-a', diff --git a/src/daemon/handlers/__tests__/lease-artifacts.test.ts b/src/daemon/handlers/__tests__/lease-artifacts.test.ts new file mode 100644 index 0000000000..550ab7585c --- /dev/null +++ b/src/daemon/handlers/__tests__/lease-artifacts.test.ts @@ -0,0 +1,265 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { CloudArtifactsQuery } from '@agent-device/contracts/observability'; +import type { DeviceLease } from '@agent-device/contracts/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; +import type { DaemonRequest, DaemonResponse } from '../../types.ts'; +import { handleLeaseCommands } from '../lease.ts'; +import { LeaseRegistry } from '../../lease-registry.ts'; + +const CLOUD_PROVIDER = 'fake-cloud'; + +test('artifacts refuses foreign and unknown provider session ids before cloud dispatch', async () => { + const world = createWorld(); + await allocateLease(world, 'tenant-a', 'run-a'); + + await assertProviderSessionNotOwned(world, { + tenantId: 'tenant-b', + runId: 'run-b', + providerSessionId: 'session-tenant-a', + }); + await assertProviderSessionNotOwned(world, { + tenantId: 'tenant-a', + runId: 'run-a', + providerSessionId: 'session-never-issued', + }); + await assertProviderSessionNotOwned(world, { + tenantId: 'tenant-a', + runId: 'run-a', + leaseProvider: 'other-cloud', + providerSessionId: 'session-tenant-a', + }); + + assert.deepEqual(world.providerCalls, []); +}); + +test('artifacts lists an owned active and recently released provider session', async () => { + const world = createWorld(); + const lease = await allocateLease(world, 'tenant-a', 'run-a'); + + const active = await listArtifacts(world, { + tenantId: 'tenant-a', + runId: 'run-a', + providerSessionId: 'session-tenant-a', + }); + assert.equal(active.ok, true); + assert.deepEqual(world.providerCalls, [ + { + provider: CLOUD_PROVIDER, + leaseId: lease.leaseId, + providerSessionId: 'session-tenant-a', + }, + ]); + + await releaseLease(world, lease); + world.providerCalls.length = 0; + + const released = await listArtifacts(world, { + tenantId: 'tenant-a', + runId: 'run-a', + providerSessionId: 'session-tenant-a', + }); + assert.equal(released.ok, true); + assert.deepEqual(world.providerCalls, [ + { + provider: CLOUD_PROVIDER, + leaseId: lease.leaseId, + providerSessionId: 'session-tenant-a', + }, + ]); +}); + +test('artifacts refuses a released provider session after the retention window', async () => { + let now = 1_000; + const world = createWorld({ + now: () => now, + providerSessionRetentionMs: 100, + }); + const lease = await allocateLease(world, 'tenant-a', 'run-a'); + + await releaseLease(world, lease); + now = 1_050; + const retained = await listArtifacts(world, { + tenantId: 'tenant-a', + runId: 'run-a', + providerSessionId: 'session-tenant-a', + }); + assert.equal(retained.ok, true); + + world.providerCalls.length = 0; + now = 1_101; + await assertProviderSessionNotOwned(world, { + tenantId: 'tenant-a', + runId: 'run-a', + providerSessionId: 'session-tenant-a', + }); + assert.deepEqual(world.providerCalls, []); +}); + +test('artifacts retains an expired provider session only through the release window', async () => { + let now = 1_000; + const world = createWorld({ + now: () => now, + defaultLeaseTtlMs: 100, + minLeaseTtlMs: 1, + maxLeaseTtlMs: 100, + providerSessionRetentionMs: 50, + }); + const lease = await allocateLease(world, 'tenant-a', 'run-a'); + + now = lease.expiresAt + 1; + const retained = await listArtifacts(world, { + tenantId: 'tenant-a', + runId: 'run-a', + providerSessionId: 'session-tenant-a', + }); + assert.equal(retained.ok, true); + + world.providerCalls.length = 0; + now = lease.expiresAt + 51; + await assertProviderSessionNotOwned(world, { + tenantId: 'tenant-a', + runId: 'run-a', + providerSessionId: 'session-tenant-a', + }); + assert.deepEqual(world.providerCalls, []); +}); + +type World = { + leaseRegistry: LeaseRegistry; + sessionStore: ReturnType; + providerCalls: CloudArtifactsQuery[]; + lifecycle: { + allocate: (lease: DeviceLease) => Promise>; + release: (lease: DeviceLease) => Promise>; + }; +}; + +function createWorld( + options: { + now?: () => number; + defaultLeaseTtlMs?: number; + minLeaseTtlMs?: number; + maxLeaseTtlMs?: number; + providerSessionRetentionMs?: number; + } = {}, +): World { + const sessionStore = makeSessionStore('agent-device-lease-artifact-ownership-'); + const providerCalls: CloudArtifactsQuery[] = []; + return { + leaseRegistry: new LeaseRegistry(options), + sessionStore, + providerCalls, + lifecycle: { + allocate: async (lease) => ({ providerSessionId: `session-${lease.tenantId}` }), + release: async (lease) => ({ providerSessionId: `session-${lease.tenantId}` }), + }, + }; +} + +async function allocateLease(world: World, tenantId: string, runId: string): Promise { + const response = await handleLeaseCommands({ + req: leaseRequest('lease_allocate', { + tenantId, + runId, + leaseBackend: 'android-instance', + leaseProvider: CLOUD_PROVIDER, + }), + sessionName: 'artifact-test', + sessionStore: world.sessionStore, + leaseRegistry: world.leaseRegistry, + leaseLifecycleProvider: world.lifecycle, + }); + assert.equal(response?.ok, true); + if (!response?.ok) throw new Error('lease allocation failed'); + return response.data?.lease as DeviceLease; +} + +async function releaseLease(world: World, lease: DeviceLease): Promise { + const response = await handleLeaseCommands({ + req: leaseRequest('lease_release', { + tenantId: lease.tenantId, + runId: lease.runId, + leaseId: lease.leaseId, + leaseBackend: lease.backend, + leaseProvider: lease.leaseProvider, + }), + sessionName: 'artifact-test', + sessionStore: world.sessionStore, + leaseRegistry: world.leaseRegistry, + leaseLifecycleProvider: world.lifecycle, + }); + assert.equal(response?.ok, true); +} + +async function listArtifacts( + world: World, + scope: { + tenantId: string; + runId: string; + providerSessionId: string; + leaseProvider?: string; + }, +): Promise { + return (await handleLeaseCommands({ + req: leaseRequest( + 'artifacts', + { + tenantId: scope.tenantId, + runId: scope.runId, + leaseProvider: scope.leaseProvider ?? CLOUD_PROVIDER, + }, + { providerSessionId: scope.providerSessionId }, + ), + sessionName: 'artifact-test', + sessionStore: world.sessionStore, + leaseRegistry: world.leaseRegistry, + cloudArtifactProvider: { + listCloudArtifacts: async (query) => { + world.providerCalls.push({ ...query }); + return { + provider: CLOUD_PROVIDER, + status: 'ready', + providerSessionId: query.providerSessionId, + cloudArtifacts: [], + }; + }, + }, + })) as DaemonResponse; +} + +async function assertProviderSessionNotOwned( + world: World, + scope: { + tenantId: string; + runId: string; + providerSessionId: string; + leaseProvider?: string; + }, +): Promise { + await assert.rejects( + () => listArtifacts(world, scope), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'UNAUTHORIZED'); + assert.equal(error.details?.reason, 'PROVIDER_SESSION_NOT_OWNED'); + return true; + }, + ); +} + +function leaseRequest( + command: string, + meta: NonNullable, + flags: Record = {}, +): DaemonRequest { + return { + command, + token: 'test-token', + session: 'artifact-test', + meta, + flags, + positionals: [], + }; +} diff --git a/src/daemon/handlers/lease.ts b/src/daemon/handlers/lease.ts index fa7293f831..36117e3109 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -25,6 +25,7 @@ import { AppError, createRequestCanceledError, errorMessage } from '@agent-devic import { LEASE_ALLOCATION_BUDGET_MS } from '../../core/command-descriptor/timeout-policy.ts'; import { getRequestSignal, isRequestCanceled } from '@agent-device/host-kit/request'; import { listDownloadableArtifacts } from '../artifact-tracking.ts'; +import { providerSessionIdFromData } from '../provider-session-ownership.ts'; type LeaseHandlerArgs = { req: DaemonRequest; @@ -54,10 +55,12 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise, + data: (await listArtifactsForRequest( + req, + artifactScope, + leaseRegistry, + cloudArtifactProvider, + )) as Record, }; } case 'lease_allocate': { @@ -74,6 +77,7 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise { const provider = lease ? await leaseLifecycleProvider?.release?.(lease, context) : undefined; + if (lease) recordProviderSession(leaseRegistry, lease, provider); return { registryReleased: leaseRegistry.releaseLease(request).released, provider }; } @@ -244,6 +249,7 @@ function assertProviderRuntimeAvailable( async function listArtifactsForRequest( req: DaemonRequest, leaseScope: ReturnType, + leaseRegistry: LeaseRegistry, cloudArtifactProvider: CloudArtifactProvider | undefined, ): Promise { const providerSessionId = readFlagString(req.flags, 'providerSessionId'); @@ -251,7 +257,12 @@ async function listArtifactsForRequest( return await listDaemonArtifacts(leaseScope.tenantId); } - return await listCloudArtifactsForRequest(leaseScope, providerSessionId, cloudArtifactProvider); + return await listCloudArtifactsForRequest( + leaseScope, + providerSessionId, + leaseRegistry, + cloudArtifactProvider, + ); } function shouldListDaemonArtifacts( @@ -274,6 +285,7 @@ async function listDaemonArtifacts(tenantId: string | undefined): Promise, providerSessionId: string | undefined, + leaseRegistry: LeaseRegistry, cloudArtifactProvider: CloudArtifactProvider | undefined, ): Promise { if (!leaseScope.leaseProvider) { @@ -288,9 +300,10 @@ async function listCloudArtifactsForRequest( 'artifacts requires an active cloud lease or --provider-session .', ); } + const providerSession = resolveProviderSession(leaseRegistry, leaseScope, providerSessionId); const result = await cloudArtifactProvider?.listCloudArtifacts?.({ provider: leaseScope.leaseProvider, - leaseId: leaseScope.leaseId, + leaseId: providerSession?.leaseId ?? leaseScope.leaseId, providerSessionId, }); if (!result) { @@ -302,10 +315,37 @@ async function listCloudArtifactsForRequest( return result; } +function resolveProviderSession( + leaseRegistry: LeaseRegistry, + leaseScope: ReturnType, + providerSessionId: string | undefined, +): ReturnType { + if (!providerSessionId) return undefined; + const providerSession = leaseRegistry.resolveProviderSession({ + provider: leaseScope.leaseProvider, + providerSessionId, + tenantId: leaseScope.tenantId, + }); + if (providerSession) return providerSession; + throw new AppError('UNAUTHORIZED', 'Provider session is not owned by the request tenant', { + reason: 'PROVIDER_SESSION_NOT_OWNED', + }); +} + function readFlagString( flags: Record | undefined, key: string, ): string | undefined { const value = flags?.[key]; - return typeof value === 'string' && value.length > 0 ? value : undefined; + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} + +function recordProviderSession( + leaseRegistry: LeaseRegistry, + lease: DeviceLease, + providerData: Record | undefined, +): void { + const providerSessionId = providerSessionIdFromData(providerData); + if (!providerSessionId) return; + leaseRegistry.recordProviderSession(lease, providerSessionId); } diff --git a/src/daemon/lease-lifecycle.ts b/src/daemon/lease-lifecycle.ts index daba39eaab..b8fbfc243e 100644 --- a/src/daemon/lease-lifecycle.ts +++ b/src/daemon/lease-lifecycle.ts @@ -10,6 +10,7 @@ import { } from './request-admission.ts'; import type { SessionStore } from './session-store.ts'; import type { DaemonRequest, SessionState } from './types.ts'; +import { providerSessionIdFromData } from './provider-session-ownership.ts'; export type ExpiredProviderLeaseRecovery = (lease: DeviceLease) => Promise; @@ -159,6 +160,10 @@ export async function releaseSessionLease(params: { const providerData = activeLease ? await params.leaseLifecycleProvider?.release?.(activeLease) : undefined; + const providerSessionId = providerSessionIdFromData(providerData); + if (activeLease && providerSessionId) { + params.leaseRegistry.recordProviderSession(activeLease, providerSessionId); + } const result = params.leaseRegistry.releaseLease(releaseRequest); emitDiagnostic({ level: 'info', diff --git a/src/daemon/lease-registry.ts b/src/daemon/lease-registry.ts index beb307c72f..14b39d3b59 100644 --- a/src/daemon/lease-registry.ts +++ b/src/daemon/lease-registry.ts @@ -3,6 +3,10 @@ import crypto from 'node:crypto'; import type { LeaseBackend } from '@agent-device/kernel/contracts'; import { AppError } from '@agent-device/kernel/errors'; import { normalizeTenantId } from './config.ts'; +import { + ProviderSessionOwnershipRegistry, + type ProviderSessionOwnership, +} from './provider-session-ownership.ts'; export type SimulatorLease = DeviceLease; @@ -11,6 +15,7 @@ export type LeaseRegistryOptions = { defaultLeaseTtlMs?: number; minLeaseTtlMs?: number; maxLeaseTtlMs?: number; + providerSessionRetentionMs?: number; now?: () => number; onLeaseExpired?: (lease: DeviceLease) => void; }; @@ -204,6 +209,7 @@ export class LeaseRegistry { private readonly maxLeaseTtlMs: number; private readonly now: () => number; private readonly onLeaseExpired?: (lease: DeviceLease) => void; + private readonly providerSessionOwnership: ProviderSessionOwnershipRegistry; constructor(options: LeaseRegistryOptions = {}) { this.maxActiveSimulatorLeases = Number.isInteger(options.maxActiveSimulatorLeases) @@ -220,6 +226,10 @@ export class LeaseRegistry { : MAX_LEASE_TTL_MS; this.now = options.now ?? (() => Date.now()); this.onLeaseExpired = options.onLeaseExpired; + this.providerSessionOwnership = new ProviderSessionOwnershipRegistry({ + now: this.now, + retentionMs: options.providerSessionRetentionMs, + }); } allocateLease(request: AllocateLeaseRequest): DeviceLease { @@ -340,6 +350,26 @@ export class LeaseRegistry { return Array.from(this.leases.values()).map((entry) => ({ ...entry })); } + recordProviderSession( + lease: Pick, + providerSessionId: string, + ): void { + this.cleanupExpiredLeases(); + this.providerSessionOwnership.record(lease, providerSessionId); + if (!this.leases.has(lease.leaseId)) { + this.providerSessionOwnership.markLeaseReleased(lease); + } + } + + resolveProviderSession(params: { + provider?: string; + providerSessionId?: string; + tenantId?: string; + }): ProviderSessionOwnership | undefined { + this.cleanupExpiredLeases(); + return this.providerSessionOwnership.resolve(params); + } + consumeExpiredLeases(): DeviceLease[] { const now = this.now(); const expired: DeviceLease[] = []; @@ -457,6 +487,7 @@ export class LeaseRegistry { if (deviceBindingKey) { this.deviceBindings.delete(deviceBindingKey); } + this.providerSessionOwnership.markLeaseReleased(lease); } private bindingKey(params: { diff --git a/src/daemon/provider-session-ownership.ts b/src/daemon/provider-session-ownership.ts new file mode 100644 index 0000000000..d781917c2a --- /dev/null +++ b/src/daemon/provider-session-ownership.ts @@ -0,0 +1,89 @@ +import type { DeviceLease } from '@agent-device/contracts/device'; + +const DEFAULT_PROVIDER_SESSION_RETENTION_MS = 5 * 60_000; + +export type ProviderSessionOwnership = Readonly<{ + provider: string; + providerSessionId: string; + leaseId: string; + tenantId: string; +}>; + +type ProviderSessionRecord = ProviderSessionOwnership & { + retainedUntil?: number; +}; + +export function providerSessionIdFromData( + providerData: Record | undefined, +): string | undefined { + const providerSessionId = providerData?.providerSessionId; + return typeof providerSessionId === 'string' && providerSessionId.trim().length > 0 + ? providerSessionId.trim() + : undefined; +} + +export class ProviderSessionOwnershipRegistry { + private readonly records = new Map(); + private readonly now: () => number; + private readonly retentionMs: number; + + constructor(options: { now?: () => number; retentionMs?: number } = {}) { + this.now = options.now ?? (() => Date.now()); + this.retentionMs = Number.isInteger(options.retentionMs) + ? Math.max(0, Number(options.retentionMs)) + : DEFAULT_PROVIDER_SESSION_RETENTION_MS; + } + + record(lease: Pick, rawId: string): void { + const provider = lease.leaseProvider?.trim(); + const providerSessionId = rawId.trim(); + if (!provider || !providerSessionId) return; + this.prune(); + const ownership: ProviderSessionOwnership = { + provider, + providerSessionId, + leaseId: lease.leaseId, + tenantId: lease.tenantId, + }; + this.records.set(this.key(provider, providerSessionId), ownership); + } + + markLeaseReleased(lease: Pick): void { + const provider = lease.leaseProvider?.trim(); + if (!provider) return; + this.prune(); + const retainedUntil = this.now() + this.retentionMs; + for (const [key, record] of this.records) { + if (record.provider !== provider || record.leaseId !== lease.leaseId) continue; + this.records.set(key, { ...record, retainedUntil }); + } + } + + resolve(params: { + provider?: string; + providerSessionId?: string; + tenantId?: string; + }): ProviderSessionOwnership | undefined { + const provider = params.provider?.trim(); + const providerSessionId = params.providerSessionId?.trim(); + const tenantId = params.tenantId?.trim(); + if (!provider || !providerSessionId || !tenantId) return undefined; + this.prune(); + const record = this.records.get(this.key(provider, providerSessionId)); + if (!record || record.tenantId !== tenantId) return undefined; + return { ...record }; + } + + private prune(): void { + const now = this.now(); + for (const [key, record] of this.records) { + if (record.retainedUntil !== undefined && record.retainedUntil <= now) { + this.records.delete(key); + } + } + } + + private key(provider: string, providerSessionId: string): string { + return JSON.stringify([provider, providerSessionId]); + } +} From f4d3d8ae69efe650c9fd21934203c1b8ee7058bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 27 Aug 2026 21:51:42 +0200 Subject: [PATCH 2/5] fix(daemon): anchor provider session retention at expiry --- src/daemon/handlers/__tests__/lease-artifacts.test.ts | 11 +---------- src/daemon/lease-registry.ts | 8 ++++---- src/daemon/provider-session-ownership.ts | 7 +++++-- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/src/daemon/handlers/__tests__/lease-artifacts.test.ts b/src/daemon/handlers/__tests__/lease-artifacts.test.ts index 550ab7585c..4a6a6ae3c8 100644 --- a/src/daemon/handlers/__tests__/lease-artifacts.test.ts +++ b/src/daemon/handlers/__tests__/lease-artifacts.test.ts @@ -97,7 +97,7 @@ test('artifacts refuses a released provider session after the retention window', assert.deepEqual(world.providerCalls, []); }); -test('artifacts retains an expired provider session only through the release window', async () => { +test('artifacts refuses an expired provider session after retention before lazy cleanup', async () => { let now = 1_000; const world = createWorld({ now: () => now, @@ -108,15 +108,6 @@ test('artifacts retains an expired provider session only through the release win }); const lease = await allocateLease(world, 'tenant-a', 'run-a'); - now = lease.expiresAt + 1; - const retained = await listArtifacts(world, { - tenantId: 'tenant-a', - runId: 'run-a', - providerSessionId: 'session-tenant-a', - }); - assert.equal(retained.ok, true); - - world.providerCalls.length = 0; now = lease.expiresAt + 51; await assertProviderSessionNotOwned(world, { tenantId: 'tenant-a', diff --git a/src/daemon/lease-registry.ts b/src/daemon/lease-registry.ts index 14b39d3b59..4ef053d174 100644 --- a/src/daemon/lease-registry.ts +++ b/src/daemon/lease-registry.ts @@ -376,7 +376,7 @@ export class LeaseRegistry { for (const lease of this.leases.values()) { if (lease.expiresAt > now) continue; this.leases.delete(lease.leaseId); - this.unbindLease(lease); + this.unbindLease(lease, lease.expiresAt); const expiredLease = { ...lease }; expired.push(expiredLease); this.onLeaseExpired?.(expiredLease); @@ -390,7 +390,7 @@ export class LeaseRegistry { const lease = this.leases.get(normalizedLeaseId); if (!lease || lease.expiresAt > this.now()) return undefined; this.leases.delete(lease.leaseId); - this.unbindLease(lease); + this.unbindLease(lease, lease.expiresAt); const expiredLease = { ...lease }; this.onLeaseExpired?.(expiredLease); return expiredLease; @@ -473,7 +473,7 @@ export class LeaseRegistry { } } - private unbindLease(lease: DeviceLease): void { + private unbindLease(lease: DeviceLease, releasedAt = this.now()): void { this.runBindings.delete( this.bindingKey({ tenantId: lease.tenantId, @@ -487,7 +487,7 @@ export class LeaseRegistry { if (deviceBindingKey) { this.deviceBindings.delete(deviceBindingKey); } - this.providerSessionOwnership.markLeaseReleased(lease); + this.providerSessionOwnership.markLeaseReleased(lease, releasedAt); } private bindingKey(params: { diff --git a/src/daemon/provider-session-ownership.ts b/src/daemon/provider-session-ownership.ts index d781917c2a..08a8b2ed7e 100644 --- a/src/daemon/provider-session-ownership.ts +++ b/src/daemon/provider-session-ownership.ts @@ -48,11 +48,14 @@ export class ProviderSessionOwnershipRegistry { this.records.set(this.key(provider, providerSessionId), ownership); } - markLeaseReleased(lease: Pick): void { + markLeaseReleased( + lease: Pick, + releasedAt = this.now(), + ): void { const provider = lease.leaseProvider?.trim(); if (!provider) return; this.prune(); - const retainedUntil = this.now() + this.retentionMs; + const retainedUntil = releasedAt + this.retentionMs; for (const [key, record] of this.records) { if (record.provider !== provider || record.leaseId !== lease.leaseId) continue; this.records.set(key, { ...record, retainedUntil }); From a46b538d4efd8b8098a8f7a79ffd7de26cac2709 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 27 Aug 2026 22:51:23 +0200 Subject: [PATCH 3/5] fix(daemon): preserve expiry for late provider sessions --- .../__tests__/lease-artifacts.test.ts | 47 +++++++++++++++++++ src/daemon/lease-registry.ts | 5 +- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/daemon/handlers/__tests__/lease-artifacts.test.ts b/src/daemon/handlers/__tests__/lease-artifacts.test.ts index 4a6a6ae3c8..9306343ada 100644 --- a/src/daemon/handlers/__tests__/lease-artifacts.test.ts +++ b/src/daemon/handlers/__tests__/lease-artifacts.test.ts @@ -117,6 +117,53 @@ test('artifacts refuses an expired provider session after retention before lazy assert.deepEqual(world.providerCalls, []); }); +test('artifacts refuses a provider session returned after allocation expiry retention', async () => { + let now = 1_000; + const world = createWorld({ + now: () => now, + defaultLeaseTtlMs: 100, + minLeaseTtlMs: 1, + maxLeaseTtlMs: 100, + providerSessionRetentionMs: 50, + }); + world.lifecycle.allocate = async (lease) => { + now = lease.expiresAt + 51; + return { providerSessionId: 'late-allocation-session' }; + }; + + await allocateLease(world, 'tenant-a', 'run-a'); + await assertProviderSessionNotOwned(world, { + tenantId: 'tenant-a', + runId: 'run-a', + providerSessionId: 'late-allocation-session', + }); + assert.deepEqual(world.providerCalls, []); +}); + +test('artifacts refuses a provider session returned after release expiry retention', async () => { + let now = 1_000; + const world = createWorld({ + now: () => now, + defaultLeaseTtlMs: 100, + minLeaseTtlMs: 1, + maxLeaseTtlMs: 100, + providerSessionRetentionMs: 50, + }); + const lease = await allocateLease(world, 'tenant-a', 'run-a'); + world.lifecycle.release = async (releasedLease) => { + now = releasedLease.expiresAt + 51; + return { providerSessionId: 'late-release-session' }; + }; + + await releaseLease(world, lease); + await assertProviderSessionNotOwned(world, { + tenantId: 'tenant-a', + runId: 'run-a', + providerSessionId: 'late-release-session', + }); + assert.deepEqual(world.providerCalls, []); +}); + type World = { leaseRegistry: LeaseRegistry; sessionStore: ReturnType; diff --git a/src/daemon/lease-registry.ts b/src/daemon/lease-registry.ts index 4ef053d174..f8c7ec53f5 100644 --- a/src/daemon/lease-registry.ts +++ b/src/daemon/lease-registry.ts @@ -351,13 +351,14 @@ export class LeaseRegistry { } recordProviderSession( - lease: Pick, + lease: Pick, providerSessionId: string, ): void { + const releasedAt = lease.expiresAt <= this.now() ? lease.expiresAt : undefined; this.cleanupExpiredLeases(); this.providerSessionOwnership.record(lease, providerSessionId); if (!this.leases.has(lease.leaseId)) { - this.providerSessionOwnership.markLeaseReleased(lease); + this.providerSessionOwnership.markLeaseReleased(lease, releasedAt); } } From 5100f8ee7862c5059a65924e48881dbc5b9a8cc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 27 Aug 2026 23:50:58 +0200 Subject: [PATCH 4/5] fix(daemon): close provider retention clock boundary --- src/daemon/handlers/__tests__/lease-artifacts.test.ts | 10 ++++------ src/daemon/lease-registry.ts | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/daemon/handlers/__tests__/lease-artifacts.test.ts b/src/daemon/handlers/__tests__/lease-artifacts.test.ts index 9306343ada..1df4553198 100644 --- a/src/daemon/handlers/__tests__/lease-artifacts.test.ts +++ b/src/daemon/handlers/__tests__/lease-artifacts.test.ts @@ -118,18 +118,16 @@ test('artifacts refuses an expired provider session after retention before lazy }); test('artifacts refuses a provider session returned after allocation expiry retention', async () => { - let now = 1_000; + const clockValues = [1_000, 1_000, 1_099, 1_151] as const; + let clockIndex = 0; const world = createWorld({ - now: () => now, + now: () => clockValues[Math.min(clockIndex++, clockValues.length - 1)], defaultLeaseTtlMs: 100, minLeaseTtlMs: 1, maxLeaseTtlMs: 100, providerSessionRetentionMs: 50, }); - world.lifecycle.allocate = async (lease) => { - now = lease.expiresAt + 51; - return { providerSessionId: 'late-allocation-session' }; - }; + world.lifecycle.allocate = async () => ({ providerSessionId: 'late-allocation-session' }); await allocateLease(world, 'tenant-a', 'run-a'); await assertProviderSessionNotOwned(world, { diff --git a/src/daemon/lease-registry.ts b/src/daemon/lease-registry.ts index f8c7ec53f5..1de9860539 100644 --- a/src/daemon/lease-registry.ts +++ b/src/daemon/lease-registry.ts @@ -354,8 +354,8 @@ export class LeaseRegistry { lease: Pick, providerSessionId: string, ): void { - const releasedAt = lease.expiresAt <= this.now() ? lease.expiresAt : undefined; this.cleanupExpiredLeases(); + const releasedAt = lease.expiresAt <= this.now() ? lease.expiresAt : undefined; this.providerSessionOwnership.record(lease, providerSessionId); if (!this.leases.has(lease.leaseId)) { this.providerSessionOwnership.markLeaseReleased(lease, releasedAt); From 6381863a9466129c9eeed068956db44550ec6f26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 27 Aug 2026 23:55:12 +0200 Subject: [PATCH 5/5] fix(daemon): type sequenced expiry clock --- src/daemon/handlers/__tests__/lease-artifacts.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/daemon/handlers/__tests__/lease-artifacts.test.ts b/src/daemon/handlers/__tests__/lease-artifacts.test.ts index 1df4553198..abf0062ee7 100644 --- a/src/daemon/handlers/__tests__/lease-artifacts.test.ts +++ b/src/daemon/handlers/__tests__/lease-artifacts.test.ts @@ -121,7 +121,7 @@ test('artifacts refuses a provider session returned after allocation expiry rete const clockValues = [1_000, 1_000, 1_099, 1_151] as const; let clockIndex = 0; const world = createWorld({ - now: () => clockValues[Math.min(clockIndex++, clockValues.length - 1)], + now: () => clockValues[Math.min(clockIndex++, clockValues.length - 1)] ?? 1_151, defaultLeaseTtlMs: 100, minLeaseTtlMs: 1, maxLeaseTtlMs: 100,