reset_av_device_selection
reset_av_device_selection
Clears the per-app microphone, camera, and speaker selection for one collab app (Teams, Slack, Zoom, Webex) so the app re-detects on next launch. Use when the user reports the app is stuck on a disconnected or unintended A/V device. Does not change broader preferences or sign the user out.
Metadata
Name
reset_av_device_selection
Updated
2 months ago
Source
Risk
Medium
Requires consent
true
Affected scope
user
Code
/**
* mcp/skills/resetAvDeviceSelection.ts — reset_av_device_selection skill
*
* Clears the per-app saved microphone / camera / speaker selection so the
* collab app re-detects on next launch. Used when a user reports the app
* is stuck on a disconnected mic / camera even after replugging the
* intended device.
*
* Each app stores its A/V selection in different places. This tool
* targets ONLY the audio/video device selection keys, never broader
* preferences (sign-in, notification settings, etc.).
*
* Per-app strategy
* ----------------
* Zoom macOS plist `~/Library/Preferences/us.zoom.xos.plist`,
* keys: ZoomChat.Audio.MicID / SpeakerID, ZoomChat.Video.CameraID
* (`defaults delete` per key).
* Teams New Teams settings file
* `~/Library/Group Containers/UBF8T346G9.com.microsoft.teams/.../media-stack-config.json`
* — JSON object, we delete the audio/video keys.
* Slack `~/Library/Application Support/Slack/storage/notifications-config.json`
* — Slack stores call-device prefs here when present. Best-
* effort: if the file is missing we report no-op.
* Webex `~/Library/Application Support/Cisco Spark/Local Storage/leveldb/`
* — Webex persists its A/V choice in LevelDB; safest action is
* to delete the audio/video config keys via the per-account
* settings file (`accounts/<accountId>/data/devices.json`).
*
* Windows mirrors each path under %APPDATA% / %LOCALAPPDATA%. Where a
* specific JSON or plist file exists we touch ONLY that file (read,
* delete the audio/video keys, write back) — never the parent dir.
*
* Dry-run: returns the file paths that would be touched and which keys
* would be cleared, without writing to disk.
*/
import * as os from "os";
import * as path from "path";
import { promises as fs } from "fs";
import { z } from "zod";
import type { CollabApp } from "./checkCollabAppStatus";
// -- Meta ---------------------------------------------------------------------
export const meta = {
name: "reset_av_device_selection",
description:
"Clears the per-app microphone, camera, and speaker selection for one " +
"collab app (Teams, Slack, Zoom, Webex) so the app re-detects on next " +
"launch. Use when the user reports the app is stuck on a disconnected " +
"or unintended A/V device. Does not change broader preferences or sign " +
"the user out.",
riskLevel: "medium",
destructive: true,
requiresConsent: true,
supportsDryRun: true,
affectedScope: ["user"],
auditRequired: true,
tccCategories: ["FullDiskAccess"],
schema: {
app: z
.enum(["teams", "slack", "zoom", "webex"])
.describe("Which collab app's A/V selection to reset."),
dryRun: z
.boolean()
.optional()
.describe("If true, report what would be reset without modifying any files."),
},
} as const;
// -- Per-app target file registry ---------------------------------------------
interface ResetTarget {
/** Absolute path to the file that holds the A/V selection. */
file: string;
/** "json": parse, delete keys, write back. "plist-defaults": run `defaults delete` per key. "delete-file": rm the file. */
strategy: "json" | "plist-defaults" | "delete-file";
/** For json: top-level keys to remove. For plist: keys to `defaults delete`. */
keys: string[];
}
function resetTargets(app: CollabApp, platform: NodeJS.Platform): ResetTarget[] {
const home = os.homedir();
const appData = process.env.APPDATA ?? path.join(home, "AppData", "Roaming");
switch (app) {
case "zoom":
if (platform === "darwin") {
return [{
file: path.join(home, "Library", "Preferences", "us.zoom.xos.plist"),
strategy: "plist-defaults",
keys: [
"ZoomChat.Audio.MicID",
"ZoomChat.Audio.SpeakerID",
"ZoomChat.Video.CameraID",
"selectedMicID",
"selectedSpeakerID",
"selectedCameraID",
],
}];
}
// Windows Zoom keeps device selection in zoomus.ini — delete the
// [audio] / [video] sections by removing the file (Zoom recreates
// it from defaults on next launch). We cannot use ini-edit here
// without an extra dep; deleting is acceptable for the alpha.
return [{
file: path.join(appData, "Zoom", "data", "zoomus.ini"),
strategy: "delete-file",
keys: ["audio", "video"],
}];
case "teams":
if (platform === "darwin") {
return [{
file: path.join(
home, "Library", "Group Containers",
"UBF8T346G9.com.microsoft.teams", "Library", "Application Support",
"Microsoft", "MSTeams", "media-stack-config.json",
),
strategy: "json",
keys: ["audioInputDevice", "audioOutputDevice", "videoDevice", "selectedMicrophone", "selectedSpeaker", "selectedCamera"],
}];
}
return [{
file: path.join(appData, "Microsoft", "Teams", "media-stack-config.json"),
strategy: "json",
keys: ["audioInputDevice", "audioOutputDevice", "videoDevice", "selectedMicrophone", "selectedSpeaker", "selectedCamera"],
}];
case "slack":
if (platform === "darwin") {
return [{
file: path.join(home, "Library", "Application Support", "Slack", "storage", "notifications-config.json"),
strategy: "json",
keys: ["microphoneId", "speakerId", "cameraId"],
}];
}
return [{
file: path.join(appData, "Slack", "storage", "notifications-config.json"),
strategy: "json",
keys: ["microphoneId", "speakerId", "cameraId"],
}];
case "webex":
// Webex stores A/V selection per-account. We don't enumerate
// accounts here — instead remove the well-known device-selection
// file under the active-account dir. Best-effort.
if (platform === "darwin") {
return [{
file: path.join(home, "Library", "Application Support", "Cisco Spark", "settings", "av-devices.json"),
strategy: "json",
keys: ["microphoneId", "speakerId", "cameraId"],
}];
}
return [{
file: path.join(appData, "Cisco Spark", "settings", "av-devices.json"),
strategy: "json",
keys: ["microphoneId", "speakerId", "cameraId"],
}];
}
}
// -- Filesystem helpers -------------------------------------------------------
async function pathExists(p: string): Promise<boolean> {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
async function clearJsonKeys(file: string, keys: string[]): Promise<{ removed: string[] }> {
const raw = await fs.readFile(file, "utf8");
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(raw) as Record<string, unknown>;
} catch {
// Not JSON — nothing to do; surface as no-op.
return { removed: [] };
}
const removed: string[] = [];
for (const k of keys) {
if (k in parsed) {
delete parsed[k];
removed.push(k);
}
}
if (removed.length > 0) {
await fs.writeFile(file, JSON.stringify(parsed, null, 2), "utf8");
}
return { removed };
}
async function clearPlistDefaults(file: string, keys: string[]): Promise<{ removed: string[] }> {
// Translate plist path → bundle id. e.g. `.../us.zoom.xos.plist` → `us.zoom.xos`.
const base = path.basename(file, ".plist");
const removed: string[] = [];
// Lazy-require to avoid pulling child_process into platforms that won't use it.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { execFile } = require("child_process") as typeof import("child_process");
const { promisify } = require("util") as typeof import("util");
const execFileAsync = promisify(execFile);
for (const key of keys) {
try {
await execFileAsync("defaults", ["delete", base, key]);
removed.push(key);
} catch {
// `defaults delete` returns non-zero if the key doesn't exist —
// treat as "already cleared", not a failure.
}
}
return { removed };
}
// -- Types --------------------------------------------------------------------
export interface ResetActionResult {
file: string;
strategy: ResetTarget["strategy"];
keysFound: string[];
keysCleared: string[];
}
export interface ResetAvDeviceSelectionResult {
app: CollabApp;
platform: NodeJS.Platform;
dryRun: boolean;
actions: ResetActionResult[];
errors: { file: string; message: string }[];
}
// -- Exported run function ----------------------------------------------------
export async function run({
app,
dryRun = false,
}: {
app: CollabApp;
dryRun?: boolean;
}): Promise<ResetAvDeviceSelectionResult> {
const platform = os.platform();
if (platform !== "darwin" && platform !== "win32") {
throw new Error(`reset_av_device_selection: unsupported platform "${platform}"`);
}
const targets = resetTargets(app, platform);
const actions: ResetActionResult[] = [];
const errors: { file: string; message: string }[] = [];
for (const t of targets) {
if (!(await pathExists(t.file))) {
// File absent — nothing to do for this target. Not an error.
actions.push({ file: t.file, strategy: t.strategy, keysFound: [], keysCleared: [] });
continue;
}
if (dryRun) {
// Dry-run: report which keys would be cleared without touching disk.
// For json, we can read + intersect; for plist + delete-file, we can
// only report the configured keys (we don't enumerate live plist
// contents in dry-run to avoid a `defaults read` per key).
let keysFound: string[] = [];
if (t.strategy === "json") {
try {
const raw = await fs.readFile(t.file, "utf8");
const parsed = JSON.parse(raw) as Record<string, unknown>;
keysFound = t.keys.filter((k) => k in parsed);
} catch {
keysFound = [];
}
} else {
// plist or delete-file — assume all configured keys are candidates.
keysFound = [...t.keys];
}
actions.push({ file: t.file, strategy: t.strategy, keysFound, keysCleared: [] });
continue;
}
try {
if (t.strategy === "json") {
const { removed } = await clearJsonKeys(t.file, t.keys);
actions.push({ file: t.file, strategy: t.strategy, keysFound: removed, keysCleared: removed });
} else if (t.strategy === "plist-defaults") {
const { removed } = await clearPlistDefaults(t.file, t.keys);
actions.push({ file: t.file, strategy: t.strategy, keysFound: removed, keysCleared: removed });
} else {
// delete-file
await fs.rm(t.file, { force: true });
actions.push({ file: t.file, strategy: t.strategy, keysFound: t.keys, keysCleared: t.keys });
}
} catch (err) {
errors.push({ file: t.file, message: (err as Error).message });
}
}
return { app, platform, dryRun, actions, errors };
}
// -- Test helpers -------------------------------------------------------------
/** Exported for unit tests only — do not use from production code. */
export const __testing = {
resetTargets,
pathExists,
clearJsonKeys,
};