diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-inventory-apps-runtime.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-inventory-apps-runtime.test.ts index 4168e3a738..2a505e95d0 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-inventory-apps-runtime.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-inventory-apps-runtime.test.ts @@ -42,7 +42,9 @@ const MACOS_DEVICE = { const appsAvailable = { available: true } as const; const unavailable = { available: false, reason: 'owner-capability-missing' } as const; -function runtimeFacts(): RuntimeFacts { +function runtimeFacts( + overrides: Partial['operations']> = {}, +): RuntimeFacts { return { device: { family: 'apple', @@ -66,6 +68,7 @@ function runtimeFacts(): RuntimeFacts { ensureReady: appsAvailable, bootTarget: unavailable, bootTargetHeadless: unavailable, + ...overrides, ...unavailableDeploymentSnapshotAndShutdownOperationFacts, ...applicationLifecycleOperationFacts({ resolveOpenTarget: unavailable, @@ -140,6 +143,49 @@ test('macOS apps consumes generic readiness and app inventory through one runtim expect(listApps).toHaveBeenCalledOnce(); }); +test('apps reports the refused operation fact typed reason on the wire', async () => { + const sessionName = 'macos-apps-unsupported'; + const sessionStore = makeSessionStore(); + sessionStore.publish(sessionName, makeSession(sessionName, MACOS_DEVICE)); + inspectFacts.mockResolvedValueOnce( + runtimeFacts({ + listApps: { + available: false, + reason: 'owner-capability-missing', + hint: 'WebDriver provider runtimes do not expose app inventory.', + }, + }), + ); + + const response = await handleSessionInventoryCommands({ + host: createTestHostEnvironment(), + platformServices: daemonPlatformServicesFixture(), + req: { + token: 'test-token', + session: sessionName, + command: 'apps', + positionals: [], + flags: { appsFilter: 'all' }, + }, + sessionName, + sessionStore, + inspectFacts, + bindDevice, + }); + + expect(response).toEqual({ + ok: false, + error: { + code: 'UNSUPPORTED_OPERATION', + message: 'apps is not supported on this device', + hint: 'WebDriver provider runtimes do not expose app inventory.', + details: { reason: 'owner-capability-missing' }, + }, + }); + expect(bindCount).toBe(0); + expect(listApps).not.toHaveBeenCalled(); +}); + test('deferred provider apps returns uploaded assets without resolving a device', async () => { const sessionStore = makeSessionStore(); const listAvailableApps = vi.fn(async () => ['Example.apk', 'Settings.apk']); diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts b/packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts index 30b8caff6b..49bd15ac18 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts @@ -13,6 +13,7 @@ import { type ProviderAppCatalogQuery, } from '@agent-device/contracts/device'; import { asAppError } from '@agent-device/kernel/errors'; +import { unsupportedOperationResponse } from '../../../core/runtime-admission.ts'; import { requireRuntimeBinding, requireRuntimeFacts, @@ -466,16 +467,7 @@ async function resolveAppsRuntime(params: { (fact) => !fact.available, ); if (unavailable && !unavailable.available) { - return { - response: { - ok: false, - error: { - code: 'UNSUPPORTED_OPERATION', - message: 'apps is not supported on this device', - ...(unavailable.hint ? { hint: unavailable.hint } : {}), - }, - }, - }; + return { response: unsupportedOperationResponse('apps', unavailable) }; } return { runtime: await requireRuntimeBinding(params.bindDevice)(params.device, appsRuntimeUse) }; } diff --git a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-logs.test.ts b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-logs.test.ts index ae717ea6b0..0c7acec797 100644 --- a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-logs.test.ts +++ b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-logs.test.ts @@ -7,6 +7,7 @@ import { applicationLifecycleOperationFacts } from '@agent-device/contracts/appl import type { CleanupOutcome } from '@agent-device/contracts/durable-resource'; import { type DeviceBinding, + type RuntimeOperationFact, localRuntimeOwner, narrowDeviceBinding, } from '@agent-device/contracts/platform-runtime'; @@ -72,12 +73,37 @@ test('logs preserves whole-command unsupported precedence before parsing the act runtime = createRuntimeHarness({ inspectAvailable: false }); const { sessionStore, sessionName } = openSession(); const response = await runLogs(sessionStore, sessionName, ['invalid'], {}, runtime.bindDevice); - expect(response).toMatchObject({ + expect(response).toEqual({ ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'logs is not supported on this device', hint: 'Use a runtime with app-log support.', + details: { reason: 'owner-capability-missing' }, + }, + }); + expect(runtime.boundUses()).toEqual([{ required: [], preferred: ['appLogInspect'] }]); +}); + +test('logs reports a refused provider app-log fact with its typed reason', async () => { + runtime = createRuntimeHarness({ + inspectFact: { + available: false, + reason: 'unsupported-provider-mode', + hint: 'The provider owns this device, so its app log is not readable here.', + }, + }); + const { sessionStore, sessionName } = openSession(); + + const response = await runLogs(sessionStore, sessionName, ['path'], {}, runtime.bindDevice); + + expect(response).toEqual({ + ok: false, + error: { + code: 'UNSUPPORTED_OPERATION', + message: 'logs is not supported on this device', + hint: 'The provider owns this device, so its app log is not readable here.', + details: { reason: 'unsupported-provider-mode' }, }, }); expect(runtime.boundUses()).toEqual([{ required: [], preferred: ['appLogInspect'] }]); @@ -325,7 +351,12 @@ function readRecord(sessionStore: SessionStore, sessionName: string) { ); } -function createRuntimeHarness(options: { inspectAvailable?: boolean } = {}) { +function createRuntimeHarness( + options: { + inspectAvailable?: boolean; + inspectFact?: RuntimeOperationFact; + } = {}, +) { const owner = localRuntimeOwner('apple'); const inspect = vi.fn(async () => ({ backend: 'ios-simulator' as const })); const doctor = vi.fn(async () => ({ @@ -408,13 +439,14 @@ function createRuntimeHarness(options: { inspectAvailable?: boolean } = {}) { }, operations: { appLogInspect: - options.inspectAvailable === false + options.inspectFact ?? + (options.inspectAvailable === false ? { available: false, reason: 'owner-capability-missing', hint: 'Use a runtime with app-log support.', } - : { available: true }, + : { available: true }), appLogDoctor: { available: true }, appLogStart: { available: true }, appLogReattach: { available: true }, diff --git a/packages/daemon/src/sessions/session-observability/internal/session-observability.ts b/packages/daemon/src/sessions/session-observability/internal/session-observability.ts index b73244b47a..d1f85c6e73 100644 --- a/packages/daemon/src/sessions/session-observability/internal/session-observability.ts +++ b/packages/daemon/src/sessions/session-observability/internal/session-observability.ts @@ -33,6 +33,7 @@ import type { InspectDeviceRuntimeFacts, } from '../../../core/request-runtime-binding.ts'; import type { SessionStore } from '../../../core/session-store.ts'; +import { unsupportedOperationResponse } from '../../../core/runtime-admission.ts'; import type { DaemonRequest } from '../../../core/daemon-request.ts'; import type { SessionRef, SessionState } from '../../../core/session-state.ts'; import { handleAudioCommand } from './session-audio.ts'; @@ -164,14 +165,7 @@ async function handleLogsCommand(params: ObservabilityInput): Promise