From 9c1e5585e908a381e2f37494ddaa61d7c1bc7ffa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 15 Oct 2026 21:22:46 +0200 Subject: [PATCH 1/2] fix(record): record a remote client's default recording on the daemon host A remote record start without a path left the default name to the daemon, which resolved ./recording-.mp4 against the client's cwd and failed with EACCES on the daemon host. The client now sends its local destination without an extension; the daemon completes it with the session platform's extension, records to a temp path on its own host, and the client downloads the file there on record stop. Found while driving an EAS cloud simulator session. --- .../src/recording/output-path.test.ts | 34 +++++++++++++++++++ .../capture-kit/src/recording/output-path.ts | 26 ++++++++++++++ .../__tests__/record-runtime-start.test.ts | 20 +++++++++++ .../src/sessions/handlers/record-runtime.ts | 14 +++++--- src/remote/daemon-artifacts.ts | 7 +++- .../remote-daemon-client.test.ts | 4 +-- 6 files changed, 97 insertions(+), 8 deletions(-) diff --git a/packages/capture-kit/src/recording/output-path.test.ts b/packages/capture-kit/src/recording/output-path.test.ts index b3ed31398f..e3c95e35f6 100644 --- a/packages/capture-kit/src/recording/output-path.test.ts +++ b/packages/capture-kit/src/recording/output-path.test.ts @@ -31,3 +31,37 @@ test('keeps display paths distinct from expanded native output paths', () => { expect(homePaths.outputPath).not.toBe(homePaths.requestedPath); expect(homePaths.outputPath).toMatch(/\/capture\.mp4$/); }); + +test.each([ + ['android', '.mp4'], + ['web', '.webm'], +] as const)( + 'records a remote caller default on the daemon host and completes its %s destination', + (platform, extension) => { + const paths = resolveRecordingOutputPaths({ + clientOutputPath: '/client/project/recording-1', + platform, + cwd: '/client/project', + }); + expect(paths.clientOutputPath).toBe(`/client/project/recording-1${extension}`); + expect(paths.requestedPath).toBe(paths.clientOutputPath); + expect(paths.outputPath).toMatch( + new RegExp(`^/tmp/agent-device-recording-\\d+-[a-z0-9]+\\${extension}$`), + ); + }, +); + +test('keeps a remote caller explicit path on the redirected daemon temp path', () => { + expect( + resolveRecordingOutputPaths({ + requestedPath: '/tmp/agent-device-recording-1-abc.mp4', + clientOutputPath: '/client/project/capture.mp4', + platform: 'android', + cwd: '/client/project', + }), + ).toEqual({ + requestedPath: '/tmp/agent-device-recording-1-abc.mp4', + outputPath: '/tmp/agent-device-recording-1-abc.mp4', + clientOutputPath: '/client/project/capture.mp4', + }); +}); diff --git a/packages/capture-kit/src/recording/output-path.ts b/packages/capture-kit/src/recording/output-path.ts index d8c5964f69..df87cccf0d 100644 --- a/packages/capture-kit/src/recording/output-path.ts +++ b/packages/capture-kit/src/recording/output-path.ts @@ -1,6 +1,7 @@ import path from 'node:path'; import type { Platform, PlatformSelector } from '@agent-device/kernel/device'; import { resolveUserPath } from '@agent-device/host-kit/file'; +import { buildRemoteTempArtifactPath } from '@agent-device/contracts/daemon-http'; const DEFAULT_RECORDING_EXTENSION = '.mp4'; const WEB_RECORDING_EXTENSION = '.webm'; @@ -22,13 +23,19 @@ export function defaultRecordingPath(platform: Platform | undefined): string { export type RecordingOutputPaths = Readonly<{ requestedPath: string; outputPath: string; + /** Where a remote caller downloads the finished recording on its own host. */ + clientOutputPath?: string; }>; export function resolveRecordingOutputPaths(params: { requestedPath?: string; + clientOutputPath?: string; platform: Platform; cwd?: string; }): RecordingOutputPaths { + if (params.requestedPath === undefined && params.clientOutputPath !== undefined) { + return resolveRemoteDefaultRecordingPaths(params.clientOutputPath, params.platform); + } const requestedPath = params.requestedPath === undefined ? defaultRecordingPath(params.platform) @@ -38,5 +45,24 @@ export function resolveRecordingOutputPaths(params: { return Object.freeze({ requestedPath, outputPath: resolveUserPath(requestedPath, { cwd: params.cwd }), + ...(params.clientOutputPath === undefined ? {} : { clientOutputPath: params.clientOutputPath }), + }); +} + +/** + * A remote caller that names no path cannot know the session platform's extension, so it sends its + * local destination without one. The daemon completes it and records to a temp path on its own + * host, which the caller downloads from; the caller's cwd does not exist here. + */ +function resolveRemoteDefaultRecordingPaths( + clientOutputStem: string, + platform: Platform, +): RecordingOutputPaths { + const extension = recordingExtensionForPlatform(platform); + const clientOutputPath = appendRecordingExtensionWhenMissing(clientOutputStem, extension); + return Object.freeze({ + requestedPath: clientOutputPath, + outputPath: buildRemoteTempArtifactPath('recording', extension), + clientOutputPath, }); } 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..7e10d58fa2 100644 --- a/packages/daemon/src/sessions/handlers/record-runtime.ts +++ b/packages/daemon/src/sessions/handlers/record-runtime.ts @@ -46,7 +46,10 @@ import { prepareRecordingRequest, readRecordingScope, } from './record-runtime-request.ts'; -import { resolveRecordingOutputPaths } from '@agent-device/capture-kit/recording-output-path'; +import { + type RecordingOutputPaths, + resolveRecordingOutputPaths, +} from '@agent-device/capture-kit/recording-output-path'; import { buildRecordingStartResponse, buildRecordingStartedAction, @@ -163,7 +166,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, @@ -205,6 +208,7 @@ function prepareRecordingStart(params: RecordRuntimeHandlerParams, session: Sess }), outputPaths: resolveRecordingOutputPaths({ requestedPath: params.req.positionals?.[1], + clientOutputPath: params.req.meta?.clientArtifactPaths?.outPath, platform: session.device.platform, cwd: params.req.meta?.cwd, }), @@ -216,12 +220,12 @@ function screenRecordingStartInput( session: SessionState, prepared: ReturnType, fence: ScreenRecordingStartInput['fence'], - outputPath: string, + outputPaths: RecordingOutputPaths, ): 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) => { From e166701772a7e1b203239222f4dd2628571a0b6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 08:08:51 +0200 Subject: [PATCH 2/2] fix(record): choose the remote default recording path in the daemon record handler Keep resolveRecordingOutputPaths transport-free: capture-kit picks the extension and resolves paths, and only the daemon knows a request came from a remote client. Document where a remote record start without a path downloads. --- .../src/recording/output-path.test.ts | 34 ------------------ .../capture-kit/src/recording/output-path.ts | 26 -------------- .../src/sessions/handlers/record-runtime.ts | 36 +++++++++++++++---- website/docs/docs/commands.md | 2 +- 4 files changed, 30 insertions(+), 68 deletions(-) diff --git a/packages/capture-kit/src/recording/output-path.test.ts b/packages/capture-kit/src/recording/output-path.test.ts index e3c95e35f6..b3ed31398f 100644 --- a/packages/capture-kit/src/recording/output-path.test.ts +++ b/packages/capture-kit/src/recording/output-path.test.ts @@ -31,37 +31,3 @@ test('keeps display paths distinct from expanded native output paths', () => { expect(homePaths.outputPath).not.toBe(homePaths.requestedPath); expect(homePaths.outputPath).toMatch(/\/capture\.mp4$/); }); - -test.each([ - ['android', '.mp4'], - ['web', '.webm'], -] as const)( - 'records a remote caller default on the daemon host and completes its %s destination', - (platform, extension) => { - const paths = resolveRecordingOutputPaths({ - clientOutputPath: '/client/project/recording-1', - platform, - cwd: '/client/project', - }); - expect(paths.clientOutputPath).toBe(`/client/project/recording-1${extension}`); - expect(paths.requestedPath).toBe(paths.clientOutputPath); - expect(paths.outputPath).toMatch( - new RegExp(`^/tmp/agent-device-recording-\\d+-[a-z0-9]+\\${extension}$`), - ); - }, -); - -test('keeps a remote caller explicit path on the redirected daemon temp path', () => { - expect( - resolveRecordingOutputPaths({ - requestedPath: '/tmp/agent-device-recording-1-abc.mp4', - clientOutputPath: '/client/project/capture.mp4', - platform: 'android', - cwd: '/client/project', - }), - ).toEqual({ - requestedPath: '/tmp/agent-device-recording-1-abc.mp4', - outputPath: '/tmp/agent-device-recording-1-abc.mp4', - clientOutputPath: '/client/project/capture.mp4', - }); -}); diff --git a/packages/capture-kit/src/recording/output-path.ts b/packages/capture-kit/src/recording/output-path.ts index df87cccf0d..d8c5964f69 100644 --- a/packages/capture-kit/src/recording/output-path.ts +++ b/packages/capture-kit/src/recording/output-path.ts @@ -1,7 +1,6 @@ import path from 'node:path'; import type { Platform, PlatformSelector } from '@agent-device/kernel/device'; import { resolveUserPath } from '@agent-device/host-kit/file'; -import { buildRemoteTempArtifactPath } from '@agent-device/contracts/daemon-http'; const DEFAULT_RECORDING_EXTENSION = '.mp4'; const WEB_RECORDING_EXTENSION = '.webm'; @@ -23,19 +22,13 @@ export function defaultRecordingPath(platform: Platform | undefined): string { export type RecordingOutputPaths = Readonly<{ requestedPath: string; outputPath: string; - /** Where a remote caller downloads the finished recording on its own host. */ - clientOutputPath?: string; }>; export function resolveRecordingOutputPaths(params: { requestedPath?: string; - clientOutputPath?: string; platform: Platform; cwd?: string; }): RecordingOutputPaths { - if (params.requestedPath === undefined && params.clientOutputPath !== undefined) { - return resolveRemoteDefaultRecordingPaths(params.clientOutputPath, params.platform); - } const requestedPath = params.requestedPath === undefined ? defaultRecordingPath(params.platform) @@ -45,24 +38,5 @@ export function resolveRecordingOutputPaths(params: { return Object.freeze({ requestedPath, outputPath: resolveUserPath(requestedPath, { cwd: params.cwd }), - ...(params.clientOutputPath === undefined ? {} : { clientOutputPath: params.clientOutputPath }), - }); -} - -/** - * A remote caller that names no path cannot know the session platform's extension, so it sends its - * local destination without one. The daemon completes it and records to a temp path on its own - * host, which the caller downloads from; the caller's cwd does not exist here. - */ -function resolveRemoteDefaultRecordingPaths( - clientOutputStem: string, - platform: Platform, -): RecordingOutputPaths { - const extension = recordingExtensionForPlatform(platform); - const clientOutputPath = appendRecordingExtensionWhenMissing(clientOutputStem, extension); - return Object.freeze({ - requestedPath: clientOutputPath, - outputPath: buildRemoteTempArtifactPath('recording', extension), - clientOutputPath, }); } diff --git a/packages/daemon/src/sessions/handlers/record-runtime.ts b/packages/daemon/src/sessions/handlers/record-runtime.ts index 7e10d58fa2..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'; @@ -47,9 +48,12 @@ import { readRecordingScope, } from './record-runtime-request.ts'; 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, @@ -206,12 +210,30 @@ function prepareRecordingStart(params: RecordRuntimeHandlerParams, session: Sess resourcePath, device: session.device, }), - outputPaths: resolveRecordingOutputPaths({ - requestedPath: params.req.positionals?.[1], - clientOutputPath: params.req.meta?.clientArtifactPaths?.outPath, - 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 }), }; } @@ -220,7 +242,7 @@ function screenRecordingStartInput( session: SessionState, prepared: ReturnType, fence: ScreenRecordingStartInput['fence'], - outputPaths: RecordingOutputPaths, + outputPaths: RecordingStartPaths, ): ScreenRecordingStartInput { return { sessionId: params.sessionName, 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