Skip to content

Commit 8990f43

Browse files
committed
fix: quote session recovery commands
1 parent b27aea3 commit 8990f43

10 files changed

Lines changed: 115 additions & 22 deletions

src/daemon-client.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
import { uploadArtifact } from './upload-client.ts';
3030
import { computeDaemonCodeSignature } from './daemon/code-signature.ts';
3131
import { PUBLIC_COMMANDS } from './command-catalog.ts';
32+
import { shellQuote } from './utils/shell-quote.ts';
3233
import {
3334
readDaemonHttpProgressResponse,
3435
shouldReadDaemonProgressStream,
@@ -1781,7 +1782,3 @@ export function resolveDaemonStartupHint(
17811782
function buildDaemonMetadataCleanupCommand(paths: Pick<DaemonPaths, 'infoPath' | 'lockPath'>) {
17821783
return `rm -f ${shellQuote(paths.infoPath)} ${shellQuote(paths.lockPath)}`;
17831784
}
1784-
1785-
function shellQuote(value: string): string {
1786-
return `'${value.replaceAll("'", "'\\''")}'`;
1787-
}

src/daemon/__tests__/request-lock-policy.test.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ const ANDROID_SESSION: SessionState = {
3636
const RECORDING_SESSION: SessionState = {
3737
...ANDROID_SESSION,
3838
name: 'default',
39-
recordingSession: true,
39+
recordOnlySession: true,
4040
recording: {
4141
platform: 'android',
4242
outPath: '/tmp/recording.mp4',
@@ -99,6 +99,33 @@ test('reject lock policy explains fresh-session recovery commands', () => {
9999
);
100100
});
101101

102+
test('reject lock policy quotes unsafe fresh session names in recovery commands', () => {
103+
assert.throws(
104+
() =>
105+
applyRequestLockPolicy({
106+
token: 'token',
107+
session: "qa ios; echo 'oops'",
108+
command: 'snapshot',
109+
positionals: [],
110+
flags: {
111+
device: 'Pixel 9',
112+
},
113+
meta: {
114+
lockPolicy: 'reject',
115+
lockPlatform: 'ios',
116+
},
117+
}),
118+
(error) => {
119+
assert.ok(error instanceof AppError);
120+
assert.match(
121+
error.details?.hint ?? '',
122+
/agent-device open <app> --session 'qa ios; echo '\\''oops'\\''' --platform ios/i,
123+
);
124+
return true;
125+
},
126+
);
127+
});
128+
102129
test('allows open to choose a fresh-session target under request lock policy', () => {
103130
const req = applyRequestLockPolicy({
104131
token: 'token',
@@ -185,7 +212,7 @@ test('reject lock policy explains existing-session recovery commands', () => {
185212
),
186213
(error) => {
187214
assert.ok(error instanceof AppError);
188-
assert.match(error.message, /locked to session "qa-ios"/i);
215+
assert.match(error.message, /already bound to session "qa-ios"/i);
189216
assert.match(error.message, /ios device "iPhone 16" \(SIM-001\)/i);
190217
assert.match(error.message, /--serial=emulator-5554/i);
191218
assert.match(error.details?.hint ?? '', /agent-device session list/i);
@@ -216,7 +243,7 @@ test('reject lock policy explains recording-session recovery commands', () => {
216243
),
217244
(error) => {
218245
assert.ok(error instanceof AppError);
219-
assert.match(error.message, /locked to session "default"/i);
246+
assert.match(error.message, /already bound to session "default"/i);
220247
assert.match(error.details?.hint ?? '', /recording session "default"/i);
221248
assert.match(error.details?.hint ?? '', /agent-device record stop --session default/i);
222249
assert.match(error.details?.hint ?? '', /agent-device close --session default/i);

src/daemon/__tests__/session-selector.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,22 @@ test('selector mismatch explains session recovery commands', () => {
6060
);
6161
});
6262

63+
test('selector mismatch quotes unsafe session names in recovery commands', () => {
64+
const session = makeSession({ name: "default session; echo 'oops'" });
65+
assert.throws(
66+
() => assertSessionSelectorMatches(session, { platform: 'ios' }),
67+
(err: unknown) => {
68+
assert.ok(err instanceof AppError);
69+
assert.match(err.details?.hint ?? '', /--session 'default session; echo '\\''oops'\\'''/);
70+
assert.match(
71+
err.details?.hint ?? '',
72+
/agent-device close --session 'default session; echo '\\''oops'\\'''/,
73+
);
74+
return true;
75+
},
76+
);
77+
});
78+
6379
test('accepts --platform apple alias for ios sessions', () => {
6480
const session = makeSession({
6581
device: {

src/daemon/handlers/__tests__/record-trace.test.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ test('record stop releases session created only for recording', async () => {
236236
kind: 'simulator',
237237
booted: true,
238238
});
239-
session.recordingSession = true;
239+
session.recordOnlySession = true;
240240
session.recording = {
241241
platform: 'ios',
242242
child: { kill: vi.fn(), pid: 123 },
@@ -268,7 +268,7 @@ test('record stop releases record-only session even when recording state is stal
268268
kind: 'simulator',
269269
booted: true,
270270
});
271-
session.recordingSession = true;
271+
session.recordOnlySession = true;
272272
sessionStore.set(sessionName, session);
273273

274274
const response = await runRecordCommand({
@@ -314,6 +314,44 @@ test('record stop keeps normal app session open after recording', async () => {
314314
expect(sessionStore.get(sessionName)?.recording).toBeUndefined();
315315
});
316316

317+
test('record stop keeps normal app session open when stop validation fails', async () => {
318+
vi.useFakeTimers();
319+
vi.setSystemTime(20_000);
320+
const sessionStore = makeSessionStore();
321+
const sessionName = 'app-session-failed-stop';
322+
const outPath = path.join(os.tmpdir(), 'app-session-failed-stop.mp4');
323+
fs.writeFileSync(outPath, 'not playable');
324+
const session = makeSession(sessionName, {
325+
platform: 'ios',
326+
id: 'ios-sim-1',
327+
name: 'iPhone 16',
328+
kind: 'simulator',
329+
booted: true,
330+
});
331+
session.appBundleId = 'com.apple.Preferences';
332+
session.recording = {
333+
platform: 'ios',
334+
child: { kill: vi.fn(), pid: 123 },
335+
wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }),
336+
outPath,
337+
startedAt: Date.now() - 500,
338+
showTouches: false,
339+
gestureEvents: [],
340+
};
341+
sessionStore.set(sessionName, session);
342+
mockIsPlayableVideo.mockImplementation(async () => false);
343+
344+
const response = await runRecordCommand({
345+
sessionStore,
346+
sessionName,
347+
positionals: ['stop'],
348+
});
349+
350+
expect(response?.ok).toBe(false);
351+
expect(sessionStore.get(sessionName)).toBe(session);
352+
expect(sessionStore.get(sessionName)?.recording).toBeUndefined();
353+
});
354+
317355
test('record start resolves relative output path from request cwd', async () => {
318356
const sessionStore = makeSessionStore();
319357
const sessionName = 'ios-device-cwd';

src/daemon/handlers/__tests__/session.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3125,7 +3125,7 @@ test('open on device owned by recording session returns recording recovery hint'
31253125
kind: 'device',
31263126
booted: true,
31273127
});
3128-
recordingSession.recordingSession = true;
3128+
recordingSession.recordOnlySession = true;
31293129
recordingSession.recording = {
31303130
platform: 'ios',
31313131
child: { kill: vi.fn(), pid: 123 },

src/daemon/handlers/record-trace-recording.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -534,7 +534,7 @@ function releaseRecordOnlySession(
534534
session: SessionState,
535535
options: { writeLog?: boolean } = {},
536536
): void {
537-
if (!session.recordingSession) {
537+
if (!session.recordOnlySession) {
538538
return;
539539
}
540540
if (options.writeLog) {
@@ -565,7 +565,7 @@ export async function handleRecordCommand(params: {
565565
name: sessionName,
566566
device,
567567
createdAt: Date.now(),
568-
recordingSession: true,
568+
recordOnlySession: true,
569569
actions: [],
570570
} satisfies SessionState);
571571

src/daemon/request-lock-policy.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from './session-selector.ts';
1111
import { isApplePlatform, normalizePlatformSelector } from '../utils/device.ts';
1212
import { buildSessionRecoveryHint, describeSessionDevice } from './session-recovery-hints.ts';
13+
import { shellQuoteIfNeeded } from '../utils/shell-quote.ts';
1314

1415
type LockPlatform = NonNullable<DaemonRequest['meta']>['lockPlatform'];
1516

@@ -84,7 +85,7 @@ function buildLockPolicyConflictMessage(
8485
const conflictList = conflicts.map(formatSessionSelectorConflict).join(', ');
8586
if (existingSession) {
8687
return (
87-
`${req.command} is locked to session "${existingSession.name}" on ${describeSessionDevice(existingSession)}, ` +
88+
`${req.command} is already bound to session "${existingSession.name}" on ${describeSessionDevice(existingSession)}, ` +
8889
`but this request selected ${conflictList}.`
8990
);
9091
}
@@ -101,7 +102,7 @@ function buildLockPolicyConflictHint(
101102
return buildSessionRecoveryHint(existingSession, 'selector-conflict');
102103
}
103104
const lockPlatform = req.meta?.lockPlatform;
104-
const sessionText = req.session ? ` --session ${req.session}` : '';
105+
const sessionText = req.session ? ` --session ${shellQuoteIfNeeded(req.session)}` : '';
105106
const openText = lockPlatform
106107
? `Run agent-device open <app>${sessionText} --platform ${lockPlatform} first if no session is active. `
107108
: `Run agent-device open <app>${sessionText} first if no session is active. `;

src/daemon/session-recovery-hints.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { SessionState } from './types.ts';
2+
import { shellQuoteIfNeeded } from '../utils/shell-quote.ts';
23

34
export type SessionRecoveryContext = 'device-in-use' | 'selector-conflict';
45

@@ -13,6 +14,7 @@ export function buildSessionRecoveryHint(
1314
session: SessionState,
1415
context: SessionRecoveryContext,
1516
): string {
17+
// Active recording state controls user recovery text; record-only ownership controls cleanup.
1618
if (session.recording) {
1719
return buildRecordingSessionRecoveryHint(session, context);
1820
}
@@ -23,15 +25,17 @@ function buildRecordingSessionRecoveryHint(
2325
session: SessionState,
2426
context: SessionRecoveryContext,
2527
): string {
26-
const closeCommand = `agent-device close --session ${session.name}`;
28+
const sessionArg = shellQuoteIfNeeded(session.name);
29+
const closeCommand = `agent-device close --session ${sessionArg}`;
30+
const recordStopCommand = `agent-device record stop --session ${sessionArg}`;
2731
const reuseText =
2832
context === 'selector-conflict'
29-
? `To keep using this device, rerun the command with --session ${session.name} and remove conflicting device selectors.`
30-
: `To keep using this device, reuse --session ${session.name} for commands that should attach to the recording session.`;
33+
? `To keep using this device, rerun the command with --session ${sessionArg} and remove conflicting device selectors.`
34+
: `To keep using this device, reuse --session ${sessionArg} for commands that should attach to the recording session.`;
3135

3236
return (
3337
`Recording session "${session.name}" owns this device. ` +
34-
`Run agent-device record stop --session ${session.name}; if the session still appears in agent-device session list, run ${closeCommand}. ` +
38+
`Run ${recordStopCommand}; if the session still appears in agent-device session list, run ${closeCommand}. ` +
3539
`${reuseText} ` +
3640
`Run agent-device session list to inspect active sessions.`
3741
);
@@ -41,18 +45,19 @@ function buildOpenSessionRecoveryHint(
4145
session: SessionState,
4246
context: SessionRecoveryContext,
4347
): string {
44-
const closeCommand = `agent-device close --session ${session.name}`;
48+
const sessionArg = shellQuoteIfNeeded(session.name);
49+
const closeCommand = `agent-device close --session ${sessionArg}`;
4550
if (context === 'selector-conflict') {
4651
return (
4752
`Run agent-device session list to inspect active sessions. ` +
48-
`To reuse this device, rerun the command with --session ${session.name} and remove conflicting device selectors. ` +
53+
`To reuse this device, rerun the command with --session ${sessionArg} and remove conflicting device selectors. ` +
4954
`To switch devices, first run ${closeCommand}, then open the desired device with a different --session name.`
5055
);
5156
}
5257

5358
return (
5459
`Run agent-device session list to inspect active sessions. ` +
55-
`To reuse this device, rerun the command with --session ${session.name}. ` +
60+
`To reuse this device, rerun the command with --session ${sessionArg}. ` +
5661
`To open a new session on this device, first run ${closeCommand}.`
5762
);
5863
}

src/daemon/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ export type SessionState = {
214214
outPath: string;
215215
startedAt: number;
216216
};
217-
recordingSession?: boolean;
217+
recordOnlySession?: boolean;
218218
recordSession?: boolean;
219219
saveScriptPath?: string;
220220
actions: SessionAction[];

src/utils/shell-quote.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
const SAFE_SHELL_ARG = /^[A-Za-z0-9_@%+=:,./-]+$/;
2+
3+
export function shellQuote(value: string): string {
4+
return `'${value.replaceAll("'", "'\\''")}'`;
5+
}
6+
7+
export function shellQuoteIfNeeded(value: string): string {
8+
return SAFE_SHELL_ARG.test(value) ? value : shellQuote(value);
9+
}

0 commit comments

Comments
 (0)