From dd875c1f479131befc04c2f7f76fb428dd4ef123 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Mon, 27 Jul 2026 15:01:11 +1000 Subject: [PATCH 1/6] Isolate development and production app channels --- build.sh | 173 +++++++-- dev-install.sh | 78 +--- docs/development.md | 38 +- run.sh | 256 +++++++++---- scripts/install-cli-command.sh | 29 +- scripts/install-macos-finder-quick-action.sh | 101 +---- scripts/validate-channel-isolation.mjs | 370 +++++++++++++++++++ scripts/validate-finder-quick-action.mjs | 26 +- scripts/validate-launch-runners.mjs | 148 +++----- scripts/validate-tauri-config.mjs | 14 + src-tauri/src/http_server.rs | 144 ++++++-- src-tauri/src/lib.rs | 2 + src-tauri/tauri.dev.conf.json | 7 + src/tauri.test.ts | 5 +- src/tauri.ts | 2 +- 15 files changed, 977 insertions(+), 416 deletions(-) create mode 100644 scripts/validate-channel-isolation.mjs create mode 100644 src-tauri/tauri.dev.conf.json diff --git a/build.sh b/build.sh index 77aa2a4..e36918b 100755 --- a/build.sh +++ b/build.sh @@ -19,40 +19,155 @@ if ! command -v cargo >/dev/null 2>&1; then exit 1 fi -APP_BUNDLE="$ROOT_DIR/src-tauri/target/release/bundle/macos/ide.app" -INSTALLED_APP="${IDE_INSTALLED_APP_PATH:-/Applications/ide.app}" -INSTALLED_APP_UPDATED=0 +DEV_TAURI_CONFIG="$ROOT_DIR/src-tauri/tauri.dev.conf.json" +DEV_APP_BUNDLE="$ROOT_DIR/src-tauri/target/release/bundle/macos/ide-dev.app" +DEV_INSTALLED_APP_INPUT="${IDE_DEV_INSTALLED_APP_PATH:-$HOME/Applications/ide-dev.app}" +DEV_BUNDLE_IDENTIFIER="com.gordonbeeming.ide.dev" -npm run tauri -- build --bundles app +resolve_dev_install_parent() { + local parent="$1" + local suffix="" -if [[ -d "$(dirname "$INSTALLED_APP")" && -w "$(dirname "$INSTALLED_APP")" ]]; then - ditto "$APP_BUNDLE" "$INSTALLED_APP" - touch "$INSTALLED_APP" + while [[ ! -d "$parent" ]]; do + if [[ -e "$parent" || -L "$parent" ]]; then + echo "Development app parent contains a non-directory component: $parent" >&2 + return 1 + fi + + local component next_parent + component="$(basename "$parent")" + next_parent="$(dirname "$parent")" + if [[ "$component" == "." || "$component" == ".." || "$next_parent" == "$parent" ]]; then + echo "Development app parent could not be resolved safely: $1" >&2 + return 1 + fi + suffix="/$component$suffix" + parent="$next_parent" + done + + local resolved_parent + resolved_parent="$(cd -P "$parent" && pwd)" + printf '%s%s\n' "$resolved_parent" "$suffix" +} + +resolve_dev_install_path() { + local app_path="$1" + + if [[ "$app_path" != /* || "$(basename "$app_path")" != "ide-dev.app" ]]; then + echo "Development app install path must be an absolute ide-dev.app bundle: $app_path" >&2 + return 1 + fi + + case "/${app_path#/}/" in + */./* | */../* | *//* ) + echo "Development app install path must not contain dot or empty segments: $app_path" >&2 + return 1 + ;; + esac + + if [[ -L "$app_path" ]]; then + echo "Development app install path must not be a symbolic link: $app_path" >&2 + return 1 + fi + + local requested_parent resolved_parent + requested_parent="$(dirname "$app_path")" + resolved_parent="$(resolve_dev_install_parent "$requested_parent")" || return 1 + + if [[ "$resolved_parent" != "$requested_parent" ]]; then + echo "Development app parent must not resolve through a symbolic-link alias: $requested_parent -> $resolved_parent" >&2 + return 1 + fi + + case "$resolved_parent" in + / | /Applications | /Applications/*) + echo "Refusing to install a development app under $resolved_parent" >&2 + return 1 + ;; + esac + + printf '%s/ide-dev.app\n' "$resolved_parent" +} + +resolve_dev_signing_identity() { + if [[ -n "${IDE_DEV_SIGNING_IDENTITY:-}" ]]; then + printf '%s\n' "$IDE_DEV_SIGNING_IDENTITY" + return 0 + fi + + if command -v security >/dev/null 2>&1; then + local identity + identity="$(security find-identity -v -p codesigning 2>/dev/null | sed -nE 's/.*"([^\"]*Apple Development:[^\"]*)".*/\1/p' | sed -n '1p')" + if [[ -n "$identity" ]]; then + printf '%s\n' "$identity" + return 0 + fi + fi + + printf '%s\n' "-" +} + +verify_dev_app_signature() { + local app_path="$1" + if ! codesign --verify --deep --strict "$app_path"; then + echo "Development app signature verification failed: $app_path" >&2 + exit 1 + fi +} + +sign_dev_app() { + local app_path="$1" + local signing_identity + + if ! command -v codesign >/dev/null 2>&1; then + echo "codesign is required to package the macOS development app." >&2 + exit 1 + fi + + signing_identity="$(resolve_dev_signing_identity)" + echo "Signing development app with: $signing_identity" + + codesign --force --deep --sign "$signing_identity" "$app_path" + codesign --force --sign "$signing_identity" --identifier "$DEV_BUNDLE_IDENTIFIER" "$app_path" + verify_dev_app_signature "$app_path" +} + +install_dev_app() { + local source_app="$1" + local installed_app="$2" + + mkdir -p "$(dirname "$installed_app")" + rm -rf "$installed_app" + ditto "$source_app" "$installed_app" + touch "$installed_app" + verify_dev_app_signature "$installed_app" if command -v mdimport >/dev/null 2>&1; then - mdimport "$INSTALLED_APP" >/dev/null 2>&1 || true - fi - INSTALLED_APP_UPDATED=1 -else - echo - echo "WARNING: /Applications app was not updated." - echo "Skipped app install because $(dirname "$INSTALLED_APP") is not writable:" - echo " $INSTALLED_APP" - echo "Run ./dev-install.sh to install the rebuilt app with a macOS admin prompt." + mdimport "$installed_app" >/dev/null 2>&1 || true + fi +} + +if [[ ! -f "$DEV_TAURI_CONFIG" ]]; then + echo "Development Tauri config was not found: $DEV_TAURI_CONFIG" >&2 + exit 1 fi -CLI_APP_BUNDLE="$APP_BUNDLE" -if [[ "$INSTALLED_APP_UPDATED" -eq 1 ]]; then - CLI_APP_BUNDLE="$INSTALLED_APP" +DEV_INSTALLED_APP="$(resolve_dev_install_path "$DEV_INSTALLED_APP_INPUT")" + +npm run tauri -- build --bundles app --config "$DEV_TAURI_CONFIG" + +if [[ ! -d "$DEV_APP_BUNDLE" ]]; then + echo "Packaged development app was not found after build:" >&2 + echo " $DEV_APP_BUNDLE" >&2 + exit 1 fi -IDE_CLI_APP_BUNDLE_PATH="$CLI_APP_BUNDLE" "$ROOT_DIR/scripts/install-cli-command.sh" + +sign_dev_app "$DEV_APP_BUNDLE" +install_dev_app "$DEV_APP_BUNDLE" "$DEV_INSTALLED_APP" + +"$ROOT_DIR/scripts/install-cli-command.sh" echo -echo "Packaged app:" -echo " $APP_BUNDLE" -if [[ "$INSTALLED_APP_UPDATED" -eq 1 ]]; then - echo "Installed app:" - echo " $INSTALLED_APP" -else - echo "Installed app:" - echo " not updated" -fi +echo "Packaged development app:" +echo " $DEV_APP_BUNDLE" +echo "Installed development app:" +echo " $DEV_INSTALLED_APP" diff --git a/dev-install.sh b/dev-install.sh index c476663..4b73d07 100755 --- a/dev-install.sh +++ b/dev-install.sh @@ -2,85 +2,11 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -APP_BUNDLE="$ROOT_DIR/src-tauri/target/release/bundle/macos/ide.app" -INSTALLED_APP="${IDE_INSTALLED_APP_PATH:-/Applications/ide.app}" if [[ "$(uname -s)" != "Darwin" ]]; then - echo "dev-install.sh currently installs the packaged macOS app only." >&2 + echo "dev-install.sh currently installs the packaged macOS development app only." >&2 echo "Use ./build.sh to build the local package on this platform." >&2 exit 1 fi -"$ROOT_DIR/build.sh" - -if [[ ! -d "$APP_BUNDLE" ]]; then - echo "Packaged app was not found after build:" >&2 - echo " $APP_BUNDLE" >&2 - exit 1 -fi - -escape_applescript_string() { - sed 's/\\/\\\\/g; s/"/\\"/g' -} - -shell_quote() { - printf "%q" "$1" -} - -refresh_app_indexes() { - local app_path="$1" - touch "$app_path" || true - if command -v mdimport >/dev/null 2>&1; then - mdimport "$app_path" >/dev/null 2>&1 || true - fi - if [[ -x /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister ]]; then - /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister \ - -f "$app_path" >/dev/null 2>&1 || true - fi -} - -quit_running_app() { - osascript -e 'tell application id "com.gordonbeeming.ide" to quit' >/dev/null 2>&1 || true -} - -copy_app_directly() { - local source_app="$1" - local installed_app="$2" - mkdir -p "$(dirname "$installed_app")" - rm -rf "$installed_app" - ditto "$source_app" "$installed_app" -} - -copy_app_with_admin_prompt() { - local source_app="$1" - local installed_app="$2" - local command escaped_command - command="mkdir -p $(shell_quote "$(dirname "$installed_app")") && rm -rf $(shell_quote "$installed_app") && ditto $(shell_quote "$source_app") $(shell_quote "$installed_app") && touch $(shell_quote "$installed_app")" - escaped_command="$(printf "%s" "$command" | escape_applescript_string)" - - osascript <` opens a repo in the default browser instead of a native window. If no instance is running yet, it starts one in the background, HTTP server up but no window on screen, and gives the target repo its own hidden session. If an instance is already running, it reuses it the same way, so several repos can be browsed at once without any of them showing a window or stealing focus. Closing a visible ide window doesn't stop a background browse session; only quitting the app (⌘Q) does. +`ide browse ` uses the production app and opens a repo in the default browser instead of a native window. If no production instance is running yet, it starts one in the background, HTTP server up but no window on screen, and gives the target repo its own hidden session. If an instance is already running, it reuses it the same way, so several repos can be browsed at once without any of them showing a window or stealing focus. Closing a visible ide window doesn't stop a background browse session; only quitting the app (⌘Q) does. Install the macOS Finder Quick Action: @@ -60,9 +72,9 @@ Install the macOS Finder Quick Action: ./scripts/install-macos-finder-quick-action.sh ``` -The service appears under Finder's Quick Actions menu as `Open in ide`. It hands the selected file or folder to an already-running app through the loopback open-path endpoint when possible, otherwise it starts the local dev app in the background. Launcher logs are written to `~/Library/Logs/ide/finder-open.log`. +The service appears under Finder's Quick Actions menu as `Open in ide`. It hands the selected file or folder to a running production app through the loopback open-path endpoint when possible; otherwise it opens `/Applications/ide.app` with that target. Install the production app through Homebrew first. Launcher logs are written to `~/Library/Logs/ide/finder-open.log`. -`npm run finder:check` validates the generated Quick Action and runner in a temporary directory. It verifies that the service registers for files and folders, emits a valid plist/workflow, and hands targets to `/api/open-path` with the local bearer token before `run-tests.sh` moves on to browser smoke tests. +`npm run finder:check` validates the generated Quick Action and runner in a temporary directory. It verifies that the service registers for files and folders, emits a valid plist/workflow, hands targets to the production `/api/open-path` endpoint with its bearer token, and falls back to `/Applications/ide.app`. Packaged builds declare Tauri `bundle.fileAssociations` for common text, code, web, config, and .NET project files. Tauri emits `RunEvent::Opened` when the OS opens a file with the app; the Rust backend converts those file URLs into launch targets and opens or focuses a workspace window inside the existing app process. The packaged CLI uses the same single-process path instead of `open -n`, so multiple workspace windows group under one Dock icon. @@ -97,13 +109,13 @@ npm run tauri:dev `npm run budget` checks the production `dist/` output after `npm run build`. Current raw-size limits are 600 KB for startup JavaScript, 80 KB for startup CSS, and 90 KB for the lazy editor chunk. These are deliberately above the current app size, but low enough to catch accidental heavy runtime dependencies. -`npm run finder:check` runs the macOS Finder Quick Action installer against temporary service/support directories, lints the generated workflow on macOS, and checks that the runner uses the authenticated loopback open-path handoff. It does not touch the real `~/Library/Services` directory. +`npm run finder:check` runs the macOS Finder Quick Action installer against temporary service/support directories, lints the generated workflow on macOS, and checks that the runner uses the production loopback handoff or opens `/Applications/ide.app`. It does not touch the real `~/Library/Services` directory. -`npm run launch:check` validates the local launch runners. It checks that `run.sh` and the generated Finder runner both use the authenticated loopback open-path handoff before attempting to start or reuse a dev server. +`npm run launch:check` validates the local launch runners. It checks that `run.sh` hands paths to `ide-dev` on its authenticated development loopback endpoint, and that the generated `ide` launcher remains pinned to the Homebrew production app. `npm run menu:check` validates the native menu contract. It checks that every declared Tauri menu item has a Rust route, every expected native event is emitted by Rust, and every emitted app/menu event has a React listener. -`npm run tauri:check` validates the Tauri bundle metadata that affects native daily-driver behavior. It checks the developer-tool category, file association shape, required common editor extensions, text-only MIME types, duplicate extensions, and rejects obvious binary/media/archive associations. +`npm run tauri:check` validates the Tauri bundle metadata that affects native daily-driver behavior. It keeps the production identifier and lowercase product name in place, checks the development overlay's distinct identifier and name, and verifies the production file associations still contain only supported text formats. `npm run smoke` starts Vite on a local ephemeral port, mocks the loopback API, and drives the real app shell through a local Chromium-family browser in light and dark mode. It covers collapsed search controls, command palette execution, workspace filtering, content search, opening a file, clean-save button state, and shell/editor theme alignment for both the empty editor canvas and the loaded CodeMirror canvas. The theme check asserts matching computed colors, expected light/dark luminance, and the actual painted editor center color, then temporarily forces the opposite editor-region theme class to prove the editor background still inherits from the shell. Set `IDE_SMOKE_BROWSER=/path/to/browser` if the script cannot find Chrome, Chromium, or Edge. diff --git a/run.sh b/run.sh index 98f4a26..370e8e9 100755 --- a/run.sh +++ b/run.sh @@ -16,6 +16,10 @@ if ! command -v npm >/dev/null 2>&1 && command -v fnm >/dev/null 2>&1; then fi OPEN_PATH="${1:-}" +DEV_API_PORT="17878" +DEV_VITE_PORT="14717" +DEV_BINARY="$ROOT_DIR/src-tauri/target/debug/ide" +export IDE_LOOPBACK_PORT="$DEV_API_PORT" if [ -n "$OPEN_PATH" ]; then if [ ! -e "$OPEN_PATH" ]; then echo "Open target does not exist: $OPEN_PATH" >&2 @@ -28,9 +32,174 @@ json_escape() { sed 's/\\/\\\\/g; s/"/\\"/g' } -handoff_to_running_app() { +process_command() { + ps -p "$1" -o command= 2>/dev/null || true +} + +process_cwd() { + lsof -a -p "$1" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1 +} + +process_is_checkout_dev_binary() { + local command="$1" + case "$command" in + "$DEV_BINARY" | "$DEV_BINARY "*) return 0 ;; + *) return 1 ;; + esac +} + +process_is_checkout_port_owner() { + local pid="$1" + local port="$2" + local command="$3" + + if [ "$port" = "$DEV_API_PORT" ]; then + process_is_checkout_dev_binary "$command" + return + fi + + if [ "$port" = "$DEV_VITE_PORT" ]; then + local cwd + cwd="$(process_cwd "$pid")" + if [ "$cwd" != "$ROOT_DIR" ]; then + return 1 + fi + case "$command" in + *"$ROOT_DIR/node_modules/"*"vite"*) return 0 ;; + *) return 1 ;; + esac + fi + + return 1 +} + +port_listener_pids() { + lsof -tiTCP:"$1" -sTCP:LISTEN 2>/dev/null || true +} + +checkout_dev_binary_pids() { + local pid command + for pid in $(ps -axo pid=); do + command="$(process_command "$pid")" + if process_is_checkout_dev_binary "$command"; then + printf '%s\n' "$pid" + fi + done +} + +verify_dev_port_ownership() { + local port="$1" + local pids + pids="$(port_listener_pids "$port")" + if [ -z "$pids" ]; then + return 0 + fi + + local pid command + for pid in $pids; do + command="$(process_command "$pid")" + if ! process_is_checkout_port_owner "$pid" "$port" "$command"; then + echo "Port $port is owned by an unrelated process (pid $pid): ${command:-unknown}" >&2 + return 1 + fi + done +} + +clear_dev_port() { + local port="$1" + verify_dev_port_ownership "$port" || return 1 + + local pids + pids="$(port_listener_pids "$port")" + if [ -z "$pids" ]; then + return 0 + fi + + local pid command + for pid in $pids; do + command="$(process_command "$pid")" + echo "Killing stale process on port $port (pid $pid): ${command:-unknown}" + kill "$pid" 2>/dev/null || true + done + + local attempt + for attempt in {1..40}; do + if [ -z "$(port_listener_pids "$port")" ]; then + return 0 + fi + sleep 0.25 + done + + echo "Port $port still in use after SIGTERM; force-killing." + verify_dev_port_ownership "$port" || return 1 + pids="$(port_listener_pids "$port")" + for pid in $pids; do + kill -9 "$pid" 2>/dev/null || true + done + for attempt in {1..20}; do + if [ -z "$(port_listener_pids "$port")" ]; then + return 0 + fi + sleep 0.25 + done + + echo "Port $port is still in use after force-kill." >&2 + exit 1 +} + +stop_checkout_dev_binaries() { + local pids + pids="$(checkout_dev_binary_pids)" + if [ -z "$pids" ]; then + return 0 + fi + + local pid command + for pid in $pids; do + command="$(process_command "$pid")" + if process_is_checkout_dev_binary "$command"; then + echo "Stopping stale checkout ide-dev process (pid $pid): $command" + kill "$pid" 2>/dev/null || true + fi + done + + local attempt + for attempt in {1..20}; do + local running=0 + for pid in $pids; do + command="$(process_command "$pid")" + if process_is_checkout_dev_binary "$command"; then + running=1 + break + fi + done + if [ "$running" -eq 0 ]; then + return 0 + fi + sleep 0.25 + done + + for pid in $pids; do + command="$(process_command "$pid")" + if process_is_checkout_dev_binary "$command"; then + kill -9 "$pid" 2>/dev/null || true + fi + done +} + +ensure_dev_port_available() { + # Verify both ports before stopping anything, so an unrelated listener leaves + # every process untouched and gets reported to the caller. + verify_dev_port_ownership "$DEV_VITE_PORT" || exit 1 + verify_dev_port_ownership "$DEV_API_PORT" || exit 1 + stop_checkout_dev_binaries + clear_dev_port "$DEV_VITE_PORT" + clear_dev_port "$DEV_API_PORT" +} + +handoff_to_running_dev_app() { local target="$1" - local api_base="http://127.0.0.1:17877" + local api_base="http://127.0.0.1:${DEV_API_PORT}" local status token escaped_target if ! status="$(curl -fsS --max-time 1 "$api_base/api/codex-mcp" 2>/dev/null)"; then return 1 @@ -48,55 +217,53 @@ handoff_to_running_app() { -X POST \ --data "{\"path\":\"$escaped_target\"}" \ "$api_base/api/open-path" >/dev/null; then - echo "Handed target to running ide: $target" + echo "Handed target to running ide-dev: $target" return 0 fi return 1 } -running_app_reachable() { - curl -fsS --max-time 1 "http://127.0.0.1:17877/api/codex-mcp" >/dev/null 2>&1 +running_dev_app_reachable() { + curl -fsS --max-time 1 "http://127.0.0.1:${DEV_API_PORT}/api/codex-mcp" >/dev/null 2>&1 } -# Plain `./run.sh` means "give me a dev instance running this working copy". -# The single-instance plugin makes any running app (usually the installed -# release build) block the dev launch, so quit it first — gracefully, then -# force-killed if it lingers — instead of bailing out. -stop_running_app() { - echo "Stopping the running ide instance so the dev build can start." +# Plain `./run.sh` owns only the development channel. Its distinct bundle +# identifier lets the installed production app continue running untouched. +stop_running_dev_app() { + verify_dev_port_ownership "$DEV_API_PORT" || return 1 + echo "Stopping the running ide-dev instance so the dev build can start." if command -v osascript >/dev/null 2>&1; then - osascript -e 'tell application id "com.gordonbeeming.ide" to quit' >/dev/null 2>&1 || true + osascript -e 'tell application id "com.gordonbeeming.ide.dev" to quit' >/dev/null 2>&1 || true fi local attempt for attempt in {1..20}; do - if ! running_app_reachable; then + if ! running_dev_app_reachable; then return 0 fi sleep 0.5 done - echo "Graceful quit timed out; force-killing the running ide instance." - pkill -f "/Applications/ide.app/Contents/MacOS/ide" 2>/dev/null || true - pkill -f "$ROOT_DIR/src-tauri/target/debug/ide" 2>/dev/null || true + echo "Graceful quit timed out; clearing the ide-dev listener." + clear_dev_port "$DEV_API_PORT" for attempt in {1..10}; do - if ! running_app_reachable; then + if ! running_dev_app_reachable; then return 0 fi sleep 0.5 done - echo "An ide instance is still holding the app port after force-kill." >&2 + echo "An ide-dev instance is still holding the development API port after cleanup." >&2 exit 1 } -if [ -n "$OPEN_PATH" ] && handoff_to_running_app "$OPEN_PATH"; then +if [ -n "$OPEN_PATH" ] && handoff_to_running_dev_app "$OPEN_PATH"; then exit 0 fi -if [ -z "$OPEN_PATH" ] && running_app_reachable; then - stop_running_app +if [ -z "$OPEN_PATH" ] && running_dev_app_reachable; then + stop_running_dev_app || exit 1 fi if ! command -v npm >/dev/null 2>&1; then @@ -113,51 +280,6 @@ if [ ! -d node_modules ]; then npm install fi -# 14717 is ide's own dev port (not Tauri's shared 1420 default), so anything -# still listening on it is a stale ide process — kill it rather than bail. -ensure_dev_port_available() { - local port="14717" - - # Leftover dev binaries from a crashed run can linger without holding the - # port; sweep them before checking the listener. - pkill -f "$ROOT_DIR/src-tauri/target/debug/ide" 2>/dev/null || true - - local pids - pids="$(lsof -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true)" - if [ -z "$pids" ]; then - return 0 - fi - - local pid command - for pid in $pids; do - command="$(ps -p "$pid" -o command= 2>/dev/null || true)" - echo "Killing stale process on port $port (pid $pid): ${command:-unknown}" - kill "$pid" 2>/dev/null || true - done - - local attempt - for attempt in {1..40}; do - if ! lsof -tiTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then - return 0 - fi - sleep 0.25 - done - - echo "Port $port still in use after SIGTERM; force-killing." - for pid in $pids; do - kill -9 "$pid" 2>/dev/null || true - done - for attempt in {1..20}; do - if ! lsof -tiTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then - return 0 - fi - sleep 0.25 - done - - echo "Port $port is still in use after force-kill." >&2 - exit 1 -} - ensure_dev_port_available -exec npm run tauri:dev +exec npm run tauri:dev -- --config src-tauri/tauri.dev.conf.json diff --git a/scripts/install-cli-command.sh b/scripts/install-cli-command.sh index e18263a..aef8250 100755 --- a/scripts/install-cli-command.sh +++ b/scripts/install-cli-command.sh @@ -3,24 +3,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" RUN_SH="$ROOT_DIR/run.sh" -PACKAGED_APP_BUNDLE="$ROOT_DIR/src-tauri/target/release/bundle/macos/ide.app" -INSTALLED_APP_BUNDLE="${IDE_INSTALLED_APP_PATH:-/Applications/ide.app}" -APP_BUNDLE="${IDE_CLI_APP_BUNDLE_PATH:-}" -if [ -z "$APP_BUNDLE" ]; then - if [ -x "$INSTALLED_APP_BUNDLE/Contents/MacOS/ide" ]; then - APP_BUNDLE="$INSTALLED_APP_BUNDLE" - else - APP_BUNDLE="$PACKAGED_APP_BUNDLE" - fi -fi -if [ -n "${IDE_CLI_APP_PATH:-}" ] && [ -z "${IDE_CLI_APP_BUNDLE_PATH:-}" ]; then - if [[ "$IDE_CLI_APP_PATH" == */Contents/MacOS/ide ]]; then - APP_BUNDLE="$(cd "$(dirname "$IDE_CLI_APP_PATH")/../.." && pwd)" - else - APP_BUNDLE="$IDE_CLI_APP_PATH" - fi -fi -APP_BINARY="$APP_BUNDLE/Contents/MacOS/ide" +PRODUCTION_APP_BUNDLE="/Applications/ide.app" BIN_DIR="${IDE_CLI_BIN_DIR:-$HOME/.local/bin}" IDE_COMMAND_PATH="$BIN_DIR/ide" IDE_DEV_COMMAND_PATH="$BIN_DIR/ide-dev" @@ -30,12 +13,6 @@ if [ ! -x "$RUN_SH" ]; then exit 1 fi -if [ ! -x "$APP_BINARY" ]; then - echo "Packaged ide app binary was not found: $APP_BINARY" >&2 - echo "Run ./build.sh first, then reinstall the CLI commands." >&2 - exit 1 -fi - mkdir -p "$BIN_DIR" install_generated_launcher() { @@ -193,8 +170,8 @@ install_link() { ln -s "$target_path" "$command_path" } -install_generated_launcher "$IDE_COMMAND_PATH" "$APP_BUNDLE" +install_generated_launcher "$IDE_COMMAND_PATH" "$PRODUCTION_APP_BUNDLE" install_link "$IDE_DEV_COMMAND_PATH" "$RUN_SH" -echo "Installed ide command at $IDE_COMMAND_PATH -> $APP_BUNDLE" +echo "Installed ide command at $IDE_COMMAND_PATH -> $PRODUCTION_APP_BUNDLE" echo "Installed ide-dev command at $IDE_DEV_COMMAND_PATH -> $RUN_SH" diff --git a/scripts/install-macos-finder-quick-action.sh b/scripts/install-macos-finder-quick-action.sh index b244444..e804f64 100755 --- a/scripts/install-macos-finder-quick-action.sh +++ b/scripts/install-macos-finder-quick-action.sh @@ -1,8 +1,6 @@ #!/usr/bin/env bash set -euo pipefail -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -RUN_SH="$ROOT_DIR/run.sh" SERVICE_NAME="Open in ide" SERVICE_ROOT="${IDE_SERVICE_ROOT:-$HOME/Library/Services/$SERVICE_NAME.workflow}" SERVICE_DIR="$SERVICE_ROOT/Contents" @@ -10,11 +8,6 @@ SUPPORT_DIR="${IDE_SUPPORT_DIR:-$HOME/Library/Application Support/ide}" RUNNER="$SUPPORT_DIR/open-from-finder.sh" SERVICES_DIR="$(dirname "$SERVICE_ROOT")" -if [ ! -x "$RUN_SH" ]; then - echo "run.sh must be executable before installing the Finder Quick Action." >&2 - exit 1 -fi - mkdir -p "$SERVICE_DIR" "$SUPPORT_DIR" cat > "$RUNNER" < "$RUNNER" </dev/null 2>&1 && command -v fnm >/dev/null 2>&1; then - eval "\$(fnm env --shell bash)" -fi - LOG_DIR="\$HOME/Library/Logs/ide" mkdir -p "\$LOG_DIR" exec >> "\$LOG_DIR/finder-open.log" 2>&1 @@ -75,88 +62,22 @@ handoff_to_running_app() { return 1 } -ensure_command() { - local name="\$1" - local install_hint="\$2" - if command -v "\$name" >/dev/null 2>&1; then - return 0 - fi - echo "\$name is required. \$install_hint" - osascript -e "display alert \\"ide\\" message \\"\$name is required. \$install_hint\\" as critical" >/dev/null 2>&1 || true - exit 1 -} - -ensure_frontend_server() { - local pids pid command cwd attempt - if curl -fsS --max-time 1 "\$FRONTEND_URL" >/dev/null 2>&1; then - echo "Vite server is already reachable at \$FRONTEND_URL." - return 0 - fi - - pids="\$(lsof -tiTCP:14717 -sTCP:LISTEN 2>/dev/null || true)" - for pid in \$pids; do - command="\$(ps -p "\$pid" -o command= 2>/dev/null || true)" - cwd="\$(lsof -a -p "\$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' || true)" - if [ "\$cwd" = "\$ROOT_DIR" ] && [[ "\$command" == *"/node_modules/.bin/vite"* ]]; then - echo "Reusing repo-local Vite listener on port 14717 (pid \$pid)." - return 0 - fi - - echo "Port 14717 is owned by another process: pid=\$pid command=\${command:-unknown}" - osascript -e 'display alert "ide" message "Port 14717 is already in use by another process." as critical' >/dev/null 2>&1 || true - exit 1 - done - - echo "Starting Vite server." - ( - cd "\$ROOT_DIR" - nohup npm run dev >> "\$LOG_DIR/finder-vite.log" 2>&1 & - ) - - for attempt in {1..80}; do - if curl -fsS --max-time 1 "\$FRONTEND_URL" >/dev/null 2>&1; then - echo "Vite server is ready." - return 0 - fi - sleep 0.25 - done - - echo "Timed out waiting for Vite server at \$FRONTEND_URL." - osascript -e 'display alert "ide" message "Timed out waiting for the ide frontend dev server." as critical' >/dev/null 2>&1 || true - exit 1 -} - -launch_app() { - echo "Launching ide dev binary." - ( - cd "\$ROOT_DIR/src-tauri" - IDE_OPEN_PATH="\$TARGET" nohup cargo run --no-default-features >> "\$LOG_DIR/finder-app.log" 2>&1 & - ) -} - if handoff_to_running_app; then exit 0 fi -ensure_command npm "Install Node.js 24 or newer." -ensure_command cargo "Install Rust 1.95 or newer." - -if [ ! -d "\$ROOT_DIR/node_modules" ]; then - echo "Installing npm dependencies." - (cd "\$ROOT_DIR" && npm install) +if [ ! -x "\$APP_BUNDLE/Contents/MacOS/ide" ]; then + echo "Production ide app was not found: \$APP_BUNDLE" + osascript -e 'display alert "ide" message "Install ide with Homebrew before using Open in ide." as critical' >/dev/null 2>&1 || true + exit 1 fi -ensure_frontend_server -launch_app - -for attempt in {1..80}; do - if handoff_to_running_app; then - exit 0 - fi - sleep 0.25 -done - -echo "ide was launched, but did not become reachable on \$API_BASE before the Finder action timed out." +echo "Opening target with production ide." +if ! open "\$APP_BUNDLE" --args "\$TARGET" >/dev/null 2>&1; then + echo "Unable to open production ide at \$APP_BUNDLE." + osascript -e 'display alert "ide" message "The production ide app could not be opened." as critical' >/dev/null 2>&1 || true + exit 1 +fi EOF chmod 755 "$RUNNER" diff --git a/scripts/validate-channel-isolation.mjs b/scripts/validate-channel-isolation.mjs new file mode 100644 index 0000000..28f9b64 --- /dev/null +++ b/scripts/validate-channel-isolation.mjs @@ -0,0 +1,370 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(scriptDir, ".."); +const tempRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), "ide-channel-isolation-")), +); + +try { + runCase("development target handoff", testDevelopmentTargetHandoff); + runCase("unrelated Vite listener preservation", () => + testUnrelatedPortIsPreserved("unrelated-vite", "14717"), + ); + runCase("unrelated API listener preservation", () => + testUnrelatedPortIsPreserved("unrelated-api", "17878"), + ); + runCase("checkout-owned cleanup", testCheckoutOwnedCleanup); + runCase("production port non-interference", testProductionPortIsIgnored); + runCase("install policy and launcher paths", testInstallPolicyAndLaunchers); + console.log("Channel isolation behavior validation passed"); +} finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); +} + +function runCase(label, callback) { + callback(); + console.log(`Passed: ${label}`); +} + +function testDevelopmentTargetHandoff() { + const harness = createHarness("handoff"); + const target = path.join(harness.base, "workspace"); + fs.mkdirSync(target); + + const result = runShell(path.join(rootDir, "run.sh"), [target], harness, "handoff"); + assertStatus(result, 0, "development target handoff"); + + const curlLog = readLog(harness, "curl.log"); + assertIncludes(curlLog, "127.0.0.1:17878/api/codex-mcp"); + assertIncludes(curlLog, "127.0.0.1:17878/api/open-path"); + assertIncludes(curlLog, "Authorization: Bearer dev-token"); + assertNotIncludes(curlLog, "17877"); + assertEmptyLog(harness, "npm.log", "handoff must not start a second development app"); + assertProductionSentinel(harness); +} + +function testUnrelatedPortIsPreserved(scenario, port) { + const harness = createHarness(scenario); + const result = runShell(path.join(rootDir, "run.sh"), [], harness, scenario); + + if (result.status === 0) { + throw new Error(`${scenario} should fail instead of stopping an unrelated listener`); + } + assertIncludes(result.stderr, `Port ${port} is owned by an unrelated process`); + assertEmptyLog(harness, "kill.log", `${scenario} must not send a signal`); + assertEmptyLog(harness, "npm.log", `${scenario} must fail before launching Tauri`); + assertProductionSentinel(harness); +} + +function testCheckoutOwnedCleanup() { + const harness = createHarness("checkout-owned-api"); + const result = runShell(path.join(rootDir, "run.sh"), [], harness, "checkout-owned-api"); + assertStatus(result, 0, "checkout-owned cleanup"); + + assertIncludes(readLog(harness, "kill.log"), "4201"); + assertIncludes(readLog(harness, "npm.log"), "run tauri:dev -- --config src-tauri/tauri.dev.conf.json"); + assertNotIncludes(readLog(harness, "lsof.log"), "17877"); + assertProductionSentinel(harness); +} + +function testProductionPortIsIgnored() { + const harness = createHarness("empty-ports"); + const result = runShell(path.join(rootDir, "run.sh"), [], harness, "empty-ports"); + assertStatus(result, 0, "empty development ports"); + + assertNotIncludes(readLog(harness, "lsof.log"), "17877"); + assertEmptyLog(harness, "kill.log", "production port must not trigger a signal"); + assertEmptyLog(harness, "osascript.log", "production app must not be addressed"); + assertProductionSentinel(harness); +} + +function testInstallPolicyAndLaunchers() { + const harness = createHarness("build-success"); + const checkout = path.join(harness.base, "checkout"); + const scriptsDir = path.join(checkout, "scripts"); + fs.mkdirSync(path.join(checkout, "src-tauri"), { recursive: true }); + fs.mkdirSync(scriptsDir, { recursive: true }); + copyExecutable(path.join(rootDir, "build.sh"), path.join(checkout, "build.sh")); + copyExecutable( + path.join(rootDir, "scripts", "install-cli-command.sh"), + path.join(scriptsDir, "install-cli-command.sh"), + ); + fs.writeFileSync(path.join(checkout, "src-tauri", "tauri.dev.conf.json"), "{}\n"); + fs.writeFileSync(path.join(checkout, "run.sh"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + + const installParent = path.join(harness.base, "installed"); + fs.mkdirSync(installParent); + const installedApp = path.join(installParent, "ide-dev.app"); + const cliBinDir = path.join(harness.base, "bin"); + const buildEnv = { + HARNESS_CHECKOUT: checkout, + HARNESS_ALLOWED_ROOT: harness.base, + IDE_DEV_INSTALLED_APP_PATH: installedApp, + IDE_DEV_SIGNING_IDENTITY: "Apple Development: Harness", + IDE_CLI_BIN_DIR: cliBinDir, + }; + + const result = runShell(path.join(checkout, "build.sh"), [], harness, "build-success", buildEnv); + assertStatus(result, 0, "stubbed development build/install"); + if (!fs.existsSync(path.join(installedApp, "Contents", "MacOS", "ide"))) { + throw new Error("stubbed build did not copy the development app to its approved target"); + } + + const codesignLog = readLog(harness, "codesign.log"); + assertIncludes(codesignLog, "--force --deep --sign Apple Development: Harness"); + assertIncludes(codesignLog, "--identifier com.gordonbeeming.ide.dev"); + assertIncludes(codesignLog, `--verify --deep --strict ${installedApp}`); + assertIncludes(readLog(harness, "ditto.log"), installedApp); + + const ideLauncher = fs.readFileSync(path.join(cliBinDir, "ide"), "utf8"); + assertIncludes(ideLauncher, 'APP_BUNDLE="/Applications/ide.app"'); + const ideDevTarget = fs.readlinkSync(path.join(cliBinDir, "ide-dev")); + if (ideDevTarget !== path.join(checkout, "run.sh")) { + throw new Error(`ide-dev launcher should link to checkout run.sh, got ${ideDevTarget}`); + } + + for (const rejectedPath of [ + `${harness.base}/installed/../escape/ide-dev.app`, + path.join(harness.base, "installed", "another.app"), + "/Applications/ide-dev.app", + ]) { + assertRejectedInstallPath(checkout, harness, rejectedPath, buildEnv); + } + + const aliasTarget = path.join(harness.base, "outside-root"); + const aliasParent = path.join(harness.base, "install-alias"); + fs.mkdirSync(aliasTarget); + fs.symlinkSync(aliasTarget, aliasParent); + assertRejectedInstallPath( + checkout, + harness, + path.join(aliasParent, "ide-dev.app"), + buildEnv, + ); + assertProductionSentinel(harness); +} + +function assertRejectedInstallPath(checkout, harness, rejectedPath, baseEnv) { + truncateLog(harness, "npm.log"); + truncateLog(harness, "rm.log"); + truncateLog(harness, "ditto.log"); + + const result = runShell(path.join(checkout, "build.sh"), [], harness, "build-rejected", { + ...baseEnv, + IDE_DEV_INSTALLED_APP_PATH: rejectedPath, + }); + if (result.status === 0) { + throw new Error(`unsafe development install path was accepted: ${rejectedPath}`); + } + assertEmptyLog(harness, "npm.log", `rejected path reached npm: ${rejectedPath}`); + assertEmptyLog(harness, "rm.log", `rejected path reached rm: ${rejectedPath}`); + assertEmptyLog(harness, "ditto.log", `rejected path reached ditto: ${rejectedPath}`); + assertProductionSentinel(harness); +} + +function createHarness(name) { + const base = path.join(tempRoot, name); + const home = path.join(base, "home"); + const state = path.join(base, "state"); + const binDir = path.join(home, ".local", "bin"); + fs.mkdirSync(binDir, { recursive: true }); + fs.mkdirSync(state, { recursive: true }); + fs.writeFileSync(path.join(state, "production-sentinel"), "production-preserved\n"); + + const bashEnv = path.join(base, "bash-env.sh"); + fs.writeFileSync( + bashEnv, + `kill() { + printf '%s\\n' "$*" >> "$HARNESS_STATE/kill.log" + for argument in "$@"; do + case "$argument" in + ''|'-'*) ;; + *) touch "$HARNESS_STATE/killed-$argument" ;; + esac + done + return 0 +} +sleep() { return 0; } +`, + ); + + const stubs = { + curl: `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$HARNESS_STATE/curl.log" +case "$HARNESS_SCENARIO" in + handoff) + case "$*" in + *'/api/open-path'*) exit 0 ;; + *'/api/codex-mcp'*) printf '%s\\n' '{"bearerToken":"dev-token"}'; exit 0 ;; + esac + ;; +esac +exit 1 +`, + lsof: `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$HARNESS_STATE/lsof.log" +case "$*" in + *'-d cwd -Fn'*) + case "$HARNESS_SCENARIO" in + unrelated-vite) printf '%s\\n' 'n/unrelated/workspace' ;; + esac + exit 0 + ;; + *'-tiTCP:14717'*) + [ "$HARNESS_SCENARIO" = 'unrelated-vite' ] && printf '%s\\n' '4101' + ;; + *'-tiTCP:17878'*) + if [ "$HARNESS_SCENARIO" = 'unrelated-api' ]; then + printf '%s\\n' '4102' + elif [ "$HARNESS_SCENARIO" = 'checkout-owned-api' ] && [ ! -e "$HARNESS_STATE/killed-4201" ]; then + printf '%s\\n' '4201' + fi + ;; +esac +exit 0 +`, + ps: `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$HARNESS_STATE/ps.log" +if [ "$*" = '-axo pid=' ]; then + if [ "$HARNESS_SCENARIO" = 'checkout-owned-api' ] && [ ! -e "$HARNESS_STATE/killed-4201" ]; then + printf '%s\\n' '4201' + fi + exit 0 +fi +case "$*" in + *'-p 4101 '*) printf '%s\\n' '/usr/local/bin/node /unrelated/node_modules/.bin/vite' ;; + *'-p 4102 '*) printf '%s\\n' '/usr/bin/python3 unrelated-server.py' ;; + *'-p 4201 '*) + [ ! -e "$HARNESS_STATE/killed-4201" ] && printf '%s\\n' "$HARNESS_REPO_ROOT/src-tauri/target/debug/ide" + ;; +esac +`, + npm: `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$HARNESS_STATE/npm.log" +if [ "$HARNESS_SCENARIO" = 'build-rejected' ]; then + exit 91 +fi +if [ "$HARNESS_SCENARIO" = 'build-success' ]; then + app="$HARNESS_CHECKOUT/src-tauri/target/release/bundle/macos/ide-dev.app" + mkdir -p "$app/Contents/MacOS" + printf '%s\\n' '#!/usr/bin/env bash' > "$app/Contents/MacOS/ide" + chmod 755 "$app/Contents/MacOS/ide" +fi +exit 0 +`, + cargo: "#!/usr/bin/env bash\nexit 0\n", + osascript: `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$HARNESS_STATE/osascript.log" +exit 0 +`, + open: `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$HARNESS_STATE/open.log" +exit 0 +`, + codesign: `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$HARNESS_STATE/codesign.log" +exit 0 +`, + security: "#!/usr/bin/env bash\nexit 0\n", + mdimport: "#!/usr/bin/env bash\nexit 0\n", + rm: `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$HARNESS_STATE/rm.log" +target="" +for argument in "$@"; do target="$argument"; done +case "$target" in + "$HARNESS_ALLOWED_ROOT"/*) exec /bin/rm "$@" ;; + *) printf '%s\\n' "blocked rm target: $target" >&2; exit 97 ;; +esac +`, + ditto: `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$HARNESS_STATE/ditto.log" +source_app="$1" +installed_app="$2" +case "$installed_app" in + "$HARNESS_ALLOWED_ROOT"/*) exec /usr/bin/ditto "$source_app" "$installed_app" ;; + *) printf '%s\\n' "blocked ditto target: $installed_app" >&2; exit 98 ;; +esac +`, + }; + + for (const [command, contents] of Object.entries(stubs)) { + writeExecutable(path.join(binDir, command), contents); + } + + return { base, home, state, bashEnv }; +} + +function runShell(script, args, harness, scenario, extraEnv = {}) { + return spawnSync("bash", [script, ...args], { + cwd: rootDir, + encoding: "utf8", + env: { + ...process.env, + HOME: harness.home, + BASH_ENV: harness.bashEnv, + HARNESS_STATE: harness.state, + HARNESS_SCENARIO: scenario, + HARNESS_REPO_ROOT: rootDir, + HARNESS_ALLOWED_ROOT: harness.base, + ...extraEnv, + }, + }); +} + +function copyExecutable(source, destination) { + fs.copyFileSync(source, destination); + fs.chmodSync(destination, 0o755); +} + +function writeExecutable(filePath, contents) { + fs.writeFileSync(filePath, contents, { mode: 0o755 }); +} + +function readLog(harness, name) { + const filePath = path.join(harness.state, name); + return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : ""; +} + +function truncateLog(harness, name) { + fs.writeFileSync(path.join(harness.state, name), ""); +} + +function assertEmptyLog(harness, name, message) { + if (readLog(harness, name).trim() !== "") { + throw new Error(`${message}: ${readLog(harness, name).trim()}`); + } +} + +function assertProductionSentinel(harness) { + const actual = fs.readFileSync(path.join(harness.state, "production-sentinel"), "utf8"); + if (actual !== "production-preserved\n") { + throw new Error("production sentinel changed during channel behavior validation"); + } +} + +function assertStatus(result, expected, label) { + if (result.status !== expected) { + throw new Error( + `${label} exited ${result.status}; stdout=${JSON.stringify(result.stdout)} stderr=${JSON.stringify(result.stderr)}`, + ); + } +} + +function assertIncludes(text, expected) { + if (!text.includes(expected)) { + throw new Error(`Expected output to include ${JSON.stringify(expected)}; got ${JSON.stringify(text)}`); + } +} + +function assertNotIncludes(text, unexpected) { + if (text.includes(unexpected)) { + throw new Error(`Output should not include ${JSON.stringify(unexpected)}; got ${JSON.stringify(text)}`); + } +} diff --git a/scripts/validate-finder-quick-action.mjs b/scripts/validate-finder-quick-action.mjs index c7e7362..91bf0d9 100644 --- a/scripts/validate-finder-quick-action.mjs +++ b/scripts/validate-finder-quick-action.mjs @@ -38,13 +38,21 @@ try { } const runner = fs.readFileSync(runnerPath, "utf8"); + assertIncludes(runner, 'APP_BUNDLE="/Applications/ide.app"'); assertIncludes(runner, 'API_BASE="http://127.0.0.1:17877"'); assertIncludes(runner, "bearerToken"); assertIncludes(runner, 'Authorization: Bearer $token'); assertIncludes(runner, "-X POST"); assertIncludes(runner, "$API_BASE/api/open-path"); - assertIncludes(runner, 'IDE_OPEN_PATH="$TARGET"'); - assertIncludes(runner, "cargo run --no-default-features"); + assertIncludes(runner, 'if [ ! -x "$APP_BUNDLE/Contents/MacOS/ide" ]; then'); + assertIncludes(runner, 'open "$APP_BUNDLE" --args "$TARGET"'); + assertNotIncludes(runner, "FRONTEND_URL"); + assertNotIncludes(runner, "ROOT_DIR"); + assertNotIncludes(runner, "npm"); + assertNotIncludes(runner, "cargo"); + assertNotIncludes(runner, "ensure_frontend_server"); + assertNotIncludes(runner, "launch_app"); + assertOrdered(runner, "if handoff_to_running_app; then", 'open "$APP_BUNDLE" --args "$TARGET"'); const infoPlist = fs.readFileSync(infoPlistPath, "utf8"); assertIncludes(infoPlist, "Open in ide"); @@ -80,6 +88,20 @@ function assertIncludes(text, expected) { } } +function assertNotIncludes(text, unexpected) { + if (text.includes(unexpected)) { + throw new Error(`Generated Finder Quick Action output should not include: ${unexpected}`); + } +} + +function assertOrdered(text, earlier, later) { + const earlierIndex = text.indexOf(earlier); + const laterIndex = text.indexOf(later); + if (earlierIndex === -1 || laterIndex === -1 || earlierIndex >= laterIndex) { + throw new Error(`Expected "${earlier}" to appear before "${later}"`); + } +} + function lintPlist(filePath) { if (process.platform !== "darwin") return; execFileSync("plutil", ["-lint", filePath], { stdio: "pipe" }); diff --git a/scripts/validate-launch-runners.mjs b/scripts/validate-launch-runners.mjs index c36cfdc..0567c96 100644 --- a/scripts/validate-launch-runners.mjs +++ b/scripts/validate-launch-runners.mjs @@ -13,92 +13,67 @@ const buildScriptPath = path.join(rootDir, "build.sh"); const devInstallScriptPath = path.join(rootDir, "dev-install.sh"); const cliInstallScript = path.join(rootDir, "scripts", "install-cli-command.sh"); const finderInstallScript = path.join(rootDir, "scripts", "install-macos-finder-quick-action.sh"); +const channelIsolationHarness = path.join(rootDir, "scripts", "validate-channel-isolation.mjs"); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ide-launch-runners-")); try { - const runScript = readExecutable(runScriptPath, "run.sh"); - assertIncludes(runScript, "handoff_to_running_app()"); - assertIncludes(runScript, 'api_base="http://127.0.0.1:17877"'); - assertIncludes(runScript, "$api_base/api/codex-mcp"); - assertIncludes(runScript, "bearerToken"); - assertIncludes(runScript, 'Authorization: Bearer $token'); - assertIncludes(runScript, "-X POST"); - assertIncludes(runScript, "$api_base/api/open-path"); - assertIncludes(runScript, 'if [ -n "$OPEN_PATH" ] && handoff_to_running_app "$OPEN_PATH"; then'); - assertIncludes(runScript, "running_app_reachable()"); - assertIncludes(runScript, "stop_running_app()"); - assertIncludes(runScript, 'tell application id "com.gordonbeeming.ide" to quit'); - assertIncludes(runScript, 'pkill -f "/Applications/ide.app/Contents/MacOS/ide"'); - assertIncludes(runScript, 'pkill -f "$ROOT_DIR/src-tauri/target/debug/ide"'); - assertIncludes(runScript, 'if [ -z "$OPEN_PATH" ] && running_app_reachable; then'); - assertIncludes(runScript, "stop_running_app"); - assertNotIncludes(runScript, "not starting a duplicate dev instance"); - assertIncludes(runScript, "ensure_dev_port_available"); - assertOrdered(runScript, "handoff_to_running_app \"$OPEN_PATH\"", "ensure_dev_port_available"); - assertOrdered(runScript, "running_app_reachable", "ensure_dev_port_available"); - // The graceful quit must be attempted before the force-kill escalation. - assertOrdered(runScript, 'tell application id "com.gordonbeeming.ide" to quit', "pkill -f"); + readExecutable(runScriptPath, "run.sh"); + execFileSync(process.execPath, [channelIsolationHarness], { + cwd: rootDir, + stdio: "pipe", + }); const buildScript = readExecutable(buildScriptPath, "build.sh"); - assertIncludes(buildScript, "npm run tauri -- build --bundles app"); - assertIncludes(buildScript, 'CLI_APP_BUNDLE="$APP_BUNDLE"'); - assertIncludes(buildScript, 'CLI_APP_BUNDLE="$INSTALLED_APP"'); - assertIncludes( + assertIncludes(buildScript, "src-tauri/tauri.dev.conf.json"); + assertIncludes(buildScript, "bundle/macos/ide-dev.app"); + assertIncludes(buildScript, 'DEV_BUNDLE_IDENTIFIER="com.gordonbeeming.ide.dev"'); + assertIncludes(buildScript, 'build --bundles app --config "$DEV_TAURI_CONFIG"'); + assertIncludes(buildScript, "IDE_DEV_SIGNING_IDENTITY"); + assertIncludes(buildScript, "security find-identity -v -p codesigning"); + assertIncludes(buildScript, "Apple Development:"); + assertIncludes(buildScript, 'printf \'%s\\n\' "-"'); + assertIncludes(buildScript, 'codesign --force --deep --sign "$signing_identity" "$app_path"'); + assertIncludes(buildScript, 'codesign --force --sign "$signing_identity" --identifier "$DEV_BUNDLE_IDENTIFIER" "$app_path"'); + assertIncludes(buildScript, 'codesign --verify --deep --strict "$app_path"'); + assertIncludes(buildScript, 'sign_dev_app "$DEV_APP_BUNDLE"'); + assertNotIncludes(buildScript, "Developer ID"); + assertNotIncludes(buildScript, "notarytool"); + assertIncludes(buildScript, '"$ROOT_DIR/scripts/install-cli-command.sh"'); + assertNotIncludes(buildScript, "IDE_CLI_APP_BUNDLE_PATH"); + assertOrdered( buildScript, - 'IDE_CLI_APP_BUNDLE_PATH="$CLI_APP_BUNDLE" "$ROOT_DIR/scripts/install-cli-command.sh"', + 'npm run tauri -- build --bundles app --config "$DEV_TAURI_CONFIG"', + 'sign_dev_app "$DEV_APP_BUNDLE"', ); - assertIncludes(buildScript, "WARNING: /Applications app was not updated."); - assertIncludes(buildScript, "Run ./dev-install.sh to install the rebuilt app with a macOS admin prompt."); - assertIncludes(buildScript, "not updated"); + assertNoProductionMutation(buildScript, "build.sh"); const devInstallScript = readExecutable(devInstallScriptPath, "dev-install.sh"); - assertIncludes(devInstallScript, '"$ROOT_DIR/build.sh"'); - assertIncludes(devInstallScript, "with administrator privileges"); - assertIncludes(devInstallScript, "open -R \"$INSTALLED_APP\""); - assertIncludes(devInstallScript, "lsregister"); + assertIncludes(devInstallScript, 'exec "$ROOT_DIR/build.sh"'); + assertNotIncludes(devInstallScript, "rm -rf"); + assertNotIncludes(devInstallScript, "ditto"); + assertNotIncludes(devInstallScript, "resolve_dev_install_path"); + assertNoProductionMutation(devInstallScript, "dev-install.sh"); + + const cliInstall = readExecutable(cliInstallScript, "scripts/install-cli-command.sh"); + assertIncludes(cliInstall, 'PRODUCTION_APP_BUNDLE="/Applications/ide.app"'); + assertIncludes(cliInstall, 'install_generated_launcher "$IDE_COMMAND_PATH" "$PRODUCTION_APP_BUNDLE"'); + assertIncludes(cliInstall, 'install_link "$IDE_DEV_COMMAND_PATH" "$RUN_SH"'); + assertNotIncludes(cliInstall, "IDE_CLI_APP_BUNDLE_PATH"); + assertNoProductionMutation(cliInstall, "scripts/install-cli-command.sh"); const cliBinDir = path.join(tempDir, "bin"); - const installedCliAppBundle = path.join(tempDir, "Applications", "ide.app"); - const installedCliAppBinary = path.join( - installedCliAppBundle, - "Contents", - "MacOS", - "ide", - ); - const cliAppBundle = path.join( - tempDir, - "release", - "bundle", - "macos", - "ide.app", - ); - const cliAppBinary = path.join( - cliAppBundle, - "Contents", - "MacOS", - "ide", - ); - fs.mkdirSync(path.dirname(installedCliAppBinary), { recursive: true }); - fs.writeFileSync(installedCliAppBinary, "#!/usr/bin/env bash\n"); - fs.chmodSync(installedCliAppBinary, 0o755); - fs.mkdirSync(path.dirname(cliAppBinary), { recursive: true }); - fs.writeFileSync(cliAppBinary, "#!/usr/bin/env bash\n"); - fs.chmodSync(cliAppBinary, 0o755); - execFileSync("bash", [cliInstallScript], { cwd: rootDir, env: { ...process.env, IDE_CLI_BIN_DIR: cliBinDir, - IDE_INSTALLED_APP_PATH: installedCliAppBundle, - IDE_CLI_APP_BUNDLE_PATH: cliAppBundle, }, stdio: "pipe", }); const ideCommand = readExecutable(path.join(cliBinDir, "ide"), "ide command"); assertIncludes(ideCommand, "Generated by ide/scripts/install-cli-command.sh"); - assertIncludes(ideCommand, `APP_BUNDLE="${cliAppBundle}"`); + assertIncludes(ideCommand, 'APP_BUNDLE="/Applications/ide.app"'); assertIncludes(ideCommand, 'APP_BINARY="$APP_BUNDLE/Contents/MacOS/ide"'); assertIncludes(ideCommand, 'if [ ! -x "$APP_BINARY" ]; then'); assertIncludes(ideCommand, "running_app_reachable()"); @@ -106,8 +81,8 @@ try { assertIncludes(ideCommand, "activate_running_app()"); assertIncludes(ideCommand, 'tell application id "com.gordonbeeming.ide" to activate'); assertIncludes(ideCommand, 'if [ "$#" -eq 0 ] && running_app_reachable; then'); - assertIncludes(ideCommand, 'url_encode()'); - assertIncludes(ideCommand, 'resolve_absolute_path()'); + assertIncludes(ideCommand, "url_encode()"); + assertIncludes(ideCommand, "resolve_absolute_path()"); assertIncludes(ideCommand, 'if [ "${1:-}" = "browse" ]; then'); assertIncludes(ideCommand, 'browse_url="http://localhost:17877/browse?path=$(url_encode "$browse_path")"'); assertIncludes(ideCommand, 'IDE_BROWSE_PATH="$browse_path" open "$APP_BUNDLE" --args browse "$browse_path"'); @@ -123,26 +98,9 @@ try { assertIncludes(ideCommand, 'open "$APP_BUNDLE" --args "${ARGS[@]}" >/dev/null 2>&1 &'); assertIncludes(ideCommand, 'open "$APP_BUNDLE" >/dev/null 2>&1 &'); assertNotIncludes(ideCommand, "open -n"); - // Fully qualified so this doesn't collide with the earlier browse-branch - // `open "$APP_BUNDLE" --args browse ...` line, which shares the same prefix. assertOrdered(ideCommand, '"$APP_BINARY" "${ARGS[@]}"', 'open "$APP_BUNDLE" --args "${ARGS[@]}"'); assertSymlinkTarget(path.join(cliBinDir, "ide-dev"), runScriptPath, "ide-dev command"); - execFileSync("bash", [cliInstallScript], { - cwd: rootDir, - env: { - ...process.env, - IDE_CLI_BIN_DIR: cliBinDir, - IDE_INSTALLED_APP_PATH: installedCliAppBundle, - }, - stdio: "pipe", - }); - const installedIdeCommand = readExecutable( - path.join(cliBinDir, "ide"), - "ide command with installed app", - ); - assertIncludes(installedIdeCommand, `APP_BUNDLE="${installedCliAppBundle}"`); - const serviceRoot = path.join(tempDir, "Services", "Open in ide.workflow"); const supportDir = path.join(tempDir, "Support", "ide"); execFileSync("bash", [finderInstallScript], { @@ -161,17 +119,19 @@ try { "Finder runner", ); assertIncludes(finderRunner, "handoff_to_running_app()"); + assertIncludes(finderRunner, 'APP_BUNDLE="/Applications/ide.app"'); assertIncludes(finderRunner, 'API_BASE="http://127.0.0.1:17877"'); assertIncludes(finderRunner, "$API_BASE/api/codex-mcp"); assertIncludes(finderRunner, "bearerToken"); assertIncludes(finderRunner, 'Authorization: Bearer $token'); assertIncludes(finderRunner, "-X POST"); assertIncludes(finderRunner, "$API_BASE/api/open-path"); - assertIncludes(finderRunner, "ensure_frontend_server"); - assertIncludes(finderRunner, "launch_app"); - if (!/if handoff_to_running_app; then[\s\S]*ensure_frontend_server[\s\S]*launch_app/.test(finderRunner)) { - throw new Error("Finder runner must hand off to a running app before starting the dev server"); - } + assertIncludes(finderRunner, 'open "$APP_BUNDLE" --args "$TARGET"'); + assertNotIncludes(finderRunner, "npm"); + assertNotIncludes(finderRunner, "cargo"); + assertNotIncludes(finderRunner, "ensure_frontend_server"); + assertNotIncludes(finderRunner, "launch_app"); + assertOrdered(finderRunner, "if handoff_to_running_app; then", 'open "$APP_BUNDLE" --args "$TARGET"'); console.log("Launch runner validation passed"); } finally { @@ -189,6 +149,18 @@ function readExecutable(filePath, label) { return fs.readFileSync(filePath, "utf8"); } +function assertNoProductionMutation(text, label) { + for (const forbidden of [ + 'tell application id "com.gordonbeeming.ide" to quit', + 'pkill -f "/Applications/ide.app/Contents/MacOS/ide"', + 'ditto "$DEV_APP_BUNDLE" "/Applications/ide.app"', + ]) { + if (text.includes(forbidden)) { + throw new Error(`${label} must not modify or stop the production app: ${forbidden}`); + } + } +} + function assertSymlinkTarget(filePath, expectedTarget, label) { if (!fs.existsSync(filePath)) { throw new Error(`${label} does not exist: ${filePath}`); diff --git a/scripts/validate-tauri-config.mjs b/scripts/validate-tauri-config.mjs index 317c634..88c4741 100644 --- a/scripts/validate-tauri-config.mjs +++ b/scripts/validate-tauri-config.mjs @@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(__dirname, ".."); const configPath = path.join(rootDir, "src-tauri", "tauri.conf.json"); +const developmentConfigPath = path.join(rootDir, "src-tauri", "tauri.dev.conf.json"); const defaultCapabilityPath = path.join( rootDir, "src-tauri", @@ -15,6 +16,7 @@ const defaultCapabilityPath = path.join( ); const config = JSON.parse(fs.readFileSync(configPath, "utf8")); +const developmentConfig = JSON.parse(fs.readFileSync(developmentConfigPath, "utf8")); const defaultCapability = JSON.parse(fs.readFileSync(defaultCapabilityPath, "utf8")); const associations = config.bundle?.fileAssociations; const bundleIcons = config.bundle?.icon; @@ -79,6 +81,18 @@ if (config.productName !== "ide") { errors.push('productName must remain lowercase "ide"'); } +if (developmentConfig.identifier !== "com.gordonbeeming.ide.dev") { + errors.push("development identifier must be com.gordonbeeming.ide.dev"); +} + +if (developmentConfig.productName !== "ide-dev") { + errors.push('development productName must remain lowercase "ide-dev"'); +} + +if (developmentConfig.bundle?.fileAssociations?.length !== 0) { + errors.push("development bundle must not register production file associations"); +} + const mainWindow = config.app?.windows?.[0]; if (mainWindow?.title !== "ide") { errors.push('main window title must remain lowercase "ide"'); diff --git a/src-tauri/src/http_server.rs b/src-tauri/src/http_server.rs index 5319228..5baa381 100644 --- a/src-tauri/src/http_server.rs +++ b/src-tauri/src/http_server.rs @@ -31,6 +31,11 @@ use crate::{ workspace_root_hash, AgentContext, LaunchTarget, WorkspaceSessionState, }; +const PRODUCTION_LOOPBACK_PORT: u16 = 17877; +const DEVELOPMENT_LOOPBACK_PORT: u16 = 17878; +const DEVELOPMENT_BUNDLE_IDENTIFIER: &str = "com.gordonbeeming.ide.dev"; +const LOOPBACK_PORT_ENV: &str = "IDE_LOOPBACK_PORT"; + #[derive(Clone)] pub struct HttpServerState { workspace_root: Arc>, @@ -63,6 +68,7 @@ struct ResolvedWorkspace { } pub struct HttpServerConfig { + pub bundle_identifier: String, pub root_path: Arc>, pub tree_scan_limit: Arc>, pub max_open_file_bytes: Arc>, @@ -225,6 +231,7 @@ struct RenameFileRequest { pub async fn start_http_server(config: HttpServerConfig) -> Result { let HttpServerConfig { + bundle_identifier, root_path, tree_scan_limit, max_open_file_bytes, @@ -350,7 +357,7 @@ pub async fn start_http_server(config: HttpServerConfig) -> Result>::into_make_service(app); @@ -366,29 +373,69 @@ pub async fn start_http_server(config: HttpServerConfig) -> Result Result { - let preferred = SocketAddr::from((Ipv4Addr::LOCALHOST, 17877)); - match TcpListener::bind(preferred).await { - Ok(listener) => Ok(listener), - Err(preferred_error) => bind_fallback_loopback(preferred, preferred_error).await, +async fn bind_loopback(bundle_identifier: &str) -> Result { + bind_fixed_loopback(loopback_port(bundle_identifier)?).await +} + +fn loopback_port(bundle_identifier: &str) -> Result { + match std::env::var(LOOPBACK_PORT_ENV) { + Ok(value) => loopback_port_from_value(bundle_identifier, Some(&value)), + Err(std::env::VarError::NotPresent) => loopback_port_from_value(bundle_identifier, None), + Err(std::env::VarError::NotUnicode(_)) => Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("{LOOPBACK_PORT_ENV} must contain a Unicode TCP port"), + )), } } -async fn bind_fallback_loopback( - preferred: SocketAddr, - preferred_error: io::Error, -) -> Result { - TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) - .await - .map_err(|fallback_error| { +fn loopback_port_from_value( + bundle_identifier: &str, + value: Option<&str>, +) -> Result { + let channel_port = channel_loopback_port(bundle_identifier); + let Some(value) = value else { + return Ok(channel_port); + }; + + let override_port = (!value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())) + .then_some(value) + .and_then(|value| value.parse::().ok()) + .filter(|port| *port != 0) + .ok_or_else(|| { io::Error::new( - fallback_error.kind(), - format!( - "failed to bind preferred loopback {preferred}: {preferred_error}; \ - failed to bind fallback loopback: {fallback_error}" - ), + io::ErrorKind::InvalidInput, + format!("{LOOPBACK_PORT_ENV} must be a non-zero TCP port, received {value:?}"), ) - }) + })?; + + if override_port != channel_port { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "{LOOPBACK_PORT_ENV} cannot change bundle {bundle_identifier:?} from its fixed port {channel_port} to {override_port}" + ), + )); + } + + Ok(override_port) +} + +fn channel_loopback_port(bundle_identifier: &str) -> u16 { + if bundle_identifier == DEVELOPMENT_BUNDLE_IDENTIFIER { + DEVELOPMENT_LOOPBACK_PORT + } else { + PRODUCTION_LOOPBACK_PORT + } +} + +async fn bind_fixed_loopback(port: u16) -> Result { + let address = SocketAddr::from((Ipv4Addr::LOCALHOST, port)); + TcpListener::bind(address).await.map_err(|error| { + io::Error::new( + error.kind(), + format!("failed to bind fixed loopback {address}: {error}"), + ) + }) } async fn cors_preflight(headers: HeaderMap) -> Response { @@ -1137,8 +1184,8 @@ async fn codex_mcp_status( // IPv4 loopback, and every other endpoint this app hands out (launcher scripts, the // copyable browser endpoint) deliberately stays on 127.0.0.1 to avoid the ::1/proxy // pitfalls — so the MCP endpoint we report back is canonicalized the same way, keeping -// whatever port the client actually connected on (it may not be the default 17877 if -// that port was taken and the server fell back to an OS-assigned one). +// the fixed port the client actually connected on (17877 for production or 17878 for +// development). // // The Host header is client-controlled input, not a trusted value, so the port is // parsed rather than copied verbatim. Un-parsed, a header like @@ -1154,7 +1201,7 @@ fn canonical_loopback_host(host: &str) -> String { .rsplit_once(':') .and_then(|(_, port)| port.parse::().ok()) .filter(|port| *port != 0) - .unwrap_or(17877); + .unwrap_or(PRODUCTION_LOOPBACK_PORT); format!("127.0.0.1:{port}") } @@ -3048,4 +3095,57 @@ mod tests { assert_eq!(canonical_loopback_host("[::1]:17877"), "127.0.0.1:17877"); assert_eq!(canonical_loopback_host("[::1]"), "127.0.0.1:17877"); } + + #[test] + fn production_bundle_defaults_to_the_production_port() { + assert_eq!( + loopback_port_from_value("com.gordonbeeming.ide", None).unwrap(), + PRODUCTION_LOOPBACK_PORT + ); + } + + #[test] + fn development_bundle_defaults_to_the_development_port() { + assert_eq!( + loopback_port_from_value(DEVELOPMENT_BUNDLE_IDENTIFIER, None).unwrap(), + DEVELOPMENT_LOOPBACK_PORT + ); + } + + #[test] + fn matching_loopback_port_override_is_valid() { + assert_eq!( + loopback_port_from_value(DEVELOPMENT_BUNDLE_IDENTIFIER, Some("17878")).unwrap(), + DEVELOPMENT_LOOPBACK_PORT + ); + } + + #[test] + fn loopback_port_rejects_invalid_environment_overrides() { + for value in ["", "0", "+17878", "-1", "17878.0", " 17878", "65536"] { + let error = + loopback_port_from_value(DEVELOPMENT_BUNDLE_IDENTIFIER, Some(value)).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput, "{value:?}"); + } + + let error = + loopback_port_from_value(DEVELOPMENT_BUNDLE_IDENTIFIER, Some("17877")).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert!(error.to_string().contains("fixed port 17878")); + } + + #[tokio::test] + async fn occupied_fixed_loopback_port_fails_instead_of_falling_back() { + let occupied = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) + .await + .unwrap(); + let port = occupied.local_addr().unwrap().port(); + + let error = bind_fixed_loopback(port).await.unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::AddrInUse); + assert!(error + .to_string() + .contains(&format!("fixed loopback 127.0.0.1:{port}"))); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d724264..2e50387 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1836,8 +1836,10 @@ pub fn run() { }); } let app_handle = app.handle().clone(); + let bundle_identifier = app.config().identifier.clone(); tauri::async_runtime::spawn(async move { match http_server::start_http_server(http_server::HttpServerConfig { + bundle_identifier, root_path: workspace_root, tree_scan_limit, max_open_file_bytes, diff --git a/src-tauri/tauri.dev.conf.json b/src-tauri/tauri.dev.conf.json new file mode 100644 index 0000000..2e1e426 --- /dev/null +++ b/src-tauri/tauri.dev.conf.json @@ -0,0 +1,7 @@ +{ + "productName": "ide-dev", + "identifier": "com.gordonbeeming.ide.dev", + "bundle": { + "fileAssociations": [] + } +} diff --git a/src/tauri.test.ts b/src/tauri.test.ts index 434d082..791793a 100644 --- a/src/tauri.test.ts +++ b/src/tauri.test.ts @@ -183,11 +183,12 @@ describe("hosted Tauri API transport", () => { expect(headers.get("Authorization")).toBe("Bearer secret-token"); }); - it("uses the loopback API base for Vite dev server locations", async () => { + it("uses the development loopback API base for Vite dev server locations", async () => { const { apiBaseForLocation } = await import("./tauri"); - expect(apiBaseForLocation({ port: "14717" })).toBe("http://127.0.0.1:17877"); + expect(apiBaseForLocation({ port: "14717" })).toBe("http://127.0.0.1:17878"); expect(apiBaseForLocation({ port: "17877" })).toBe(""); + expect(apiBaseForLocation({ port: "" })).toBe(""); }); it("canonicalizes a localhost-opened origin back to 127.0.0.1 for copyable endpoints", async () => { diff --git a/src/tauri.ts b/src/tauri.ts index bd6a2ff..3e145d8 100644 --- a/src/tauri.ts +++ b/src/tauri.ts @@ -1446,7 +1446,7 @@ function httpBase() { export function apiBaseForLocation(location: Pick) { if (location.port === "14717") { - return "http://127.0.0.1:17877"; + return "http://127.0.0.1:17878"; } return ""; } From 716f0817b428342465ed3eaea0b1e4df90f000a4 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Mon, 27 Jul 2026 15:01:24 +1000 Subject: [PATCH 2/6] Bump PostCSS past path traversal advisory --- package-lock.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 72ec928..e29aac1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3648,9 +3648,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -3762,9 +3762,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -3782,7 +3782,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, From 621c864235ad4e5b07b1b4e4b17516556bd09a7d Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Mon, 27 Jul 2026 15:24:25 +1000 Subject: [PATCH 3/6] Fix PR review feedback Address reviewer comments: - Keep MCP endpoint fallbacks on the active channel port --- src-tauri/src/http_server.rs | 75 +++++++++++++++++++++++++++--------- 1 file changed, 56 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/http_server.rs b/src-tauri/src/http_server.rs index 5baa381..76b68df 100644 --- a/src-tauri/src/http_server.rs +++ b/src-tauri/src/http_server.rs @@ -50,6 +50,7 @@ pub struct HttpServerState { lsp_manager: LspManager, frontend_dist: PathBuf, mcp_token: String, + loopback_port: u16, window_sessions: Arc>>, } @@ -248,6 +249,7 @@ pub async fn start_http_server(config: HttpServerConfig) -> Result Result Result>::into_make_service(app); @@ -373,10 +376,6 @@ pub async fn start_http_server(config: HttpServerConfig) -> Result Result { - bind_fixed_loopback(loopback_port(bundle_identifier)?).await -} - fn loopback_port(bundle_identifier: &str) -> Result { match std::env::var(LOOPBACK_PORT_ENV) { Ok(value) => loopback_port_from_value(bundle_identifier, Some(&value)), @@ -1170,8 +1169,8 @@ async fn codex_mcp_status( let host = headers .get(header::HOST) .and_then(|value| value.to_str().ok()) - .unwrap_or("127.0.0.1:17877"); - let host = canonical_loopback_host(host); + .unwrap_or(""); + let host = canonical_loopback_host(host, state.loopback_port); Json(CodexMcpStatus { endpoint: format!("http://{host}/mcp"), @@ -1196,12 +1195,12 @@ async fn codex_mcp_status( // still matters there: rsplit_once(':') mis-splits a bracketed "[::1]" (no port) and // the malformed suffix falls back to the default, while "[::1]:17877" splits cleanly // and its real port is kept. -fn canonical_loopback_host(host: &str) -> String { +fn canonical_loopback_host(host: &str, fallback_port: u16) -> String { let port = host .rsplit_once(':') .and_then(|(_, port)| port.parse::().ok()) .filter(|port| *port != 0) - .unwrap_or(PRODUCTION_LOOPBACK_PORT); + .unwrap_or(fallback_port); format!("127.0.0.1:{port}") } @@ -2136,6 +2135,7 @@ mod tests { lsp_manager: LspManager::new(), frontend_dist: dir.path().to_path_buf(), mcp_token: "token".to_string(), + loopback_port: PRODUCTION_LOOPBACK_PORT, window_sessions: Arc::new(std::sync::RwLock::new(HashMap::new())), }; @@ -2181,6 +2181,7 @@ mod tests { lsp_manager: LspManager::new(), frontend_dist: dir.path().to_path_buf(), mcp_token: "token".to_string(), + loopback_port: PRODUCTION_LOOPBACK_PORT, window_sessions: Arc::new(std::sync::RwLock::new(HashMap::new())), }; @@ -2219,6 +2220,7 @@ mod tests { lsp_manager: LspManager::new(), frontend_dist: dir.path().to_path_buf(), mcp_token: "token".to_string(), + loopback_port: PRODUCTION_LOOPBACK_PORT, window_sessions: Arc::new(std::sync::RwLock::new(HashMap::new())), }; let mut headers = HeaderMap::new(); @@ -2261,6 +2263,7 @@ mod tests { lsp_manager: LspManager::new(), frontend_dist: dir.path().to_path_buf(), mcp_token: "token".to_string(), + loopback_port: PRODUCTION_LOOPBACK_PORT, window_sessions: Arc::new(std::sync::RwLock::new(HashMap::new())), }; let mut headers = HeaderMap::new(); @@ -2303,6 +2306,7 @@ mod tests { lsp_manager: LspManager::new(), frontend_dist: dir.path().to_path_buf(), mcp_token: "token".to_string(), + loopback_port: PRODUCTION_LOOPBACK_PORT, window_sessions: Arc::new(std::sync::RwLock::new(HashMap::new())), }; @@ -2340,6 +2344,7 @@ mod tests { lsp_manager: LspManager::new(), frontend_dist: dir.path().to_path_buf(), mcp_token: "token".to_string(), + loopback_port: PRODUCTION_LOOPBACK_PORT, window_sessions: Arc::new(std::sync::RwLock::new(HashMap::new())), }; let mut headers = HeaderMap::new(); @@ -2381,6 +2386,7 @@ mod tests { lsp_manager: LspManager::new(), frontend_dist: dir.path().to_path_buf(), mcp_token: "token".to_string(), + loopback_port: PRODUCTION_LOOPBACK_PORT, window_sessions: Arc::new(std::sync::RwLock::new(HashMap::new())), }; @@ -2416,6 +2422,7 @@ mod tests { lsp_manager: LspManager::new(), frontend_dist: dir.path().to_path_buf(), mcp_token: "token".to_string(), + loopback_port: PRODUCTION_LOOPBACK_PORT, window_sessions: Arc::new(std::sync::RwLock::new(HashMap::new())), }; let mut headers = HeaderMap::new(); @@ -2457,6 +2464,7 @@ mod tests { lsp_manager: LspManager::new(), frontend_dist: dir.path().to_path_buf(), mcp_token: "token".to_string(), + loopback_port: PRODUCTION_LOOPBACK_PORT, window_sessions: Arc::new(std::sync::RwLock::new(HashMap::new())), }; @@ -2496,6 +2504,7 @@ mod tests { lsp_manager: LspManager::new(), frontend_dist: dir.path().to_path_buf(), mcp_token: "token".to_string(), + loopback_port: PRODUCTION_LOOPBACK_PORT, window_sessions: Arc::new(std::sync::RwLock::new(HashMap::new())), }; let mut headers = HeaderMap::new(); @@ -2538,6 +2547,7 @@ mod tests { lsp_manager: LspManager::new(), frontend_dist: dir.path().to_path_buf(), mcp_token: "token".to_string(), + loopback_port: PRODUCTION_LOOPBACK_PORT, window_sessions: Arc::new(std::sync::RwLock::new(HashMap::new())), }; @@ -2575,6 +2585,7 @@ mod tests { lsp_manager: LspManager::new(), frontend_dist: dir.path().to_path_buf(), mcp_token: "token".to_string(), + loopback_port: PRODUCTION_LOOPBACK_PORT, window_sessions: Arc::new(std::sync::RwLock::new(HashMap::new())), }; let mut headers = HeaderMap::new(); @@ -2622,6 +2633,7 @@ mod tests { lsp_manager: LspManager::new(), frontend_dist: dir.path().to_path_buf(), mcp_token: "token".to_string(), + loopback_port: PRODUCTION_LOOPBACK_PORT, window_sessions: Arc::new(std::sync::RwLock::new(HashMap::new())), }; @@ -2700,6 +2712,7 @@ mod tests { lsp_manager: LspManager::new(), frontend_dist: dir.path().to_path_buf(), mcp_token: "token".to_string(), + loopback_port: PRODUCTION_LOOPBACK_PORT, window_sessions: Arc::new(std::sync::RwLock::new(HashMap::new())), }; @@ -2743,6 +2756,7 @@ mod tests { lsp_manager: LspManager::new(), frontend_dist: dir.path().to_path_buf(), mcp_token: "token".to_string(), + loopback_port: PRODUCTION_LOOPBACK_PORT, window_sessions: Arc::new(std::sync::RwLock::new(HashMap::new())), }; @@ -2937,6 +2951,7 @@ mod tests { lsp_manager: LspManager::new(), frontend_dist: frontend_dist.to_path_buf(), mcp_token: "token".to_string(), + loopback_port: PRODUCTION_LOOPBACK_PORT, window_sessions: Arc::new(std::sync::RwLock::new(sessions)), } } @@ -3072,28 +3087,50 @@ mod tests { #[test] fn canonical_loopback_host_replaces_hostname_keeps_port() { assert_eq!( - canonical_loopback_host("localhost:17877"), + canonical_loopback_host("localhost:17877", PRODUCTION_LOOPBACK_PORT), + "127.0.0.1:17877" + ); + assert_eq!( + canonical_loopback_host("127.0.0.1:17877", PRODUCTION_LOOPBACK_PORT), "127.0.0.1:17877" ); + // Falls back to the listener's channel port when the header has no port at all. assert_eq!( - canonical_loopback_host("127.0.0.1:17877"), + canonical_loopback_host("localhost", PRODUCTION_LOOPBACK_PORT), "127.0.0.1:17877" ); - // Falls back to the default port when the header has no port at all. - assert_eq!(canonical_loopback_host("localhost"), "127.0.0.1:17877"); + assert_eq!( + canonical_loopback_host("localhost", DEVELOPMENT_LOOPBACK_PORT), + "127.0.0.1:17878" + ); // ...or when the port suffix doesn't parse as a valid non-zero u16. - assert_eq!(canonical_loopback_host("localhost:"), "127.0.0.1:17877"); - assert_eq!(canonical_loopback_host("localhost:abc"), "127.0.0.1:17877"); - assert_eq!(canonical_loopback_host("localhost:0"), "127.0.0.1:17877"); + assert_eq!( + canonical_loopback_host("localhost:", PRODUCTION_LOOPBACK_PORT), + "127.0.0.1:17877" + ); + assert_eq!( + canonical_loopback_host("localhost:abc", PRODUCTION_LOOPBACK_PORT), + "127.0.0.1:17877" + ); + assert_eq!( + canonical_loopback_host("localhost:0", PRODUCTION_LOOPBACK_PORT), + "127.0.0.1:17877" + ); // A malicious Host header can't smuggle a userinfo/host through the port slot — // the suffix has to parse as a port, so it falls back to the default. assert_eq!( - canonical_loopback_host("localhost:17877@attacker.example"), + canonical_loopback_host("localhost:17877@attacker.example", PRODUCTION_LOOPBACK_PORT), "127.0.0.1:17877" ); // rsplit_once(':') would otherwise mis-split inside IPv6 brackets. - assert_eq!(canonical_loopback_host("[::1]:17877"), "127.0.0.1:17877"); - assert_eq!(canonical_loopback_host("[::1]"), "127.0.0.1:17877"); + assert_eq!( + canonical_loopback_host("[::1]:17877", PRODUCTION_LOOPBACK_PORT), + "127.0.0.1:17877" + ); + assert_eq!( + canonical_loopback_host("[::1]", PRODUCTION_LOOPBACK_PORT), + "127.0.0.1:17877" + ); } #[test] From 665a9875d9f32a7cbc472e8ffec3bcc4e0aa8e16 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Mon, 27 Jul 2026 15:33:57 +1000 Subject: [PATCH 4/6] Fix PR review feedback Address reviewer comments: - Allow safe development install paths through symlinked prefixes - Run channel-isolation scripts with harness command stubs first --- build.sh | 10 ++-------- scripts/validate-channel-isolation.mjs | 16 +++++++++++----- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/build.sh b/build.sh index e36918b..400bb5a 100755 --- a/build.sh +++ b/build.sh @@ -70,14 +70,8 @@ resolve_dev_install_path() { return 1 fi - local requested_parent resolved_parent - requested_parent="$(dirname "$app_path")" - resolved_parent="$(resolve_dev_install_parent "$requested_parent")" || return 1 - - if [[ "$resolved_parent" != "$requested_parent" ]]; then - echo "Development app parent must not resolve through a symbolic-link alias: $requested_parent -> $resolved_parent" >&2 - return 1 - fi + local resolved_parent + resolved_parent="$(resolve_dev_install_parent "$(dirname "$app_path")")" || return 1 case "$resolved_parent" in / | /Applications | /Applications/*) diff --git a/scripts/validate-channel-isolation.mjs b/scripts/validate-channel-isolation.mjs index 28f9b64..3669a12 100644 --- a/scripts/validate-channel-isolation.mjs +++ b/scripts/validate-channel-isolation.mjs @@ -142,12 +142,17 @@ function testInstallPolicyAndLaunchers() { const aliasParent = path.join(harness.base, "install-alias"); fs.mkdirSync(aliasTarget); fs.symlinkSync(aliasTarget, aliasParent); - assertRejectedInstallPath( - checkout, + const symlinkPrefixResult = runShell( + path.join(checkout, "build.sh"), + [], harness, - path.join(aliasParent, "ide-dev.app"), - buildEnv, + "build-success", + { + ...buildEnv, + IDE_DEV_INSTALLED_APP_PATH: path.join(aliasParent, "ide-dev.app"), + }, ); + assertStatus(symlinkPrefixResult, 0, "symlink-prefix development install"); assertProductionSentinel(harness); } @@ -298,7 +303,7 @@ esac writeExecutable(path.join(binDir, command), contents); } - return { base, home, state, bashEnv }; + return { base, home, state, bashEnv, binDir }; } function runShell(script, args, harness, scenario, extraEnv = {}) { @@ -308,6 +313,7 @@ function runShell(script, args, harness, scenario, extraEnv = {}) { env: { ...process.env, HOME: harness.home, + PATH: `${harness.binDir}:${process.env.PATH ?? ""}`, BASH_ENV: harness.bashEnv, HARNESS_STATE: harness.state, HARNESS_SCENARIO: scenario, From 50cc4d03777ef8462b08abe4731cc8a5355edf91 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Mon, 27 Jul 2026 15:40:14 +1000 Subject: [PATCH 5/6] Fix PR review feedback Address reviewer comments: - Guard macOS development packaging paths - Protect canonical Applications aliases --- build.sh | 8 +++++++- dev-install.sh | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/build.sh b/build.sh index 400bb5a..564fc96 100755 --- a/build.sh +++ b/build.sh @@ -2,6 +2,12 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "build.sh packages the macOS development app only." >&2 + echo "Use npm run tauri -- build on this platform." >&2 + exit 1 +fi cd "$ROOT_DIR" export PATH="$HOME/.local/bin:$HOME/Library/pnpm:$HOME/.cargo/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${PATH:-}" @@ -74,7 +80,7 @@ resolve_dev_install_path() { resolved_parent="$(resolve_dev_install_parent "$(dirname "$app_path")")" || return 1 case "$resolved_parent" in - / | /Applications | /Applications/*) + / | /Applications | /Applications/* | /System/Volumes/Data/Applications | /System/Volumes/Data/Applications/*) echo "Refusing to install a development app under $resolved_parent" >&2 return 1 ;; diff --git a/dev-install.sh b/dev-install.sh index 4b73d07..49bd61a 100755 --- a/dev-install.sh +++ b/dev-install.sh @@ -5,7 +5,7 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" if [[ "$(uname -s)" != "Darwin" ]]; then echo "dev-install.sh currently installs the packaged macOS development app only." >&2 - echo "Use ./build.sh to build the local package on this platform." >&2 + echo "Use npm run tauri -- build to build the local package on this platform." >&2 exit 1 fi From 0b8ca807098d2a5f4aa50a70c2b07d885c0d0b30 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Mon, 27 Jul 2026 17:11:21 +1000 Subject: [PATCH 6/6] Clarify development listener ownership --- docs/development.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/development.md b/docs/development.md index b82d6e1..93fc555 100644 --- a/docs/development.md +++ b/docs/development.md @@ -34,7 +34,7 @@ Folder targets become the workspace root. File targets open their parent folder When a file or folder target is supplied and an `ide-dev` instance is already reachable on its loopback API, `run.sh` authenticates with the persisted app-local bearer token and hands the target to `/api/open-path` instead of starting another development instance. -When no target is supplied and an `ide-dev` instance is already reachable, `run.sh` replaces that development instance with the current working copy. Plain `./run.sh` always means "run the code in this working copy"; production stays running if it is open. +When no target is supplied, `run.sh` replaces a reachable `ide-dev` instance only when this checkout owns its listener. If another process owns the port, it refuses rather than interfering. Plain `./run.sh` always means "run the code in this working copy"; production stays running if it is open. Install the production app through Homebrew: