diff --git a/packages/daemon/src/sessions/handlers/__tests__/record-runtime-start.test.ts b/packages/daemon/src/sessions/handlers/__tests__/record-runtime-start.test.ts index 4119854f24..39f87b5de6 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/record-runtime-start.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/record-runtime-start.test.ts @@ -38,6 +38,26 @@ test('record start preserves the requested path while the runtime receives the e ); }); +test('record start without a path records a remote caller default on the daemon host', async () => { + const harness = makeRecordRuntimeHarness('record-runtime-remote-default-'); + + const response = await harness.run(['start'], { + cwd: '/client/project', + clientArtifactPaths: { outPath: '/client/project/recording-1' }, + }); + + expect(response).toMatchObject({ + ok: true, + data: { outPath: '/client/project/recording-1.mp4' }, + }); + expect(harness.runtime.start).toHaveBeenCalledWith( + expect.objectContaining({ + outputPath: expect.stringMatching(/^\/tmp\/agent-device-recording-\d+-[a-z0-9]+\.mp4$/), + clientOutputPath: '/client/project/recording-1.mp4', + }), + ); +}); + test.each(['linux', 'vega'] as const)( 'record start on %s reports public unsupported guidance', async (platform) => { diff --git a/packages/daemon/src/sessions/handlers/record-runtime.ts b/packages/daemon/src/sessions/handlers/record-runtime.ts index a211f436e9..855dfacd01 100644 --- a/packages/daemon/src/sessions/handlers/record-runtime.ts +++ b/packages/daemon/src/sessions/handlers/record-runtime.ts @@ -18,6 +18,7 @@ import { } from '@agent-device/contracts/screen-recording-runtime-plan'; import { isWholeScreenRecordingScope } from '@agent-device/contracts/recording'; import { AppError, normalizeError } from '@agent-device/kernel/errors'; +import type { Platform } from '@agent-device/kernel/device'; import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve'; import { ensureBoundDeviceReady } from '../../core/request-runtime-binding.ts'; import { type ScreenRecordingAdmissionLedger } from '@agent-device/capture-kit/screen-recording-admission-ledger'; @@ -46,7 +47,13 @@ import { prepareRecordingRequest, readRecordingScope, } from './record-runtime-request.ts'; -import { resolveRecordingOutputPaths } from '@agent-device/capture-kit/recording-output-path'; +import { + appendRecordingExtensionWhenMissing, + type RecordingOutputPaths, + recordingExtensionForPlatform, + resolveRecordingOutputPaths, +} from '@agent-device/capture-kit/recording-output-path'; +import { buildRemoteTempArtifactPath } from '@agent-device/contracts/daemon-http'; import { buildRecordingStartResponse, buildRecordingStartedAction, @@ -163,7 +170,7 @@ async function startRecording( const { fence, outputPaths } = prepareRecordingStart(params, session); binding.assertAdoptable(); const started = await runtime.operations.screenRecordingStart( - screenRecordingStartInput(params, session, prepared, fence, outputPaths.outputPath), + screenRecordingStartInput(params, session, prepared, fence, outputPaths), ); await adoptStartedScreenRecording({ admissionLedger: params.admissionLedger, @@ -203,11 +210,30 @@ function prepareRecordingStart(params: RecordRuntimeHandlerParams, session: Sess resourcePath, device: session.device, }), - outputPaths: resolveRecordingOutputPaths({ - requestedPath: params.req.positionals?.[1], - platform: session.device.platform, - cwd: params.req.meta?.cwd, - }), + outputPaths: resolveRecordingStartPaths(params.req, session.device.platform), + }; +} + +type RecordingStartPaths = RecordingOutputPaths & Readonly<{ clientOutputPath?: string }>; + +function resolveRecordingStartPaths(req: DaemonRequest, platform: Platform): RecordingStartPaths { + const requestedPath = req.positionals?.[1]; + const clientOutputPath = req.meta?.clientArtifactPaths?.outPath; + if (requestedPath === undefined && clientOutputPath !== undefined) { + // A remote caller that names no path cannot know the session platform's extension, so it sends + // its local destination without one; its cwd does not exist on this host. + const extension = recordingExtensionForPlatform(platform); + return { + ...resolveRecordingOutputPaths({ + requestedPath: buildRemoteTempArtifactPath('recording', extension), + platform, + }), + clientOutputPath: appendRecordingExtensionWhenMissing(clientOutputPath, extension), + }; + } + return { + ...resolveRecordingOutputPaths({ requestedPath, platform, cwd: req.meta?.cwd }), + ...(clientOutputPath === undefined ? {} : { clientOutputPath }), }; } @@ -216,12 +242,12 @@ function screenRecordingStartInput( session: SessionState, prepared: ReturnType, fence: ScreenRecordingStartInput['fence'], - outputPath: string, + outputPaths: RecordingStartPaths, ): ScreenRecordingStartInput { return { sessionId: params.sessionName, - outputPath, - clientOutputPath: params.req.meta?.clientArtifactPaths?.outPath, + outputPath: outputPaths.outputPath, + clientOutputPath: outputPaths.clientOutputPath, scope: prepared.scope, showTouches: prepared.showTouches, hideTouchesRequested: prepared.hideTouchesRequested, diff --git a/src/remote/daemon-artifacts.ts b/src/remote/daemon-artifacts.ts index 39aaee1395..f0537ec3ed 100644 --- a/src/remote/daemon-artifacts.ts +++ b/src/remote/daemon-artifacts.ts @@ -272,7 +272,12 @@ function prepareRemoteArtifactCommand( } if (req.command === 'record' && (positionals[0] ?? '').toLowerCase() === 'start') { if (!recordingHasRequestedClientPath(req) && req.flags?.platform === undefined) { - return null; + // The extension follows the session platform, which only the daemon knows: it completes + // this destination and records to a temp path on its own host. + return { + field: 'outPath', + localPath: resolveAbsoluteClientPath(`recording-${Date.now()}`, req.meta?.cwd), + }; } const fallbackExtension = recordingFallbackExtension(req); const localPath = normalizeRecordingClientArtifactPath( diff --git a/test/integration/provider-scenarios/remote-daemon-client.test.ts b/test/integration/provider-scenarios/remote-daemon-client.test.ts index f6e0b61ea9..4b332b8169 100644 --- a/test/integration/provider-scenarios/remote-daemon-client.test.ts +++ b/test/integration/provider-scenarios/remote-daemon-client.test.ts @@ -709,7 +709,7 @@ test('remote web recording appends WebM extension to extensionless client paths' assert.equal(prepared.clientArtifactPaths?.outPath, '/tmp/project/recording.webm'); }); -test('remote recording without platform or requested path lets daemon choose session-specific default', async () => { +test('remote recording without platform or requested path lets daemon name the client default', async () => { const prepared = await prepareRemoteRequestArtifacts( { session: 'default', @@ -723,7 +723,7 @@ test('remote recording without platform or requested path lets daemon choose ses ); assert.deepEqual(prepared.positionals, ['start']); - assert.equal(prepared.clientArtifactPaths, undefined); + assert.match(prepared.clientArtifactPaths?.outPath ?? '', /^\/tmp\/project\/recording-\d+$/); }); test('Provider-backed integration daemon proxy forwards remote client RPC commands', async (t) => { diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 0f6894cc06..40de67525a 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -124,7 +124,7 @@ Remote daemons and tenants: - `--tenant`, `--session-isolation tenant`, `--run-id`, and `--lease-id` enforce lease admission on tenant-scoped daemons. - `--daemon-base-url http(s)://host:port[/base-path]` calls a remote HTTP daemon directly instead of starting a local one. Add `--daemon-auth-token ` (or `AGENT_DEVICE_DAEMON_AUTH_TOKEN`) for non-loopback daemon URLs. -- Screenshots and recordings from a remote daemon are downloaded to the path you pass, so `screenshot page.png` and `record start session.mp4` work as they do locally. +- Screenshots and recordings from a remote daemon are downloaded to the path you pass, so `screenshot page.png` and `record start session.mp4` work as they do locally. `record start` without a path downloads to `recording-.mp4` (`.webm` on web) in your working directory. - To drive a Mac's simulators and devices from another machine, see [Remote Proxy](/docs/remote-proxy). For hosted devices in CI, see [BrowserStack](/docs/browserstack), [AWS Device Farm](/docs/aws-device-farm), or [TestMu AI](/docs/testmu). For `connect --remote-config`, see [Remote Metro workflow](#remote-metro-workflow). ```bash