From fb9509d9be08187077baf44d2141aadc2518e551 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:57:33 -0400 Subject: [PATCH] fix: make CCS Bar launch descriptor use private shim Use a private safe ccs.js shim for native CCS Bar launch descriptors so symlinked Bun installs do not fall back to an app environment without node on PATH. --- eslint-rules/throw-error-baseline.json | 26 +++---- src/commands/bar/bar-paths.ts | 2 +- src/commands/bar/install-subcommand.ts | 11 +-- src/commands/bar/launch-descriptor.ts | 68 +++++++++++++++++++ src/commands/bar/launch-subcommand.ts | 17 +---- tests/unit/commands/bar-command.test.ts | 3 + .../bar-lifecycle-subcommands.test.ts | 59 ++++++++++++++++ 7 files changed, 150 insertions(+), 36 deletions(-) create mode 100644 src/commands/bar/launch-descriptor.ts diff --git a/eslint-rules/throw-error-baseline.json b/eslint-rules/throw-error-baseline.json index 9cbbe875..0536a9d7 100644 --- a/eslint-rules/throw-error-baseline.json +++ b/eslint-rules/throw-error-baseline.json @@ -99,19 +99,19 @@ "src/codex-auth/commands/import-default-command.ts:134", "src/codex-auth/commands/import-default-command.ts:146", "src/codex-auth/commands/import-default-command.ts:167", - "src/commands/bar/install-subcommand.ts:137", - "src/commands/bar/install-subcommand.ts:141", - "src/commands/bar/install-subcommand.ts:149", - "src/commands/bar/install-subcommand.ts:172", - "src/commands/bar/install-subcommand.ts:181", - "src/commands/bar/install-subcommand.ts:187", - "src/commands/bar/install-subcommand.ts:228", - "src/commands/bar/install-subcommand.ts:249", - "src/commands/bar/install-subcommand.ts:259", - "src/commands/bar/install-subcommand.ts:271", - "src/commands/bar/install-subcommand.ts:289", - "src/commands/bar/install-subcommand.ts:316", - "src/commands/bar/launch-subcommand.ts:212", + "src/commands/bar/install-subcommand.ts:138", + "src/commands/bar/install-subcommand.ts:142", + "src/commands/bar/install-subcommand.ts:150", + "src/commands/bar/install-subcommand.ts:173", + "src/commands/bar/install-subcommand.ts:182", + "src/commands/bar/install-subcommand.ts:188", + "src/commands/bar/install-subcommand.ts:229", + "src/commands/bar/install-subcommand.ts:250", + "src/commands/bar/install-subcommand.ts:260", + "src/commands/bar/install-subcommand.ts:272", + "src/commands/bar/install-subcommand.ts:290", + "src/commands/bar/install-subcommand.ts:317", + "src/commands/bar/launch-subcommand.ts:207", "src/commands/config-channels-command.ts:431", "src/commands/config-channels-command.ts:436", "src/commands/config-channels-command.ts:447", diff --git a/src/commands/bar/bar-paths.ts b/src/commands/bar/bar-paths.ts index c8edfe93..78780733 100644 --- a/src/commands/bar/bar-paths.ts +++ b/src/commands/bar/bar-paths.ts @@ -41,7 +41,7 @@ export interface LaunchJson { schema: typeof LAUNCH_JSON_SCHEMA; /** Absolute path to the node/bun binary (process.execPath). */ runtime: string; - /** Absolute CCS entry point + subcommand args: [process.argv[1], 'bar', 'serve']. */ + /** Absolute private CCS launcher shim + subcommand args: [ccs.js, 'bar', 'serve']. */ args: string[]; /** os.homedir() — cwd for the spawned server. */ home: string; diff --git a/src/commands/bar/install-subcommand.ts b/src/commands/bar/install-subcommand.ts index d0fd2903..fc435278 100644 --- a/src/commands/bar/install-subcommand.ts +++ b/src/commands/bar/install-subcommand.ts @@ -20,8 +20,9 @@ import * as os from 'os'; import * as path from 'path'; import { getCcsDir } from '../../config/config-loader-facade'; import { hasAnyFlag } from '../arg-extractor'; -import { getLaunchJsonPath, LAUNCH_JSON_SCHEMA } from './bar-paths'; +import { getLaunchJsonPath } from './bar-paths'; import type { LaunchJson } from './bar-paths'; +import { createBarLaunchDescriptor } from './launch-descriptor'; // --------------------------------------------------------------------------- // Constants @@ -671,13 +672,7 @@ export async function handleBarInstall( // Non-fatal — install has already succeeded at this point. try { const launchJsonPath = getLaunchJsonPath(ccsDir); - const launchDescriptor: LaunchJson = { - schema: LAUNCH_JSON_SCHEMA, - runtime: process.execPath, - args: [process.argv[1], 'bar', 'serve'], - home: os.homedir(), - ...(process.env.CCS_HOME ? { ccsHome: process.env.CCS_HOME } : {}), - }; + const launchDescriptor = createBarLaunchDescriptor(); writeLaunchDescriptor(launchJsonPath, launchDescriptor); } catch (err) { const msg = err instanceof Error ? err.message : String(err); diff --git a/src/commands/bar/launch-descriptor.ts b/src/commands/bar/launch-descriptor.ts new file mode 100644 index 00000000..61ededb0 --- /dev/null +++ b/src/commands/bar/launch-descriptor.ts @@ -0,0 +1,68 @@ +/** + * Safe launch descriptor builder for the native CCS Bar app. + * + * The Swift app intentionally distrusts `~/.ccs/bar/launch.json`; it only + * accepts a regular, non-group-writable/non-world-writable `ccs.js` entrypoint. + * Bun global installs expose `~/.bun/bin/ccs` as a symlink and the target file + * can be group/world writable, so the descriptor points at a private shim + * instead of the package-manager entrypoint. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { ConfigError } from '../../errors/error-types'; +import { LAUNCH_JSON_SCHEMA } from './bar-paths'; +import type { LaunchJson } from './bar-paths'; + +const SHIM_MODE = 0o700; + +export interface LaunchDescriptorOptions { + entrypointPath?: string; + runtime?: string; + home?: string; + ccsHome?: string; +} + +export function getLaunchShimPath(home: string = os.homedir()): string { + return path.join(home, 'Library', 'Application Support', 'CCS Bar', 'launcher', 'ccs.js'); +} + +function resolveEntrypoint(entrypointPath?: string): string { + const candidate = entrypointPath ?? process.argv[1]; + if (!candidate) { + throw new ConfigError('Unable to resolve the current CCS entrypoint for CCS Bar launch.json.'); + } + return fs.realpathSync(candidate); +} + +export function writeLaunchShim(home: string, entrypointPath?: string): string { + const resolvedEntrypoint = resolveEntrypoint(entrypointPath); + const shimPath = getLaunchShimPath(home); + const shimDir = path.dirname(shimPath); + const contents = [ + '#!/usr/bin/env node', + `require(${JSON.stringify(resolvedEntrypoint)});`, + '', + ].join('\n'); + + fs.mkdirSync(shimDir, { recursive: true, mode: SHIM_MODE }); + fs.writeFileSync(shimPath, contents, { mode: SHIM_MODE }); + fs.chmodSync(shimDir, SHIM_MODE); + fs.chmodSync(shimPath, SHIM_MODE); + + return shimPath; +} + +export function createBarLaunchDescriptor(options: LaunchDescriptorOptions = {}): LaunchJson { + const home = options.home ?? os.homedir(); + const entrypoint = writeLaunchShim(home, options.entrypointPath); + const ccsHome = options.ccsHome ?? process.env.CCS_HOME; + return { + schema: LAUNCH_JSON_SCHEMA, + runtime: options.runtime ?? process.execPath, + args: [entrypoint, 'bar', 'serve'], + home, + ...(ccsHome ? { ccsHome } : {}), + }; +} diff --git a/src/commands/bar/launch-subcommand.ts b/src/commands/bar/launch-subcommand.ts index 132c7563..4b22a182 100644 --- a/src/commands/bar/launch-subcommand.ts +++ b/src/commands/bar/launch-subcommand.ts @@ -22,14 +22,9 @@ import * as path from 'path'; import type { ChildProcess } from 'child_process'; import { getCcsDir } from '../../config/config-loader-facade'; import { BAR_AUTH_TOKEN_HEADER, getOrCreateBarAuthToken } from '../../utils/bar-auth-token'; -import { - getBarDir, - getBarJsonPath, - getLaunchJsonPath, - getServeLogPath, - LAUNCH_JSON_SCHEMA, -} from './bar-paths'; +import { getBarDir, getBarJsonPath, getLaunchJsonPath, getServeLogPath } from './bar-paths'; import type { LaunchJson } from './bar-paths'; +import { createBarLaunchDescriptor } from './launch-descriptor'; import { defaultFindRunningServer as _defaultFindRunningServer, resolveBarPort as _resolveBarPort, @@ -305,14 +300,8 @@ export async function handleBarLaunch( } // 2b. Write/refresh launch.json so the Swift app can self-start next time. - const launchDescriptor: LaunchJson = { - schema: LAUNCH_JSON_SCHEMA, - runtime: process.execPath, - args: [process.argv[1], 'bar', 'serve'], - home: os.homedir(), - ...(process.env.CCS_HOME ? { ccsHome: process.env.CCS_HOME } : {}), - }; try { + const launchDescriptor = createBarLaunchDescriptor(); writeLaunchDescriptor(launchJsonPath, launchDescriptor); } catch (err) { // Non-fatal — the Swift app falls back to resolving `ccs` via PATH. diff --git a/tests/unit/commands/bar-command.test.ts b/tests/unit/commands/bar-command.test.ts index de542930..f94de371 100644 --- a/tests/unit/commands/bar-command.test.ts +++ b/tests/unit/commands/bar-command.test.ts @@ -99,6 +99,7 @@ beforeEach(() => { afterEach(() => { restoreConsole(); mock.restore(); + process.exitCode = 0; if (originalCcsHome === undefined) { delete process.env.CCS_HOME; @@ -197,6 +198,8 @@ describe('bar command dispatcher (index.ts)', () => { const handleBarCommand = await loadHandleBarCommand(); // Should print help or error but not crash await expect(handleBarCommand(['unknown-subcommand'])).resolves.toBeUndefined(); + expect(process.exitCode).toBe(1); + process.exitCode = 0; }); it('dispatches `ccs bar --help` to help subcommand and does not launch', async () => { diff --git a/tests/unit/commands/bar-lifecycle-subcommands.test.ts b/tests/unit/commands/bar-lifecycle-subcommands.test.ts index 89ac7747..d7a4d285 100644 --- a/tests/unit/commands/bar-lifecycle-subcommands.test.ts +++ b/tests/unit/commands/bar-lifecycle-subcommands.test.ts @@ -98,6 +98,11 @@ async function loadInstallSubcommand() { }; } +async function loadLaunchDescriptor() { + moduleSeq++; + return import(`../../../src/commands/bar/launch-descriptor?test=${Date.now()}-${moduleSeq}`); +} + // --------------------------------------------------------------------------- // Setup / teardown // --------------------------------------------------------------------------- @@ -718,6 +723,8 @@ describe('launch: detached-spawn model', () => { }; expect(desc.schema).toBe(1); expect(desc.runtime).toBe(process.execPath); + expect(path.basename(desc.args[0])).toBe('ccs.js'); + expect(desc.args[0]).not.toContain(`${path.sep}.ccs${path.sep}`); expect(desc.args).toContain('bar'); expect(desc.args).toContain('serve'); expect(desc.home).toBe(os.homedir()); @@ -754,6 +761,56 @@ describe('launch: detached-spawn model', () => { }); }); +// --------------------------------------------------------------------------- +// launch-descriptor: safe shim for native app self-start +// --------------------------------------------------------------------------- + +describe('launch descriptor shim', () => { + it('creates a private ccs.js shim for symlinked Bun-style entrypoints', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const packageDist = path.join( + tempHome, + '.bun', + 'install', + 'global', + 'node_modules', + '@kaitranntt', + 'ccs', + 'dist' + ); + const binDir = path.join(tempHome, '.bun', 'bin'); + const realEntrypoint = path.join(packageDist, 'ccs.js'); + const symlinkedEntrypoint = path.join(binDir, 'ccs'); + + fs.mkdirSync(packageDist, { recursive: true }); + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync(realEntrypoint, 'console.log("ccs");\n', { mode: 0o777 }); + fs.symlinkSync(realEntrypoint, symlinkedEntrypoint); + + const { createBarLaunchDescriptor, getLaunchShimPath } = await loadLaunchDescriptor(); + const descriptor = createBarLaunchDescriptor({ + entrypointPath: symlinkedEntrypoint, + runtime: '/usr/local/bin/node', + home: tempHome, + ccsHome: ccsDir, + }); + + const shimPath = getLaunchShimPath(tempHome); + expect(descriptor.runtime).toBe('/usr/local/bin/node'); + expect(descriptor.args).toEqual([shimPath, 'bar', 'serve']); + expect(path.basename(descriptor.args[0])).toBe('ccs.js'); + expect(descriptor.args[0]).not.toContain(`${path.sep}.ccs${path.sep}`); + expect(fs.lstatSync(descriptor.args[0]).isSymbolicLink()).toBe(false); + + const mode = fs.statSync(descriptor.args[0]).mode & 0o777; + expect((mode & 0o022) === 0).toBe(true); + const resolvedEntrypoint = fs.realpathSync(realEntrypoint); + expect(fs.readFileSync(descriptor.args[0], 'utf8')).toContain( + `require(${JSON.stringify(resolvedEntrypoint)});` + ); + }); +}); + // --------------------------------------------------------------------------- // install-subcommand: writeLaunchDescriptor after successful install // --------------------------------------------------------------------------- @@ -800,6 +857,8 @@ describe('install: writeLaunchDescriptor called after successful install', () => }; expect(desc.schema).toBe(1); expect(desc.runtime).toBe(process.execPath); + expect(path.basename(desc.args[0])).toBe('ccs.js'); + expect(desc.args[0]).not.toContain(`${path.sep}.ccs${path.sep}`); expect(desc.args).toContain('bar'); expect(desc.args).toContain('serve'); expect(desc.home).toBe(os.homedir());