From a684b8d81efc967b64a44226fc7a027f85a37f32 Mon Sep 17 00:00:00 2001 From: Sergey Afonin Date: Mon, 7 Sep 2026 16:01:05 +0300 Subject: [PATCH 1/2] Guard inspector activation before sending SIGUSR1 --- CHANGELOG.md | 1 + README.md | 8 +- docs/CHANGELOG.ko.md | 1 + docs/README.ko.md | 8 +- docs/TROUBLESHOOTING.ko.md | 10 +- docs/TROUBLESHOOTING.md | 10 +- src/electron-fuses.js | 119 ++++++++++++++++++++ src/micro-cdp.js | 11 +- src/micro-main-inspector.js | 13 +++ test/electron-fuses.test.js | 180 ++++++++++++++++++++++++++++++ test/micro-cdp.test.js | 80 +++++++++++++ test/micro-main-inspector.test.js | 113 ++++++++++++++++++- 12 files changed, 540 insertions(+), 14 deletions(-) create mode 100644 src/electron-fuses.js create mode 100644 test/electron-fuses.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 341b6b2..91a9436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## Unreleased +- Guard inspector bootstrap against Codex builds that disable `SIGUSR1` activation. ThreadDeck checks the loaded Electron framework's inspector fuse and rechecks the process identity before signaling; disabled or unverifiable builds use the existing safe fallback without sending the signal. Existing process-owned inspector connections remain usable. - Tightened the goal-only timing capsule so the unfinished-goal marker and elapsed time read as one centered group, while preserving the existing compact goal-and-queue layout. ## 0.5.15 — 2026-07-27 diff --git a/README.md b/README.md index 9592ada..3aeb7ae 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ The overview and gesture demos are generated from the plugin's real SVG key rend - **Completion feedback** — after a fresh final-turn end is confirmed with no queued continuation, every visible ThreadDeck-owned key acknowledges the first completion frame. The matching task then keeps a slower green pulse until that exact task is successfully opened or viewed frontmost in Codex. Queue edits and dequeue-to-execution transitions never count as completion. - **Workflow controls** — change the current model's reasoning effort, toggle Codex Fast mode, create a task or Side Chat, send, change pages, and optionally show a weekly quota ring. - **Five focused actions** — choose Current or Top 1–8 from one **Codex task** action, and New task, Side Chat, or Send from one **Codex command** action in the autosaving Property Inspector. -- **Micro-first reliability without restarts** — Effort, Fast, Side Chat, push-to-talk, Send, New Task, and the six native Micro task slots use Codex's own internal commands first. An ordinary Codex launch needs no special relaunch: when no persistent renderer endpoint exists, ThreadDeck briefly bootstraps an authenticated local command socket at plugin startup and closes the inspector before the first key press. Accessibility and shortcuts remain a verified fallback for the eight-task dashboard and unsupported builds. +- **Micro-first reliability without restarts** — Effort, Fast, Side Chat, push-to-talk, Send, New Task, and the six native Micro task slots use Codex's own internal commands first. When no persistent renderer endpoint exists, ThreadDeck checks whether the running Codex build supports inspector activation before preparing an authenticated local command socket at startup. It closes an inspector it opened before the first key press. Disabled or unverifiable builds skip activation; Accessibility and shortcuts remain a verified fallback for the eight-task dashboard and unsupported builds. ## Install in 60 seconds @@ -65,7 +65,7 @@ The overview and gesture demos are generated from the plugin's real SVG key rend On current macOS versions the switch is named **Elgato Stream Deck.app**. No Screen Recording, Input Monitoring, or Full Disk Access permission is required. -3. Confirm the four Codex shortcuts below so the verified fallback remains available, then test the microphone key. A missing persistent Micro renderer endpoint never requires a Codex restart: ThreadDeck prepares a private command socket against the exact running Codex process before the first press, while passive monitoring and fallback stay on the local/Accessibility adapters. +3. Confirm the four Codex shortcuts below so the verified fallback remains available, then test the microphone key. With no persistent Micro renderer endpoint, ThreadDeck prepares a private command socket only when the running build supports inspector activation. Otherwise it skips activation and retains the safe local/Accessibility fallback; no Codex restart or extra permission enables this build-time capability. The package contains an editable ready-to-use profile, one universal Apple silicon/Intel helper, and both English and Korean localization. For screenshots of every setup step, updates, removal, and the read-only doctor command, see [Install ThreadDeck on another Mac](docs/INSTALL.md). @@ -184,7 +184,7 @@ ThreadDeck has no account, telemetry, analytics, update server, or cloud backend | `~/Library/Application Support/ThreadDeck/unread-completions-v1.json` | Local read/write | Task UUID and numeric completion/notice timestamps for unviewed completion cues; no title or conversation text | | `~/Library/Application Support/ThreadDeck/media-pause-lease-v1.plist` | Temporary local read/write | Bundle identifiers of media apps paused by the current voice hold; expires after ten minutes and contains no title, URL, PID, or media text | | `~/Library/Application Support/ThreadDeck/bin/keybridge-` | Local executable cache | Content-addressed copy of the packaged native helper, used so Marketplace DRM can keep the distributed plugin immutable; contains executable code only and no user data | -| Codex renderer bridge on loopback | Local read/control | Reuses a persistent process-owned endpoint when available. Otherwise plugin startup briefly opens the exact Codex main process's loopback Node inspector, verifies the listener owner, installs an in-memory authenticated Unix-domain command socket with mode `0600`, and closes the inspector before the first key press. Commands reuse that private socket; passive polling never does | +| Codex renderer bridge on loopback | Local read/control | Reuses a persistent process-owned endpoint when available. Otherwise it verifies the loaded Electron framework's inspector fuse and rechecks the Codex process identity before signaling. Unsupported or unverifiable builds skip activation. On supported builds it opens the loopback inspector, verifies the listener owner, prepares an authenticated mode-`0600` Unix-domain command socket, and closes its inspector before the first key press. Commands reuse that socket; passive polling never does | | `codex-micro-bootstrap-v1.json` and `codex-micro-bridge.json` | Local read/write | Process generation, loopback port, health, cooldown, and numeric timestamps only; no title, prompt, transcript, or credential | | CodexBar CLI | Optional child process | Weekly remaining quota only; CodexBar has its own provider behavior | | Stream Deck plugin socket | Localhost | Receive key events and send rendered key images | @@ -197,7 +197,7 @@ ThreadDeck never writes to Codex database or session files, but a physical key p | Symptom | First check | |---|---| | No key actions work | Follow the key warning: `Allow access` means Accessibility, `Input access` means event posting, `Check Codex` means confirmed Codex-operation failures, and `Check media` means active playback could not be safely controlled. ThreadDeck rechecks permissions every 30 seconds and clears operation warnings after a verified recovery. | -| A native control cannot connect | Keep using the deck. ThreadDeck first reuses an existing process-owned endpoint, then prepares its exact-process local command socket at startup or after a Codex generation change. If another process owns the bootstrap inspector port or the Codex build changed, it fails closed and uses the verified fallback where replay is safe; no Codex restart is required. | +| A native control cannot connect | ThreadDeck reuses an existing process-owned endpoint or prepares its local command socket only after verifying inspector compatibility. Disabled or unverifiable builds, or a port owned by another process, use the verified fallback where replay is safe. This does not require enabling another macOS permission. See [Micro connection troubleshooting](docs/TROUBLESHOOTING.md#a-micro-native-control-does-not-respond). | | Music or browser audio keeps playing during dictation | Update to the latest build. ThreadDeck now resolves any active Core Audio process to its GUI owner and uses verified semantic controls; Apple Music plus Chrome and Safari YouTube were physically tested. | | Korean input source blocks dictation | Confirm Codex Start dictation is `⌃⇧D`; ThreadDeck sends a Latin `D` independently of the active layout | | Microphone release does not send | This is expected for the dedicated microphone; it leaves a draft. Use a task-key hold for auto-submit or press Send afterward | diff --git a/docs/CHANGELOG.ko.md b/docs/CHANGELOG.ko.md index ba8a666..de455dc 100644 --- a/docs/CHANGELOG.ko.md +++ b/docs/CHANGELOG.ko.md @@ -4,6 +4,7 @@ ## 미배포 +- `SIGUSR1`로 inspector를 활성화할 수 없는 Codex 빌드에서 초기 연결이 앱을 종료시키지 않도록 보호합니다. 신호를 보내기 전에 로드된 Electron 프레임워크의 inspector fuse와 프로세스 식별자를 확인하며, 비활성화되었거나 확인할 수 없는 빌드에는 신호를 보내지 않고 기존의 안전한 폴백을 사용합니다. 해당 프로세스가 이미 소유한 inspector 연결은 계속 사용할 수 있습니다. - 목표만 있는 시간 캡슐에서 미완료 목표 아이콘과 경과 시간을 중앙의 한 묶음처럼 더 가깝게 배치하고, 기존 목표+대기열 조합의 촘촘한 배치는 그대로 유지했습니다. ## 0.5.15 — 2026-07-27 diff --git a/docs/README.ko.md b/docs/README.ko.md index ba4690b..232cd3f 100644 --- a/docs/README.ko.md +++ b/docs/README.ko.md @@ -41,7 +41,7 @@ ThreadDeck은 Stream Deck을 Codex의 물리 작업 모니터이자 컨트롤러 - **완료 피드백** — 대기 후속 요청이 없는 마지막 턴의 새 종료값이 확인된 뒤에만 화면의 모든 ThreadDeck 소유 버튼이 첫 완료 프레임을 함께 표시합니다. 이후 해당 작업 버튼은 그 작업을 실제로 열거나 Codex 전면에서 확인할 때까지 느린 초록 펄스를 계속 냅니다. 대기 명령 수정이나 실행 순서로의 전환은 완료로 세지 않습니다. - **작업 흐름 제어** — 현재 모델의 추론 강도 조절, Codex Fast mode 전환, 새 작업, 사이드챗, 보내기, 페이지 이동, 선택 기능인 주간 한도 링. - **Codex 중심 액션 5개** — 하나의 **Codex 작업** 액션에서 현재 작업·상위 작업 1~8을 고르고, 하나의 **Codex 명령** 액션에서 새 작업·사이드챗·보내기를 자동 저장 속성 검사기로 고릅니다. -- **재실행 없는 Micro 우선 안정성** — Effort, Fast, 사이드챗, 누르는 동안 말하기, 보내기, 새 작업, Micro 기본 슬롯 6개는 Codex 내부 명령을 먼저 사용합니다. 일반 방식으로 실행한 Codex에 상시 렌더러 endpoint가 없어도 플러그인 시작 시 정확한 프로세스에 인증된 전용 명령 소켓을 미리 준비하고 inspector는 첫 버튼 입력 전에 닫습니다. 8개 작업 대시보드와 지원되지 않는 빌드에는 검증된 손쉬운 사용·단축키 폴백을 유지합니다. +- **재실행 없는 Micro 우선 안정성** — Effort, Fast, 사이드챗, 누르는 동안 말하기, 보내기, 새 작업, Micro 기본 슬롯 6개는 Codex 내부 명령을 먼저 사용합니다. 상시 렌더러 endpoint가 없으면 실행 중인 Codex 빌드의 inspector 활성화 지원 여부를 확인한 뒤 플러그인 시작 시 인증된 전용 명령 소켓을 준비합니다. 직접 연 inspector는 첫 버튼 입력 전에 닫습니다. 비활성화되었거나 확인할 수 없는 빌드에서는 활성화를 건너뛰며, 8개 작업 대시보드와 지원되지 않는 빌드에는 검증된 손쉬운 사용·단축키 폴백을 유지합니다. ## 60초 설치 @@ -65,7 +65,7 @@ ThreadDeck은 Stream Deck을 Codex의 물리 작업 모니터이자 컨트롤러 최근 macOS에서는 **Elgato Stream Deck.app**으로 표시됩니다. 화면 기록·입력 모니터링·전체 디스크 접근 권한은 필요하지 않습니다. -3. 검증된 폴백을 위해 아래 Codex 단축키 4개를 확인하고 마이크 버튼을 시험합니다. 상시 Micro 렌더러 endpoint가 없어도 Codex를 재실행할 필요가 없습니다. 내부 명령은 정확한 실행 중 Codex 프로세스에 잠시 연결하고, 평소 모니터링과 폴백은 로컬·손쉬운 사용 어댑터를 유지합니다. +3. 검증된 폴백을 위해 아래 Codex 단축키 4개를 확인하고 마이크 버튼을 시험합니다. 상시 Micro 렌더러 endpoint가 없으면 실행 중인 빌드가 inspector 활성화를 지원할 때만 전용 명령 소켓을 준비합니다. 지원하지 않으면 활성화를 건너뛰고 안전한 로컬·손쉬운 사용 폴백을 유지합니다. 이 빌드 설정은 Codex 재실행이나 추가 권한 허용으로 켤 수 없습니다. 설치 파일 하나에 바로 쓸 수 있는 편집 가능 프로필, Apple Silicon/Intel 공용 헬퍼, 영어/한국어 현지화가 모두 들어갑니다. 화면별 설치 방법과 업데이트·삭제·읽기 전용 진단 명령은 [다른 Mac에 설치하기](INSTALL.ko.md)를 확인하세요. @@ -184,7 +184,7 @@ ThreadDeck에는 계정, 텔레메트리, 분석 도구, 업데이트 서버, | `~/Library/Application Support/ThreadDeck/unread-completions-v1.json` | 로컬 읽기·쓰기 | 확인하지 않은 완료 표시를 위한 작업 UUID와 숫자형 완료·알림 시각만 보관; 제목·대화 원문은 저장하지 않음 | | `~/Library/Application Support/ThreadDeck/media-pause-lease-v1.plist` | 임시 로컬 읽기·쓰기 | 현재 음성 입력이 멈춘 미디어 앱의 번들 식별자만 보관; 10분 뒤 만료되며 제목·URL·PID·미디어 문구는 저장하지 않음 | | `~/Library/Application Support/ThreadDeck/bin/keybridge-` | 로컬 실행 파일 캐시 | Marketplace DRM이 배포 플러그인을 불변으로 유지할 수 있도록 패키지 네이티브 헬퍼를 내용 해시 기반으로 복사; 실행 코드만 포함하며 사용자 데이터는 저장하지 않음 | -| 루프백 Codex 렌더러 연결 | 로컬 읽기·제어 | 기존 프로세스 소유 endpoint가 있으면 재사용합니다. 없으면 플러그인 시작 시 정확한 Codex 메인 프로세스의 Node inspector를 잠시 열고 listener 소유자를 검증해 메인 `app://` 렌더러용 인증된 권한 `0600` Unix 도메인 소켓을 준비한 뒤 첫 입력 전에 inspector를 닫습니다. 버튼은 준비된 소켓을 재사용하고 상시 폴링에는 사용하지 않습니다 | +| 루프백 Codex 렌더러 연결 | 로컬 읽기·제어 | 기존 프로세스 소유 endpoint가 있으면 재사용합니다. 없으면 로드된 Electron 프레임워크의 inspector fuse와 Codex 프로세스 식별자를 확인한 뒤 신호를 보냅니다. 지원하지 않거나 확인할 수 없는 빌드는 활성화를 건너뜁니다. 지원하는 빌드에서는 루프백 inspector와 listener 소유자를 확인해 인증된 권한 `0600` Unix 도메인 명령 소켓을 준비하고 첫 입력 전에 직접 연 inspector를 닫습니다. 버튼은 준비된 소켓을 재사용하고 상시 폴링에는 사용하지 않습니다 | | `codex-micro-bootstrap-v1.json`, `codex-micro-bridge.json` | 로컬 읽기·쓰기 | 프로세스 세대, 루프백 포트, 건강 상태, 재시도 간격, 숫자 시각만 저장; 제목·프롬프트·받아쓰기·인증 정보 없음 | | CodexBar CLI | 선택 하위 프로세스 | 남은 주간 한도만 확인; CodexBar의 제공자 동작은 별개 | | Stream Deck 플러그인 소켓 | 로컬호스트 | 버튼 이벤트 수신과 렌더링 이미지 전송 | @@ -197,7 +197,7 @@ ThreadDeck은 Codex DB와 세션 파일에는 쓰지 않지만, 사용자가 물 | 증상 | 먼저 확인할 것 | |---|---| | 어떤 버튼도 동작하지 않음 | 버튼 경고 확인: `권한 필요`는 손쉬운 사용, `입력 권한`은 합성 키 입력, `Codex 점검`은 Codex 실제 조작 실패, `미디어 점검`은 활성 재생을 안전하게 제어하지 못한 상태입니다. ThreadDeck이 권한을 30초마다 다시 검사하고 실제 복구가 확인되면 동작 경고도 자동 해제합니다. | -| 내부 명령이 연결되지 않음 | 그대로 사용하세요. 기존 프로세스 소유 endpoint를 먼저 재사용하고, 없으면 플러그인 시작 또는 Codex 프로세스 세대 변경 뒤 정확한 프로세스 명령 소켓을 미리 준비합니다. 다른 프로세스가 초기 준비용 inspector 포트를 사용 중이거나 Codex 구조가 바뀌면 실패 폐쇄 후 안전한 동작만 검증된 폴백으로 넘깁니다. Codex 재실행은 필요하지 않습니다. | +| 내부 명령이 연결되지 않음 | 기존 프로세스 소유 endpoint를 재사용하거나 inspector 호환성을 확인한 뒤에만 전용 명령 소켓을 준비합니다. 비활성화되었거나 확인할 수 없는 빌드, 다른 프로세스가 소유한 포트에서는 재실행이 안전한 동작만 검증된 폴백으로 넘깁니다. 추가 macOS 권한을 켤 필요는 없습니다. [Micro 연결 문제 해결](TROUBLESHOOTING.ko.md#micro-내부-제어가-반응하지-않습니다)을 확인하세요. | | 받아쓰기 중 Music이나 브라우저 소리가 계속 재생됨 | 최신 빌드로 업데이트하세요. 이제 앱 허용 목록 없이 활성 Core Audio 프로세스의 화면 앱과 의미가 확인된 재생·일시정지 컨트롤을 사용합니다. Apple Music과 Chrome·Safari YouTube를 실기 검증했습니다. | | 한글 입력 상태에서 받아쓰기가 시작되지 않음 | Codex의 받아쓰기 시작이 `⌃⇧D`인지 확인; ThreadDeck은 입력 배열과 무관한 라틴 `D`를 전송 | | 마이크 버튼을 놓아도 메시지가 전송되지 않음 | 정상 동작입니다. 전용 마이크는 초안만 남깁니다. 자동 제출은 작업 버튼을 길게 누르거나 이후 보내기 사용 | diff --git a/docs/TROUBLESHOOTING.ko.md b/docs/TROUBLESHOOTING.ko.md index d90a83a..5ef1748 100644 --- a/docs/TROUBLESHOOTING.ko.md +++ b/docs/TROUBLESHOOTING.ko.md @@ -18,12 +18,18 @@ Neo 프로필은 플러그인과 함께 설치되지만 현재 프로필을 강 ## Micro 내부 제어가 반응하지 않습니다 -ThreadDeck은 연결을 위해 Codex를 자동으로 종료·재실행·전면 활성화하지 않습니다. 먼저 현재 Codex 프로세스가 실제로 소유한 건강한 루프백 렌더러 endpoint를 재사용합니다. 없으면 플러그인 시작 시 정확한 메인 PID에 Node 루프백 inspector를 잠시 열고, 같은 PID가 9229 포트를 소유하는지 확인한 뒤 메인 `app://` 렌더러용 인증된 권한 `0600` Unix 도메인 소켓을 준비합니다. ThreadDeck이 연 inspector는 첫 버튼 입력 전에 닫고, 물리 명령은 준비된 소켓을 재사용하며 평소 폴링은 이 소켓을 사용하지 않습니다. +ThreadDeck은 먼저 현재 Codex 프로세스가 실제로 소유한 정상적인 루프백 렌더러 endpoint를 재사용합니다. 없으면 Node 루프백 inspector를 여는 `SIGUSR1` 신호를 보내기 전에 로드된 Electron 프레임워크의 `EnableNodeCliInspectArguments` fuse를 확인합니다. 활성화가 명시적으로 허용되어야 하며, 비활성화·누락·읽기 실패·알 수 없는 형식이면 이 단계를 건너뛰고 안전한 동작에 기존 손쉬운 사용·단축키 어댑터를 사용합니다. 프로세스나 프레임워크 파일이 바뀌어도 활성화를 취소합니다. 연결을 위해 Codex 파일을 수정하거나 앱을 재실행·전면 활성화하지 않습니다. -소스 체크아웃에서 `pnpm run doctor`를 실행하면 읽기 전용 상태를 볼 수 있습니다. `연결됨`은 상시 렌더러 endpoint와 메인 `app://` 대상이 모두 응답했다는 뜻이고, 상시 브리지가 없으면 플러그인 시작 시 정확한 프로세스 명령 소켓을 준비할 수 있습니다. `중지`는 Codex가 닫혀 있다는 뜻입니다. doctor는 초기 준비용 inspector를 열지 않고 두 구성 요소 모두 Codex를 시작하거나 종료하지 않습니다. +지원하는 빌드에서는 같은 PID가 9229 포트를 소유하는지 확인한 뒤 메인 `app://` 렌더러용 인증된 권한 `0600` Unix 도메인 소켓을 준비하고, 직접 연 inspector는 첫 버튼 입력 전에 닫습니다. 물리 명령은 준비된 소켓을 재사용하며 평소 폴링은 이 소켓을 사용하지 않습니다. 해당 프로세스가 이미 소유한 inspector는 활성화 신호 없이도 재사용할 수 있습니다. + +소스 체크아웃에서 `pnpm run doctor`를 실행하면 읽기 전용 상태를 볼 수 있습니다. `연결됨`은 상시 렌더러 endpoint와 메인 `app://` 대상이 모두 응답했다는 뜻이며, 상시 브리지가 없으면 inspector 호환성이 확인된 경우에만 명령 소켓을 준비할 수 있습니다. `중지`는 Codex가 닫혀 있다는 뜻입니다. doctor는 초기 준비용 inspector를 열거나 Codex를 시작·종료하지 않습니다. 다른 프로세스가 이미 9229 포트를 소유하면 ThreadDeck은 거기에 연결하지 않고, 안전한 동작만 검증된 손쉬운 사용·단축키 어댑터로 넘깁니다. Codex 업데이트 뒤 내부 동작 하나만 실패하면 반복해서 누르지 마세요. 렌더러에 전달됐는지 모호한 동작은 Fast 두 번 토글이나 중복 제출을 막기 위해 폴백으로 재실행하지 않습니다. ThreadDeck을 업데이트하거나 Codex 버전과 사용한 버튼을 알려주세요. +### ThreadDeck을 시작하면 Codex가 종료됩니다 + +이전 ThreadDeck 빌드는 호환성 확인 없이 `SIGUSR1` 활성화를 시도했습니다. 일부 Codex 빌드는 이 신호를 받으면 종료되며, [이슈 #15](https://github.com/y5862000/threaddeck-for-codex/issues/15)에서 추적하고 있습니다. 호환성 보호 기능이 포함된 빌드를 사용하기 전까지 Stream Deck을 종료하거나 ThreadDeck을 비활성화하세요. [Electron inspector fuse](https://www.electronjs.org/docs/latest/tutorial/fuses#nodecliinspect)는 Codex를 패키징할 때 정해지므로 macOS 권한을 추가로 허용해도 해결되지 않습니다. 이를 켜기 위해 서명된 Codex 앱을 수정하지 마세요. + ## 단축키·원격 전환이 모두 동작하지 않습니다 ThreadDeck은 시작할 때와 30초마다 권한과 기존 제어 단계를 따로 확인합니다. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index da0fb08..060ba0a 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -18,12 +18,18 @@ The Neo profile is installed with the plugin but is not forced over your current ## A Micro-native control does not respond -ThreadDeck never terminates, relaunches, or foregrounds Codex to connect. It first reuses a healthy loopback renderer endpoint owned by the exact existing Codex process. If none exists, plugin startup opens Node's loopback inspector only on that exact main PID, verifies that the same PID owns port 9229, prepares an authenticated mode-`0600` Unix-domain socket for the main `app://` renderer, and closes an inspector it opened before the first press. Physical commands reuse that socket; passive polling never uses it. +ThreadDeck first reuses a healthy loopback renderer endpoint owned by the exact existing Codex process. If none exists, it checks the loaded Electron framework's `EnableNodeCliInspectArguments` fuse before sending `SIGUSR1` to open Node's loopback inspector. The fuse must explicitly allow activation; disabled, missing, unreadable, or unrecognized data skips this step and leaves safe controls on the existing Accessibility/shortcut adapter. A changed process or framework file also cancels activation. ThreadDeck does not modify Codex's files, relaunch it, or bring it forward to connect. -Run `pnpm run doctor` from a source checkout for a read-only report. `connected` means a persistent renderer endpoint and main `app://` target both responded; no persistent bridge means plugin startup can prepare the exact-process command socket. `stopped` means Codex is closed. The doctor never opens the bootstrap inspector and neither component starts or closes Codex. +On supported builds, ThreadDeck verifies that the same PID owns port 9229, prepares an authenticated mode-`0600` Unix-domain socket for the main `app://` renderer, and closes an inspector it opened before the first press. Physical commands reuse that socket; passive polling never uses it. A process-owned inspector that is already open can still be reused without sending the activation signal. + +Run `pnpm run doctor` from a source checkout for a read-only report. `connected` means a persistent renderer endpoint and main `app://` target both responded; without one, a command socket can be prepared only if inspector compatibility is verified. `stopped` means Codex is closed. The doctor never opens the bootstrap inspector and does not start or close Codex. If another process already owns port 9229, ThreadDeck refuses to attach to it and uses the verified Accessibility/shortcut adapter where a fallback is safe. If only a native control fails after a Codex update, do not keep pressing it: an ambiguous renderer delivery is intentionally never replayed because that could double-toggle Fast or submit twice. Update ThreadDeck or report the Codex version and exact key used. +### Codex exits when ThreadDeck starts + +Older ThreadDeck builds attempted `SIGUSR1` activation without checking compatibility. Some Codex builds terminate on that signal; this is tracked in [issue #15](https://github.com/y5862000/threaddeck-for-codex/issues/15). Stop Stream Deck or disable ThreadDeck until you have a build with the compatibility guard. Enabling another macOS permission will not fix this: the [Electron inspector fuse](https://www.electronjs.org/docs/latest/tutorial/fuses#nodecliinspect) is set when Codex is packaged. Do not change the signed Codex application to enable it. + ## No shortcut or remote-switch action works ThreadDeck checks its permission and legacy-control layers independently at startup and every 30 seconds: diff --git a/src/electron-fuses.js b/src/electron-fuses.js new file mode 100644 index 0000000..bc1eabb --- /dev/null +++ b/src/electron-fuses.js @@ -0,0 +1,119 @@ +"use strict"; + +const { execFile } = require("node:child_process"); +const { open } = require("node:fs/promises"); +const { promisify } = require("node:util"); + +const execFileAsync = promisify(execFile); +const FUSE_SENTINEL = Buffer.from("dL7pKGdnNz796PbbjQWNKmHXBZaB9tsX"); +const READ_SIZE = 64 * 1024; + +// Electron's public schema-v1 fuse wire stores EnableNodeCliInspectArguments +// at index 3. Every copy (including other universal-binary slices) must agree. +async function readNodeCliInspectFuse(handle, options = {}) { + const chunkSize = Math.max(1, Math.min(READ_SIZE, Math.trunc(options.chunkSize ?? READ_SIZE))); + const chunk = Buffer.alloc(chunkSize); + let pending = Buffer.alloc(0); + let found = false; + while (true) { + const { bytesRead } = await handle.read(chunk, 0, chunk.length, null); + const bytes = Buffer.concat([pending, chunk.subarray(0, bytesRead)]); + let offset = 0; + let incomplete = -1; + while ((offset = bytes.indexOf(FUSE_SENTINEL, offset)) !== -1) { + const header = offset + FUSE_SENTINEL.length; + if (bytes.length < header + 2) { + incomplete = offset; + break; + } + const version = bytes[header]; + const count = bytes[header + 1]; + if (version !== 1 || count < 4) return false; + if (bytes.length < header + 2 + count) { + incomplete = offset; + break; + } + const wire = bytes.subarray(header + 2, header + 2 + count); + if (wire[3] !== 0x31 || wire.some((state) => ![0x30, 0x31, 0x72].includes(state))) return false; + found = true; + offset += 1; + } + if (bytesRead === 0) return incomplete === -1 && found; + // An incomplete record is at most sentinel + two header bytes + 254 wire + // bytes. Otherwise only a possible split sentinel needs to survive a read. + pending = Buffer.from(bytes.subarray(incomplete === -1 + ? Math.max(0, bytes.length - FUSE_SENTINEL.length + 1) + : incomplete)); + } +} + +function numericIdentity(value) { + return /^(?:0x[\da-f]+|\d+)$/i.test(value ?? "") ? BigInt(value) : null; +} + +function loadedFramework(main, output) { + const app = String(main.command ?? "").match( + /^((?:\/[^/\r\n]+)*\/(?:ChatGPT|Codex)\.app)\/Contents\/MacOS\/[^/\s]+(?:\s|$)/ + )?.[1]; + if (!app) return null; + const prefix = `${app}/Contents/Frameworks/`; + const mappings = new Map(); + let record = null; + let pid = main.pid; + let invalid = false; + const finishRecord = () => { + if (record?.f !== "txt" || !record.n?.startsWith(prefix)) return; + const relative = record.n.slice(prefix.length); + if (!/^([^/]+ Framework)\.framework\/(?:Versions\/[^/]+\/)?\1$/.test(relative)) return; + const dev = numericIdentity(record.D); + const ino = numericIdentity(record.i); + if (record.invalid || pid !== main.pid || dev === null || ino === null) { + invalid = true; + return; + } + const key = `${dev}:${ino}`; + if (!mappings.has(key)) mappings.set(key, { path: record.n, dev, ino }); + }; + for (const line of String(output ?? "").split("\n")) { + const field = line[0]; + const value = line.slice(1); + if (field === "f" || field === "p") { + finishRecord(); + record = field === "f" ? { f: value } : null; + if (field === "p") pid = Number(value); + } else if (record && ["D", "i", "n"].includes(field)) { + if (record[field] !== undefined) record.invalid = true; + record[field] = value; + } + } + finishRecord(); + return !invalid && mappings.size === 1 ? mappings.values().next().value : null; +} + +async function supportsNodeCliInspectArguments(main, options = {}) { + let handle; + try { + const result = await (options.execFile ?? execFileAsync)("/usr/sbin/lsof", [ + "-nP", "-a", "-p", String(main.pid), "-d", "txt", "-FfniD" + ], { timeout: 2500, maxBuffer: 2 * 1024 * 1024 }); + const mapping = loadedFramework(main, result?.stdout ?? result ?? ""); + if (!mapping) return false; + handle = await (options.open ?? open)(mapping.path, "r"); + const before = await handle.stat({ bigint: true }); + if (!before.isFile() || before.dev !== mapping.dev || before.ino !== mapping.ino) return false; + if (!await readNodeCliInspectFuse(handle, options)) return false; + const after = await handle.stat({ bigint: true }); + return ["dev", "ino", "size", "mtimeNs", "ctimeNs"].every((key) => before[key] === after[key]); + } catch { + // Missing/unknown fuses and inspection failures are not permission to send + // SIGUSR1: that signal can terminate Electron when inspector support is off. + return false; + } finally { + await handle?.close().catch(() => {}); + } +} + +module.exports = { + readNodeCliInspectFuse, + supportsNodeCliInspectArguments +}; diff --git a/src/micro-cdp.js b/src/micro-cdp.js index 7d3c8ee..6495372 100644 --- a/src/micro-cdp.js +++ b/src/micro-cdp.js @@ -1279,7 +1279,16 @@ class CodexMicroBridge { await this.connect(); } catch (error) { if (error?.delivery !== "none" || !await this.preparedBridge.canAttach()) throw error; - await this.prepareCommandBridge(); + try { + await this.prepareCommandBridge(); + } catch (prepareError) { + // Preparation precedes the requested command. Preserve a definite + // compatibility rejection here without reclassifying later delivery. + if (prepareError?.code === "MICRO_UNAVAILABLE" && prepareError.delivery === "none") { + throw microUnavailable(prepareError.message, prepareError); + } + throw prepareError; + } } })(); try { diff --git a/src/micro-main-inspector.js b/src/micro-main-inspector.js index fa01237..7a00f89 100644 --- a/src/micro-main-inspector.js +++ b/src/micro-main-inspector.js @@ -2,6 +2,7 @@ const { execFile } = require("node:child_process"); const { promisify } = require("node:util"); +const { supportsNodeCliInspectArguments } = require("./electron-fuses"); const { parseCodexMainProcess, @@ -130,6 +131,9 @@ class CodexMainInspectorEvaluator { this.fetch = options.fetch ?? globalThis.fetch; this.WebSocket = options.WebSocket ?? globalThis.WebSocket; this.sendSignal = options.sendSignal ?? ((pid, signal) => process.kill(pid, signal)); + this.checkInspectorFuse = options.checkInspectorFuse ?? ((main) => ( + supportsNodeCliInspectArguments(main, { execFile: this.execFile }) + )); this.sleep = options.sleep ?? ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))); this.now = options.now ?? Date.now; this.port = options.port ?? DEFAULT_INSPECTOR_PORT; @@ -295,6 +299,15 @@ class CodexMainInspectorEvaluator { `Loopback inspector port ${this.port} is already owned by another process.` ); } + if (await this.checkInspectorFuse(main).catch(() => false) !== true) { + throw inspectorUnavailable( + "Codex inspector compatibility could not be confirmed: the loaded Electron framework must explicitly enable Node CLI inspector arguments." + ); + } + const current = await this.findMainProcess(); + if (!current || current.generation !== main.generation) { + throw inspectorUnavailable("Codex stopped or restarted before the inspector compatibility check completed."); + } try { this.sendSignal(main.pid, "SIGUSR1"); } catch (error) { diff --git a/test/electron-fuses.test.js b/test/electron-fuses.test.js new file mode 100644 index 0000000..7a908d1 --- /dev/null +++ b/test/electron-fuses.test.js @@ -0,0 +1,180 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); + +const { + readNodeCliInspectFuse, + supportsNodeCliInspectArguments +} = require("../src/electron-fuses"); + +const SENTINEL = Buffer.from("dL7pKGdnNz796PbbjQWNKmHXBZaB9tsX"); + +function fuse(wire = "010111001", version = 1, count = wire.length) { + return Buffer.concat([SENTINEL, Buffer.from([version, count]), Buffer.from(wire)]); +} + +function memoryHandle(bytes, options = {}) { + let position = 0; + return { + async read(buffer, offset, length) { + if (options.readError) throw new Error("unreadable fixture"); + const bytesRead = Math.min(length, bytes.length - position); + bytes.copy(buffer, offset, position, position + bytesRead); + position += bytesRead; + return { bytesRead }; + } + }; +} + +test("only explicitly enabled schema-v1 inspector fuses are supported", async () => { + assert.equal(await readNodeCliInspectFuse(memoryHandle(fuse())), true); + assert.equal(await readNodeCliInspectFuse(memoryHandle(fuse("rrr1"))), true); + for (const bytes of [ + Buffer.alloc(0), Buffer.from("ordinary binary"), SENTINEL, + Buffer.concat([SENTINEL, Buffer.from([1])]), + fuse("010011001"), fuse("010r11001"), fuse("010x11001"), + fuse("010111001", 2), fuse("010", 1), fuse("0101", 1, 9), + fuse("0101x"), + Buffer.concat([fuse(), fuse("0100")]), + Buffer.concat([fuse(), SENTINEL]) + ]) { + assert.equal(await readNodeCliInspectFuse(memoryHandle(bytes)), false, bytes.toString("hex")); + } +}); + +test("the scanner validates every fuse across marker, header and wire read boundaries", async () => { + const record = fuse("0101" + "r".repeat(251)); + for (let chunkSize = 1; chunkSize <= record.length + 2; chunkSize += 1) { + const bytes = Buffer.concat([Buffer.alloc(13), record, Buffer.alloc(37), fuse()]); + assert.equal(await readNodeCliInspectFuse(memoryHandle(bytes), { chunkSize }), true, `chunk ${chunkSize}`); + assert.equal(await readNodeCliInspectFuse(memoryHandle(Buffer.concat([bytes, fuse("0100")])), { chunkSize }), false); + } +}); + +test("read errors cannot become compatibility evidence", async () => { + await assert.rejects(readNodeCliInspectFuse(memoryHandle(fuse(), { readError: true })), /unreadable/); +}); + +async function frameworkFixture(t) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "threaddeck-fuses-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const app = path.join(root, "Relocated Apps", "ChatGPT.app"); + const framework = path.join(app, "Contents/Frameworks/Codex Framework.framework/Versions/152.0/Codex Framework"); + await fs.mkdir(path.dirname(framework), { recursive: true }); + await fs.writeFile(framework, fuse()); + const stats = await fs.stat(framework, { bigint: true }); + const main = { pid: 740, command: `${app}/Contents/MacOS/ChatGPT --example` }; + const record = (file = framework, inode = stats.ino, dev = `0x${stats.dev.toString(16)}`) => ( + `ftxt\nD${dev}\ni${inode}\nn${file}\n` + ); + return { framework, main, record, stats }; +} + +test("loaded framework checks use exact lsof arguments, read-only open and numeric file identity", async (t) => { + const fixture = await frameworkFixture(t); + let opens = 0; + const supported = await supportsNodeCliInspectArguments(fixture.main, { + execFile: async (command, args) => { + assert.equal(command, "/usr/sbin/lsof"); + assert.deepEqual(args, ["-nP", "-a", "-p", "740", "-d", "txt", "-FfniD"]); + return { stdout: `p740\n${fixture.record()}${fixture.record()}` }; + }, + open: async (file, flags) => { + assert.equal(file, fixture.framework); + assert.equal(flags, "r"); + opens += 1; + return fs.open(file, flags); + } + }); + assert.equal(supported, true); + assert.equal(opens, 1); +}); + +test("unrelated and nested frameworks are ignored; aliases of the same file are deduplicated", async (t) => { + const fixture = await frameworkFixture(t); + const alias = fixture.framework.replace("/Versions/152.0", ""); + const output = [ + fixture.record("/Other/Codex.app/Contents/Frameworks/Electron Framework.framework/Electron Framework"), + fixture.record(fixture.framework.replace("/Contents/Frameworks/", "/Contents/Frameworks/Helper.app/Contents/Frameworks/")), + fixture.record(), fixture.record(alias, fixture.stats.ino, fixture.stats.dev.toString()) + ].join(""); + assert.equal(await supportsNodeCliInspectArguments(fixture.main, { + execFile: async () => ({ stdout: output }) + }), true); +}); + +test("missing, ambiguous, malformed or replaced mapped frameworks fail closed", async (t) => { + const fixture = await frameworkFixture(t); + const other = fixture.framework.replaceAll("Codex Framework", "Electron Framework"); + for (const output of [ + "", `p740\n${fixture.record(other)}`, + fixture.record() + fixture.record(other, fixture.stats.ino + 1n), + fixture.record(fixture.framework, fixture.stats.ino + 1n), + fixture.record(fixture.framework, fixture.stats.ino, fixture.stats.dev + 1n), + fixture.record().replace(/^i\d+$/m, "iunknown"), + fixture.record().replace(/^D.+$/m, "Dunknown"), + fixture.record().replace(/^D.+\n/m, ""), + fixture.record().replace(/^D.+$/m, "D1\nD2"), + `fmem\nD${fixture.stats.dev}\ni${fixture.stats.ino}\nn${fixture.framework}\n`, + `p999\n${fixture.record()}` + ]) { + assert.equal(await supportsNodeCliInspectArguments(fixture.main, { + execFile: async () => ({ stdout: output }) + }), false, output); + } + assert.equal(await supportsNodeCliInspectArguments({ ...fixture.main, command: `wrapper ${fixture.main.command}` }, { + execFile: async () => ({ stdout: fixture.record() }) + }), false); +}); + +test("a framework that changes during the scan is not considered compatible", async (t) => { + const fixture = await frameworkFixture(t); + for (const changedKey of ["dev", "ino", "size", "mtimeNs", "ctimeNs"]) { + let statCalls = 0; + assert.equal(await supportsNodeCliInspectArguments(fixture.main, { + execFile: async () => ({ stdout: fixture.record() }), + open: async () => ({ + ...memoryHandle(fuse()), + async stat() { + statCalls += 1; + return statCalls === 1 ? fixture.stats : { + ...fixture.stats, + [changedKey]: fixture.stats[changedKey] + 1n + }; + }, + async close() {} + }) + }), false, changedKey); + assert.equal(statCalls, 2); + } +}); + +test("lsof, open, stat and read failures fail closed and opened files are closed", async (t) => { + const fixture = await frameworkFixture(t); + assert.equal(await supportsNodeCliInspectArguments(fixture.main, { + execFile: async () => { throw new Error("lsof unavailable"); } + }), false); + for (const failingOperation of ["open", "stat", "read"]) { + let closed = false; + assert.equal(await supportsNodeCliInspectArguments(fixture.main, { + execFile: async () => ({ stdout: fixture.record() }), + open: async () => { + if (failingOperation === "open") throw new Error("open failed"); + return { + async stat(options) { + assert.equal(options.bigint, true); + if (failingOperation === "stat") throw new Error("stat failed"); + return fixture.stats; + }, + async read() { throw new Error("read failed"); }, + async close() { closed = true; } + }; + } + }), false); + assert.equal(closed, failingOperation !== "open"); + } +}); diff --git a/test/micro-cdp.test.js b/test/micro-cdp.test.js index f428f47..5e6383e 100644 --- a/test/micro-cdp.test.js +++ b/test/micro-cdp.test.js @@ -2,6 +2,11 @@ const test = require("node:test"); const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); +const { CodexControlPlane } = require("../src/control-plane"); +const { CodexMainInspectorEvaluator, inspectorUnavailable } = require("../src/micro-main-inspector"); const { ACTIVATE_RUNTIME_EXPRESSION, @@ -244,6 +249,81 @@ test("prepares a local command bridge without requiring a renderer launch port", assert.match(evaluated, /__threadDeckPendingEvaluations/); }); +test("a cold Micro command preserves a real fuse rejection for safe legacy fallback", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "threaddeck-cold-inspector-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const app = path.join(root, "ChatGPT.app"); + const framework = path.join(app, "Contents/Frameworks/Codex Framework.framework/Codex Framework"); + await fs.mkdir(path.dirname(framework), { recursive: true }); + await fs.writeFile(framework, Buffer.concat([ + Buffer.from("dL7pKGdnNz796PbbjQWNKmHXBZaB9tsX"), Buffer.from([1, 9]), Buffer.from("010011001") + ])); + const stats = await fs.stat(framework, { bigint: true }); + let signals = 0; + let contacts = 0; + let evaluations = 0; + let legacyCalls = 0; + let mappings = 0; + const execFile = async (command, args) => { + if (command === "/bin/ps") { + return { stdout: `740 1 Sun Jul 26 22:46:04 2026 ${app}/Contents/MacOS/ChatGPT` }; + } + if (args.includes("-FfniD")) { + mappings += 1; + return { stdout: `p740\nftxt\nD0x${stats.dev.toString(16)}\ni${stats.ino}\nn${framework}\n` }; + } + return { stdout: "" }; + }; + const fetch = async () => { contacts += 1; throw new Error("Unexpected inspector contact"); }; + class NoWebSocket { + static OPEN = 1; + static CONNECTING = 0; + constructor() { contacts += 1; throw new Error("Unexpected inspector socket"); } + } + const mainInspector = new CodexMainInspectorEvaluator({ + platform: "darwin", execFile, fetch, WebSocket: NoWebSocket, + sendSignal: () => { signals += 1; } + }); + mainInspector.request = async () => { evaluations += 1; return true; }; + const bridge = new CodexMicroBridge({ + platform: "darwin", execFile, fetch, WebSocket: NoWebSocket, mainInspector, + readFile: async () => { throw new Error("No bridge port file"); } + }); + const plane = new CodexControlPlane({ micro: bridge }); + const result = await plane.execute("fast", { + micro: (micro) => micro.runKeycap("FAST"), + legacy: async () => { legacyCalls += 1; return true; } + }); + assert.equal(result.backend, "legacy"); + assert.equal(result.ok, true); + assert.equal(mappings, 1); + assert.equal(legacyCalls, 1); + assert.equal(signals, 0); + assert.equal(contacts, 0); + assert.equal(evaluations, 0); +}); + +test("inspector errors during native evaluation remain ambiguous and never fall back", async () => { + const bridge = new CodexMicroBridge(); + const plane = new CodexControlPlane({ micro: bridge }); + let evaluations = 0; + let legacyCalls = 0; + bridge.ensureConnected = async () => {}; + bridge.evaluate = async () => { + evaluations += 1; + throw inspectorUnavailable("Inspector closed before responding."); + }; + const result = await plane.execute("fast", { + micro: (micro) => micro.runKeycap("FAST"), + legacy: async () => { legacyCalls += 1; return true; } + }); + assert.equal(result.backend, "micro"); + assert.equal(result.ambiguous, true); + assert.equal(result.ok, false); + assert.equal(evaluations, 1); + assert.equal(legacyCalls, 0); +}); + test("all generated renderer entrypoints remain syntactically valid", () => { assertRendererExpressionParses(READ_ONLY_SNAPSHOT_EXPRESSION); assertRendererExpressionParses(ACTIVATE_RUNTIME_EXPRESSION); diff --git a/test/micro-main-inspector.test.js b/test/micro-main-inspector.test.js index 6d8b478..1e0c9e2 100644 --- a/test/micro-main-inspector.test.js +++ b/test/micro-main-inspector.test.js @@ -1,7 +1,11 @@ "use strict"; const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); const test = require("node:test"); +const { CodexControlPlane } = require("../src/control-plane"); const { CodexMainInspectorEvaluator, @@ -112,6 +116,7 @@ test("a running Codex receives one SIGUSR1 and its temporary inspector evaluates let signaled = false; let signalCalls = 0; const evaluator = new CodexMainInspectorEvaluator({ + checkInspectorFuse: async () => true, WebSocket: FakeWebSocket, sendSignal(pid, signal) { assert.equal(pid, 740); @@ -135,6 +140,7 @@ test("a running Codex receives one SIGUSR1 and its temporary inspector evaluates test("an inspector already owned by another process is never contacted", async () => { let signalCalls = 0; + let fetchCalls = 0; const evaluator = new CodexMainInspectorEvaluator({ WebSocket: FakeWebSocket, sendSignal() { @@ -145,13 +151,17 @@ test("an inspector already owned by another process is never contacted", async ( if (args.includes("-Fp")) return { stdout: "p999\n" }; return { stdout: "p740\n" }; }, - fetch: async () => ({ ok: false, json: async () => [] }) + fetch: async () => { + fetchCalls += 1; + return { ok: false, json: async () => [] }; + } }); await assert.rejects( evaluator.evaluate("document.title"), /already owned by another process/ ); assert.equal(signalCalls, 0); + assert.equal(fetchCalls, 0); }); test("an expired ThreadDeck lease never closes a later external inspector", async () => { @@ -173,3 +183,104 @@ test("an expired ThreadDeck lease never closes a later external inspector", asyn assert.match(FakeWebSocket.instances.at(-1).payload.params.expression, /const shouldClose = false/); assert.equal(evaluator.ownedGeneration, null); }); + +async function guardedInspectorFixture(t, options = {}) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "threaddeck-inspector-fuse-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const app = path.join(root, "Moved Apps", "ChatGPT.app"); + const framework = path.join(app, "Contents/Frameworks/Codex Framework.framework/Codex Framework"); + await fs.mkdir(path.dirname(framework), { recursive: true }); + const wire = options.wire ?? "010111001"; + await fs.writeFile(framework, Buffer.concat([ + Buffer.from("dL7pKGdnNz796PbbjQWNKmHXBZaB9tsX"), + Buffer.from([1, wire.length]), Buffer.from(wire) + ])); + const stats = await fs.stat(framework, { bigint: true }); + const processRow = codexProcessRow().replace("/Applications/ChatGPT.app", app); + const calls = { signals: 0, fetches: 0, processes: 0, mappings: 0 }; + const evaluator = new CodexMainInspectorEvaluator({ + platform: "darwin", + WebSocket: FakeWebSocket, + sendSignal(pid, signal) { + assert.equal(pid, 740); + assert.equal(signal, "SIGUSR1"); + calls.signals += 1; + }, + execFile: async (command, args) => { + if (command === "/bin/ps") { + calls.processes += 1; + return { stdout: calls.processes > 1 && options.nextProcess !== undefined + ? options.nextProcess.replace("/Applications/ChatGPT.app", app) + : processRow }; + } + if (args.includes("-FfniD")) { + calls.mappings += 1; + return { stdout: `p740\nftxt\nD0x${stats.dev.toString(16)}\ni${stats.ino}\nn${framework}\n` }; + } + if (args.includes("-Fp")) return { stdout: "" }; + return { stdout: options.existing || calls.signals > 0 ? "p740\nn127.0.0.1:9229\n" : "p740\n" }; + }, + fetch: async () => { + calls.fetches += 1; + return { ok: true, json: async () => inspectorTargets() }; + }, + sleep: async () => {} + }); + return { evaluator, calls }; +} + +test("an enabled loaded framework and unchanged process generation permit exactly one signal", async (t) => { + const { evaluator, calls } = await guardedInspectorFixture(t); + assert.deepEqual(await evaluator.evaluate("document.title"), { title: "Codex" }); + assert.equal(calls.signals, 1); + assert.equal(calls.processes, 2); + assert.equal(calls.mappings, 1); +}); + +test("disabled or unknown loaded-framework fuses reject before any signal or contact", async (t) => { + for (const wire of ["010011001", "010r11001", "010x11001", "", "010"]) { + const { evaluator, calls } = await guardedInspectorFixture(t, { wire }); + await assert.rejects(evaluator.evaluate("document.title"), { code: "MICRO_UNAVAILABLE", delivery: "none" }); + assert.equal(calls.signals, 0, wire); + assert.equal(calls.fetches, 0, wire); + } +}); + +test("a vanished or changed process after the fuse check receives no signal", async (t) => { + for (const nextProcess of [ + "", codexProcessRow().replace("22:46:04", "22:46:05"), + codexProcessRow().replace("740", "741"), + codexProcessRow().replace("MacOS/ChatGPT", "MacOS/Codex") + ]) { + const { evaluator, calls } = await guardedInspectorFixture(t, { nextProcess }); + await assert.rejects(evaluator.evaluate("document.title"), { code: "MICRO_UNAVAILABLE", delivery: "none" }); + assert.equal(calls.signals, 0); + assert.equal(calls.fetches, 0); + } +}); + +test("an existing process-owned external inspector works even with the fuse disabled and is not closed", async (t) => { + const { evaluator, calls } = await guardedInspectorFixture(t, { wire: "010011001", existing: true }); + let closes = 0; + evaluator.bestEffortCloseOwnedInspector = async () => { closes += 1; }; + assert.deepEqual(await evaluator.evaluate("document.title"), { title: "Codex" }); + assert.equal(calls.signals, 0); + assert.equal(calls.mappings, 0); + assert.match(FakeWebSocket.instances.at(-1).payload.params.expression, /const shouldClose = false/); + assert.equal(closes, 0); +}); + +test("a compatibility rejection reaches the existing safe legacy fallback without native delivery", async (t) => { + const { evaluator, calls } = await guardedInspectorFixture(t, { wire: "010011001" }); + const plane = new CodexControlPlane({ micro: {} }); + let legacyCalls = 0; + const result = await plane.execute("fast", { + micro: () => evaluator.evaluate("nativeCommand()"), + legacy: async () => { legacyCalls += 1; return true; } + }); + assert.equal(result.backend, "legacy"); + assert.equal(result.ok, true); + assert.equal(legacyCalls, 1); + assert.equal(calls.signals, 0); + assert.equal(calls.fetches, 0); +}); From a44743842d18a185b1627d0f9f56f353016520a7 Mon Sep 17 00:00:00 2001 From: Sergey Afonin Date: Mon, 7 Sep 2026 17:26:06 +0300 Subject: [PATCH 2/2] Keep plugin verification isolated from live Codex --- CHANGELOG.md | 1 + docs/CHANGELOG.ko.md | 1 + scripts/verify.sh | 7 +---- src/plugin.js | 31 ++++++++++++++++++++- test/helpers/deny-live-io.cjs | 48 ++++++++++++++++++++++++++++++++ test/plugin-verification.test.js | 27 ++++++++++++++++++ 6 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 test/helpers/deny-live-io.cjs create mode 100644 test/plugin-verification.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 91a9436..28a377a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## Unreleased +- Keep fixture verification offline: mock New Task state reads and permission checks, skip native startup/cleanup in contract modes, and fail verification before any external command, connection, process signal, or file write. - Guard inspector bootstrap against Codex builds that disable `SIGUSR1` activation. ThreadDeck checks the loaded Electron framework's inspector fuse and rechecks the process identity before signaling; disabled or unverifiable builds use the existing safe fallback without sending the signal. Existing process-owned inspector connections remain usable. - Tightened the goal-only timing capsule so the unfinished-goal marker and elapsed time read as one centered group, while preserving the existing compact goal-and-queue layout. diff --git a/docs/CHANGELOG.ko.md b/docs/CHANGELOG.ko.md index de455dc..a74e85d 100644 --- a/docs/CHANGELOG.ko.md +++ b/docs/CHANGELOG.ko.md @@ -4,6 +4,7 @@ ## 미배포 +- 검증용 테스트를 실행 중인 앱에서 분리했습니다. 새 작업의 상태 읽기와 권한 확인을 모의 처리하고, 검증 모드에서는 네이티브 초기화·정리를 생략하며, 외부 명령·연결·프로세스 신호·파일 쓰기를 시도하면 즉시 검증을 실패시킵니다. - `SIGUSR1`로 inspector를 활성화할 수 없는 Codex 빌드에서 초기 연결이 앱을 종료시키지 않도록 보호합니다. 신호를 보내기 전에 로드된 Electron 프레임워크의 inspector fuse와 프로세스 식별자를 확인하며, 비활성화되었거나 확인할 수 없는 빌드에는 신호를 보내지 않고 기존의 안전한 폴백을 사용합니다. 해당 프로세스가 이미 소유한 inspector 연결은 계속 사용할 수 있습니다. - 목표만 있는 시간 캡슐에서 미완료 목표 아이콘과 경과 시간을 중앙의 한 묶음처럼 더 가깝게 배치하고, 기존 목표+대기열 조합의 촘촘한 배치는 그대로 유지했습니다. diff --git a/scripts/verify.sh b/scripts/verify.sh index 39378db..e9bd574 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -21,12 +21,7 @@ for bundled in "$PLUGIN_DIR"/bin/*.js(N); do exit 1 } done -node "$PLUGIN_DIR/bin/plugin.js" --verify-completion -node "$PLUGIN_DIR/bin/plugin.js" --verify-refresh-resilience -node "$PLUGIN_DIR/bin/plugin.js" --verify-usage-cache -node "$PLUGIN_DIR/bin/plugin.js" --verify-voice-submit -node "$PLUGIN_DIR/bin/plugin.js" --verify-interactions -pnpm run test +THREADDECK_VERIFY_PLUGIN="$PLUGIN_DIR/bin/plugin.js" pnpm run test ( cd "$ROOT_DIR/reference/codex-micro-protocol" shasum -a 256 -c UPSTREAM.sha256 diff --git a/src/plugin.js b/src/plugin.js index c8a43d4..223ee38 100644 --- a/src/plugin.js +++ b/src/plugin.js @@ -9723,6 +9723,7 @@ async function openThread(context, slot, options = {}) { const deepLinkNavigation = options.navigateDeepLink ?? navigateDeepLinkThread; const remember = options.rememberThread ?? rememberVerifiedThread; const acknowledge = options.acknowledgeCompletion ?? acknowledgeCompletion; + const noteFailure = options.noteBridgeFailure ?? noteBridgeFailure; const focusThreadComposer = options.focusThreadComposer ?? (() => focusCurrentComposer(context)); const scheduleRefresh = options.scheduleRefresh @@ -9813,7 +9814,7 @@ async function openThread(context, slot, options = {}) { const exitCode = keyBridgeExitCode(error); const permissionFailure = exitCode === 3 || thread.titleAmbiguous ? false - : noteBridgeFailure(permissionCommand, error, context); + : noteFailure(permissionCommand, error, context); const label = thread.remote ? exitCode === 3 || thread.titleAmbiguous ? "제목 중복" : "원격 확인" : "열기 실패"; @@ -13377,6 +13378,7 @@ async function verifyInteractionPolicy() { console.error = () => {}; const failedLocalNavigation = await openThread(currentSlotContext, 1, { navigateDeepLink: async () => { throw new Error("simulated navigation failure"); }, + noteBridgeFailure: () => false, scheduleRefresh: () => {}, feedback: () => {}, rememberThread: (thread) => rememberVerifiedThread(thread, { refreshFastMode: false }) @@ -14805,6 +14807,10 @@ async function verifyInteractionPolicy() { activeFastModeUpdate = newThreadLease; let newThreadMutations = 0; const deferredNewThread = openNewThread("interaction-new-thread-after-fast", { + synchronizeCurrent: async () => currentThreadForDisplay(), + readGlobalState: async () => ({}), + readKnownThreadIds: async () => new Set(), + scheduleRefreshes: () => {}, openApp: async () => { newThreadMutations += 1; }, sleep: async () => {}, bridge: () => { @@ -15406,6 +15412,10 @@ async function verifyInteractionPolicy() { const creationReachedSleep = new Promise((resolve) => { creationSleepStarted = resolve; }); const creationThenFastOrder = []; const creationBeforeFast = openNewThread("interaction-creation-before-fast", { + synchronizeCurrent: async () => currentThreadForDisplay(), + readGlobalState: async () => ({}), + readKnownThreadIds: async () => new Set(), + scheduleRefreshes: () => {}, openApp: async () => { creationThenFastOrder.push("open"); }, sleep: async () => { creationSleepStarted(); @@ -15491,6 +15501,10 @@ async function verifyInteractionPolicy() { await supersededNavigationReady; let supersedingCreationBridges = 0; const supersedingCreation = openNewThread("interaction-navigation-superseded", { + synchronizeCurrent: async () => currentThreadForDisplay(), + readGlobalState: async () => ({}), + readKnownThreadIds: async () => new Set(), + scheduleRefreshes: () => {}, openApp: async () => {}, sleep: async () => {}, bridge: () => { @@ -15527,6 +15541,10 @@ async function verifyInteractionPolicy() { const interleavedCreationGate = new Promise((resolve) => { releaseInterleavedCreation = resolve; }); const interleavedCreationReady = new Promise((resolve) => { interleavedCreationStarted = resolve; }); const interleavedCreation = openNewThread("interaction-same-target-interleave", { + synchronizeCurrent: async () => currentThreadForDisplay(), + readGlobalState: async () => ({}), + readKnownThreadIds: async () => new Set(), + scheduleRefreshes: () => {}, openApp: async () => {}, sleep: async () => { interleavedCreationStarted(); @@ -15935,6 +15953,17 @@ function runSelectedMode() { } function main() { + // Fixture verification must not stage a native helper or install live input + // cleanup handlers. Only a registered plugin owns those process resources. + const verificationOnly = completionContractMode + || refreshResilienceContractMode + || usageCacheContractMode + || voiceSubmitContractMode + || interactionContractMode; + if (verificationOnly) { + runSelectedMode(); + return; + } const renderingOnly = Boolean( demoOutput || demoLightOutput diff --git a/test/helpers/deny-live-io.cjs b/test/helpers/deny-live-io.cjs new file mode 100644 index 0000000..35633c5 --- /dev/null +++ b/test/helpers/deny-live-io.cjs @@ -0,0 +1,48 @@ +"use strict"; + +// Preload before plugin imports: a missed fixture dependency must fail the +// subprocess before it can activate Codex, send input, or signal its inspector. +const fs = require("node:fs"); +const path = require("node:path"); +const { fileURLToPath } = require("node:url"); +const root = path.resolve(__dirname, "../.."); +const writeDiagnostic = fs.writeSync.bind(fs); + +function deny(operation) { + writeDiagnostic(2, `${new Error(`Live I/O forbidden in verification: ${operation}`).stack}\n`); + process.exit(86); +} + +const childProcess = require("node:child_process"); +for (const name of ["exec", "execSync", "execFile", "execFileSync", "spawn", "spawnSync", "fork"]) { + childProcess[name] = () => deny(`child_process.${name}`); +} +process.kill = () => deny("process.kill"); +require("node:net").Socket.prototype.connect = () => deny("net.Socket.connect"); +globalThis.fetch = () => deny("fetch"); +globalThis.WebSocket = class { + static OPEN = 1; + constructor() { deny("WebSocket"); } +}; + +function allowRepositoryRead(value) { + if (typeof value === "number") return; + const filename = path.resolve(value instanceof URL ? fileURLToPath(value) : String(value)); + if (filename !== root && !filename.startsWith(`${root}${path.sep}`)) deny("host file read"); +} + +for (const target of [fs, fs.promises]) { + for (const name of ["readFile", "readFileSync", "readdir", "readdirSync", "createReadStream"]) { + if (typeof target[name] !== "function") continue; + const original = target[name].bind(target); + target[name] = (filename, ...args) => { + allowRepositoryRead(filename); + return original(filename, ...args); + }; + } + for (const name of ["writeFile", "writeFileSync", "appendFile", "appendFileSync", "mkdir", "mkdirSync", + "rename", "renameSync", "rm", "rmSync", "unlink", "unlinkSync", "copyFile", "copyFileSync", + "chmod", "chmodSync", "createWriteStream", "open", "openSync"]) { + if (typeof target[name] === "function") target[name] = () => deny(`fs.${name}`); + } +} diff --git a/test/plugin-verification.test.js b/test/plugin-verification.test.js new file mode 100644 index 0000000..de1197b --- /dev/null +++ b/test/plugin-verification.test.js @@ -0,0 +1,27 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("node:path"); +const { execFile } = require("node:child_process"); +const { promisify } = require("node:util"); +const execFileAsync = promisify(execFile); +const root = path.resolve(__dirname, ".."); +const guard = path.join(__dirname, "helpers/deny-live-io.cjs"); +const plugin = process.env.THREADDECK_VERIFY_PLUGIN ?? path.join(root, "src/plugin.js"); + +for (const mode of ["completion", "refresh-resilience", "usage-cache", "voice-submit", "interactions"]) { + test(`--verify-${mode} passes without live host I/O`, { timeout: 30_000 }, async () => { + const { stdout, stderr } = await execFileAsync(process.execPath, [ + "--require", guard, plugin, `--verify-${mode}` + ], { + cwd: root, + env: { ...process.env, THREADDECK_APPEARANCE: "light", THREADDECK_LANGUAGE: "en" }, + timeout: 25_000, + maxBuffer: 1024 * 1024 + }); + assert.equal(stderr, ""); + const report = stdout.trim().split("\n").map((line) => JSON.parse(line)).at(-1); + assert.equal(report.passed, true); + }); +}