Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
dd5f3a4
refactor(simulator): extract errors and device catalog modules (#708 …
shaun0927 May 7, 2026
d935982
refactor(simulator): harden simctl JSON parsing and fuzzy resolver
shaun0927 May 7, 2026
7f09fe5
refactor(simulator): extract lifecycle module (#708 2/4)
shaun0927 May 7, 2026
45ef297
refactor(simulator): keep shutdown best-effort after nuclear erase
shaun0927 May 7, 2026
3009414
refactor(simulator): extract app manager module (#708 3/4)
shaun0927 May 7, 2026
755cfc5
fix(simulator): tighten bundleId regex and centralize not-installed d…
shaun0927 May 7, 2026
94ade8b
fix(simulator): strip all trailing bracket groups in launchctl labels…
shaun0927 May 7, 2026
69567a6
refactor(simulator): extract UI controller and finalize facade (#708 …
shaun0927 May 7, 2026
269c0c3
fix(simulator): hoist promisify and route simctl rotate through deps.…
shaun0927 May 7, 2026
8d9fee7
refactor(simulator): extract UI controller and finalize facade (#708 …
shaun0927 May 8, 2026
f8b495f
refactor(simulator): extract lifecycle module (#708 2/4) (#740)
shaun0927 May 8, 2026
006a125
refactor(simulator): extract app manager module (#708 3/4) (#742)
shaun0927 May 8, 2026
88c62cc
Merge remote-tracking branch 'origin/refactor/708b-lifecycle' into he…
hermes-agent May 8, 2026
77bfa68
Merge develop into refactor/708a-errors-catalog
shaun0927 May 8, 2026
86a1920
fix(tests, audit): align with develop's HTTP transport hardening
shaun0927 May 8, 2026
f346491
fix(test): assert flutter-evaluate audit on redacted len=N format
shaun0927 May 8, 2026
f66aa3f
Preserve audit secrecy while landing simulator decomposition
shaun0927 May 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions src/security/audit-logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,9 @@ const SENSITIVE_KEYS = [
'privatekey',
'private_key',
'session',
// Free-form user input that flows through MCP tool calls (e.g.
// `type.text`, `select_option.value`) can carry passwords or OTPs, so
// redact these keys defensively even though they are not credentials
// by name.
// Free-form user input that flows through MCP tool calls (for example
// `type.text` and `select_option.value`) can carry passwords, OTPs, or
// other secrets even when the key is not credential-named.
'text',
'value',
];
Comment on lines 52 to 58
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore redaction for free-form text/value audit fields

Keep text and value in SENSITIVE_KEYS; removing them causes raw user-entered form content to be written to args_summary in audit logs. MCP tool payloads commonly carry credentials/OTPs under keys like type.text and select_option.value, so this change reintroduces a concrete secret-leak path even when other credential-named fields are redacted.

Useful? React with 👍 / 👎.

Expand Down Expand Up @@ -230,7 +229,13 @@ function rotateLogIfNeeded(logPath: string, bytesToAppend: number): void {
fs.chmodSync(rotatedPath, AUDIT_FILE_MODE);
}

export function logAuditEntry(tool: string, sessionId: string, args: Record<string, unknown>, pageUrl?: string): void {
export function logAuditEntry(
tool: string,
sessionId: string,
args: Record<string, unknown>,
pageUrl?: string,
status?: AuditEntry['status'],
): void {
const entry: AuditEntry = {
timestamp: new Date().toISOString(),
tool,
Expand All @@ -250,4 +255,4 @@ export function logAuditEntry(tool: string, sessionId: string, args: Record<stri
} catch (err) {
console.error('[audit-logger] write failed:', (err as NodeJS.ErrnoException).code);
}
}
}
234 changes: 234 additions & 0 deletions src/simulator/app-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
/**
* App lifecycle operations — install, launch, terminate, uninstall,
* activate, list running apps, and reset app state.
*
* Extracted from SimulatorManager as part of #708 (step 4).
* All functions take injected dependencies for testability.
*
* Behavior is strictly preserved from the original manager:
* - launchApp: maps simctl launch output to pid; surfaces AppNotInstalledError
* for "domain not found" / "not installed"; wraps other errors in AppLaunchError.
* - terminateApp: returns terminated:false (not error) when app is not running;
* surfaces AppNotInstalledError for "domain not found".
* - activateApp: uses simctl launch to bring running app to foreground, or
* starts it if not running.
* - listRunningApps: parses launchctl list UIKitApplication entries.
* - resetApp: terminate → privacy reset → uninstall (caller reinstalls).
*/

import { SimctlError } from './simctl';
import { DeviceNotBootedError, AppNotInstalledError, AppLaunchError } from './errors';
import { SimulatorDevice } from './types';

/**
* Returns true when the simctl error message indicates the bundle is not installed.
* Centralises detection of "domain not found" / "not installed" patterns used by
* launchApp, terminateApp, activateApp, and resetApp.
*/
function isAppNotInstalledError(err: SimctlError): boolean {
return err.message.includes('domain not found') || err.message.includes('not installed');
}

/** Minimal simctl interface the app-manager functions depend on. */
export interface AppManagerSimctl {
exec(args: string[], options?: { timeout?: number; env?: Record<string, string> }): Promise<string>;
}

/** Minimal device-lookup interface the app-manager functions depend on. */
export interface AppManagerDeviceLookup {
getDevice(deviceId: string): Promise<SimulatorDevice | null>;
}

/**
* Launch an app on a booted simulator.
* Returns the pid parsed from simctl output.
* Throws DeviceNotBootedError if the device is not booted.
* Throws AppNotInstalledError if the bundle is not installed.
* Throws AppLaunchError for any other launch failure.
*/
export async function launchApp(
deviceId: string,
bundleId: string,
options: { args?: string[]; env?: Record<string, string> } | undefined,
deps: { simctl: AppManagerSimctl; lookup: AppManagerDeviceLookup },
): Promise<{ pid: number; bundleId: string; deviceId: string }> {
const device = await deps.lookup.getDevice(deviceId);
if (!device || device.state !== 'Booted') {
throw new DeviceNotBootedError(deviceId);
}

const cmdArgs = ['launch', deviceId, bundleId];
if (options?.args) {
cmdArgs.push(...options.args);
}

// simctl passes SIMCTL_CHILD_* env vars to the launched app
const childEnv: Record<string, string> = {};
if (options?.env) {
for (const [key, value] of Object.entries(options.env)) {
childEnv[`SIMCTL_CHILD_${key}`] = value;
}
}

try {
const output = await deps.simctl.exec(cmdArgs, { env: childEnv });
const pidMatch = output.match(/:\s*(\d+)/);
const pid = pidMatch ? parseInt(pidMatch[1], 10) : -1;
return { pid, bundleId, deviceId };
} catch (err) {
if (err instanceof SimctlError) {
if (isAppNotInstalledError(err)) {
throw new AppNotInstalledError(bundleId, deviceId);
}
}
throw new AppLaunchError(bundleId, deviceId, err instanceof Error ? err.message : String(err));
}
}

/**
* Terminate a running app on a booted simulator.
* Returns terminated:false (not an error) when the app is not running.
* Throws DeviceNotBootedError if the device is not booted.
* Throws AppNotInstalledError if the bundle is not installed.
*/
export async function terminateApp(
deviceId: string,
bundleId: string,
deps: { simctl: AppManagerSimctl; lookup: AppManagerDeviceLookup },
): Promise<{ terminated: boolean; bundleId: string; deviceId: string }> {
const device = await deps.lookup.getDevice(deviceId);
if (!device || device.state !== 'Booted') {
throw new DeviceNotBootedError(deviceId);
}

try {
await deps.simctl.exec(['terminate', deviceId, bundleId]);
return { terminated: true, bundleId, deviceId };
} catch (err) {
if (err instanceof SimctlError) {
if (err.message.includes('not running') || err.message.includes('Failed to terminate')) {
return { terminated: false, bundleId, deviceId };
}
if (isAppNotInstalledError(err)) {
throw new AppNotInstalledError(bundleId, deviceId);
}
}
throw err;
}
}

/**
* Activate (bring to foreground) an app on a booted simulator.
* simctl launch brings an already-running app to the foreground;
* if the app is not running it starts it.
* Throws DeviceNotBootedError if the device is not booted.
* Throws AppNotInstalledError if the bundle is not installed.
*/
export async function activateApp(
deviceId: string,
bundleId: string,
deps: { simctl: AppManagerSimctl; lookup: AppManagerDeviceLookup },
): Promise<{ activated: boolean; bundleId: string; deviceId: string; pid: number }> {
const device = await deps.lookup.getDevice(deviceId);
if (!device || device.state !== 'Booted') {
throw new DeviceNotBootedError(deviceId);
}

try {
const output = await deps.simctl.exec(['launch', deviceId, bundleId]);
const pidMatch = output.match(/:\s*(\d+)/);
const pid = pidMatch ? parseInt(pidMatch[1], 10) : -1;
return { activated: true, bundleId, deviceId, pid };
} catch (err) {
if (err instanceof SimctlError) {
if (isAppNotInstalledError(err)) {
throw new AppNotInstalledError(bundleId, deviceId);
}
}
throw err;
}
}

/**
* List running foreground apps by parsing launchctl list UIKitApplication entries.
* Throws DeviceNotBootedError if the device is not booted.
*/
export async function listRunningApps(
deviceId: string,
deps: { simctl: AppManagerSimctl; lookup: AppManagerDeviceLookup },
): Promise<Array<{ label: string; pid: number }>> {
const device = await deps.lookup.getDevice(deviceId);
if (!device || device.state !== 'Booted') {
throw new DeviceNotBootedError(deviceId);
}

const output = await deps.simctl.exec(['spawn', deviceId, 'launchctl', 'list']);
const lines = output.split('\n');
const apps: Array<{ label: string; pid: number }> = [];

for (const line of lines) {
const parts = line.split('\t');
if (parts.length < 3) continue;
const pid = parseInt(parts[0], 10);
const label = parts[2];
// Filter for UIKitApplication entries (running foreground apps)
if (!isNaN(pid) && pid > 0 && label.startsWith('UIKitApplication:')) {
const bundleId = label.replace('UIKitApplication:', '').replace(/(\[[^\]]*\])+$/, '');
apps.push({ label: bundleId, pid });
}
}

return apps;
}

/**
* Reset app state on a simulator.
* Strategy: terminate app, reset privacy permissions, clear app data container.
* Note: simctl has no "clear data" command; the documented strategy is
* uninstall + reinstall. We uninstall here; the caller can reinstall.
* Throws DeviceNotBootedError if the device is not booted.
* Throws AppNotInstalledError if uninstall confirms the bundle is not installed.
*/
export async function resetApp(
deviceId: string,
bundleId: string,
deps: { simctl: AppManagerSimctl; lookup: AppManagerDeviceLookup },
): Promise<{ reset: boolean; bundleId: string; deviceId: string; steps: string[] }> {
const device = await deps.lookup.getDevice(deviceId);
if (!device || device.state !== 'Booted') {
throw new DeviceNotBootedError(deviceId);
}

const steps: string[] = [];

// Step 1: Terminate the app if running
try {
await deps.simctl.exec(['terminate', deviceId, bundleId]);
steps.push('terminated');
} catch {
steps.push('terminate_skipped');
}

// Step 2: Reset privacy permissions
try {
await deps.simctl.exec(['privacy', deviceId, 'reset', 'all', bundleId]);
steps.push('privacy_reset');
} catch {
steps.push('privacy_reset_skipped');
}

// Step 3: Uninstall and note (cannot clear data container directly)
try {
await deps.simctl.exec(['uninstall', deviceId, bundleId]);
steps.push('uninstalled');
} catch (err) {
if (err instanceof SimctlError) {
if (isAppNotInstalledError(err)) {
throw new AppNotInstalledError(bundleId, deviceId);
}
}
steps.push('uninstall_failed');
}

return { reset: true, bundleId, deviceId, steps };
}
94 changes: 94 additions & 0 deletions src/simulator/device-catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { SimctlExecutor } from './simctl';
import { SimulatorDevice, SimulatorRuntime } from './types';
import { DEVICE_PRESETS } from './presets';
import { DeviceNotFoundError } from './errors';

interface SimctlListResult {
// `simctl list devices -j` returns only `devices`; `simctl list runtimes -j`
// returns only `runtimes`. Treat both as optional so a missing key is not a crash.
devices?: Record<string, Array<{
udid: string;
name: string;
state: string;
isAvailable: boolean;
}>>;
runtimes?: Array<{
identifier: string;
version: string;
isAvailable: boolean;
platform: string;
}>;
}

export async function listDevices(simctl: SimctlExecutor): Promise<SimulatorDevice[]> {
const result = await simctl.execJson<SimctlListResult>(['list', 'devices']);
const devices: SimulatorDevice[] = [];

for (const [runtimeId, deviceList] of Object.entries(result.devices ?? {})) {
const version = runtimeId.match(/iOS-(\d+)-(\d+)/);
const runtimeVersion = version ? `${version[1]}.${version[2]}` : 'unknown';

for (const device of deviceList) {
if (device.isAvailable) {
devices.push({
udid: device.udid,
name: device.name,
state: device.state as SimulatorDevice['state'],
isAvailable: device.isAvailable,
runtime: runtimeId,
runtimeVersion,
});
}
}
}

return devices;
}

export async function listRuntimes(simctl: SimctlExecutor): Promise<SimulatorRuntime[]> {
const result = await simctl.execJson<SimctlListResult>(['list', 'runtimes']);
return (result.runtimes ?? []).filter(r => r.isAvailable);
}

export async function getDevice(simctl: SimctlExecutor, deviceId: string): Promise<SimulatorDevice | null> {
const devices = await listDevices(simctl);
return devices.find(d => d.udid === deviceId) ?? null;
}

/**
* Resolve a preset key or device name to an actual device.
* Tries: exact UDID match → preset name match → fuzzy name match
*/
export async function resolveDevice(simctl: SimctlExecutor, presetKey: string): Promise<SimulatorDevice> {
const devices = await listDevices(simctl);

// 1. Exact UDID match
const byUdid = devices.find(d => d.udid === presetKey);
if (byUdid) return byUdid;

// 2. Preset name match
const preset = DEVICE_PRESETS[presetKey];
if (preset) {
const exact = devices.find(d => d.name === preset.name);
if (exact) return exact;
}

// 3. Case-insensitive substring match
const lower = presetKey.toLowerCase();
const substring = devices.find(d => d.name.toLowerCase().includes(lower));
if (substring) return substring;

// 4. Fuzzy: split words, match all keywords. Filter empty tokens so that
// whitespace/separator-only input doesn't reduce to `every(kw => …includes(""))`,
// which would silently match the first available device.
const keywords = lower.split(/[\s-]+/).filter(Boolean);
if (keywords.length > 0) {
const fuzzy = devices.find(d => {
const dLower = d.name.toLowerCase();
return keywords.every(kw => dLower.includes(kw));
});
if (fuzzy) return fuzzy;
}

throw new DeviceNotFoundError(presetKey, devices.map(d => d.name));
}
Loading
Loading