Skip to content

Commit 5bf1b38

Browse files
committed
fix: validate macOS recording finalization
1 parent b8dd6a5 commit 5bf1b38

3 files changed

Lines changed: 185 additions & 58 deletions

File tree

packages/platform-apple/src/recording/completion.ts

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { asAppError } from '@agent-device/kernel/errors';
12
import type { ScreenRecordingLiveSnapshot } from '@agent-device/contracts/platform';
23
import { createScreenRecordingCompletion } from '@agent-device/capture-kit';
34
import type { AppleScreenRecordingOperationHost } from './recovery.ts';
@@ -10,22 +11,27 @@ export async function completeAppleRecording(
1011
if (snapshot.invalidatedReason && !snapshot.showTouches) {
1112
throw new Error(`recording invalidated: ${snapshot.invalidatedReason}`);
1213
}
13-
const finalization = await host.screenRecording.finalize.complete({
14-
outputPath: snapshot.outPath,
15-
showTouches: snapshot.invalidatedReason ? false : snapshot.showTouches,
16-
gestureEvents: snapshot.gestureEvents,
17-
exportQuality: snapshot.exportQuality ?? 'medium',
18-
...(snapshot.runnerStartedAtUptimeMs !== undefined &&
19-
snapshot.targetAppReadyUptimeMs !== undefined
20-
? {
21-
trimStartMs: Math.max(
22-
0,
23-
snapshot.targetAppReadyUptimeMs - snapshot.runnerStartedAtUptimeMs,
24-
),
25-
}
26-
: {}),
27-
targetLabel,
28-
});
14+
let finalization;
15+
try {
16+
finalization = await host.screenRecording.finalize.complete({
17+
outputPath: snapshot.outPath,
18+
showTouches: snapshot.invalidatedReason ? false : snapshot.showTouches,
19+
gestureEvents: snapshot.gestureEvents,
20+
exportQuality: snapshot.exportQuality ?? 'medium',
21+
...(snapshot.runnerStartedAtUptimeMs !== undefined &&
22+
snapshot.targetAppReadyUptimeMs !== undefined
23+
? {
24+
trimStartMs: Math.max(
25+
0,
26+
snapshot.targetAppReadyUptimeMs - snapshot.runnerStartedAtUptimeMs,
27+
),
28+
}
29+
: {}),
30+
targetLabel,
31+
});
32+
} catch (error) {
33+
throw asAppError(error, 'COMMAND_FAILED');
34+
}
2935
return createScreenRecordingCompletion(snapshot, {
3036
...finalization,
3137
...(snapshot.invalidatedReason

packages/platform-apple/src/recording/runtime.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device';
2+
import { asAppError } from '@agent-device/kernel/errors';
23
import type {
34
CleanupOutcome,
45
RuntimeOwnerRef,
@@ -138,13 +139,11 @@ async function startAppleRunnerRecording(params: AppleRecordingStartParams) {
138139
} as const;
139140
let runnerStop: Promise<void> | undefined;
140141
const stopRunner = () =>
141-
(runnerStop ??= host.screenRecording.apple
142-
.runRunner(device, {
143-
kind: 'stop',
144-
appBundleId,
145-
...runnerOwnership,
146-
})
147-
.then(() => undefined));
142+
(runnerStop ??= runAppleRecordingOperation(() =>
143+
host.screenRecording.apple
144+
.runRunner(device, { kind: 'stop', appBundleId, ...runnerOwnership })
145+
.then(() => undefined),
146+
));
148147
if (!runnerDescriptorMatchesDevice(device, result.remotePath)) {
149148
await stopRunner().catch(() => {});
150149
throw new Error('Apple runner recording did not expose coherent durable media ownership');
@@ -189,6 +188,14 @@ async function startAppleRunnerRecording(params: AppleRecordingStartParams) {
189188
});
190189
}
191190

191+
async function runAppleRecordingOperation<T>(operation: () => Promise<T>): Promise<T> {
192+
try {
193+
return await operation();
194+
} catch (error) {
195+
throw asAppError(error, 'COMMAND_FAILED');
196+
}
197+
}
198+
192199
function runnerDescriptorMatchesDevice(
193200
device: DeviceInfo,
194201
remotePath: string | undefined,

test/integration/provider-scenarios/macos-recording.test.ts

Lines changed: 149 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,65 +2,124 @@ import assert from 'node:assert/strict';
22
import fs from 'node:fs';
33
import path from 'node:path';
44
import { test } from 'vitest';
5-
import { assertRecordingStarted, assertRecordingStopped, assertRpcOk } from './assertions.ts';
5+
import {
6+
assertRecordingStarted,
7+
assertRecordingStopped,
8+
assertRpcError,
9+
assertRpcOk,
10+
} from './assertions.ts';
611
import { PROVIDER_SCENARIO_MACOS } from './fixtures.ts';
7-
import { createProviderScenarioTempPath, withProviderScenarioResource } from './harness.ts';
12+
import {
13+
createProviderScenarioTempPath,
14+
type ProviderScenarioHarness,
15+
withProviderScenarioResource,
16+
} from './harness.ts';
817
import { createMacOsDesktopWorld } from './macos-world.ts';
918
import {
1019
createAppleRunnerProviderFromTranscript,
1120
createAppleRunnerScreenRecordingTransportFromTranscript,
1221
} from './providers.ts';
22+
import { screenRecordingResourceStore } from '../../../src/daemon/screen-recording-resource-store.ts';
1323
import { createProviderTranscript } from './transcript.ts';
1424

1525
test('Provider-backed integration macOS recording uses focused exact runner authority', async () => {
1626
const recordingPath = createProviderScenarioTempPath(
1727
'agent-device-provider-scenario-macos-record',
1828
'mp4',
1929
);
20-
const runnerTranscript = createProviderTranscript([
21-
{
22-
command: 'macos.runner.recordStart',
23-
deviceId: PROVIDER_SCENARIO_MACOS.id,
24-
platform: 'apple',
25-
request: {
26-
command: 'recordStart',
27-
outPath: recordingPath,
30+
const { runnerTranscript, appleRunnerProvider, appleRunnerScreenRecordingTransport } =
31+
createMacOsRecordingScenario(recordingPath, (outputPath) =>
32+
fs.copyFileSync(
33+
path.join(process.cwd(), 'website/docs/public/agent-device-contacts.mp4'),
34+
outputPath,
35+
),
36+
);
37+
await withProviderScenarioResource(
38+
async () =>
39+
await createMacOsDesktopWorld({
40+
appleRunnerProvider,
41+
appleRunnerScreenRecordingTransport,
42+
}),
43+
async ({ daemon }) => {
44+
const open = await daemon.callCommand('open', ['settings'], { platform: 'macos' });
45+
assert.equal(assertRpcOk(open).appBundleId, 'com.apple.systempreferences');
46+
47+
const recordStart = await daemon.callCommand('record', ['start', recordingPath], {
48+
hideTouches: true,
2849
fps: 30,
29-
appBundleId: 'com.apple.systempreferences',
30-
},
31-
result: { runnerSessionId: 'macos-runner-recording-1' },
50+
});
51+
assertRecordingStarted(recordStart, { outPath: recordingPath, showTouches: false });
52+
53+
const recordStop = await daemon.callCommand('record', ['stop']);
54+
assertRecordingStopped(recordStop, recordingPath, { showTouches: false });
55+
56+
runnerTranscript.assertComplete();
3257
},
33-
{
34-
command: 'macos.runner.recordStop',
35-
deviceId: PROVIDER_SCENARIO_MACOS.id,
36-
platform: 'apple',
37-
request: { command: 'recordStop', appBundleId: 'com.apple.systempreferences' },
38-
result: {},
58+
);
59+
});
60+
61+
test('Provider-backed integration macOS recording rejects runner-stop failure and retains cleanup state', async () => {
62+
const recordingPath = createProviderScenarioTempPath(
63+
'agent-device-provider-scenario-macos-stop-failure',
64+
'mp4',
65+
);
66+
const stopError = 'macOS runner recordStop rejected';
67+
const { runnerTranscript, appleRunnerProvider, appleRunnerScreenRecordingTransport } =
68+
createMacOsRecordingScenario(recordingPath, undefined, stopError);
69+
70+
await withProviderScenarioResource(
71+
async () =>
72+
await createMacOsDesktopWorld({
73+
appleRunnerProvider,
74+
appleRunnerScreenRecordingTransport,
75+
}),
76+
async ({ daemon }) => {
77+
await openMacOsSettings(daemon);
78+
79+
const recordStart = await daemon.callCommand('record', ['start', recordingPath], {
80+
hideTouches: true,
81+
fps: 30,
82+
});
83+
assertRecordingStarted(recordStart, { outPath: recordingPath, showTouches: false });
84+
85+
const recordStop = await daemon.callCommand('record', ['stop']);
86+
assertRpcError(recordStop, 'COMMAND_FAILED', /recordStop rejected/);
87+
assert.equal(recordStop.json?.result, undefined);
88+
89+
const session = daemon.session();
90+
assert.ok(session?.screenRecording, 'failed stop must retain the live session resource');
91+
const resource = screenRecordingResourceStore.read(
92+
screenRecordingResourceStore.resolvePath(daemon.sessionDir()),
93+
);
94+
assert.equal(resource.status, 'decoded');
95+
if (resource.status === 'decoded') {
96+
assert.equal(resource.envelope.lifecycle, 'open');
97+
assert.equal(resource.envelope.metadata?.phase, 'cleanup-pending');
98+
assert.equal(resource.envelope.metadata?.cleanupStatus, 'cleanup-pending');
99+
}
100+
runnerTranscript.assertComplete();
39101
},
40-
]);
41-
const appleRunnerProvider = createAppleRunnerProviderFromTranscript(
42-
runnerTranscript,
43-
'macos.runner',
44102
);
45-
const appleRunnerScreenRecordingTransport =
46-
createAppleRunnerScreenRecordingTransportFromTranscript(
47-
runnerTranscript,
48-
'macos.runner',
49-
(outputPath) =>
50-
fs.copyFileSync(
51-
path.join(process.cwd(), 'website/docs/public/agent-device-contacts.mp4'),
52-
outputPath,
53-
),
103+
});
104+
105+
test('Provider-backed integration macOS recording rejects an invalid MP4 before artifact publication', async () => {
106+
const recordingPath = createProviderScenarioTempPath(
107+
'agent-device-provider-scenario-macos-invalid-video',
108+
'mp4',
109+
);
110+
const { runnerTranscript, appleRunnerProvider, appleRunnerScreenRecordingTransport } =
111+
createMacOsRecordingScenario(recordingPath, (outputPath) =>
112+
fs.writeFileSync(outputPath, Buffer.from('not an MP4')),
54113
);
114+
55115
await withProviderScenarioResource(
56116
async () =>
57117
await createMacOsDesktopWorld({
58118
appleRunnerProvider,
59119
appleRunnerScreenRecordingTransport,
60120
}),
61121
async ({ daemon }) => {
62-
const open = await daemon.callCommand('open', ['settings'], { platform: 'macos' });
63-
assert.equal(assertRpcOk(open).appBundleId, 'com.apple.systempreferences');
122+
await openMacOsSettings(daemon);
64123

65124
const recordStart = await daemon.callCommand('record', ['start', recordingPath], {
66125
hideTouches: true,
@@ -69,9 +128,64 @@ test('Provider-backed integration macOS recording uses focused exact runner auth
69128
assertRecordingStarted(recordStart, { outPath: recordingPath, showTouches: false });
70129

71130
const recordStop = await daemon.callCommand('record', ['stop']);
72-
assertRecordingStopped(recordStop, recordingPath, { showTouches: false });
131+
assertRpcError(recordStop, 'COMMAND_FAILED', /was not finalized into a playable video/);
132+
assert.equal(recordStop.json?.result, undefined);
133+
assert.equal(daemon.session()?.screenRecording, undefined);
73134

135+
const resource = screenRecordingResourceStore.read(
136+
screenRecordingResourceStore.resolvePath(daemon.sessionDir()),
137+
);
138+
assert.equal(resource.status, 'decoded');
139+
if (resource.status === 'decoded') {
140+
assert.equal(resource.envelope.lifecycle, 'completed');
141+
assert.equal(resource.envelope.metadata?.phase, 'completed');
142+
assert.equal(resource.envelope.metadata?.cleanupStatus, 'cleaned');
143+
}
74144
runnerTranscript.assertComplete();
75145
},
76146
);
77147
});
148+
149+
function createMacOsRecordingScenario(
150+
recordingPath: string,
151+
onStopped?: (outputPath: string) => void,
152+
stopError?: string,
153+
) {
154+
const runnerTranscript = createProviderTranscript([
155+
{
156+
command: 'macos.runner.recordStart',
157+
deviceId: PROVIDER_SCENARIO_MACOS.id,
158+
platform: 'apple',
159+
request: {
160+
command: 'recordStart',
161+
outPath: recordingPath,
162+
fps: 30,
163+
appBundleId: 'com.apple.systempreferences',
164+
},
165+
result: { runnerSessionId: 'macos-runner-recording-1' },
166+
},
167+
{
168+
command: 'macos.runner.recordStop',
169+
deviceId: PROVIDER_SCENARIO_MACOS.id,
170+
platform: 'apple',
171+
request: { command: 'recordStop', appBundleId: 'com.apple.systempreferences' },
172+
...(stopError === undefined ? { result: {} } : { error: stopError }),
173+
},
174+
]);
175+
return {
176+
runnerTranscript,
177+
appleRunnerProvider: createAppleRunnerProviderFromTranscript(runnerTranscript, 'macos.runner'),
178+
appleRunnerScreenRecordingTransport: createAppleRunnerScreenRecordingTransportFromTranscript(
179+
runnerTranscript,
180+
'macos.runner',
181+
onStopped,
182+
),
183+
};
184+
}
185+
186+
async function openMacOsSettings(
187+
daemon: Pick<ProviderScenarioHarness, 'callCommand'>,
188+
): Promise<void> {
189+
const open = await daemon.callCommand('open', ['settings'], { platform: 'macos' });
190+
assert.equal(assertRpcOk(open).appBundleId, 'com.apple.systempreferences');
191+
}

0 commit comments

Comments
 (0)