From 7275a664b5fc558e43f3893609d94ead4a904198 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 13:15:10 +0200 Subject: [PATCH] fix: fence session admission and runtime hint cleanup --- .../session-replay-repair.fixtures.test.ts | 33 +++++++ .../session-replay-repair.fixtures.ts | 2 +- .../snapshot-command-runtime.test.ts | 29 ++++++ .../__tests__/session-clipboard.test.ts | 25 +++++ .../__tests__/session-runtime-command.test.ts | 98 +++++++++++++++++++ src/daemon/handlers/session-clipboard.ts | 1 + .../handlers/session-runtime-command.ts | 19 ++-- .../handlers/session-selector-dispatch.ts | 8 +- .../interaction-touch-response.test.ts | 39 ++++++++ .../internal/interaction-touch-response.ts | 3 +- src/daemon/server/daemon-runtime.ts | 3 +- .../__tests__/session-open-foreground.test.ts | 1 + .../internal/session-open-foreground.ts | 2 +- src/daemon/session-runtime.ts | 2 +- src/daemon/session-store.ts | 10 +- src/daemon/snapshot-command-runtime.ts | 3 +- src/daemon/snapshot-runtime-binding.ts | 8 +- src/daemon/snapshot-session.ts | 5 +- 18 files changed, 267 insertions(+), 24 deletions(-) create mode 100644 src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.test.ts diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.test.ts new file mode 100644 index 0000000000..a3c8f2806b --- /dev/null +++ b/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from 'vitest'; +import { + makeIosSession, + authoringPublication, +} from '../../../__tests__/test-utils/session-factories.ts'; +import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; +import { makeRecordingReplayInvoke } from './session-replay-repair.fixtures.ts'; + +test('recording replay fixtures use the selected scoped journal without a public-name slot', async () => { + const store = makeSessionStore(); + const address = 'cwd:recording-fixture:default'; + const ref = store.publish( + address, + makeIosSession('default', { + scriptPublication: authoringPublication('armed'), + }), + ); + const invoke = makeRecordingReplayInvoke({ sessionStore: store, sessionName: address }); + expect( + await invoke({ + token: 'test', + session: 'default', + command: 'click', + positionals: ['1', '2'], + flags: {}, + }), + ).toMatchObject({ ok: true }); + await store.flushEvents(); + expect(store.requireCurrent(ref).actions).toHaveLength(1); + expect(store.readEvents(address).events).toHaveLength(1); + expect(store.lookup('default')).toBeUndefined(); + expect(store.readEvents('default').events).toEqual([]); +}); diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.ts b/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.ts index 77f99b41b2..41efd58afa 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.ts @@ -60,7 +60,7 @@ export function makeRecordingReplayInvoke(config: RecordingReplayInvokeConfig): } const session = resolveInvokeSession(config, req); const evidence = isSessionRecording(session) ? config.evidence?.(req) : undefined; - sessionStore.recordAction(storeSessionForTest(sessionStore, session), { + sessionStore.recordAction(storeSessionForTest(sessionStore, session, config.sessionName), { command: req.command, positionals: req.positionals ?? [], flags: req.flags ?? {}, diff --git a/src/daemon/__tests__/snapshot-command-runtime.test.ts b/src/daemon/__tests__/snapshot-command-runtime.test.ts index 2105920e7a..347ad83554 100644 --- a/src/daemon/__tests__/snapshot-command-runtime.test.ts +++ b/src/daemon/__tests__/snapshot-command-runtime.test.ts @@ -147,3 +147,32 @@ for (const change of ['unchanged', 'rebuild', 'replace'] as const) { } }); } + +test('a composed snapshot refuses its supplied retired lifetime before facts or capture', async () => { + const sessionStore = makeSessionStore(); + const address = 'cwd:composed-snapshot:default'; + const ref = sessionStore.publish(address, makeAndroidSession('default')); + sessionStore.retire(ref); + const successor = sessionStore.publish(address, makeAndroidSession('default')); + const fixture = snapshotRuntimeFixture(); + const facts = fixture.inspectFacts; + let inspections = 0; + captureMock.mockResolvedValue({ nodes: [], truncated: false, backend: 'uiautomator' }); + await expect( + dispatchSnapshotViaRuntime({ + req: { command: 'snapshot', positionals: [], token: 't', session: 'default' }, + sessionName: address, + sessionRef: ref, + logPath: '/dev/null', + sessionStore, + ...fixture, + inspectFacts: async (device) => { + inspections++; + return await facts(device); + }, + }), + ).rejects.toMatchObject({ details: { reason: 'session_lifetime_ended' } }); + expect(inspections).toBe(0); + expect(captureMock).not.toHaveBeenCalled(); + expect(sessionStore.requireCurrent(successor)).toBe(successor.session); +}); diff --git a/src/daemon/handlers/__tests__/session-clipboard.test.ts b/src/daemon/handlers/__tests__/session-clipboard.test.ts index b2aadb1449..fd82f24675 100644 --- a/src/daemon/handlers/__tests__/session-clipboard.test.ts +++ b/src/daemon/handlers/__tests__/session-clipboard.test.ts @@ -216,3 +216,28 @@ test('clipboard write with no text argument reports how to clear instead', async 'clipboard write requires text (use "" to clear clipboard)', ); }); + +for (const action of ['read', 'write'] as const) { + test(`clipboard ${action} refuses retirement during device resolution before binding`, async () => { + const spies = harness({ read: available, write: available }); + const input = request(action === 'read' ? ['read'] : ['write', 'text']); + const ref = input.sessionStore.lookup(input.sessionName)!; + mockResolveTargetDevice.mockImplementationOnce(async () => { + input.sessionStore.retire(ref); + input.sessionStore.publish(input.sessionName, makeSession(input.sessionName, androidDevice)); + return androidDevice; + }); + await expect( + handleSessionClipboardCommand({ + ...input, + req: { ...input.req, flags: { platform: 'android', serial: androidDevice.id } }, + ...spies, + }), + ).rejects.toMatchObject({ details: { reason: 'session_lifetime_ended' } }); + expect(spies.inspectFacts).not.toHaveBeenCalled(); + expect(spies.bindDevice).not.toHaveBeenCalled(); + expect(mockEnsureDeviceReady).not.toHaveBeenCalled(); + expect(spies.readClipboard).not.toHaveBeenCalled(); + expect(spies.writeClipboard).not.toHaveBeenCalled(); + }); +} diff --git a/src/daemon/handlers/__tests__/session-runtime-command.test.ts b/src/daemon/handlers/__tests__/session-runtime-command.test.ts index 49bc41455e..c9c8dfc034 100644 --- a/src/daemon/handlers/__tests__/session-runtime-command.test.ts +++ b/src/daemon/handlers/__tests__/session-runtime-command.test.ts @@ -290,3 +290,101 @@ test('runtime gesture-viewport admits and binds the exact viewport operation onc }), ); }); + +for (const phase of ['admission', 'effect'] as const) { + test(`runtime clear does not clear a successor's hints after retirement during ${phase}`, async () => { + const sessionStore = makeSessionStore(); + const address = `cwd:runtime-clear-${phase}:default`; + const device = { + platform: 'android' as const, + id: `runtime-clear-${phase}`, + name: 'Pixel', + kind: 'emulator' as const, + booted: true, + }; + const ref = sessionStore.publish(address, { + ...makeSession('default', device), + appBundleId: 'com.example.old', + }); + sessionStore.setRuntimeHints(address, { platform: 'android', metroHost: 'old' }); + const successorHints = { platform: 'android' as const, metroHost: 'successor' }; + const replace = () => { + sessionStore.retire(ref); + sessionStore.publish(address, { + ...makeSession('default', device), + appBundleId: 'com.example.new', + }); + sessionStore.setRuntimeHints(address, successorHints); + }; + if (phase === 'admission') { + mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (target) => { + replace(); + return lifecycleRuntimeFacts(target); + }); + } else { + mockClearRuntimeHints.mockImplementationOnce(async () => { + replace(); + }); + } + await expect( + handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'runtime', + positionals: ['clear'], + flags: {}, + }, + sessionName: address, + logPath: '/dev/null', + sessionStore, + invoke: noopInvoke, + }), + ).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'session_lifetime_ended' }, + }); + expect(sessionStore.getRuntimeHints(address)).toBe(successorHints); + expect(refFrameState(sessionStore.get(address)!)).toBe('active'); + expect(mockClearRuntimeHints).toHaveBeenCalledTimes(phase === 'admission' ? 0 : 1); + }); +} + +test('runtime clear expires and uses the latest matching record after admission', async () => { + const sessionStore = makeSessionStore(); + const device = { + platform: 'android' as const, + id: 'runtime-clear-rebuild', + name: 'Pixel', + kind: 'emulator' as const, + booted: true, + }; + const ref = sessionStore.publish('clear-rebuild', { + ...makeSession('clear-rebuild', device), + appBundleId: 'old.app', + }); + sessionStore.setRuntimeHints(ref.address, { platform: 'android', metroHost: 'old' }); + mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (target) => { + sessionStore.update(ref, { appBundleId: 'rebuilt.app' }); + return lifecycleRuntimeFacts(target); + }); + const response = await handleSessionCommands({ + req: { + token: 't', + session: ref.address, + command: 'runtime', + positionals: ['clear'], + flags: {}, + }, + sessionName: ref.address, + logPath: '/dev/null', + sessionStore, + invoke: noopInvoke, + }); + expect(response).toMatchObject({ ok: true, data: { cleared: true } }); + expect(mockClearRuntimeHints).toHaveBeenCalledWith( + expect.objectContaining({ appId: 'rebuilt.app' }), + ); + expect(refFrameState(sessionStore.requireCurrent(ref))).toBe('expired'); + expect(refFrameState(ref.session)).toBe('active'); +}); diff --git a/src/daemon/handlers/session-clipboard.ts b/src/daemon/handlers/session-clipboard.ts index 7806b6fe28..b4522ca903 100644 --- a/src/daemon/handlers/session-clipboard.ts +++ b/src/daemon/handlers/session-clipboard.ts @@ -156,6 +156,7 @@ export async function handleSessionClipboardCommand(params: { } const device = await resolveCommandDevice({ session, flags }); + if (ref) sessionStore.requireCurrent(ref); const bound = await resolveBoundClipboardRuntime({ device, action, diff --git a/src/daemon/handlers/session-runtime-command.ts b/src/daemon/handlers/session-runtime-command.ts index 8bdef171f8..0bea2f5106 100644 --- a/src/daemon/handlers/session-runtime-command.ts +++ b/src/daemon/handlers/session-runtime-command.ts @@ -2,6 +2,7 @@ import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; import { publicPlatformString } from '@agent-device/kernel/device'; import { clearRuntimeHintsRuntimeUse } from '@agent-device/contracts/application-lifecycle-runtime-plan'; import { SessionStore } from '../session-store.ts'; +import type { SessionRef } from '../session-state.ts'; import { expireRefFrame } from '../ref-frame.ts'; import { admitRuntimeUse } from '../runtime-admission.ts'; import { @@ -64,13 +65,14 @@ export async function handleRuntimeCommand(params: { 'runtime requires set, show, clear, port-reverse, or gesture-viewport', ); } - const session = sessionStore.get(sessionName); + const ref = sessionStore.lookup(sessionName); + const session = ref?.session; const current = sessionStore.getRuntimeHints(sessionName); if (action === 'clear') { return await clearRuntimeCommand({ sessionName, sessionStore, - session, + ref, current, inspectFacts: params.inspectFacts, bindDevice: params.bindDevice, @@ -118,12 +120,13 @@ function isRuntimeAction(action: string): action is RuntimeAction { async function clearRuntimeCommand(params: { sessionName: string; sessionStore: SessionStore; - session: ReturnType; + ref: SessionRef | undefined; current: ReturnType; inspectFacts?: InspectDeviceRuntimeFacts; bindDevice?: BindDeviceRuntime; }): Promise { - const { sessionName, sessionStore, session, current, inspectFacts, bindDevice } = params; + const { sessionName, sessionStore, ref, current, inspectFacts, bindDevice } = params; + const session = ref ? sessionStore.requireCurrent(ref) : undefined; if (hasRuntimeTransportHints(current) && session?.appBundleId) { const admission = await admitClearRuntime({ device: session.device, @@ -133,13 +136,15 @@ async function clearRuntimeCommand(params: { if (admission.type === 'response') return admission.response; // Native hint removal can change the app's reachable surface. Expire the existing frame at // the mutation boundary, after admission and immediately before the bound package effect. - expireRefFrame(session); + const currentSession = sessionStore.requireCurrent(ref!); + expireRefFrame(currentSession); await admission.runtime.operations.clearRuntimeHints({ - appId: session.appBundleId, + appId: currentSession.appBundleId, values: runtimeHintValues(current), }); } - const cleared = sessionStore.clearRuntimeHints(sessionName); + const cleared = ref ? sessionStore.clearRuntimeHints(ref) : Boolean(current); + if (!ref) sessionStore.setRuntimeHints(sessionName, undefined); return { ok: true, data: { session: sessionName, cleared } }; } diff --git a/src/daemon/handlers/session-selector-dispatch.ts b/src/daemon/handlers/session-selector-dispatch.ts index 275e7e2d29..078a26e61b 100644 --- a/src/daemon/handlers/session-selector-dispatch.ts +++ b/src/daemon/handlers/session-selector-dispatch.ts @@ -32,9 +32,9 @@ type SessionCommandPrepareOutcome = /** * The one orchestration every session/selector-route leaf shares: guard, resolve the device, * admit-then-prepare via the caller's own strategy, expire the ref frame if the command mutates - * (immediately before the prepared invocation runs, never after), derive and record the next - * session. `prepare` is where each leaf's own admission and binding lives; everything around it - * is identical, so it lives here once instead of once per command. Every leaf on this route now + * (immediately before the prepared invocation runs, never after), apply the optional session + * patch and record the action. `prepare` owns each leaf's admission and binding; the shared + * orchestration lives here once. Every leaf on this route now * supplies a bind-and-execute thunk — R57 retired the last capability-gate-then-`dispatchCommand` * one with `trigger-app-event`. */ @@ -144,7 +144,7 @@ type SessionRouteRuntimeResolver = ( /** * The whole shape a migrated session-route leaf needs: admit and bind through the caller's own * resolver, then hand `runSessionOrSelectorDispatch` the bound runtime's `execute` to invoke after - * expiring the frame. Only the resolver, the command name and the optional session derivation + * expiring the frame. Only the resolver, the command name and the optional post-execution session patch * differ per leaf, so one entry point here is what keeps `keyboard` and `trigger-app-event` from * drifting into two copies of the same wiring. */ diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-response.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-response.test.ts index b941c43661..f10554e77e 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-response.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-response.test.ts @@ -1,3 +1,10 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { + withDiagnosticsScope, + flushDiagnosticsToSessionFile, +} from '@agent-device/host-kit/diagnostics'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import { attachRefs } from '@agent-device/kernel/snapshot'; import { makeSessionStore } from '../../../../__tests__/test-utils/store-factory.ts'; @@ -612,3 +619,35 @@ test('fill @ref preserves fallback coordinates for recording when platform resul expect(event?.x).toBe(60); expect(event?.y).toBe(40); }); + +test('an already retired coordinate touch skips its frame probe without a warning', async () => { + const sessionStore = makeSessionStore(); + const ref = sessionStore.publish('retired-point', makeSession('retired-point')); + sessionStore.retire(ref); + const capture = vi.fn(); + const logPath = path.join(mkdtempForTestSync('retired-point-'), 'request.log'); + await withDiagnosticsScope( + { command: 'click', session: ref.address, logPath, debug: true }, + async () => { + const payloads = await buildTargetedTouchResponsePayloads({ + params: { + req: { token: 't', command: 'click', positionals: ['1', '2'], session: ref.address }, + sessionName: ref.address, + sessionRef: ref, + sessionStore, + contextFromFlags, + captureSnapshotForSession: capture, + }, + result: { kind: 'point', point: { x: 1, y: 2 } }, + staleRefsWarning: undefined, + extra: {}, + }); + expect(payloads.responseData).toMatchObject({ x: 1, y: 2 }); + flushDiagnosticsToSessionFile({ force: true }); + }, + ); + expect(capture).not.toHaveBeenCalled(); + expect(fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : '').not.toContain( + 'touch_reference_frame_resolve_failed', + ); +}); diff --git a/src/daemon/interaction/internal/interaction-touch-response.ts b/src/daemon/interaction/internal/interaction-touch-response.ts index 74fed83a21..567cf6f7f2 100644 --- a/src/daemon/interaction/internal/interaction-touch-response.ts +++ b/src/daemon/interaction/internal/interaction-touch-response.ts @@ -293,8 +293,9 @@ export async function buildTargetedTouchResponsePayloads(params: { extra: Record; }): Promise { const { params: handlerParams, result, publicData, extra } = params; + const liveBeforeProbe = handlerParams.sessionStore.resolveCurrent(handlerParams.sessionRef!); const probedFrame = - result.kind === 'point' + liveBeforeProbe && result.kind === 'point' ? await resolveDirectTouchReferenceFrameSafely({ ref: handlerParams.sessionRef!, flags: handlerParams.req.flags, diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 13aa4c346a..01fa495c2c 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -458,6 +458,7 @@ export async function startDaemonRuntime( const teardownDaemonSession = async (ref: SessionRef): Promise => { const session = sessionStore.resolveCurrent(ref) ?? ref.session; + const runtimeHints = runtimeHintValues(sessionStore.getRuntimeHints(ref.address)); try { await teardownDaemonSessionForShutdown({ ref, @@ -469,7 +470,7 @@ export async function startDaemonRuntime( scope: createDaemonRecoveryPlatformScope(), session: sessionToFinalize, stateDir: baseDir, - runtimeHints: runtimeHintValues(sessionStore.getRuntimeHints(sessionToFinalize.name)), + runtimeHints, }), beforeDelete: async (sessionToFinalize) => { await finalizeDaemonSessionLease({ diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-foreground.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-foreground.test.ts index 3274cbb9ee..7794912797 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-foreground.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-foreground.test.ts @@ -202,6 +202,7 @@ test('attaches the initial INTERACTIVE snapshot by delegating to the existing sn flags: { ...req.flags, snapshotInteractiveOnly: true }, }, sessionName: 'default', + sessionRef: ref, logPath: '/tmp/daemon.log', sessionStore, inspectFacts, diff --git a/src/daemon/session-lifecycle/internal/session-open-foreground.ts b/src/daemon/session-lifecycle/internal/session-open-foreground.ts index 3b3a095834..201b68c740 100644 --- a/src/daemon/session-lifecycle/internal/session-open-foreground.ts +++ b/src/daemon/session-lifecycle/internal/session-open-foreground.ts @@ -114,7 +114,6 @@ export async function composeOpenWithInitialSnapshot(params: { if (!openResponse.ok || req.flags?.foreground !== true) return openResponse; try { - sessionStore.requireCurrent(ref); const snapshotResponse = await dispatchSnapshotViaRuntime({ req: { ...req, @@ -126,6 +125,7 @@ export async function composeOpenWithInitialSnapshot(params: { flags: { ...req.flags, snapshotInteractiveOnly: true }, }, sessionName: ref.address, + sessionRef: ref, logPath, sessionStore, inspectFacts: params.inspectFacts, diff --git a/src/daemon/session-runtime.ts b/src/daemon/session-runtime.ts index 2cc69922f2..e218c35e27 100644 --- a/src/daemon/session-runtime.ts +++ b/src/daemon/session-runtime.ts @@ -199,7 +199,7 @@ export function setSessionRuntimeHintsForOpen( ): SessionRuntimeHints | undefined { if (!runtime) return undefined; if (countConfiguredRuntimeHints(runtime) === 0) { - sessionStore.clearRuntimeHints(sessionName); + sessionStore.setRuntimeHints(sessionName, undefined); return undefined; } sessionStore.setRuntimeHints(sessionName, runtime); diff --git a/src/daemon/session-store.ts b/src/daemon/session-store.ts index 3df053f544..996ec78211 100644 --- a/src/daemon/session-store.ts +++ b/src/daemon/session-store.ts @@ -175,12 +175,14 @@ export class SessionStore { return this.runtimeHints.get(name); } - setRuntimeHints(name: string, hints: SessionRuntimeHints): void { - this.runtimeHints.set(name, hints); + setRuntimeHints(address: string, hints: SessionRuntimeHints | undefined): void { + if (hints) this.runtimeHints.set(address, hints); + else this.runtimeHints.delete(address); } - clearRuntimeHints(name: string): boolean { - return this.runtimeHints.delete(name); + clearRuntimeHints(ref: SessionRef): boolean { + this.requireCurrent(ref); + return this.runtimeHints.delete(ref.address); } recordAction(ref: SessionRef, entry: RecordActionEntry): void { diff --git a/src/daemon/snapshot-command-runtime.ts b/src/daemon/snapshot-command-runtime.ts index 76199e7b96..48d460825d 100644 --- a/src/daemon/snapshot-command-runtime.ts +++ b/src/daemon/snapshot-command-runtime.ts @@ -63,7 +63,8 @@ export async function dispatchSnapshotRuntimeCommand( session, device, async () => { - const { req, sessionName, logPath, sessionStore } = params; + const { req, logPath, sessionStore } = params; + const sessionName = ref?.address ?? params.sessionName; const capturedQuality: CapturedSnapshotQuality = {}; const { runtime, sessions } = createSnapshotRuntime({ req, diff --git a/src/daemon/snapshot-runtime-binding.ts b/src/daemon/snapshot-runtime-binding.ts index f4df411463..961dd40fd8 100644 --- a/src/daemon/snapshot-runtime-binding.ts +++ b/src/daemon/snapshot-runtime-binding.ts @@ -41,6 +41,7 @@ import { errorResponse } from '@agent-device/kernel/contracts'; export type SnapshotRuntimeRouteParams = { req: DaemonRequest; sessionName: string; + sessionRef?: SessionRef; logPath: string; sessionStore: SessionStore; inspectFacts?: InspectDeviceRuntimeFacts; @@ -135,7 +136,12 @@ export async function resolveBoundSnapshotCaptureRuntime( command: 'snapshot' | 'diff', ): Promise { const { req, sessionName, sessionStore } = params; - const { ref, session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); + const { ref, session, device } = await resolveSessionDevice( + sessionStore, + sessionName, + req.flags, + params.sessionRef, + ); const resolvedScope = resolveSnapshotScope(req.flags?.snapshotScope, session); if (!resolvedScope.ok) return { ok: false, response: resolvedScope }; diff --git a/src/daemon/snapshot-session.ts b/src/daemon/snapshot-session.ts index 74f1a2e08a..a30e5f07d0 100644 --- a/src/daemon/snapshot-session.ts +++ b/src/daemon/snapshot-session.ts @@ -10,9 +10,10 @@ export async function resolveSessionDevice( sessionStore: SessionStore, sessionName: string, flags: DaemonRequest['flags'], + boundRef?: SessionRef, ) { - const ref = sessionStore.lookup(sessionName); - const session = ref?.session; + const ref = boundRef ?? sessionStore.lookup(sessionName); + const session = ref ? sessionStore.requireCurrent(ref) : undefined; const device = session?.device ?? (await resolveTargetDevice(flags ?? {})); return { ref, session, device }; }