From c776e184344132ad9b5bf6d7fcca88d8251e9414 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:28:24 -0400 Subject: [PATCH] fix(bar): preserve Gatekeeper quarantine on install (#1534) Preserves the Gatekeeper quarantine attribute on the installed CCS Bar app so the user makes the right-click to Open trust decision, instead of silently clearing it. --- docs/ccs-bar.md | 12 +- src/commands/bar/help-subcommand.ts | 6 +- src/commands/bar/install-subcommand.ts | 57 ++----- tests/unit/commands/bar-command.test.ts | 217 ++---------------------- 4 files changed, 29 insertions(+), 263 deletions(-) diff --git a/docs/ccs-bar.md b/docs/ccs-bar.md index 7afcfd42..dccaf91a 100644 --- a/docs/ccs-bar.md +++ b/docs/ccs-bar.md @@ -35,7 +35,7 @@ If `CCS Bar.app` is already installed, the command shows the current version and After installation, CCS reads the app version directly from the bundle's `Info.plist` and pins it to `~/.ccs/bar/.version`. It then performs a reachability check against the bar API (`GET /api/bar/summary`). A 404 response means the running CCS server predates CCS Bar support — update CCS to a version that includes CCS Bar, then restart `ccs bar`. -After a successful install, CCS asks whether to launch CCS Bar immediately (default: yes). Pass `--launch` to skip the prompt and launch right away, or `--no-launch` to suppress the prompt entirely: +After a successful install, CCS leaves the macOS Gatekeeper quarantine marker in place so macOS can perform its normal first-launch verification. CCS then asks whether to launch CCS Bar immediately (default: yes). Pass `--launch` to skip the prompt and launch right away, or `--no-launch` to suppress the prompt entirely: ```bash ccs bar install --launch # install and launch immediately @@ -44,13 +44,9 @@ ccs bar install --no-launch # install, skip launch prompt ### Gatekeeper note -The install command automatically clears the macOS Gatekeeper quarantine attribute (`xattr -dr com.apple.quarantine`) on the downloaded app. If clearing fails for any reason, the command falls back to printing the manual command: +The install command does not automatically clear the macOS Gatekeeper quarantine attribute on the downloaded app. This preserves macOS first-launch verification for the floating release download. -```bash -xattr -dr com.apple.quarantine "$HOME/Applications/CCS Bar.app" -``` - -Or right-click the app and choose Open. +If macOS blocks the app on first launch, make an explicit trust decision by right-clicking the app and choosing Open. ## Run @@ -109,7 +105,7 @@ This removes `~/Applications/CCS Bar.app` and the installed version pin. It is a - Install fails with "server predates CCS Bar" or bar API returns 404: the CCS server running does not yet include CCS Bar. Update CCS (`npm i -g ccs@latest` or equivalent), then restart `ccs bar`. - Server failed to start: `ccs bar` first checks whether a CCS server is already running on the candidate ports (3000, 3001, 3002, 8000, 8080) and reuses it if found. A true failure here means a non-CCS process is occupying all candidate ports. Free one of those ports and re-run `ccs bar`. Check `~/.ccs/bar/serve.log` for the background server's output. -- App won't open (Gatekeeper): right-click and Open, or clear quarantine with the `xattr` command above. +- App won't open (Gatekeeper): right-click the app and choose Open to make an explicit trust decision. - Menu shows "CCS is not running": open the menu again to let the app start the server, or run `ccs bar status` to check and `ccs bar` to start it. - Quota not updating: re-open the menu to force a refresh, or confirm the server is still reachable on loopback. diff --git a/src/commands/bar/help-subcommand.ts b/src/commands/bar/help-subcommand.ts index b244a277..0511a1ba 100644 --- a/src/commands/bar/help-subcommand.ts +++ b/src/commands/bar/help-subcommand.ts @@ -60,15 +60,13 @@ export async function showHelp(): Promise { } console.log(dim(' macOS only. The app communicates with the CCS web-server on localhost only.')); + console.log(dim(' Gatekeeper quarantine is kept in place for macOS first-launch verification.')); console.log( dim(' `ccs bar launch` spawns the server detached — the terminal is freed immediately.') ); console.log(dim(' The server persists until stopped with `ccs bar stop` or system reboot.')); console.log( - dim( - ' Gatekeeper quarantine is cleared automatically after install. If the app is still blocked,' - ) + dim(' If macOS blocks the app, right-click > Open to make an explicit trust decision.') ); - console.log(dim(' right-click > Open or run `xattr -dr com.apple.quarantine` manually.')); console.log(''); } diff --git a/src/commands/bar/install-subcommand.ts b/src/commands/bar/install-subcommand.ts index a1fc58f8..96893ba4 100644 --- a/src/commands/bar/install-subcommand.ts +++ b/src/commands/bar/install-subcommand.ts @@ -85,13 +85,6 @@ export interface InstallDeps { getCcsDir: () => string; /** Destination directory for the .app bundle (~/Applications by default). */ getAppsDir: () => string; - /** - * Clear the macOS Gatekeeper quarantine attribute from the installed app. - * Run via `/usr/bin/xattr -dr com.apple.quarantine ` (execFile, not shell-string). - * Returns true on success, false if xattr is unavailable or the call fails (non-fatal). - * Injectable for tests — avoids touching the real system. - */ - clearQuarantine: (appPath: string) => Promise; /** * Invoke handleBarLaunch after a successful install when the user consents. * Injectable so tests can assert invocation without starting a real server. @@ -416,25 +409,6 @@ function defaultGetAppsDir(): string { return path.join(os.homedir(), 'Applications'); } -/** - * Clear the macOS Gatekeeper quarantine attribute from the installed app. - * Uses absolute path `/usr/bin/xattr` to avoid PATH hijacking. - * Runs `/usr/bin/xattr -dr com.apple.quarantine ` via execFile (not shell-string) - * so the path is passed as an argument, not interpolated into a shell command. - * Returns true on success, false on any error (non-fatal — install already succeeded). - */ -async function defaultClearQuarantine(appPath: string): Promise { - try { - const { execFile } = await import('child_process'); - const { promisify } = await import('util'); - const execFileAsync = promisify(execFile); - await execFileAsync('/usr/bin/xattr', ['-dr', 'com.apple.quarantine', appPath]); - return true; - } catch { - return false; - } -} - function defaultRemoveExistingApp(appPath: string): void { fs.rmSync(appPath, { recursive: true, force: true }); } @@ -524,7 +498,6 @@ export async function handleBarInstall( const downloadAndExtract = deps.downloadAndExtract ?? defaultDownloadAndExtract; const verifyCompat = deps.verifyCompat ?? defaultVerifyCompat; const readAppBundleVersion = deps.readAppBundleVersion ?? defaultReadAppBundleVersion; - const clearQuarantine = deps.clearQuarantine ?? defaultClearQuarantine; const launchBar = deps.launchBar ?? defaultLaunchBar; const promptLaunch = deps.promptLaunch ?? defaultPromptLaunch; const isBarRunning = deps.isBarRunning ?? defaultIsBarRunning; @@ -678,13 +651,11 @@ export async function handleBarInstall( } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`[!] Could not write launch.json: ${msg}`); - // Non-fatal — continue to quarantine clear + launch handoff. + // Non-fatal — continue to Gatekeeper note + launch handoff. } // 5. Capability handshake via GET /api/bar/summary. - // Runs BEFORE the quarantine-clear + launch handoff because it is a server-side - // check unrelated to Gatekeeper. A failed quarantine clear must not prevent the - // compat result from being shown to the user. + // This server-side check is unrelated to Gatekeeper. // Read bar.json for baseUrl if present; otherwise fall back to localhost:3000. const barJsonPath = path.join(ccsDir, 'bar.json'); let baseUrl = 'http://127.0.0.1:3000'; @@ -715,22 +686,14 @@ export async function handleBarInstall( console.log('[i] Run `ccs bar` to start the server and recheck.'); } - // 6. Quarantine handling: run `xattr -dr com.apple.quarantine` automatically. - // This clears the Gatekeeper quarantine flag that ad-hoc builds receive on download. - // On success: print [OK] confirmation. On failure: fall back to printed guidance and - // SKIP the launch handoff entirely — launching a still-quarantined app hits the - // Gatekeeper block, so the user must clear quarantine manually first. - const quarantineCleared = await clearQuarantine(appPath); - if (quarantineCleared) { - console.log('[OK] Cleared Gatekeeper quarantine.'); - } else { - console.log('[i] Gatekeeper note (ad-hoc build):'); - console.log(' If macOS says the app is "damaged" or "unverified", run:'); - console.log(` xattr -dr com.apple.quarantine "${appPath}"`); - console.log(' Or right-click the app and select Open.'); - console.log('[i] After clearing quarantine, run `ccs bar` to launch.'); - return; - } + // 6. Gatekeeper handling: keep quarantine in place for downloaded apps. + // CCS Bar is installed from a floating release asset and is not verified by a + // pinned checksum/signature here, so do not strip macOS's quarantine marker. + // If Gatekeeper blocks first launch, the user can make an explicit manual + // trust decision outside the installer. + console.log('[i] Gatekeeper note:'); + console.log(' macOS may verify this downloaded app on first launch.'); + console.log(' If macOS blocks it, right-click the app and select Open.'); // 7. Launch handoff. // Already-running check: if CCS Bar is running after a (re)install, skip the diff --git a/tests/unit/commands/bar-command.test.ts b/tests/unit/commands/bar-command.test.ts index 0c0744be..adde60df 100644 --- a/tests/unit/commands/bar-command.test.ts +++ b/tests/unit/commands/bar-command.test.ts @@ -411,7 +411,6 @@ describe('bar install subcommand', () => { downloadAndExtract: fakeExtract(appsDir), verifyCompat: async (_baseUrl: string) => ({ compatible: true, reason: 'ok' }), readAppBundleVersion: (_appPath: string) => FAKE_VERSION, - clearQuarantine: async () => true, isBarRunning: async () => false, promptLaunch: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), @@ -437,7 +436,6 @@ describe('bar install subcommand', () => { }, verifyCompat: async () => ({ compatible: true, reason: 'ok' }), readAppBundleVersion: (_appPath: string) => FAKE_VERSION, - clearQuarantine: async () => true, isBarRunning: async () => false, promptLaunch: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), @@ -459,7 +457,6 @@ describe('bar install subcommand', () => { downloadAndExtract: fakeExtract(appsDir), verifyCompat: async () => ({ compatible: true, reason: 'ok' }), readAppBundleVersion: (_appPath: string) => FAKE_VERSION, - clearQuarantine: async () => true, isBarRunning: async () => false, promptLaunch: async () => false, getCcsDir: () => ccsDir, @@ -485,7 +482,6 @@ describe('bar install subcommand', () => { return { compatible: true, reason: 'ok' }; }, readAppBundleVersion: (_appPath: string) => FAKE_VERSION, - clearQuarantine: async () => true, isBarRunning: async () => false, promptLaunch: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), @@ -505,8 +501,7 @@ describe('bar install subcommand', () => { downloadAndExtract: fakeExtract(appsDir), verifyCompat: async () => ({ compatible: false, reason: 'no-bar-api' }), readAppBundleVersion: (_appPath: string) => FAKE_VERSION, - clearQuarantine: async () => true, - isBarRunning: async () => false, + isBarRunning: async () => false, promptLaunch: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, @@ -521,14 +516,12 @@ describe('bar install subcommand', () => { const appsDir = path.join(tempHome, 'Applications'); const { handleBarInstall } = await loadInstallSubcommand(); - // Inject clearQuarantine returning false so the fallback xattr guidance is - // always printed regardless of host platform (/usr/bin/xattr availability). + // Gatekeeper quarantine is preserved (not cleared) — the note is always printed. await handleBarInstall([], { fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }), downloadAndExtract: fakeExtract(appsDir), verifyCompat: async () => ({ compatible: true, reason: 'ok' }), readAppBundleVersion: (_appPath: string) => FAKE_VERSION, - clearQuarantine: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, }); @@ -597,7 +590,6 @@ describe('bar install: redirect-following download (#8)', () => { downloadAndExtract: redirectFollowingExtract, verifyCompat: async () => ({ compatible: true, reason: 'ok' }), readAppBundleVersion: (_appPath: string) => FAKE_VERSION, - clearQuarantine: async () => true, isBarRunning: async () => false, promptLaunch: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), @@ -769,7 +761,6 @@ describe('bar install: compat capability handshake', () => { return { compatible: true, reason: 'ok' }; }, readAppBundleVersion: (_appPath: string) => '1.4.0', - clearQuarantine: async () => true, isBarRunning: async () => false, promptLaunch: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), @@ -790,7 +781,6 @@ describe('bar install: compat capability handshake', () => { downloadAndExtract: fakeExtract(appsDir), verifyCompat: async () => ({ compatible: true, reason: 'ok' }), readAppBundleVersion: (_appPath: string) => '1.4.0', - clearQuarantine: async () => true, isBarRunning: async () => false, promptLaunch: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), @@ -812,7 +802,6 @@ describe('bar install: compat capability handshake', () => { downloadAndExtract: fakeExtract(appsDir), verifyCompat: async () => ({ compatible: false, reason: 'no-bar-api' }), readAppBundleVersion: (_appPath: string) => '1.4.0', - clearQuarantine: async () => true, isBarRunning: async () => false, promptLaunch: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), @@ -835,7 +824,6 @@ describe('bar install: compat capability handshake', () => { downloadAndExtract: fakeExtract(appsDir), verifyCompat: async () => ({ compatible: false, reason: 'unreachable' }), readAppBundleVersion: (_appPath: string) => '1.4.0', - clearQuarantine: async () => true, isBarRunning: async () => false, promptLaunch: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), @@ -860,8 +848,7 @@ describe('bar install: compat capability handshake', () => { throw new Error('network explosion'); }, readAppBundleVersion: (_appPath: string) => '1.4.0', - clearQuarantine: async () => true, - isBarRunning: async () => false, + isBarRunning: async () => false, promptLaunch: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, @@ -892,7 +879,6 @@ describe('bar install: post-extract app-exists assertion (#12)', () => { }, verifyCompat: async () => ({ compatible: true, reason: 'ok' }), readAppBundleVersion: (_appPath: string) => '1.0.0', - clearQuarantine: async () => true, isBarRunning: async () => false, promptLaunch: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), @@ -978,7 +964,6 @@ describe('bar install: Info.plist version extraction regression tests', () => { verifyCompat: async () => ({ compatible: true, reason: 'ok' }), // readAppBundleVersion returns the real version from Info.plist readAppBundleVersion: (_appPath: string) => '1.4.0', - clearQuarantine: async () => true, isBarRunning: async () => false, promptLaunch: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), @@ -1003,7 +988,6 @@ describe('bar install: Info.plist version extraction regression tests', () => { downloadAndExtract: fakeExtract(appsDir), verifyCompat: async () => ({ compatible: true, reason: 'ok' }), readAppBundleVersion: (_appPath: string) => '1.4.0', - clearQuarantine: async () => true, isBarRunning: async () => false, promptLaunch: async () => false, getCcsDir: () => ccsDir, @@ -1030,7 +1014,6 @@ describe('bar install: Info.plist version extraction regression tests', () => { verifyCompat: async () => ({ compatible: true, reason: 'ok' }), // Unreadable Info.plist — returns null readAppBundleVersion: (_appPath: string) => null, - clearQuarantine: async () => true, isBarRunning: async () => false, promptLaunch: async () => false, getCcsDir: () => ccsDir, @@ -1109,7 +1092,6 @@ describe('bar install: Info.plist version extraction regression tests', () => { }, verifyCompat: async () => ({ compatible: true, reason: 'ok' }), // readAppBundleVersion intentionally omitted → uses production default - clearQuarantine: async () => true, isBarRunning: async () => false, promptLaunch: async () => false, getCcsDir: () => ccsDir, @@ -1147,7 +1129,6 @@ describe('bar install: Info.plist version extraction regression tests', () => { }, verifyCompat: async () => ({ compatible: true, reason: 'ok' }), // readAppBundleVersion omitted → uses production default - clearQuarantine: async () => true, isBarRunning: async () => false, promptLaunch: async () => false, getCcsDir: () => ccsDir, @@ -1487,7 +1468,6 @@ describe('bar install: zip-slip guard (fix #14)', () => { downloadAndExtract: safeExtract, verifyCompat: async () => ({ compatible: true, reason: 'ok' }), readAppBundleVersion: (_appPath: string) => FAKE_VERSION, - clearQuarantine: async () => true, isBarRunning: async () => false, promptLaunch: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), @@ -1531,7 +1511,6 @@ describe('bar install: stale version-pin removal on null plist read (Fix 1)', () verifyCompat: async () => ({ compatible: true, reason: 'ok' }), // Simulate unreadable Info.plist readAppBundleVersion: (_appPath: string) => null, - clearQuarantine: async () => true, isBarRunning: async () => false, promptLaunch: async () => false, getCcsDir: () => ccsDir, @@ -2028,7 +2007,6 @@ describe('bar install: already-installed detection (GH-1504)', () => { if (fs.existsSync(appPath)) return '1.0.0'; return null; }, - clearQuarantine: async () => true, launchBar: async () => { calls.push('launch'); }, @@ -2056,7 +2034,6 @@ describe('bar install: already-installed detection (GH-1504)', () => { }, verifyCompat: async () => ({ compatible: true, reason: 'ok' }), readAppBundleVersion: () => null, - clearQuarantine: async () => true, launchBar: async () => { calls.push('launch'); }, @@ -2082,7 +2059,6 @@ describe('bar install: already-installed detection (GH-1504)', () => { }, verifyCompat: async () => ({ compatible: true, reason: 'ok' }), readAppBundleVersion: () => '2.0.0', - clearQuarantine: async () => true, launchBar: async () => { calls.push('launch'); }, @@ -2110,9 +2086,8 @@ describe('bar install: quarantine handling (GH-1504)', () => { }; } - it('calls clearQuarantine with the correct app path after successful extraction', async () => { + it('does not clear quarantine automatically after successful extraction', async () => { const appsDir = path.join(tempHome, 'Applications'); - const quarantineCalls: string[] = []; const { handleBarInstall } = await loadInstallSubcommand(); @@ -2121,10 +2096,6 @@ describe('bar install: quarantine handling (GH-1504)', () => { downloadAndExtract: fakeExtract(appsDir), verifyCompat: async () => ({ compatible: true, reason: 'ok' }), readAppBundleVersion: () => '1.0.0', - clearQuarantine: async (appPath: string) => { - quarantineCalls.push(appPath); - return true; - }, launchBar: async () => { /* noop */ }, @@ -2133,59 +2104,15 @@ describe('bar install: quarantine handling (GH-1504)', () => { getAppsDir: () => appsDir, }); - expect(quarantineCalls).toHaveLength(1); - expect(quarantineCalls[0]).toMatch(/CCS Bar\.app/); - expect(quarantineCalls[0]).toBe(path.join(appsDir, 'CCS Bar.app')); - const allOutput = consoleOutput.join('\n'); - expect(allOutput).toMatch(/\[OK\].*[Cc]leared.*[Qq]uarantine/); + expect(allOutput).toMatch(/Gatekeeper note/i); + expect(allOutput).toMatch(/right-click.*Open/i); + expect(allOutput).not.toMatch(/Cleared Gatekeeper quarantine/i); + expect(allOutput).not.toMatch(/xattr.*quarantine/i); }); - it('quarantine failure is non-fatal: falls back to printed xattr hint; launch handoff skipped', async () => { - // Launch Retry finding: when clearQuarantine fails, the entire launch handoff must be - // skipped (prompt and launchBar must NOT be called) and the follow-up hint must be printed. + it('install exits early when extraction does not produce the expected app bundle', async () => { const appsDir = path.join(tempHome, 'Applications'); - let launchCalled = false; - let promptCalled = false; - - const { handleBarInstall } = await loadInstallSubcommand(); - - await handleBarInstall([], { - fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }), - downloadAndExtract: fakeExtract(appsDir), - verifyCompat: async () => ({ compatible: true, reason: 'ok' }), - readAppBundleVersion: () => '1.0.0', - clearQuarantine: async () => false, - launchBar: async () => { - launchCalled = true; - }, - promptLaunch: async () => { - promptCalled = true; - return false; - }, - getCcsDir: () => path.join(tempHome, '.ccs'), - getAppsDir: () => appsDir, - }); - - // Install still reports success - const allOutput = consoleOutput.join('\n'); - expect(allOutput).toMatch(/\[OK\].*CCS Bar/); - expect(allOutput).not.toMatch(/\[X\]/); - - // Fallback xattr hint printed - expect(allOutput).toMatch(/xattr.*quarantine/i); - - // Launch Retry finding: prompt and launch must both be skipped - expect(promptCalled).toBe(false); - expect(launchCalled).toBe(false); - - // Follow-up hint must guide the user to run `ccs bar` after manual clear - expect(allOutput).toMatch(/After clearing quarantine.*ccs bar/i); - }); - - it('clearQuarantine is NOT called when extraction fails (app path absent)', async () => { - const appsDir = path.join(tempHome, 'Applications'); - const quarantineCalls: string[] = []; const { handleBarInstall } = await loadInstallSubcommand(); @@ -2197,10 +2124,6 @@ describe('bar install: quarantine handling (GH-1504)', () => { }, verifyCompat: async () => ({ compatible: true, reason: 'ok' }), readAppBundleVersion: () => '1.0.0', - clearQuarantine: async (appPath: string) => { - quarantineCalls.push(appPath); - return true; - }, launchBar: async () => { /* noop */ }, @@ -2210,17 +2133,16 @@ describe('bar install: quarantine handling (GH-1504)', () => { }); // Should have returned early due to missing app - expect(quarantineCalls).toHaveLength(0); const allOutput = consoleOutput.join('\n'); expect(allOutput).toMatch(/\[X\]/); }); }); // --------------------------------------------------------------------------- -// Launch Retry finding — quarantine failure blocks entire launch handoff +// Gatekeeper preservation — quarantine is not cleared before launch handoff // --------------------------------------------------------------------------- -describe('bar install: launch retry finding — quarantine failure skips launch handoff', () => { +describe('bar install: Gatekeeper quarantine preservation', () => { const FAKE_DOWNLOAD_URL = 'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip'; @@ -2243,7 +2165,7 @@ describe('bar install: launch retry finding — quarantine failure skips launch }; } - it('clearQuarantine false: promptLaunch is NOT called', async () => { + it('promptLaunch is still called after install (quarantine preserved, not cleared)', async () => { const appsDir = path.join(tempHome, 'Applications'); let promptCalled = false; @@ -2251,66 +2173,6 @@ describe('bar install: launch retry finding — quarantine failure skips launch await handleBarInstall([], { ...baseDeps(appsDir), - clearQuarantine: async () => false, - launchBar: async () => { - /* noop */ - }, - promptLaunch: async () => { - promptCalled = true; - return true; - }, - }); - - expect(promptCalled).toBe(false); - }); - - it('clearQuarantine false + --launch: launchBar is NOT called (explicit flag cannot override failed clear)', async () => { - // --launch does not bypass a failed quarantine clear — Gatekeeper would still block. - const appsDir = path.join(tempHome, 'Applications'); - let launchCalled = false; - - const { handleBarInstall } = await loadInstallSubcommand(); - - await handleBarInstall(['--launch'], { - ...baseDeps(appsDir), - clearQuarantine: async () => false, - launchBar: async () => { - launchCalled = true; - }, - promptLaunch: async () => false, - }); - - expect(launchCalled).toBe(false); - }); - - it('clearQuarantine false: follow-up hint printed directing user to run `ccs bar` after manual clear', async () => { - const appsDir = path.join(tempHome, 'Applications'); - - const { handleBarInstall } = await loadInstallSubcommand(); - - await handleBarInstall([], { - ...baseDeps(appsDir), - clearQuarantine: async () => false, - launchBar: async () => { - /* noop */ - }, - promptLaunch: async () => false, - }); - - const allOutput = consoleOutput.join('\n'); - expect(allOutput).toMatch(/After clearing quarantine.*ccs bar/i); - }); - - it('clearQuarantine true: launch handoff proceeds normally (prompt called)', async () => { - // Successful clear must not change existing behavior — prompt is still called. - const appsDir = path.join(tempHome, 'Applications'); - let promptCalled = false; - - const { handleBarInstall } = await loadInstallSubcommand(); - - await handleBarInstall([], { - ...baseDeps(appsDir), - clearQuarantine: async () => true, launchBar: async () => { /* noop */ }, @@ -2323,7 +2185,7 @@ describe('bar install: launch retry finding — quarantine failure skips launch expect(promptCalled).toBe(true); }); - it('clearQuarantine true + --launch: launchBar invoked as before', async () => { + it('--launch invokes launchBar', async () => { const appsDir = path.join(tempHome, 'Applications'); let launchCalled = false; @@ -2331,7 +2193,6 @@ describe('bar install: launch retry finding — quarantine failure skips launch await handleBarInstall(['--launch'], { ...baseDeps(appsDir), - clearQuarantine: async () => true, launchBar: async () => { launchCalled = true; }, @@ -2362,7 +2223,6 @@ describe('bar install: launch flags and prompt (GH-1504)', () => { downloadAndExtract: fakeExtract(appsDir), verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }), readAppBundleVersion: () => '1.0.0', - clearQuarantine: async () => true, // isBarRunning injected as false so tests are deterministic regardless of // whether the real CCS Bar process is running on the test machine. isBarRunning: async () => false, @@ -2472,52 +2332,6 @@ describe('bar install: launch flags and prompt (GH-1504)', () => { }); }); -// --------------------------------------------------------------------------- -// Finding 1 — PATH hijack: xattr must use absolute path /usr/bin/xattr -// (The default impl is internal; tested via injectable clearQuarantine seam. -// The production behavior is captured by the contract test below.) -// --------------------------------------------------------------------------- - -describe('bar install: xattr absolute path contract (Finding 1)', () => { - const FAKE_DOWNLOAD_URL = - 'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip'; - - function fakeExtract(appsDir: string) { - return async (_url: string, dest: string) => { - fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true }); - }; - } - - it('clearQuarantine injectable dep receives the correct app path (contract test)', async () => { - const appsDir = path.join(tempHome, 'Applications'); - const quarantineArgs: string[] = []; - - const { handleBarInstall } = await loadInstallSubcommand(); - - await handleBarInstall([], { - fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }), - downloadAndExtract: fakeExtract(appsDir), - verifyCompat: async () => ({ compatible: true, reason: 'ok' }), - readAppBundleVersion: () => '1.0.0', - clearQuarantine: async (appPath: string) => { - quarantineArgs.push(appPath); - return true; - }, - launchBar: async () => { - /* noop */ - }, - promptLaunch: async () => false, - isBarRunning: async () => false, - getCcsDir: () => path.join(tempHome, '.ccs'), - getAppsDir: () => appsDir, - }); - - // Production invokes clearQuarantine with the full app path — not a bare binary name. - expect(quarantineArgs).toHaveLength(1); - expect(quarantineArgs[0]).toBe(path.join(appsDir, 'CCS Bar.app')); - }); -}); - // --------------------------------------------------------------------------- // Finding 2 — TTY gate: prompt gated on stdin.isTTY, not stdout.isTTY // --------------------------------------------------------------------------- @@ -2538,7 +2352,6 @@ describe('bar install: stdin-TTY gate for launch prompt (Finding 2)', () => { downloadAndExtract: fakeExtract(appsDir), verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }), readAppBundleVersion: () => '1.0.0', - clearQuarantine: async () => true, isBarRunning: async () => false, getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, @@ -2627,7 +2440,6 @@ describe('bar install: already-running detection (Finding 3)', () => { downloadAndExtract: fakeExtract(appsDir), verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }), readAppBundleVersion: () => '1.0.0', - clearQuarantine: async () => true, getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, ...extra, @@ -2792,7 +2604,6 @@ describe('bar install: whitespace-only CFBundleShortVersionString yields null (F }, verifyCompat: async () => ({ compatible: true, reason: 'ok' }), // readAppBundleVersion intentionally omitted → uses production default - clearQuarantine: async () => true, isBarRunning: async () => false, promptLaunch: async () => false, getCcsDir: () => ccsDir, @@ -2825,7 +2636,6 @@ describe('bar install: stage-then-swap safety (Data Loss finding)', () => { fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }), verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }), readAppBundleVersion: () => '2.0.0', - clearQuarantine: async () => true, isBarRunning: async () => false, launchBar: async () => { /* noop */ @@ -3024,7 +2834,6 @@ describe('bar install: silent-decline fix — hint on user decline (review findi }, verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }), readAppBundleVersion: () => '1.0.0', - clearQuarantine: async () => true, isBarRunning: async () => false, launchBar: async () => { /* noop */