diff --git a/.github/SECURITY.md b/.github/SECURITY.md
index 3de96fa..c784007 100644
--- a/.github/SECURITY.md
+++ b/.github/SECURITY.md
@@ -6,6 +6,7 @@ ThreadDeck runs locally. It has no telemetry, analytics, update server, remote A
## Local access
+- Task-bound Approve/Decline connects to the existing same-user Codex desktop IPC socket after checking filesystem ownership and the running app session. The desktop may send a full conversation snapshot; ThreadDeck retains only bounded pending-request records and protocol identities, discarding conversation history. Request arguments can include command, file or permission details and remain in process memory only; none are written to diagnostics or uploaded. A physical press answers one revalidated request in the explicitly selected local task. The separate foreground path activates a verified native card; safety-review acknowledgment remains a manual user action.
- The plugin reads local Codex state under `~/.codex` and Codex Desktop logs to draw user task cards, reconstruct remote turn timing, and track temporary Side Chats. Remote cards come only from metadata already cached locally by Codex Desktop; ThreadDeck does not contact the remote Mac. Internal helper and review tasks are filtered before rendering. The plugin does not write to Codex database or session files.
- The optional quota ring starts the separately installed `codexbar` command and reads its JSON output.
- The preferred control adapter reuses a Chrome DevTools Protocol endpoint explicitly bound to loopback when the exact Codex process already owns one. Its read-only snapshot returns only active task identity, next-run Effort, Fast state, theme, six privacy-bounded Micro slots, and capability booleans; prompt, response, queued-message, and transcript text do not cross this boundary.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 02fdd43..3a023a4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
## Unreleased
+- Add Approve/Decline to Codex command and the dedicated Task actions selector. Both share explicit task-key IPC targeting and foreground native-card handling, request revalidation, duplicate-delivery prevention, and English/Korean/Russian feedback. No installed profile or approval-policy changes are required. Background IPC is covered by fixtures and read-only live checks; physical background approval remains unverified.
- 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.
- Treat failed local turns as errors instead of successful completions. Task keys show review-required safety pauses as yellow **Need input** and other errors in red, preserve the final elapsed time, and do not return to Thinking while a child process is still running.
diff --git a/README.md b/README.md
index 83623e7..566b492 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,7 @@ The overview and gesture demos are generated from the plugin's real SVG key rend
- **Hardware dictation** — hold a task key to speak and auto-submit a follow-up, or use the dedicated microphone to leave a draft for review.
- **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.
+- **Custom controls** — choose Current or Top 1–8 from **Codex task**, and New task, Side Chat, Send, Approve, or Decline from **Codex command**. The dedicated **Task actions** action exposes the same Approve/Decline behavior and target settings.
- **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
@@ -92,6 +92,7 @@ With the Micro bridge connected, these actions use Codex's native internal comma
| Codex task: Top 1–8 | Starts opening the configured position in the sorted local, pinned-remote, and Side Chat list | Hold at least **0.55 s** to start dictation in that task; release to transcribe, auto-submit, and verify the draft cleared |
| Microphone | Starts dictation in the verified current composer and pauses supported audio-producing media apps | For a brand-new Side Chat, can use its verified right-side composer before a task UUID exists; keep held while speaking, then release to leave a draft **without submitting** and resume media |
| Codex command: Send | Verifies the current composer; release before 0.6 s to send Return | Waits for an in-progress task or Side Chat switch; at **0.6 s** the key turns blue, then release to send Command+Return |
+| Codex command / Task actions: Approve or Decline | Captures the request for the configured target and shows Checking | Release sends one decision after the request is rechecked; a short tap is sufficient. Blue Sent reports dispatch, not completion of the underlying operation |
| Weekly quota | — | Release to refresh CodexBar immediately |
| Codex command: New task / Side Chat | — | New Task mirrors Current Task's scope: project tasks create inside that project, standalone tasks stay outside projects. Until Codex assigns an ID, Current Task points to a provisional `New task` composer. ThreadDeck uses native `NEW` when context-aware creation is available, with `⇧⌘O` / `⌥⌘O` fallbacks; Side Chat uses native `PARTY` or `⌥⌘S` and keeps the same protected provisional-composer behavior |
| Reasoning + Fast | Release before 0.6 s to move the next-run level (`LIGHT`–`ULTRA`); the track begins a smooth 320 ms transition immediately from the first tap | Rapid taps are folded into the last ping-pong position. After input settles, ThreadDeck sends one exact model/Effort choice through the mounted power controller when exposed; otherwise it opens `Advanced`, scans the live account/model list, and selects only the final requested level. It never replays every intermediate physical-knob step. Direct Codex changes are mirrored with the same animation. An exact Ultra warning selects only `Use Full access`, never `Continue`. At **0.6 s**, the key toggles next-run Fast mode immediately through Codex's native command. When Fast is verified, the level name remains exactly centered and its bolt sits immediately to the left so a pressing finger does not cover it |
@@ -101,6 +102,19 @@ The Dashboard's Codex task key is configured as Current and follows the task sel
Reasoning and speed have two deliberate timelines. A working task card keeps the exact Effort and Fast/standard setting captured when that turn started; changing the Codex composer or ThreadDeck's combined control does not rewrite an answer already in progress. The combined control instead shows the live setting for the **next run**, including a setting changed directly in Codex. Both tracks animate smoothly when their own trustworthy value changes: the control follows the next-run composer immediately, while a task header moves only when new turn metadata reaches that card. Codex's current queue stores the follow-up content and starts it later through the live composer, so a queued follow-up uses the setting present when it actually begins. Once dequeued, its new turn metadata becomes the task-card header. The amber `+N` remains a queue count rather than pretending every queued item has its own frozen setting.
+## Approve and Decline
+
+
+
+Add **Task actions** to any key and choose Approve or Decline, or select the same commands in **Codex command**. Both use the same handler. Existing profiles and keyboard shortcuts are preserved.
+
+- **Task selected on Stream Deck** (default): press a **Codex task** key first. Its title appears on Approve/Decline, and that exact task stays selected even if you switch tabs manually in Codex. A subsequent approval press uses the running desktop's local IPC and works without focusing Codex or sending a keystroke. Select the task again after restarting the plugin. Only local tasks are supported.
+- **Current dialog (foreground)**: keep the intended task and permission card in front. ThreadDeck uses an already-ready Micro connection or activates the verified native approval control. For **Review findings**, review the findings and check the acknowledgment in Codex yourself; Approve can then activate **Continue chat**. Decline does not dismiss that safety review.
+
+An empty, ambiguous, changed, or unsupported request is not acted on. A task-bound request never falls through to another visible dialog. Decisions are scoped to one request, with no session-wide permission grant, bulk approval, or replay after uncertain delivery. The task-key target is explicit; ThreadDeck does not automatically select the only waiting request across all tasks.
+
+The local IPC and Micro interfaces are internal and versioned, so a Codex update can make them unavailable. The background IPC route has fixture and read-only live coverage; physical background approval/decline on Neo remains unverified. Blue **Sent** confirms that the desktop accepted dispatch or the native control was activated, not that the backend operation succeeded.
+
## Keys with hold gestures
### Task key: open or dictate and submit
diff --git a/com.yechan.threaddeck.sdPlugin/en.json b/com.yechan.threaddeck.sdPlugin/en.json
index 9eb3bbe..c86fe0c 100644
--- a/com.yechan.threaddeck.sdPlugin/en.json
+++ b/com.yechan.threaddeck.sdPlugin/en.json
@@ -11,7 +11,11 @@
},
"com.yechan.threaddeck.newthread": {
"Name": "Codex command",
- "Tooltip": "Choose New task, Side Chat, or Send in the Property Inspector."
+ "Tooltip": "Choose New task, Side Chat, Send, Approve, or Decline in the Property Inspector."
+ },
+ "com.yechan.threaddeck.taskactions": {
+ "Name": "Task actions",
+ "Tooltip": "Choose Approve or Decline and an approval target in the Property Inspector."
},
"com.yechan.threaddeck.voice": {
"Name": "Codex dictation",
diff --git a/com.yechan.threaddeck.sdPlugin/ko.json b/com.yechan.threaddeck.sdPlugin/ko.json
index e5730a2..ee9ba84 100644
--- a/com.yechan.threaddeck.sdPlugin/ko.json
+++ b/com.yechan.threaddeck.sdPlugin/ko.json
@@ -11,7 +11,11 @@
},
"com.yechan.threaddeck.newthread": {
"Name": "Codex 명령",
- "Tooltip": "속성 검사기에서 새 작업, 사이드챗, 보내기 중 하나를 고릅니다."
+ "Tooltip": "속성 관리자에서 새 작업, 사이드챗, 보내기, 승인 또는 거절을 선택합니다."
+ },
+ "com.yechan.threaddeck.taskactions": {
+ "Name": "작업 액션",
+ "Tooltip": "속성 관리자에서 승인 또는 거절과 승인 대상을 선택합니다."
},
"com.yechan.threaddeck.voice": {
"Name": "Codex 음성 입력",
diff --git a/com.yechan.threaddeck.sdPlugin/manifest.json b/com.yechan.threaddeck.sdPlugin/manifest.json
index de76a3e..ecf2842 100644
--- a/com.yechan.threaddeck.sdPlugin/manifest.json
+++ b/com.yechan.threaddeck.sdPlugin/manifest.json
@@ -47,10 +47,27 @@
"ShowTitle": false
}
],
- "Tooltip": "Choose New task, Side Chat, or Send in the Property Inspector.",
+ "Tooltip": "Choose New task, Side Chat, Send, Approve, or Decline in the Property Inspector.",
"UserTitleEnabled": false,
"UUID": "com.yechan.threaddeck.newthread"
},
+ {
+ "Controllers": [
+ "Keypad"
+ ],
+ "Icon": "images/action",
+ "Name": "Task actions",
+ "PropertyInspectorPath": "property-inspector/index.html",
+ "States": [
+ {
+ "Image": "images/key",
+ "ShowTitle": false
+ }
+ ],
+ "Tooltip": "Choose Approve or Decline and an approval target in the Property Inspector.",
+ "UserTitleEnabled": false,
+ "UUID": "com.yechan.threaddeck.taskactions"
+ },
{
"Controllers": [
"Keypad"
diff --git a/com.yechan.threaddeck.sdPlugin/property-inspector/index.html b/com.yechan.threaddeck.sdPlugin/property-inspector/index.html
index ed9bb07..1cfbf0d 100644
--- a/com.yechan.threaddeck.sdPlugin/property-inspector/index.html
+++ b/com.yechan.threaddeck.sdPlugin/property-inspector/index.html
@@ -34,8 +34,29 @@
New task
Side Chat
Send
+ Approve
+ Decline
-
Send uses Return on a tap and Command+Return after the key turns blue.
+ Send uses Return on a tap and Command+Return after the key turns blue.
+
+
+
+ Task action
+
+ Approve
+ Decline
+
+ Approve a pending request once or decline it. Blue Sent means the action was sent.
+
+
+
+ Approval target
+
+ Choose an approval target…
+ Task selected on Stream Deck
+ Current dialog (foreground)
+
+
diff --git a/com.yechan.threaddeck.sdPlugin/property-inspector/property-inspector.js b/com.yechan.threaddeck.sdPlugin/property-inspector/property-inspector.js
index 018d178..d57c4fe 100644
--- a/com.yechan.threaddeck.sdPlugin/property-inspector/property-inspector.js
+++ b/com.yechan.threaddeck.sdPlugin/property-inspector/property-inspector.js
@@ -2,6 +2,7 @@
const TASK_ACTION = "com.yechan.threaddeck.thread1";
const COMMAND_ACTION = "com.yechan.threaddeck.newthread";
+const TASK_ACTIONS_ACTION = "com.yechan.threaddeck.taskactions";
const NAVIGATION_ACTION = "com.yechan.threaddeck.page.previous";
const COPY = {
en: {
@@ -16,6 +17,17 @@ const COPY = {
sideChat: "Side Chat",
send: "Send",
commandHelp: "Send uses Return on a tap and Command+Return after the key turns blue.",
+ taskActionLabel: "Task action",
+ approve: "Approve",
+ decline: "Decline",
+ approvalHelp: "Approve a pending request once or decline it. Blue Sent means the action was sent.",
+ approvalTargetLabel: "Approval target",
+ chooseApprovalTarget: "Choose an approval target…",
+ approvalTaskKey: "Task selected on Stream Deck",
+ approvalCurrentDialog: "Current dialog (foreground)",
+ approvalTaskKeyHelp: "Press a Codex task key to choose its task. Its name appears on Approve and Decline. The same task stays selected when you switch tasks manually in Codex. Works while another app has focus.",
+ approvalCurrentDialogHelp: "Keep the intended task and request in the foreground. Approve or Decline acts on the visible permission request. For Review findings, review the findings and check the acknowledgment in Codex first; Approve can then press Continue chat. No keyboard shortcut setup is needed.",
+ approvalUnknownTargetHelp: "The saved approval target is not supported. Choose a target to enable this key.",
directionLabel: "Direction",
previousPage: "Previous page",
nextPage: "Next page",
@@ -34,6 +46,17 @@ const COPY = {
sideChat: "사이드챗",
send: "보내기",
commandHelp: "보내기는 짧게 누르면 Return, 파란색이 될 때까지 누르면 Command+Return입니다.",
+ taskActionLabel: "작업 액션",
+ approve: "승인",
+ decline: "거절",
+ approvalHelp: "대기 중인 요청을 한 번 승인하거나 거절합니다. 파란색 전송됨은 동작 전송을 의미합니다.",
+ approvalTargetLabel: "승인 대상",
+ chooseApprovalTarget: "승인 대상 선택…",
+ approvalTaskKey: "Stream Deck에서 선택한 작업",
+ approvalCurrentDialog: "현재 대화상자 (맨 앞)",
+ approvalTaskKeyHelp: "Codex 작업 버튼을 눌러 작업을 선택하세요. 승인과 거절 버튼에 작업 이름이 표시됩니다. Codex에서 다른 작업으로 직접 전환해도 선택한 작업은 유지됩니다. 다른 앱이 맨 앞에 있어도 사용할 수 있습니다.",
+ approvalCurrentDialogHelp: "대상 작업과 요청을 맨 앞에 두세요. 승인 또는 거절은 표시된 권한 요청에 적용됩니다. Review findings는 먼저 Codex에서 내용을 검토하고 확인란을 선택하세요. 이후 승인 버튼으로 Continue chat을 누를 수 있습니다. 단축키 설정은 필요하지 않습니다.",
+ approvalUnknownTargetHelp: "저장된 승인 대상은 지원되지 않습니다. 이 버튼을 사용하려면 대상을 선택하세요.",
directionLabel: "방향",
previousPage: "이전 페이지",
nextPage: "다음 페이지",
@@ -52,6 +75,17 @@ const COPY = {
sideChat: "Дополнительный чат",
send: "Отправить",
commandHelp: "Короткое нажатие отправляет Return. Удержание до синего цвета — Command+Return.",
+ taskActionLabel: "Действие с запросом",
+ approve: "Одобрить",
+ decline: "Отклонить",
+ approvalHelp: "Одобрить ожидающий запрос один раз или отклонить его. Синяя надпись «Отправлено» означает, что действие отправлено.",
+ approvalTargetLabel: "Цель подтверждения",
+ chooseApprovalTarget: "Выберите цель подтверждения…",
+ approvalTaskKey: "Задача, выбранная на Stream Deck",
+ approvalCurrentDialog: "Текущий диалог (на переднем плане)",
+ approvalTaskKeyHelp: "Нажмите кнопку задачи Codex, чтобы выбрать её. Название появится на кнопках одобрения и отклонения. При ручном переходе к другой задаче в Codex выбор сохраняется. Работает, даже когда на переднем плане другое приложение.",
+ approvalCurrentDialogHelp: "Откройте нужную задачу и запрос на переднем плане. Одобрение или отклонение применяется к видимому запросу разрешения. Для Review findings сначала просмотрите замечания и установите флажок подтверждения в Codex; после этого кнопка одобрения может нажать Continue chat. Настраивать сочетания клавиш не требуется.",
+ approvalUnknownTargetHelp: "Сохранённая цель подтверждения не поддерживается. Выберите цель, чтобы включить эту кнопку.",
directionLabel: "Направление",
previousPage: "Предыдущая страница",
nextPage: "Следующая страница",
@@ -63,15 +97,17 @@ const COPY = {
let socket = null;
let context = "";
let action = "";
+let actionContext = "";
+let controlsBound = false;
let settings = {};
let statusTimer = null;
let settingsPending = false;
let hostLanguage = "";
function parseJson(value, fallback = {}) {
- if (value && typeof value === "object") return value;
try {
- return JSON.parse(value);
+ const parsed = typeof value === "string" ? JSON.parse(value) : value;
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : fallback;
} catch {
return fallback;
}
@@ -120,13 +156,30 @@ function setSettings(nextSettings) {
showStatus();
}
+function updateCommandHelp() {
+ const copy = COPY[language()];
+ document.getElementById("command-help").textContent = /^(?:approve|decline)$/.test(settings.command)
+ ? copy.approvalHelp : copy.commandHelp;
+ const visible = action === TASK_ACTIONS_ACTION
+ ? /^(?:approve|decline)$/.test(document.getElementById("task-action").value)
+ : action === COMMAND_ACTION && /^(?:approve|decline)$/.test(settings.command);
+ document.getElementById("approval-target-settings").hidden = !visible;
+ const target = !Object.hasOwn(settings, "approvalTarget") ? "task-key"
+ : settings.approvalTarget === "task-key" || settings.approvalTarget === "current-dialog" ? settings.approvalTarget : "";
+ document.getElementById("approval-target").value = target;
+ document.getElementById("approval-target-help").textContent = target === "task-key" ? copy.approvalTaskKeyHelp
+ : target === "current-dialog" ? copy.approvalCurrentDialogHelp : copy.approvalUnknownTargetHelp;
+}
+
function initializeControls() {
const loadingPanel = document.getElementById("settings-loading");
const taskPanel = document.getElementById("task-settings");
const commandPanel = document.getElementById("command-settings");
+ const taskActionsPanel = document.getElementById("task-actions-settings");
const navigationPanel = document.getElementById("navigation-settings");
taskPanel.hidden = action !== TASK_ACTION;
commandPanel.hidden = action !== COMMAND_ACTION;
+ taskActionsPanel.hidden = action !== TASK_ACTIONS_ACTION;
navigationPanel.hidden = action !== NAVIGATION_ACTION;
loadingPanel.hidden = true;
@@ -136,17 +189,29 @@ function initializeControls() {
taskSource.value = /^(?:current|top[1-8])$/.test(settings.taskSource)
? settings.taskSource
: "current";
- command.value = /^(?:new-task|side-chat|send)$/.test(settings.command)
+ command.value = /^(?:new-task|side-chat|send|approve|decline)$/.test(settings.command)
? settings.command
: "new-task";
+ const taskAction = String(settings.command ?? "approve").trim().toLowerCase();
+ document.getElementById("task-action").value = /^(?:approve|decline)$/.test(taskAction) ? taskAction : "";
pageDirection.value = /^(?:previous|next)$/.test(settings.pageDirection)
? settings.pageDirection
: "previous";
- for (const select of document.querySelectorAll("select[data-setting]")) {
- select.addEventListener("change", () => {
- setSettings({ ...settings, [select.dataset.setting]: select.value });
- });
+ updateCommandHelp();
+ if (!controlsBound) {
+ controlsBound = true;
+ for (const select of document.querySelectorAll("select[data-setting]")) {
+ select.addEventListener("change", () => {
+ if (select === document.getElementById("task-action")
+ && (action !== TASK_ACTIONS_ACTION || !/^(?:approve|decline)$/.test(select.value))) return;
+ if (select.dataset.setting === "approvalTarget"
+ && (document.getElementById("approval-target-settings").hidden
+ || !["task-key", "current-dialog"].includes(select.value))) return;
+ setSettings({ ...settings, [select.dataset.setting]: select.value });
+ updateCommandHelp();
+ });
+ }
}
const main = document.getElementById("settings");
@@ -160,20 +225,37 @@ function connectElgatoStreamDeckSocket(
info,
actionInfo
) {
+ const previousSocket = socket;
+ socket = null;
+ if (previousSocket) previousSocket.close();
+ settingsPending = false;
const registrationInfo = parseJson(info);
hostLanguage = String(registrationInfo?.application?.language ?? "").trim();
context = uuid;
const parsedActionInfo = parseJson(actionInfo);
+ actionContext = typeof parsedActionInfo.context === "string" && parsedActionInfo.context
+ ? parsedActionInfo.context : uuid;
action = parsedActionInfo.action ?? "";
- settings = parsedActionInfo.payload?.settings ?? {};
+ settings = parseJson(parsedActionInfo.payload?.settings);
localize();
initializeControls();
- socket = new WebSocket(`ws://127.0.0.1:${port}`);
- socket.addEventListener("open", () => {
- socket.send(JSON.stringify({ event: registerEvent, uuid }));
+ const nextSocket = new WebSocket(`ws://127.0.0.1:${port}`);
+ socket = nextSocket;
+ nextSocket.addEventListener("open", () => {
+ if (socket !== nextSocket) return;
+ nextSocket.send(JSON.stringify({ event: registerEvent, uuid }));
if (settingsPending) setSettings(settings);
});
+ nextSocket.addEventListener("message", (event) => {
+ if (socket !== nextSocket || settingsPending) return;
+ const message = parseJson(event.data);
+ if (message.event !== "didReceiveSettings"
+ || (message.context !== context && message.context !== actionContext)
+ || (message.action && message.action !== action)) return;
+ settings = parseJson(parseJson(message.payload).settings);
+ initializeControls();
+ });
}
localize();
diff --git a/com.yechan.threaddeck.sdPlugin/ru.json b/com.yechan.threaddeck.sdPlugin/ru.json
index 1ce6be7..8eec6d2 100644
--- a/com.yechan.threaddeck.sdPlugin/ru.json
+++ b/com.yechan.threaddeck.sdPlugin/ru.json
@@ -11,7 +11,11 @@
},
"com.yechan.threaddeck.newthread": {
"Name": "Команда Codex",
- "Tooltip": "В настройках кнопки выберите «Новая задача», «Дополнительный чат» или «Отправить»."
+ "Tooltip": "Выберите «Новая задача», «Дополнительный чат», «Отправить», «Одобрить» или «Отклонить» в настройках кнопки."
+ },
+ "com.yechan.threaddeck.taskactions": {
+ "Name": "Действия с запросом",
+ "Tooltip": "Выберите одобрение или отклонение и цель подтверждения в настройках кнопки."
},
"com.yechan.threaddeck.voice": {
"Name": "Диктовка в Codex",
diff --git a/docs/CHANGELOG.ko.md b/docs/CHANGELOG.ko.md
index c226406..6f1fba2 100644
--- a/docs/CHANGELOG.ko.md
+++ b/docs/CHANGELOG.ko.md
@@ -4,6 +4,7 @@
## 미배포
+- Codex 명령과 전용 작업 액션에 승인·거절을 추가했습니다. 두 액션은 작업 키로 선택한 대상의 IPC 제어와 전면 요청 카드 제어, 요청 재검증, 중복 전송 방지, 영어·한국어·러시아어 안내를 공유합니다. 설치된 프로파일이나 승인 정책을 변경하지 않습니다. 백그라운드 IPC는 모의 테스트와 실행 중인 앱의 읽기 전용 검사로 확인했으며, 실제 기기의 백그라운드 승인은 아직 검증하지 않았습니다.
- 검증용 테스트를 실행 중인 앱에서 분리했습니다. 새 작업의 상태 읽기와 권한 확인을 모의 처리하고, 검증 모드에서는 네이티브 초기화·정리를 생략하며, 외부 명령·연결·프로세스 신호·파일 쓰기를 시도하면 즉시 검증을 실패시킵니다.
- `SIGUSR1`로 inspector를 활성화할 수 없는 Codex 빌드에서 초기 연결이 앱을 종료시키지 않도록 보호합니다. 신호를 보내기 전에 로드된 Electron 프레임워크의 inspector fuse와 프로세스 식별자를 확인하며, 비활성화되었거나 확인할 수 없는 빌드에는 신호를 보내지 않고 기존의 안전한 폴백을 사용합니다. 해당 프로세스가 이미 소유한 inspector 연결은 계속 사용할 수 있습니다.
- 실패한 로컬 턴을 성공적인 완료로 표시하지 않도록 수정했습니다. 검토가 필요한 안전 일시 중지는 노란색 **입력 필요**, 다른 오류는 빨간색으로 표시하며, 최종 경과 시간을 유지하고 자식 프로세스가 실행 중이어도 작업 중 상태로 돌아가지 않습니다.
diff --git a/docs/PROFILE.ko.md b/docs/PROFILE.ko.md
index 8a10198..3273613 100644
--- a/docs/PROFILE.ko.md
+++ b/docs/PROFILE.ko.md
@@ -37,6 +37,7 @@ ThreadDeck은 8키 **Stream Deck Neo**에 맞춘 편집 가능한 Codex 전용 2
- 큰 재배치 전에는 Stream Deck에서 프로파일을 복제하세요.
- 버튼을 선택한 뒤 자동 저장 속성 검사기에서 작업 위치나 명령을 바꾸세요.
+- **작업 액션**을 추가하거나 **Codex 명령**에서 승인·거절을 선택하면 같은 확인 기능을 사용할 수 있습니다. 기본 대상인 **Stream Deck에서 선택한 작업**에서는 먼저 작업 키를 누르세요. 승인 키에 작업 이름이 표시되고 다른 앱으로 전환해도 그 작업을 유지합니다. **현재 대화상자 (맨 앞)**은 표시된 요청을 대상으로 합니다. [승인 동작과 제한 사항](../README.md#approve-and-decline)을 참고하세요.
- 두 페이지를 유지한다면 ThreadDeck의 **페이지 전환** 키를 남겨 두세요. 이 키는 ThreadDeck 액션 목록에서도 다시 추가할 수 있고 이전·다음 방향을 고를 수 있으며, Stream Deck 기본 탐색 액션과 달리 라이트·다크 화면 모드를 따릅니다.
- 프로파일 원본은 하드웨어 UUID를 포함하지 않으며 [`profiles/source/unpacked`](../profiles/source/unpacked)에 있습니다.
- 릴리스 감사가 공개 전에 Neo 모델과 추천 키 좌표를 모두 검증합니다.
diff --git a/docs/PROFILE.md b/docs/PROFILE.md
index 89d87e5..b3f88af 100644
--- a/docs/PROFILE.md
+++ b/docs/PROFILE.md
@@ -37,6 +37,7 @@ Every task key on this page is another copy of the same **Codex task** action wi
- Duplicate the profile in Stream Deck before making a large rearrangement.
- Select a key to change its Task slot or Command in the autosaving Property Inspector.
+- Add **Task actions** or choose Approve/Decline in **Codex command** for identical confirmation controls. With the default **Task selected on Stream Deck** target, press a task key first; its name appears on the approval keys, and it remains selected while another app has focus. **Current dialog (foreground)** acts on the visible request instead. See [approval behavior and limitations](../README.md#approve-and-decline).
- Keep the bundled ThreadDeck **Page navigation** key if you retain both pages. It is also available in the ThreadDeck action list, lets you choose Previous or Next, and follows light/dark appearance unlike Stream Deck's generic navigation actions.
- The profile source is hardware-UUID-free and lives under [`profiles/source/unpacked`](../profiles/source/unpacked).
- The release audit verifies the Neo model and every recommended key coordinate before publishing.
diff --git a/docs/README.ko.md b/docs/README.ko.md
index a168e24..8afd2b7 100644
--- a/docs/README.ko.md
+++ b/docs/README.ko.md
@@ -40,7 +40,7 @@ ThreadDeck은 Stream Deck을 Codex의 물리 작업 모니터이자 컨트롤러
- **물리 버튼 음성 입력** — 작업 버튼을 누른 채 말해 자동 제출하거나, 전용 마이크로 검토할 초안만 남깁니다.
- **완료 피드백** — 대기 후속 요청이 없는 마지막 턴의 새 종료값이 확인된 뒤에만 화면의 모든 ThreadDeck 소유 버튼이 첫 완료 프레임을 함께 표시합니다. 이후 해당 작업 버튼은 그 작업을 실제로 열거나 Codex 전면에서 확인할 때까지 느린 초록 펄스를 계속 냅니다. 대기 명령 수정이나 실행 순서로의 전환은 완료로 세지 않습니다.
- **작업 흐름 제어** — 현재 모델의 추론 강도 조절, Codex Fast mode 전환, 새 작업, 사이드챗, 보내기, 페이지 이동, 선택 기능인 주간 한도 링.
-- **Codex 중심 액션 5개** — 하나의 **Codex 작업** 액션에서 현재 작업·상위 작업 1~8을 고르고, 하나의 **Codex 명령** 액션에서 새 작업·사이드챗·보내기를 자동 저장 속성 검사기로 고릅니다.
+- **사용자 지정 제어** — **Codex 작업**에서 현재 작업·상위 작업 1~8을 고르고, **Codex 명령**에서 새 작업·사이드챗·보내기·승인·거절을 선택합니다. 전용 **작업 액션**도 같은 승인·거절 동작과 대상 설정을 제공합니다.
- **재실행 없는 Micro 우선 안정성** — Effort, Fast, 사이드챗, 누르는 동안 말하기, 보내기, 새 작업, Micro 기본 슬롯 6개는 Codex 내부 명령을 먼저 사용합니다. 상시 렌더러 endpoint가 없으면 실행 중인 Codex 빌드의 inspector 활성화 지원 여부를 확인한 뒤 플러그인 시작 시 인증된 전용 명령 소켓을 준비합니다. 직접 연 inspector는 첫 버튼 입력 전에 닫습니다. 비활성화되었거나 확인할 수 없는 빌드에서는 활성화를 건너뛰며, 8개 작업 대시보드와 지원되지 않는 빌드에는 검증된 손쉬운 사용·단축키 폴백을 유지합니다.
## 60초 설치
@@ -92,6 +92,7 @@ Micro 연결이 활성화되면 이 기능들은 Codex 내부 명령을 사용
| Codex 작업: 상위 1~8 | 정렬된 로컬·고정 원격·사이드챗 목록에서 설정한 순번의 작업 열기를 시작 | **0.55초** 이상 누르면 해당 작업에서 음성 입력 시작; 놓으면 받아쓰기, 자동 제출, 작성창 초기화 확인 |
| 전용 마이크 | 확인된 현재 작성창에서 음성 입력을 시작하고 지원되는 오디오 출력 미디어 앱을 잠시 멈춤 | 새 사이드챗은 작업 UUID가 생기기 전에도 확인된 오른쪽 작성창을 사용 가능; 말하는 동안 계속 누르고 놓으면 미디어를 재개하며 초안만 남김 |
| Codex 명령: 보내기 | 현재 작성창을 확인하고 0.6초 전에 놓으면 Return | 작업·사이드챗 전환 중이면 새 작성창을 기다림; **0.6초**가 되면 파란색으로 전환되고 놓으면 Command+Return |
+| Codex 명령 / 작업 액션: 승인·거절 | 선택한 대상의 요청을 확인하고 확인 중 표시 | 짧게 눌렀다 놓아도 재검증 후 결정 하나를 전송합니다. 파란색 전송됨은 동작 전송을 뜻하며 작업 완료를 보장하지 않습니다. |
| 주간 한도 | — | 놓으면 CodexBar 값을 즉시 새로 고침 |
| Codex 명령: 새 작업 / 사이드챗 | — | 새 작업은 현재 작업의 범위를 그대로 따릅니다. 프로젝트 작업에서는 같은 프로젝트 안에, 프로젝트 밖 작업에서는 프로젝트 밖에 만들며, 실제 ID가 생기기 전까지 현재 작업 버튼은 임시 `새 작업` 작성창을 가리킵니다. 문맥 생성은 `NEW`, 폴백은 `⇧⌘O` / `⌥⌘O`를 사용합니다. 사이드챗은 `PARTY` 또는 `⌥⌘S`를 사용하고 같은 보호된 임시 작성창 동작을 유지합니다. |
| 추론 강도 + Fast | 0.6초 전에 놓으면 다음 응답 단계(`LIGHT`–`ULTRA`)로 이동하며 첫 클릭부터 트랙이 부드러운 320ms 전환을 시작 | 연타는 마지막 왕복 위치 하나로 합칩니다. 입력이 멈추면 노출된 경우 마운트된 power controller에 정확한 모델·Effort 조합 하나를 보내고, 아니면 `Advanced`를 열어 현재 계정·모델 목록을 스캔한 뒤 최종 요청 단계만 선택합니다. 중간 물리 노브 단계를 전부 재생하지 않습니다. Codex에서 직접 바꾼 값도 같은 애니메이션으로 반영합니다. 정확한 Ultra 경고에서는 `Use Full access`만 선택하고 `Continue`는 누르지 않습니다. **0.6초**가 되면 Codex 내부 Fast 명령을 즉시 실행하며, 단계명은 항상 정확히 중앙에 두고 확인된 Fast 번개만 바로 왼쪽에 붙여 누르는 손가락에 가리지 않도록 합니다. |
@@ -101,6 +102,17 @@ Micro 연결이 활성화되면 이 기능들은 Codex 내부 명령을 사용
추론 강도와 속도는 두 시점을 구분합니다. 진행 중인 작업 카드는 해당 응답이 시작될 때 확정된 Effort와 Fast/일반 설정을 끝까지 유지합니다. Codex 작성창이나 ThreadDeck 결합 버튼에서 값을 바꿔도 이미 진행 중인 응답 표시를 덮어쓰지 않습니다. 결합 버튼은 Codex에서 직접 바꾼 값까지 포함해 **다음 응답**에 적용될 현재 작성창 설정을 보여줍니다. 두 트랙은 각자의 신뢰할 값이 바뀔 때 부드럽게 움직입니다. 결합 버튼은 다음 응답 값을 즉시 따라가고, 작업 카드 상단은 실제 새 턴 메타데이터가 들어올 때만 움직입니다. 현재 Codex 대기열은 후속 요청 내용만 보관한 뒤 실제 실행 시점의 작성창 설정으로 새 턴을 시작하므로, 대기 중에 값을 바꾸면 그 요청이 시작될 때의 값이 적용됩니다. 실행이 시작되면 새 턴 메타데이터가 작업 카드 상단으로 넘어옵니다. 주황 `+N`은 각 대기 요청마다 고정된 설정이 있는 것처럼 추정하지 않고 개수만 표시합니다.
+## 승인과 거절
+
+
+
+**작업 액션**에서 승인·거절을 고르거나 **Codex 명령**에서 같은 명령을 선택하세요. 두 액션은 같은 처리기를 사용하며 기존 프로파일과 단축키를 유지합니다.
+
+- 기본 대상인 **Stream Deck에서 선택한 작업**에서는 먼저 Codex 작업 키를 누릅니다. 승인 키에 표시된 그 작업은 Codex의 탭이나 전면 앱을 바꿔도 유지됩니다. 로컬 IPC로 요청을 처리하며 플러그인을 다시 시작하면 작업을 다시 선택해야 합니다. 로컬 작업만 지원합니다.
+- **현재 대화상자 (맨 앞)**에서는 대상 작업과 요청 카드를 전면에 둡니다. 준비된 Micro 연결이나 확인된 네이티브 컨트롤을 사용합니다. **Review findings**는 사용자가 직접 검토하고 확인란을 선택해야 하며, 그 뒤 승인 키로 **Continue chat**을 누를 수 있습니다. 거절 키는 이 안전 검토를 닫지 않습니다.
+
+요청이 없거나 여러 개이거나 변경되었거나 지원하지 않으면 동작하지 않습니다. 선택한 작업에서 다른 대화상자로 넘어가 승인하지 않으며, 불확실한 전송을 반복하지 않습니다. 전체 작업에서 유일한 대기 요청을 자동으로 고르는 기능은 포함하지 않습니다. 내부 API는 Codex 업데이트로 사용할 수 없게 될 수 있습니다. 백그라운드 IPC는 모의 테스트와 읽기 전용 실제 연결 검사를 통과했지만, Neo의 실제 백그라운드 승인·거절은 아직 검증하지 않았습니다. 파란색 **전송됨**은 동작 전송을 뜻하며 서버 작업의 성공을 보장하지 않습니다.
+
## 길게 누르면 달라지는 버튼
### 작업 버튼: 열기 또는 말해서 자동 제출
diff --git a/docs/SECURITY.ko.md b/docs/SECURITY.ko.md
index 0ce6fd6..751f5fa 100644
--- a/docs/SECURITY.ko.md
+++ b/docs/SECURITY.ko.md
@@ -6,6 +6,7 @@ ThreadDeck은 로컬에서만 작동합니다. 텔레메트리, 분석 도구,
## 로컬 접근 범위
+- 작업을 지정한 승인·거절은 파일 소유권과 실행 중인 앱 세션을 확인한 뒤 같은 사용자의 기존 Codex 데스크톱 IPC 소켓에 연결합니다. 데스크톱이 전체 대화 스냅샷을 보낼 수 있지만 ThreadDeck은 크기가 제한된 대기 요청과 프로토콜 식별 정보만 보관하고 대화 기록은 버립니다. 명령·파일·권한 정보가 포함될 수 있는 요청 인자는 프로세스 메모리에만 유지하며 진단 기록이나 외부로 전송하지 않습니다. 물리 버튼 입력은 명시적으로 선택한 로컬 작업에서 재검증한 요청 하나에만 응답합니다. 별도 전면 모드는 확인된 네이티브 카드에 동작하며 안전 검토의 확인란은 사용자가 직접 선택해야 합니다.
- 플러그인은 사용자 작업 카드, 원격 턴 시간, 임시 사이드챗 수명 주기를 표시하기 위해 `~/.codex` 아래 Codex 상태와 Codex Desktop 로그를 읽습니다. 원격 카드는 Codex Desktop이 이미 로컬에 캐시한 메타데이터에서만 만들며 ThreadDeck이 원격 Mac과 직접 통신하지 않습니다. 내부 보조·검토 작업은 렌더링 전에 제외합니다. Codex 데이터베이스와 세션 파일에는 쓰지 않습니다.
- 선택 기능인 한도 링은 별도로 설치한 `codexbar` 명령을 실행하고 JSON 출력만 읽습니다.
- 우선 제어 어댑터는 정확한 Codex 프로세스가 이미 소유한 루프백 Chrome DevTools Protocol endpoint가 있으면 재사용합니다. 읽기 전용 스냅샷은 현재 작업 ID, 다음 응답 Effort, Fast, 테마, 개인정보를 제한한 Micro 슬롯 6개, 기능 불리언만 반환하며 프롬프트·응답·대기 메시지·받아쓰기 원문은 이 경계를 넘지 않습니다.
diff --git a/docs/media/approval-actions.png b/docs/media/approval-actions.png
new file mode 100644
index 0000000..20eed3c
Binary files /dev/null and b/docs/media/approval-actions.png differ
diff --git a/native/approval-api-context.h b/native/approval-api-context.h
new file mode 100644
index 0000000..5647ca1
--- /dev/null
+++ b/native/approval-api-context.h
@@ -0,0 +1,200 @@
+// Read-only process/session identity for direct IPC approvals. No window or
+// foreground requirement, and no inventory of renderer/thread owners. The IPC
+// caller must separately validate the explicitly selected task/request.
+typedef struct {
+ pid_t pid;
+ NSTimeInterval launch_seconds;
+ uint64_t launch_microseconds;
+ uint32_t session_user;
+ uint32_t session_console;
+} ApprovalApiContext;
+
+static bool approval_api_pid(id value, pid_t *result) {
+ if (value == nil || CFGetTypeID((__bridge CFTypeRef)value) != CFNumberGetTypeID()) return false;
+ int64_t number = -1;
+ double exact = 0;
+ if (!CFNumberGetValue((__bridge CFNumberRef)value, kCFNumberSInt64Type, &number)
+ || number <= 1 || number > INT_MAX
+ || !CFNumberGetValue((__bridge CFNumberRef)value, kCFNumberDoubleType, &exact)
+ || exact != (double)number) return false;
+ *result = (pid_t)number;
+ return true;
+}
+
+static bool approval_api_launch_time(id value, NSTimeInterval *seconds, uint64_t *microseconds) {
+ if (value == nil || CFGetTypeID((__bridge CFTypeRef)value) != CFNumberGetTypeID()) return false;
+ double time = 0;
+ if (!CFNumberGetValue((__bridge CFNumberRef)value, kCFNumberDoubleType, &time) || !isfinite(time) || time <= 0) return false;
+ long double scaled = (long double)time * 1000000.0L;
+ if (!isfinite(scaled) || scaled < 1 || scaled >= (long double)INT64_MAX) return false;
+ *seconds = time;
+ *microseconds = (uint64_t)llroundl(scaled);
+ return true;
+}
+
+static const char *approval_api_select_application(id applications, ApprovalApiContext *selected) {
+ if (![applications isKindOfClass:NSArray.class]) return "codex-unavailable";
+ unsigned count = 0;
+ ApprovalApiContext context = {0};
+ for (id app in applications) {
+ if (![app isKindOfClass:NSDictionary.class]
+ || ![app[@"bundle"] isEqual:@"com.openai.codex"]
+ || app[@"terminated"] == nil
+ || CFGetTypeID((__bridge CFTypeRef)app[@"terminated"]) != CFBooleanGetTypeID()) return "codex-unavailable";
+ if ([app[@"terminated"] boolValue]) continue;
+ if (!approval_api_pid(app[@"pid"], &context.pid)) return "codex-unavailable";
+ if (!approval_api_launch_time(app[@"launchTime"], &context.launch_seconds, &context.launch_microseconds)) return "launch-unavailable";
+ count += 1;
+ }
+ if (count == 0) return "codex-unavailable";
+ if (count != 1) return "multiple-instances";
+ *selected = context;
+ return NULL;
+}
+
+static bool approval_api_context_stable(ApprovalApiContext first, ApprovalApiContext final) {
+ return first.pid == final.pid && first.launch_seconds == final.launch_seconds
+ && first.launch_microseconds == final.launch_microseconds
+ && first.session_user == final.session_user && first.session_console == final.session_console;
+}
+
+static NSString *approval_api_context_token(ApprovalApiContext context) {
+ if (context.pid <= 1 || context.launch_microseconds == 0) return nil;
+ return [NSString stringWithFormat:@"api1:%d:%llu:%u:%u", context.pid,
+ (unsigned long long)context.launch_microseconds, context.session_user, context.session_console];
+}
+
+static const char *approval_api_live_application(ApprovalApiContext *selected) {
+ NSArray *running = [NSRunningApplication runningApplicationsWithBundleIdentifier:@"com.openai.codex"];
+ if (running == nil) return "codex-unavailable";
+ NSMutableArray *applications = [NSMutableArray arrayWithCapacity:running.count];
+ for (NSRunningApplication *app in running) {
+ NSDate *launch = app.launchDate;
+ [applications addObject:@{ @"bundle": app.bundleIdentifier != nil ? app.bundleIdentifier : NSNull.null,
+ @"pid": @(app.processIdentifier), @"terminated": @(app.terminated),
+ @"launchTime": launch != nil ? @(launch.timeIntervalSince1970) : NSNull.null }];
+ }
+ return approval_api_select_application(applications, selected);
+}
+
+static const char *approval_api_read_live_context(ApprovalApiContext *result, bool *frontmost) {
+ // Reuse the existing logged-in console/UID checks and explicit lock-flag
+ // rejection. An absent undocumented lock flag is not proof of unlocked state.
+ uint32_t user = 0, console = 0;
+ const char *error = approval_read_live_session(&user, &console);
+ if (error != NULL) return error;
+ ApprovalApiContext first = {0}, final = {0};
+ error = approval_api_live_application(&first);
+ if (error != NULL) return error;
+ first.session_user = user;
+ first.session_console = console;
+ error = approval_api_live_application(&final);
+ if (error != NULL) return error;
+ error = approval_read_live_session(&final.session_user, &final.session_console);
+ if (error != NULL) return error;
+ if (!approval_api_context_stable(first, final)) return "context-changed";
+ NSRunningApplication *front = NSWorkspace.sharedWorkspace.frontmostApplication;
+ *frontmost = front.processIdentifier == final.pid && [front.bundleIdentifier isEqualToString:@"com.openai.codex"];
+ *result = final;
+ return NULL;
+}
+
+static NSDictionary *approval_api_context_payload(ApprovalApiContext context, bool frontmost) {
+ NSString *token = approval_api_context_token(context);
+ if (token == nil) return @{ @"ok": @NO, @"error": @"context-unavailable" };
+ return @{ @"ok": @YES, @"pid": @(context.pid), @"token": token, @"frontmost": @(frontmost) };
+}
+
+static int approval_api_print_error(const char *error, int code) {
+ printf("{\"ok\":false,\"error\":\"%s\"}\n", error);
+ return code;
+}
+
+static int print_codex_approval_api_context(void) {
+ ApprovalApiContext context = {0};
+ bool frontmost = false;
+ const char *error = approval_api_read_live_context(&context, &frontmost);
+ if (error != NULL) return approval_api_print_error(error, 1);
+ NSData *data = [NSJSONSerialization dataWithJSONObject:approval_api_context_payload(context, frontmost) options:0 error:NULL];
+ if (data == nil) return approval_api_print_error("context-unavailable", 1);
+ puts([[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding].UTF8String);
+ return 0;
+}
+
+static int approval_api_context_selftest(void) {
+ // Pure process/launch/session/identity fixtures. No app reads or input.
+ unsigned checks = 0, failures = 0;
+#define API_CHECK(condition) do { checks += 1; if (!(condition)) { failures += 1; fprintf(stderr, "approval-api-context-selftest line %d failed\n", __LINE__); } } while (0)
+ NSDictionary *app = @{ @"pid": @123, @"bundle": @"com.openai.codex", @"terminated": @NO, @"launchTime": @1700000000.25 };
+ ApprovalApiContext context = {0};
+ API_CHECK(approval_api_select_application(@[app], &context) == NULL && context.pid == 123
+ && context.launch_seconds == 1700000000.25 && context.launch_microseconds == UINT64_C(1700000000250000));
+ API_CHECK(approval_error_is(approval_api_select_application(@[], &context), "codex-unavailable"));
+ API_CHECK(approval_error_is(approval_api_select_application(nil, &context), "codex-unavailable"));
+ API_CHECK(approval_error_is(approval_api_select_application(@[app, app], &context), "multiple-instances"));
+ for (id invalid in @[@1, @{}, @{ @"pid": @123, @"bundle": @"com.example.other", @"terminated": @NO },
+ @{ @"pid": @123, @"bundle": @"com.openai.codex", @"terminated": @0 }]) {
+ API_CHECK(approval_error_is(approval_api_select_application(@[invalid], &context), "codex-unavailable"));
+ }
+ for (id invalid in @[@0, @1, @(-1), @YES, @"123", @2147483648, @123.5]) {
+ NSMutableDictionary *bad = [app mutableCopy]; bad[@"pid"] = invalid;
+ API_CHECK(approval_error_is(approval_api_select_application(@[bad], &context), "codex-unavailable"));
+ }
+ for (id invalid in @[NSNull.null, @0, @(-1), @YES, @"1700000000.25", @(NAN), @(INFINITY), @(DBL_MAX), @0.0000001]) {
+ NSMutableDictionary *bad = [app mutableCopy]; bad[@"launchTime"] = invalid;
+ API_CHECK(approval_error_is(approval_api_select_application(@[bad], &context), "launch-unavailable"));
+ }
+ NSMutableDictionary *missing = [app mutableCopy]; [missing removeObjectForKey:@"launchTime"];
+ API_CHECK(approval_error_is(approval_api_select_application(@[missing], &context), "launch-unavailable"));
+ NSMutableDictionary *terminated = [app mutableCopy]; terminated[@"terminated"] = @YES;
+ [terminated removeObjectForKey:@"launchTime"];
+ API_CHECK(approval_api_select_application(@[app, terminated], &context) == NULL);
+ API_CHECK(approval_error_is(approval_api_select_application(@[terminated], &context), "codex-unavailable"));
+ NSDictionary *session = @{ (__bridge NSString *)kCGSessionOnConsoleKey: @YES,
+ (__bridge NSString *)kCGSessionLoginDoneKey: @YES, (__bridge NSString *)kCGSessionUserIDKey: @501,
+ (__bridge NSString *)kCGSessionConsoleSetKey: @1, @"CGSSessionScreenIsLocked": @NO };
+ uint32_t user = 0, console = 0;
+ API_CHECK(approval_session_from_dictionary(session, 501, &user, &console) == NULL && user == 501 && console == 1);
+ API_CHECK(approval_error_is(approval_session_from_dictionary(session, 502, &user, &console), "session-inactive"));
+ for (NSString *key in @[(__bridge NSString *)kCGSessionOnConsoleKey, (__bridge NSString *)kCGSessionLoginDoneKey]) {
+ NSMutableDictionary *inactive = [session mutableCopy]; inactive[key] = @NO;
+ API_CHECK(approval_error_is(approval_session_from_dictionary(inactive, 501, &user, &console), "session-inactive"));
+ }
+ NSMutableDictionary *locked = [session mutableCopy]; locked[@"CGSSessionScreenIsLocked"] = @YES;
+ API_CHECK(approval_error_is(approval_session_from_dictionary(locked, 501, &user, &console), "session-locked"));
+ locked[@"CGSSessionScreenIsLocked"] = @"false";
+ API_CHECK(approval_error_is(approval_session_from_dictionary(locked, 501, &user, &console), "session-unavailable"));
+ API_CHECK(approval_error_is(approval_session_from_dictionary(nil, 501, &user, &console), "session-unavailable"));
+ NSMutableDictionary *optional = [session mutableCopy];
+ [optional removeObjectForKey:@"CGSSessionScreenIsLocked"];
+ [optional removeObjectForKey:(__bridge NSString *)kCGSessionConsoleSetKey];
+ API_CHECK(approval_session_from_dictionary(optional, 501, &user, &console) == NULL && console == 0);
+ context.session_user = 501; context.session_console = 1;
+ API_CHECK(approval_api_context_stable(context, context));
+ for (unsigned field = 0; field < 5; field++) {
+ ApprovalApiContext changed = context;
+ if (field == 0) changed.pid += 1;
+ if (field == 1) changed.launch_seconds += 0.0000002;
+ if (field == 2) changed.launch_microseconds += 1;
+ if (field == 3) changed.session_user += 1;
+ if (field == 4) changed.session_console += 1;
+ API_CHECK(!approval_api_context_stable(context, changed));
+ }
+ NSDictionary *background = approval_api_context_payload(context, false);
+ API_CHECK([background[@"ok"] isEqual:@YES] && [background[@"frontmost"] isEqual:@NO]
+ && [background[@"token"] isEqual:@"api1:123:1700000000250000:501:1"]);
+ API_CHECK([approval_api_context_payload(context, true)[@"frontmost"] isEqual:@YES]);
+ API_CHECK(background.count == 4 && background[@"windowCount"] == nil);
+ API_CHECK([approval_api_context_payload((ApprovalApiContext){0}, false)[@"ok"] isEqual:@NO]);
+ for (unsigned field = 0; field < 4; field++) {
+ ApprovalApiContext changed = context;
+ if (field == 0) changed.pid += 1;
+ if (field == 1) changed.launch_microseconds += 1;
+ if (field == 2) changed.session_user += 1;
+ if (field == 3) changed.session_console += 1;
+ API_CHECK(![approval_api_context_token(context) isEqualToString:approval_api_context_token(changed)]);
+ }
+#undef API_CHECK
+ printf("{\"checks\":%u,\"failures\":%u,\"live_io\":false}\n", checks, failures);
+ return failures == 0 ? 0 : 1;
+}
diff --git a/native/approval-card.h b/native/approval-card.h
new file mode 100644
index 0000000..1262a12
--- /dev/null
+++ b/native/approval-card.h
@@ -0,0 +1,979 @@
+// Ordinary approvals focus the verified permission-card button and activate it
+// with one Return (Allow once) or Space (Deny) pair. There is no unfocused
+// approval shortcut or AXPress fallback.
+// This deliberately supports known English terminal requests and the observed
+// Ask permission card; unknown cards remain available for review in Codex.
+static NSString *const APPROVAL_CARD_ALLOW = @"Allow once";
+static NSString *const APPROVAL_CARD_DENY = @"Deny";
+static NSArray *approval_card_visible_children(NSDictionary *node);
+
+typedef struct {
+ NSString *__strong state;
+ NSString *__strong token;
+ NSString *__strong reason;
+ id __strong card;
+ id __strong header;
+ id __strong allow;
+ id __strong deny;
+} ApprovalCardCapture;
+
+typedef struct {
+ const char *(*prepare_keys)(const char *, void *);
+ const char *(*read_context)(ApprovalShortcutContext *, void *);
+ ApprovalCardCapture (*read_card)(pid_t, void *);
+ bool (*same_element)(id, id, void *);
+ const char *(*validate_controls)(ApprovalCardCapture, void *);
+ const char *(*focus_button)(id, pid_t, void *);
+ const char *(*validate_focus)(id, pid_t, void *);
+ const char *(*post_keys)(id, void *);
+ void *context;
+} ApprovalCardOperations;
+
+static NSArray *approval_card_prompts(void) {
+ return @[@"Allow ChatGPT to run this command?", @"Do you want ChatGPT to run this command?",
+ @"Do you want Codex to run this command?"];
+}
+
+static bool approval_card_has_prompt(NSDictionary *node) {
+ for (NSString *prompt in approval_card_prompts()) if (review_has_label(node, prompt)) return true;
+ return false;
+}
+
+static NSString *approval_card_name(NSDictionary *node) {
+ for (NSString *key in @[@"title", @"description", @"value"]) {
+ if ([node[key] isKindOfClass:NSString.class] && [node[key] length] > 0) return node[key];
+ }
+ return nil;
+}
+
+static bool approval_card_name_matches(NSString *name, NSString *label) {
+ if ([name isEqualToString:label]) return true;
+ // Only shortcuts known from this integration are accepted as a suffix. No
+ // arbitrary text, case folding, or substring matches can select a button.
+ NSArray *suffixes = [label isEqualToString:APPROVAL_CARD_ALLOW]
+ ? @[@"↵", @"⏎", @"Enter", @"Return", @"⌃⌥⌘F13"]
+ : @[@"Esc", @"Escape", @"⎋", @"⌃⌥⌘F14"];
+ for (NSString *suffix in suffixes) {
+ for (NSString *separator in @[@" ", @"", @"\n"]) {
+ if ([name isEqualToString:[NSString stringWithFormat:@"%@%@%@", label, separator, suffix]]) return true;
+ }
+ }
+ return false;
+}
+
+static bool approval_card_button(NSDictionary *node, NSString *label) {
+ if (![node[@"role"] isEqualToString:@"AXButton"]) return false;
+ NSString *name = approval_card_name(node);
+ if (!approval_card_name_matches(name, label)) return false;
+ if ([name isEqualToString:label]) return true;
+ // Chromium may flatten the action and its known shortcut badge into a leaf.
+ // The card classifier additionally requires the trusted application page,
+ // main landmark and exact known permission structure before any dispatch.
+ if (approval_card_visible_children(node).count == 0) return true;
+ // A concatenated accessible name must also expose the exact action text in
+ // its descendants, distinguishing the action from an arbitrary longer name.
+ NSMutableArray *inside = [NSMutableArray array];
+ review_collect_nodes(node, inside);
+ for (NSDictionary *child in inside) {
+ if (child != node && [child[@"role"] isEqualToString:@"AXStaticText"]
+ && review_has_label(child, label)) return true;
+ }
+ return false;
+}
+
+static bool approval_card_alert(NSDictionary *node) {
+ return [node[@"role"] isEqualToString:@"AXAlert"]
+ || ([node[@"role"] isEqualToString:@"AXGroup"]
+ && [node[@"subrole"] isEqualToString:@"AXApplicationAlert"]);
+}
+
+static bool approval_card_blocking_surface(NSDictionary *node) {
+ return review_node_is_dialog(node)
+ || [node[@"role"] isEqualToString:@"AXAlertDialog"]
+ || [node[@"subrole"] isEqualToString:@"AXAlertDialog"]
+ || [node[@"subrole"] isEqualToString:@"AXApplicationAlertDialog"]
+ || [node[@"role"] isEqualToString:@"AXMenu"]
+ || [node[@"role"] isEqualToString:@"AXMenuItem"];
+}
+
+static bool approval_card_contains(NSDictionary *root, NSDictionary *target) {
+ if ([root[@"hidden"] boolValue]) return false;
+ if (root == target) return true;
+ for (NSDictionary *child in root[@"children"]) if (approval_card_contains(child, target)) return true;
+ return false;
+}
+
+static NSArray *approval_card_visible_children(NSDictionary *node) {
+ NSMutableArray *children = [NSMutableArray array];
+ for (NSDictionary *child in node[@"children"]) if (![child[@"hidden"] boolValue]) [children addObject:child];
+ return children;
+}
+
+static bool approval_card_ask_header(NSDictionary *node) {
+ if (!approval_card_alert(node)) return false;
+ NSArray *children = approval_card_visible_children(node);
+ if (children.count != 2 || ![children[0][@"role"] isEqualToString:@"AXStaticText"]
+ || !review_has_label(children[0], @"Ask permission") || ![children[1][@"role"] isEqualToString:@"AXGroup"]) return false;
+ NSArray *reason = approval_card_visible_children(children[1]);
+ return reason.count == 1 && [reason[0][@"role"] isEqualToString:@"AXStaticText"]
+ && approval_card_name(reason[0]).length > 0;
+}
+
+static bool approval_card_ask_structure(NSDictionary *card, NSDictionary *header,
+ NSDictionary *allow, NSDictionary *deny) {
+ NSArray *children = approval_card_visible_children(card);
+ if (children.count != 2 || children[0] != header || ![children[1][@"role"] isEqualToString:@"AXGroup"]) return false;
+ NSArray *actions = approval_card_visible_children(children[1]);
+ return actions.count == 2 && actions[0] == deny && actions[1] == allow;
+}
+
+static bool approval_card_trusted_web_area(NSDictionary *node) {
+ if (![node[@"role"] isEqualToString:@"AXWebArea"] || ![node[@"url"] isKindOfClass:NSString.class]) return false;
+ NSURLComponents *url = [NSURLComponents componentsWithString:node[@"url"]];
+ // Observed through read-only AX on the installed Codex app, not inferred
+ // from its PID. A browser/preview webarea must not impersonate app controls.
+ return [url.scheme isEqualToString:@"app"] && [url.host isEqualToString:@"-"]
+ && [url.percentEncodedPath isEqualToString:@"/index.html"]
+ && url.user == nil && url.password == nil && url.port == nil && url.query == nil;
+}
+
+static NSString *approval_card_fingerprint(NSDictionary *tree) {
+ NSString *review_token = tree != nil ? review_fingerprint(tree) : nil;
+ return review_token.length == 67 ? [@"a1:" stringByAppendingString:[review_token substringFromIndex:3]] : nil;
+}
+
+static ApprovalCardCapture approval_card_classify_tree(NSDictionary *tree, bool complete) {
+ ApprovalCardCapture unavailable = { .state = @"unavailable", .reason = @"scan-incomplete" };
+ if (!complete || ![tree isKindOfClass:NSDictionary.class]) return unavailable;
+ NSMutableArray *nodes = [NSMutableArray array];
+ review_collect_nodes(tree, nodes);
+ NSMutableArray *allows = [NSMutableArray array], *denies = [NSMutableArray array];
+ NSMutableArray *headers = [NSMutableArray array], *web_areas = [NSMutableArray array];
+ bool marker = false, blocked = false;
+ for (NSDictionary *node in nodes) {
+ if (approval_card_has_prompt(node) || review_has_label(node, @"Ask permission") || review_has_label(node, APPROVAL_CARD_ALLOW)
+ || approval_card_button(node, APPROVAL_CARD_ALLOW)) marker = true;
+ if (approval_card_blocking_surface(node)) blocked = true;
+ if ([node[@"role"] isEqualToString:@"AXWebArea"]) [web_areas addObject:node];
+ if (approval_card_button(node, APPROVAL_CARD_ALLOW)) [allows addObject:node];
+ if (approval_card_button(node, APPROVAL_CARD_DENY)) [denies addObject:node];
+ if (approval_card_alert(node)) {
+ NSMutableArray *inside = [NSMutableArray array];
+ review_collect_nodes(node, inside);
+ bool terminal = false, prompt = false;
+ for (NSDictionary *child in inside) {
+ terminal |= review_has_label(child, @"Terminal");
+ prompt |= approval_card_has_prompt(child);
+ }
+ if ((terminal && prompt) || approval_card_ask_header(node)) [headers addObject:node];
+ }
+ }
+ if (!marker) return (ApprovalCardCapture){0};
+ unavailable.token = approval_card_fingerprint(tree);
+ if (blocked) { unavailable.reason = @"blocking-surface"; return unavailable; }
+ if (allows.count != 1 || denies.count != 1) { unavailable.reason = @"actions-unavailable"; return unavailable; }
+ if (headers.count != 1) { unavailable.reason = @"header-unavailable"; return unavailable; }
+ unavailable.reason = @"card-structure";
+ NSDictionary *allow = allows.firstObject, *deny = denies.firstObject, *header = headers.firstObject;
+ NSDictionary *card = nil;
+ NSUInteger card_size = NSUIntegerMax;
+ for (NSDictionary *node in nodes) {
+ if (![node[@"role"] isEqualToString:@"AXGroup"] || approval_card_alert(node)
+ || !approval_card_contains(node, allow) || !approval_card_contains(node, deny)
+ || !approval_card_contains(node, header)) continue;
+ NSMutableArray *inside = [NSMutableArray array];
+ review_collect_nodes(node, inside);
+ if (inside.count < card_size) { card = node; card_size = inside.count; }
+ }
+ if (card == nil || card_size > 180) return unavailable;
+ bool ask_structure = approval_card_ask_header(header);
+ if (ask_structure && !approval_card_ask_structure(card, header, allow, deny)) return unavailable;
+ if (!ask_structure && ((![approval_card_name(allow) isEqualToString:APPROVAL_CARD_ALLOW]
+ && approval_card_visible_children(allow).count == 0)
+ || (![approval_card_name(deny) isEqualToString:APPROVAL_CARD_DENY]
+ && approval_card_visible_children(deny).count == 0))) return unavailable;
+ unsigned owning_web_areas = 0;
+ NSDictionary *owner = nil;
+ for (NSDictionary *web in web_areas) if (approval_card_contains(web, card)) { owning_web_areas += 1; owner = web; }
+ unavailable.reason = @"untrusted-surface";
+ if (owning_web_areas != 1 || !approval_card_trusted_web_area(owner)) return unavailable;
+ unsigned main_landmarks = 0;
+ for (NSDictionary *node in nodes) {
+ if ([node[@"role"] isEqualToString:@"AXGroup"] && [node[@"subrole"] isEqualToString:@"AXLandmarkMain"]
+ && approval_card_contains(owner, node) && approval_card_contains(node, card)) main_landmarks += 1;
+ }
+ if (main_landmarks != 1) return unavailable;
+ unavailable.reason = @"card-structure";
+ NSMutableArray *inside = [NSMutableArray array];
+ review_collect_nodes(card, inside);
+ for (NSDictionary *node in inside) {
+ // An input form, nested page, or another alert cannot be part of this
+ // terminal permission card. The composer itself is outside the card.
+ if ([node[@"role"] isEqualToString:@"AXTextField"] || [node[@"role"] isEqualToString:@"AXTextArea"]
+ || [node[@"role"] isEqualToString:@"AXComboBox"] || [node[@"role"] isEqualToString:@"AXCheckBox"]
+ || [node[@"role"] isEqualToString:@"AXWebArea"]
+ || (approval_card_alert(node) && node != header)) return unavailable;
+ }
+ unavailable.token = approval_card_fingerprint(card);
+ unavailable.reason = @"controls-unavailable";
+ if (unavailable.token == nil || card[@"element"] == nil || header[@"element"] == nil
+ || allow[@"element"] == nil || deny[@"element"] == nil
+ || ![allow[@"enabled"] isEqual:@YES] || ![deny[@"enabled"] isEqual:@YES]
+ || ![allow[@"pressable"] isEqual:@YES] || ![deny[@"pressable"] isEqual:@YES]
+ || ![allow[@"focusable"] isEqual:@YES] || ![deny[@"focusable"] isEqual:@YES]) return unavailable;
+ return (ApprovalCardCapture){ .state = @"ready", .token = unavailable.token,
+ .card = card[@"element"], .header = header[@"element"], .allow = allow[@"element"], .deny = deny[@"element"] };
+}
+
+static ApprovalCardCapture approval_card_read_live(pid_t pid, void *unused) {
+ (void)unused;
+ ReviewScanBudget budget = { .complete = true };
+ NSDictionary *tree = review_read_live_snapshot(pid, &budget);
+ // A safety review takes precedence even if an ordinary card is still in AX.
+ if (review_classify_tree(tree, budget.complete).state != nil) return (ApprovalCardCapture){ .state = @"unavailable" };
+ return approval_card_classify_tree(tree, budget.complete);
+}
+
+static const char *approval_card_validate_live_controls(ApprovalCardCapture capture, void *unused) {
+ (void)unused;
+ NSArray *elements = @[capture.allow, capture.deny];
+ NSArray *labels = @[APPROVAL_CARD_ALLOW, APPROVAL_CARD_DENY];
+ for (NSUInteger i = 0; i < elements.count; i++) {
+ NSDictionary *node = review_read_live_control(elements[i]);
+ if (![node[@"role"] isEqualToString:@"AXButton"]
+ || !approval_card_name_matches(approval_card_name(node), labels[i])) return "approval-changed";
+ if (![node[@"enabled"] isEqual:@YES] || [node[@"hidden"] boolValue]) return "approval-unavailable";
+ CFArrayRef actions = NULL;
+ bool pressable = AXUIElementCopyActionNames((__bridge AXUIElementRef)elements[i], &actions) == kAXErrorSuccess
+ && actions != NULL && [(__bridge NSArray *)actions containsObject:(__bridge NSString *)kAXPressAction];
+ if (actions != NULL) CFRelease(actions);
+ if (!pressable) return "approval-unavailable";
+ }
+ return NULL;
+}
+
+enum { APPROVAL_CARD_RETURN_KEY = 0x24, APPROVAL_CARD_SPACE_KEY = 0x31, APPROVAL_CARD_KEYPAD_ENTER_KEY = 0x4C };
+
+static bool approval_card_event_pair(const char *decision, ApprovalShortcutKeyEvent events[2]) {
+ if (decision == NULL) return false;
+ CGKeyCode key;
+ if (strcmp(decision, "approve") == 0) key = APPROVAL_CARD_RETURN_KEY;
+ else if (strcmp(decision, "decline") == 0) key = APPROVAL_CARD_SPACE_KEY;
+ else return false;
+ events[0] = (ApprovalShortcutKeyEvent){ key, 0, true };
+ events[1] = (ApprovalShortcutKeyEvent){ key, 0, false };
+ return true;
+}
+
+typedef struct {
+ CGEventRef down;
+ CGEventRef up;
+ NSTimeInterval deadline;
+} ApprovalCardLiveActivation;
+
+static uint64_t approval_card_packet_tag(uint32_t nonce) {
+ // Diagnostic identification only; this is not an authorization credential.
+ return UINT64_C(0x5444434100000000) | nonce;
+}
+
+typedef struct {
+ void (*post)(CGEventRef, void *);
+ void (*wait)(useconds_t, void *);
+ void *context;
+} ApprovalCardPacketOperations;
+
+static void approval_card_emit_pair(ApprovalCardLiveActivation *state, ApprovalCardPacketOperations ops) {
+ ops.post(state->down, ops.context);
+ ops.wait(9000, ops.context);
+ ops.post(state->up, ops.context);
+ ops.wait(9000, ops.context);
+}
+
+static const char *approval_card_input_error(CGEventFlags flags, bool space_held, bool return_held, bool keypad_enter_held) {
+ CGEventFlags interference = kCGEventFlagMaskShift | kCGEventFlagMaskControl
+ | kCGEventFlagMaskAlternate | kCGEventFlagMaskCommand | kCGEventFlagMaskSecondaryFn;
+ // Caps Lock alone does not alter activation. Never mix this pair with a
+ // user's modifiers or an already held activation key.
+ return (flags & interference) != 0 || space_held || return_held || keypad_enter_held ? "input-active" : NULL;
+}
+
+static const char *approval_card_live_input_error(void) {
+ CGEventFlags flags = CGEventSourceFlagsState(kCGEventSourceStateHIDSystemState)
+ | CGEventSourceFlagsState(kCGEventSourceStateCombinedSessionState);
+ bool space_held = CGEventSourceKeyState(kCGEventSourceStateHIDSystemState, APPROVAL_CARD_SPACE_KEY)
+ || CGEventSourceKeyState(kCGEventSourceStateCombinedSessionState, APPROVAL_CARD_SPACE_KEY);
+ bool return_held = CGEventSourceKeyState(kCGEventSourceStateHIDSystemState, APPROVAL_CARD_RETURN_KEY)
+ || CGEventSourceKeyState(kCGEventSourceStateCombinedSessionState, APPROVAL_CARD_RETURN_KEY);
+ bool keypad_enter_held = CGEventSourceKeyState(kCGEventSourceStateHIDSystemState, APPROVAL_CARD_KEYPAD_ENTER_KEY)
+ || CGEventSourceKeyState(kCGEventSourceStateCombinedSessionState, APPROVAL_CARD_KEYPAD_ENTER_KEY);
+ return approval_card_input_error(flags, space_held, return_held, keypad_enter_held);
+}
+
+static const char *approval_card_activation_budget(ApprovalCardLiveActivation *state) {
+ return state->deadline > 0 && NSProcessInfo.processInfo.systemUptime < state->deadline
+ ? NULL : "activation-timeout";
+}
+
+static const char *approval_card_prepare_live_keys(const char *decision, void *opaque) {
+ ApprovalCardLiveActivation *state = opaque;
+ // A monotonic guard prevents input after a slow AX operation has consumed
+ // the helper's budget. The caller additionally has a 2200 ms hard timeout.
+ state->deadline = NSProcessInfo.processInfo.systemUptime + 1.8;
+ const char *error = approval_card_live_input_error();
+ if (error != NULL) return error;
+ if (!CGPreflightPostEventAccess()) return "permission-denied";
+ ApprovalShortcutKeyEvent events[2];
+ if (!approval_card_event_pair(decision, events)) return "invalid-arguments";
+ // Use the existing post_key transport's raw virtual keys. Return and Space
+ // need no Unicode override; retain the complete pair before changing focus.
+ state->down = create_key_event(events[0].key, events[0].down, events[0].flags, NULL, 0);
+ state->up = create_key_event(events[1].key, events[1].down, events[1].flags, NULL, 0);
+ if (state->down == NULL || state->up == NULL) return "event-unavailable";
+ uint64_t tag = approval_card_packet_tag(arc4random());
+ CGEventSetIntegerValueField(state->down, kCGEventSourceUserData, (int64_t)tag);
+ CGEventSetIntegerValueField(state->up, kCGEventSourceUserData, (int64_t)tag);
+ CGEventSetIntegerValueField(state->down, kCGKeyboardEventAutorepeat, 0);
+ CGEventSetIntegerValueField(state->up, kCGKeyboardEventAutorepeat, 0);
+ return approval_card_activation_budget(state);
+}
+
+static const char *approval_card_activation_context(ApprovalShortcutContext *context, void *opaque) {
+ ApprovalCardLiveActivation *state = opaque;
+ const char *error = approval_card_activation_budget(state);
+ if (error != NULL) return error;
+ error = approval_read_live_context(context, NULL);
+ return error != NULL ? error : approval_card_activation_budget(state);
+}
+
+static const char *approval_card_validate_live_focus(id target, pid_t pid, void *opaque) {
+ ApprovalCardLiveActivation *state = opaque;
+ const char *error = approval_card_activation_budget(state);
+ if (error != NULL) return error;
+ NSRunningApplication *front = NSWorkspace.sharedWorkspace.frontmostApplication;
+ if (front.processIdentifier != pid || ![front.bundleIdentifier isEqualToString:@"com.openai.codex"]) return "not-frontmost";
+ AXUIElementRef app = AXUIElementCreateApplication(pid);
+ if (app == NULL) return "focus-unavailable";
+ AXUIElementSetMessagingTimeout(app, 0.06);
+ CFTypeRef focused = NULL;
+ AXError read = AXUIElementCopyAttributeValue(app, kAXFocusedUIElementAttribute, &focused);
+ CFRelease(app);
+ bool exact = read == kAXErrorSuccess && focused != NULL
+ && CFGetTypeID(focused) == AXUIElementGetTypeID()
+ && CFEqual(focused, (__bridge CFTypeRef)target);
+ if (focused != NULL) CFRelease(focused);
+ if (!exact) return "focus-unverified";
+ front = NSWorkspace.sharedWorkspace.frontmostApplication;
+ if (front.processIdentifier != pid || ![front.bundleIdentifier isEqualToString:@"com.openai.codex"]) return "not-frontmost";
+ error = approval_card_live_input_error();
+ return error != NULL ? error : approval_card_activation_budget(state);
+}
+
+static const char *approval_card_focus_live_button(id target, pid_t pid, void *opaque) {
+ ApprovalCardLiveActivation *state = opaque;
+ const char *error = approval_card_activation_budget(state);
+ if (error != NULL) return error;
+ error = approval_card_live_input_error();
+ if (error != NULL) return error;
+ AXUIElementRef button = (__bridge AXUIElementRef)target;
+ AXUIElementSetMessagingTimeout(button, 0.06);
+ Boolean settable = false;
+ if (AXUIElementIsAttributeSettable(button, kAXFocusedAttribute, &settable) != kAXErrorSuccess
+ || !settable) return "focus-unavailable";
+ error = approval_card_activation_budget(state);
+ if (error != NULL) return error;
+ NSRunningApplication *front = NSWorkspace.sharedWorkspace.frontmostApplication;
+ if (front.processIdentifier != pid || ![front.bundleIdentifier isEqualToString:@"com.openai.codex"]) return "not-frontmost";
+ if (AXUIElementSetAttributeValue(button, kAXFocusedAttribute, kCFBooleanTrue) != kAXErrorSuccess) return "focus-unavailable";
+ NSTimeInterval deadline = MIN(state->deadline, NSProcessInfo.processInfo.systemUptime + 0.12);
+ do {
+ error = approval_card_validate_live_focus(target, pid, opaque);
+ if (error == NULL || strcmp(error, "focus-unverified") != 0) return error;
+ if (NSProcessInfo.processInfo.systemUptime >= deadline) break;
+ usleep(8000);
+ } while (NSProcessInfo.processInfo.systemUptime < deadline);
+ return "focus-unverified";
+}
+
+static void approval_card_post_live_event(CGEventRef event, void *opaque) {
+ (void)opaque;
+ CGEventPost(kCGHIDEventTap, event);
+}
+
+static void approval_card_wait_live_event(useconds_t delay, void *opaque) {
+ (void)opaque;
+ usleep(delay);
+}
+
+static const char *approval_card_post_live_keys(id target, void *opaque) {
+ (void)target;
+ // Match post_key's pulse and exit dwell without intervening AX reads. Both
+ // events already exist; never fall back to AXPress or send another pair.
+ approval_card_emit_pair(opaque, (ApprovalCardPacketOperations){
+ .post = approval_card_post_live_event, .wait = approval_card_wait_live_event
+ });
+ return NULL;
+}
+
+static const char *approval_card_dispatch(const char *decision, pid_t pid, NSString *window_token,
+ NSString *card_token, ApprovalCardOperations ops, bool *attempted) {
+ *attempted = false;
+ if (decision == NULL || (strcmp(decision, "approve") != 0 && strcmp(decision, "decline") != 0)) return "invalid-arguments";
+ const char *error = ops.prepare_keys(decision, ops.context);
+ if (error != NULL) return error;
+ ApprovalCardCapture previous = {0};
+ for (unsigned i = 0; i < 2; i++) {
+ ApprovalShortcutContext context = {0};
+ error = ops.read_context(&context, ops.context);
+ if (error != NULL) return error;
+ if (!approval_context_matches(context, pid, window_token)) return "context-changed";
+ ApprovalCardCapture current = ops.read_card(pid, ops.context);
+ if (![current.state isEqualToString:@"ready"] || current.card == nil || current.header == nil
+ || current.allow == nil || current.deny == nil) return "approval-unavailable";
+ if (![current.token isEqualToString:card_token]) return "approval-changed";
+ if (i > 0 && (!ops.same_element(previous.card, current.card, ops.context)
+ || !ops.same_element(previous.header, current.header, ops.context)
+ || !ops.same_element(previous.allow, current.allow, ops.context)
+ || !ops.same_element(previous.deny, current.deny, ops.context))) return "approval-changed";
+ previous = current;
+ if (i == 0) {
+ error = ops.validate_controls(current, ops.context);
+ if (error != NULL) return error;
+ error = ops.read_context(&context, ops.context);
+ if (error != NULL) return error;
+ if (!approval_context_matches(context, pid, window_token)) return "context-changed";
+ id target = strcmp(decision, "approve") == 0 ? current.allow : current.deny;
+ error = ops.focus_button(target, pid, ops.context);
+ if (error != NULL) return error;
+ // The second complete scan rebinds the trusted card after focus, without
+ // performing any activation if focus changed the card or request.
+ }
+ }
+ error = ops.validate_controls(previous, ops.context);
+ if (error != NULL) return error;
+ // This final check occurs after all potentially slower AX reads, immediately
+ // before activation. Nothing sets a checkbox or widens permission.
+ ApprovalShortcutContext context = {0};
+ error = ops.read_context(&context, ops.context);
+ if (error != NULL) return error;
+ if (!approval_context_matches(context, pid, window_token)) return "context-changed";
+ id target = strcmp(decision, "approve") == 0 ? previous.allow : previous.deny;
+ error = ops.validate_focus(target, pid, ops.context);
+ if (error != NULL) return error;
+ *attempted = true;
+ return ops.post_keys(target, ops.context) == NULL ? NULL : "delivery-unknown";
+}
+
+static NSDictionary *approval_card_context_metadata(ApprovalCardCapture capture) {
+ if (capture.state == nil) return nil;
+ return @{ @"state": capture.state, @"token": capture.token != nil ? capture.token : NSNull.null,
+ @"reason": capture.reason != nil ? capture.reason : NSNull.null };
+}
+
+static int print_codex_task_action_context(void) {
+ ApprovalShortcutContext context = {0};
+ const char *error = approval_read_live_context(&context, NULL);
+ if (error != NULL) return approval_print_error(error, 1);
+ ReviewScanBudget budget = { .complete = true };
+ NSDictionary *tree = review_read_live_snapshot(context.pid, &budget);
+ ReviewCapture review = review_classify_tree(tree, budget.complete);
+ ApprovalCardCapture card = review.state == nil ? approval_card_classify_tree(tree, budget.complete) : (ApprovalCardCapture){0};
+ ApprovalShortcutContext final_context = {0};
+ error = approval_read_live_context(&final_context, NULL);
+ if (error != NULL) return approval_print_error(error, 1);
+ if (!approval_context_matches(final_context, context.pid, approval_context_token(context))) return approval_print_error("context-changed", 1);
+ NSDictionary *review_metadata = review_context_metadata(review), *card_metadata = approval_card_context_metadata(card);
+ NSDictionary *result = @{ @"pid": @(context.pid), @"token": approval_context_token(context),
+ @"review": review_metadata != nil ? review_metadata : NSNull.null,
+ @"approval": card_metadata != nil ? card_metadata : NSNull.null,
+ @"scanDiagnostics": review_scan_summary(budget) };
+ NSData *data = [NSJSONSerialization dataWithJSONObject:result options:0 error:NULL];
+ if (data == nil) return approval_print_error("context-unavailable", 1);
+ printf("%s\n", [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding].UTF8String);
+ return 0;
+}
+
+static int codex_approval_card(int argc, char **argv) {
+ if (argc != 6 || (strcmp(argv[2], "approve") != 0 && strcmp(argv[2], "decline") != 0)
+ || strlen(argv[4]) > 64 || strlen(argv[5]) != 67 || strncmp(argv[5], "a1:", 3) != 0) return approval_print_error("invalid-arguments", 64);
+ for (unsigned i = 3; i < 67; i++) if (!isxdigit((unsigned char)argv[5][i])) return approval_print_error("invalid-arguments", 64);
+ char *end = NULL;
+ errno = 0;
+ long pid = strtol(argv[3], &end, 10);
+ if (errno != 0 || end == argv[3] || *end != '\0' || pid <= 1 || pid > INT_MAX) return approval_print_error("invalid-arguments", 64);
+ bool attempted = false;
+ ApprovalCardLiveActivation activation = {0};
+ const char *error = approval_card_dispatch(argv[2], (pid_t)pid,
+ [NSString stringWithUTF8String:argv[4]], [NSString stringWithUTF8String:argv[5]], (ApprovalCardOperations){
+ .prepare_keys = approval_card_prepare_live_keys,
+ .read_context = approval_card_activation_context, .read_card = approval_card_read_live,
+ .same_element = review_same_live_element, .validate_controls = approval_card_validate_live_controls,
+ .focus_button = approval_card_focus_live_button, .validate_focus = approval_card_validate_live_focus,
+ .post_keys = approval_card_post_live_keys, .context = &activation
+ }, &attempted);
+ if (activation.down != NULL) CFRelease(activation.down);
+ if (activation.up != NULL) CFRelease(activation.up);
+ if (error != NULL) {
+ if (!attempted) return approval_print_error(error, 1);
+ printf("{\"sent\":null,\"error\":\"delivery-unknown\"}\n");
+ return 1;
+ }
+ // HID event emission is not approval resolution. Never retry here.
+ printf("{\"sent\":true}\n");
+ return 0;
+}
+
+static NSDictionary *approval_card_diagnostics(NSDictionary *tree, ReviewScanBudget budget) {
+ NSMutableArray *nodes = [NSMutableArray array];
+ if (tree != nil) review_collect_nodes(tree, nodes);
+ unsigned allow = 0, deny = 0, prompts = 0, terminal = 0, ask = 0, alerts = 0, blocking = 0, trusted = 0;
+ unsigned allow_enabled = 0, deny_enabled = 0, allow_pressable = 0, deny_pressable = 0, allow_focusable = 0, deny_focusable = 0;
+ for (NSDictionary *node in nodes) {
+ bool is_allow = approval_card_button(node, APPROVAL_CARD_ALLOW), is_deny = approval_card_button(node, APPROVAL_CARD_DENY);
+ allow += is_allow; deny += is_deny;
+ allow_enabled += is_allow && [node[@"enabled"] isEqual:@YES];
+ deny_enabled += is_deny && [node[@"enabled"] isEqual:@YES];
+ allow_pressable += is_allow && [node[@"pressable"] isEqual:@YES];
+ deny_pressable += is_deny && [node[@"pressable"] isEqual:@YES];
+ allow_focusable += is_allow && [node[@"focusable"] isEqual:@YES];
+ deny_focusable += is_deny && [node[@"focusable"] isEqual:@YES];
+ prompts += approval_card_has_prompt(node); terminal += review_has_label(node, @"Terminal");
+ ask += review_has_label(node, @"Ask permission"); trusted += approval_card_trusted_web_area(node);
+ alerts += approval_card_alert(node); blocking += approval_card_blocking_surface(node);
+ }
+ ApprovalCardCapture capture = approval_card_classify_tree(tree, budget.complete);
+ return @{ @"scan": review_scan_diagnostics(budget), @"state": capture.state != nil ? capture.state : @"absent",
+ @"reason": capture.reason != nil ? capture.reason : NSNull.null,
+ @"knownLabels": @{ @"Allow once": @(allow), @"Deny": @(deny), @"Terminal": @(terminal), @"Ask permission": @(ask), @"commandPrompt": @(prompts) },
+ @"controls": @{ @"allowEnabled": @(allow_enabled), @"denyEnabled": @(deny_enabled),
+ @"allowPressable": @(allow_pressable), @"denyPressable": @(deny_pressable),
+ @"allowFocusable": @(allow_focusable), @"denyFocusable": @(deny_focusable) },
+ @"alerts": @(alerts), @"blockingSurfaces": @(blocking), @"trustedWebAreas": @(trusted) };
+}
+
+static int print_codex_approval_card_diagnostics(void) {
+ ApprovalShortcutContext context = {0};
+ const char *error = approval_read_live_context(&context, NULL);
+ if (error != NULL) return approval_print_error(error, 1);
+ ReviewScanBudget budget = { .complete = true };
+ NSDictionary *tree = review_read_live_snapshot(context.pid, &budget);
+ ApprovalShortcutContext final_context = {0};
+ error = approval_read_live_context(&final_context, NULL);
+ if (error != NULL) return approval_print_error(error, 1);
+ if (!approval_context_matches(final_context, context.pid, approval_context_token(context))) return approval_print_error("context-changed", 1);
+ // Fixed labels, aggregate roles and states only; never output the tree,
+ // request/command text, window or task identities, or captured tokens.
+ NSData *data = [NSJSONSerialization dataWithJSONObject:approval_card_diagnostics(tree, budget) options:0 error:NULL];
+ if (data == nil) return approval_print_error("diagnostics-unavailable", 1);
+ printf("%s\n", [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding].UTF8String);
+ return 0;
+}
+
+static NSMutableDictionary *approval_card_selftest_fixture(void) {
+ return [@{ @"role": @"AXGroup", @"element": @"card-1", @"identity": @1, @"path": @"0/0/0",
+ @"children": @[
+ @{ @"role": @"AXGroup", @"subrole": @"AXApplicationAlert", @"element": @"header-2", @"identity": @2,
+ @"children": @[
+ @{ @"role": @"AXStaticText", @"value": @"Terminal", @"identity": @3 },
+ @{ @"role": @"AXStaticText", @"value": @"Allow ChatGPT to run this command?", @"identity": @4 }
+ ] },
+ @{ @"role": @"AXStaticText", @"value": @"Fixture private command A", @"identity": @5 },
+ @{ @"role": @"AXButton", @"title": @"Deny", @"enabled": @YES, @"pressable": @YES, @"focusable": @YES,
+ @"element": @"deny-6", @"identity": @6 },
+ @{ @"role": @"AXButton", @"title": @"Allow once", @"enabled": @YES, @"pressable": @YES, @"focusable": @YES,
+ @"element": @"allow-7", @"identity": @7 }
+ ] } mutableCopy];
+}
+
+static NSMutableDictionary *approval_card_selftest_ask_fixture(void) {
+ NSMutableDictionary *card = approval_card_selftest_fixture();
+ NSArray *existing = card[@"children"];
+ card[@"children"] = @[
+ @{ @"role": @"AXGroup", @"subrole": @"AXApplicationAlert", @"element": @"ask-header", @"identity": @20,
+ @"children": @[
+ @{ @"role": @"AXStaticText", @"value": @"Ask permission", @"identity": @21 },
+ @{ @"role": @"AXGroup", @"children": @[
+ @{ @"role": @"AXStaticText", @"value": @"Fixture private justification A", @"identity": @22 }
+ ] }
+ ] },
+ @{ @"role": @"AXGroup", @"children": @[existing[2], existing[3]] }
+ ];
+ return card;
+}
+
+static NSDictionary *approval_card_selftest_window(NSDictionary *card, NSArray *siblings) {
+ return @{ @"role": @"AXWindow", @"children": @[
+ @{ @"role": @"AXWebArea", @"url": @"app://-/index.html", @"children": @[
+ @{ @"role": @"AXGroup", @"subrole": @"AXLandmarkMain",
+ @"children": [@[card] arrayByAddingObjectsFromArray:siblings != nil ? siblings : @[]] }
+ ] }
+ ] };
+}
+
+typedef struct {
+ ApprovalShortcutContext contexts[4];
+ const char *context_errors[4];
+ ApprovalCardCapture cards[2];
+ unsigned context_reads, card_reads, presses, validations, prepared, focuses, focus_checks, events;
+ AXError press_result;
+ const char *control_errors[2];
+ const char *prepare_error;
+ const char *focus_error;
+ const char *focus_check_error;
+ CGKeyCode prepared_key;
+ id __strong target;
+ id __strong focused_target;
+} ApprovalCardSelftestState;
+
+static const char *approval_card_selftest_prepare(const char *decision, void *opaque) {
+ ApprovalCardSelftestState *state = opaque;
+ state->prepared += 1;
+ ApprovalShortcutKeyEvent events[2];
+ if (!approval_card_event_pair(decision, events)) return "invalid-arguments";
+ state->prepared_key = events[0].key;
+ return state->prepare_error;
+}
+
+static const char *approval_card_selftest_context(ApprovalShortcutContext *context, void *opaque) {
+ ApprovalCardSelftestState *state = opaque;
+ unsigned index = state->context_reads++;
+ if (index > 3) return "unexpected-read";
+ *context = state->contexts[index];
+ return state->context_errors[index];
+}
+
+static ApprovalCardCapture approval_card_selftest_read(pid_t pid, void *opaque) {
+ (void)pid;
+ ApprovalCardSelftestState *state = opaque;
+ unsigned index = state->card_reads++;
+ return index < 2 ? state->cards[index] : (ApprovalCardCapture){ .state = @"unavailable" };
+}
+
+static const char *approval_card_selftest_controls(ApprovalCardCapture capture, void *opaque) {
+ (void)capture;
+ ApprovalCardSelftestState *state = opaque;
+ unsigned index = state->validations++;
+ return index < 2 ? state->control_errors[index] : "unexpected-validation";
+}
+
+static const char *approval_card_selftest_focus(id target, pid_t pid, void *opaque) {
+ (void)pid;
+ ApprovalCardSelftestState *state = opaque;
+ state->focuses += 1;
+ state->focused_target = target;
+ return state->focus_error;
+}
+
+static const char *approval_card_selftest_verify_focus(id target, pid_t pid, void *opaque) {
+ (void)pid;
+ ApprovalCardSelftestState *state = opaque;
+ state->focus_checks += 1;
+ if (![target isEqual:state->focused_target]) return "focus-unverified";
+ return state->focus_check_error;
+}
+
+static const char *approval_card_selftest_press(id target, void *opaque) {
+ ApprovalCardSelftestState *state = opaque;
+ state->presses += 1;
+ state->events += state->press_result == kAXErrorSuccess ? 2 : 1;
+ state->target = target;
+ return state->press_result == kAXErrorSuccess ? NULL : "delivery-unknown";
+}
+
+static ApprovalCardSelftestState approval_card_selftest_state(ApprovalCardCapture capture) {
+ return (ApprovalCardSelftestState){
+ .contexts = {{123,45,501,1},{123,45,501,1},{123,45,501,1},{123,45,501,1}},
+ .cards = {capture,capture}, .press_result = kAXErrorSuccess
+ };
+}
+
+static const char *approval_card_selftest_dispatch(const char *decision, ApprovalCardSelftestState *state,
+ NSString *token, bool *attempted) {
+ return approval_card_dispatch(decision, 123, @"v2:123:45:501:1", token, (ApprovalCardOperations){
+ .prepare_keys = approval_card_selftest_prepare,
+ .read_context = approval_card_selftest_context, .read_card = approval_card_selftest_read,
+ .same_element = review_same_live_element, .validate_controls = approval_card_selftest_controls,
+ .focus_button = approval_card_selftest_focus, .validate_focus = approval_card_selftest_verify_focus,
+ .post_keys = approval_card_selftest_press, .context = state
+ }, attempted);
+}
+
+static void approval_card_selftest_post_event(CGEventRef event, void *opaque) {
+ [(__bridge NSMutableArray *)opaque addObject:(__bridge id)event];
+}
+
+static void approval_card_selftest_wait_event(useconds_t delay, void *opaque) {
+ [(__bridge NSMutableArray *)opaque addObject:@(delay)];
+}
+
+static int approval_card_selftest(void) {
+ // Fixtures and injected operations only. No application reads or input.
+ unsigned checks = 0, failures = 0;
+#define CARD_CHECK(condition) do { checks += 1; if (!(condition)) { failures += 1; fprintf(stderr, "approval-card-selftest line %d failed\n", __LINE__); } } while (0)
+ NSMutableDictionary *card = approval_card_selftest_fixture();
+ NSDictionary *window = approval_card_selftest_window(card, nil);
+ ApprovalCardCapture ready = approval_card_classify_tree(window, true);
+ CARD_CHECK([ready.state isEqualToString:@"ready"] && ready.token.length == 67
+ && [ready.token hasPrefix:@"a1:"] && ready.allow != nil && ready.deny != nil);
+ CARD_CHECK([approval_card_classify_tree(window, true).token isEqualToString:ready.token]);
+ CARD_CHECK(approval_card_classify_tree(@{ @"role": @"AXWindow", @"children": @[] }, true).state == nil);
+ CARD_CHECK([approval_card_classify_tree(window, false).state isEqualToString:@"unavailable"]);
+ CARD_CHECK([approval_card_classify_tree(nil, true).state isEqualToString:@"unavailable"]);
+ CARD_CHECK(approval_card_context_metadata((ApprovalCardCapture){0}) == nil);
+ CARD_CHECK([approval_card_context_metadata(ready)[@"state"] isEqualToString:@"ready"]);
+ CARD_CHECK(approval_card_context_metadata(ready)[@"reason"] == NSNull.null);
+ CARD_CHECK([review_url_string(@"app://-/index.html") isEqualToString:@"app://-/index.html"]);
+ CARD_CHECK([review_url_string([NSURL URLWithString:@"app://-/index.html"]) isEqualToString:@"app://-/index.html"]);
+ CARD_CHECK(review_url_string(NSNull.null) == nil && review_url_string(nil) == nil);
+ for (id url in @[@"https://example.invalid/index.html", @"http://-/index.html", @"file:///index.html",
+ @"app://other/index.html", @"app://-/other.html", @"app://user@-/index.html", @"app://-:80/index.html",
+ @"app://-/index.html?preview=true", @"app://-/%69ndex.html", NSNull.null]) {
+ NSMutableDictionary *foreign = [@{ @"role": @"AXWebArea", @"children": @[
+ @{ @"role": @"AXGroup", @"subrole": @"AXLandmarkMain", @"children": @[approval_card_selftest_ask_fixture()] }
+ ] } mutableCopy];
+ if (url != NSNull.null) foreign[@"url"] = url;
+ NSDictionary *sibling_window = @{ @"role": @"AXWindow", @"children": @[
+ @{ @"role": @"AXWebArea", @"url": @"app://-/index.html", @"children": @[] }, foreign
+ ] };
+ ApprovalCardCapture foreign_capture = approval_card_classify_tree(sibling_window, true);
+ CARD_CHECK([foreign_capture.state isEqualToString:@"unavailable"] && [foreign_capture.reason isEqualToString:@"untrusted-surface"]);
+ }
+ NSDictionary *no_main = @{ @"role": @"AXWindow", @"children": @[
+ @{ @"role": @"AXWebArea", @"url": @"app://-/index.html", @"children": @[approval_card_selftest_ask_fixture()] }
+ ] };
+ CARD_CHECK([approval_card_classify_tree(no_main, true).state isEqualToString:@"unavailable"]);
+ NSDictionary *nested_web = @{ @"role": @"AXWebArea", @"url": @"app://-/index.html", @"children": @[
+ @{ @"role": @"AXGroup", @"subrole": @"AXLandmarkMain", @"children": @[approval_card_selftest_ask_fixture()] }
+ ] };
+ CARD_CHECK([approval_card_classify_tree(approval_card_selftest_window(nested_web, nil), true).state isEqualToString:@"unavailable"]);
+ NSDictionary *nested_main = @{ @"role": @"AXGroup", @"subrole": @"AXLandmarkMain", @"children": @[approval_card_selftest_ask_fixture()] };
+ CARD_CHECK([approval_card_classify_tree(approval_card_selftest_window(nested_main, nil), true).state isEqualToString:@"unavailable"]);
+ card = approval_card_selftest_ask_fixture();
+ ApprovalCardCapture ask = approval_card_classify_tree(approval_card_selftest_window(card, nil), true);
+ CARD_CHECK([ask.state isEqualToString:@"ready"] && [ask.allow isEqual:@"allow-7"] && [ask.deny isEqual:@"deny-6"]);
+ for (NSArray *names in @[@[@"Deny Esc", @"Allow once ⏎"], @[@"Deny⌃⌥⌘F14", @"Allow once⌃⌥⌘F13"]]) {
+ NSMutableDictionary *flat = approval_card_selftest_ask_fixture();
+ NSMutableDictionary *actions = [flat[@"children"][1] mutableCopy];
+ review_selftest_set_child(actions, 0, @"title", names[0]);
+ review_selftest_set_child(actions, 1, @"title", names[1]);
+ review_selftest_set_child(flat, 1, @"children", actions[@"children"]);
+ CARD_CHECK([approval_card_classify_tree(approval_card_selftest_window(flat, nil), true).state isEqualToString:@"ready"]);
+ }
+ NSMutableDictionary *ask_header = [card[@"children"][0] mutableCopy];
+ NSMutableDictionary *ask_reason = [ask_header[@"children"][1] mutableCopy];
+ review_selftest_set_child(ask_reason, 0, @"value", @"Fixture private justification B");
+ review_selftest_set_child(ask_header, 1, @"children", ask_reason[@"children"]);
+ review_selftest_set_child(card, 0, @"children", ask_header[@"children"]);
+ CARD_CHECK(![approval_card_classify_tree(approval_card_selftest_window(card, nil), true).token isEqualToString:ask.token]);
+ for (NSString *label in @[@"Always allow", @"Request", @"Ask Permission"]) {
+ card = approval_card_selftest_ask_fixture();
+ ask_header = [card[@"children"][0] mutableCopy]; review_selftest_set_child(ask_header, 0, @"value", label);
+ review_selftest_set_child(card, 0, @"children", ask_header[@"children"]);
+ CARD_CHECK([approval_card_classify_tree(approval_card_selftest_window(card, nil), true).state isEqualToString:@"unavailable"]);
+ }
+ card = approval_card_selftest_ask_fixture();
+ NSArray *ask_actions = card[@"children"][1][@"children"];
+ review_selftest_set_child(card, 1, @"children", @[ask_actions[1], ask_actions[0]]);
+ CARD_CHECK([approval_card_classify_tree(approval_card_selftest_window(card, nil), true).state isEqualToString:@"unavailable"]);
+ card = approval_card_selftest_ask_fixture();
+ card[@"children"] = [card[@"children"] arrayByAddingObject:@{ @"role": @"AXButton", @"title": @"Always allow" }];
+ CARD_CHECK([approval_card_classify_tree(approval_card_selftest_window(card, nil), true).state isEqualToString:@"unavailable"]);
+ card = approval_card_selftest_ask_fixture();
+ review_selftest_set_child(card, 1, @"children", @[
+ @{ @"role": @"AXGroup", @"children": @[ask_actions[0]] },
+ @{ @"role": @"AXGroup", @"children": @[ask_actions[1]] }
+ ]);
+ CARD_CHECK([approval_card_classify_tree(approval_card_selftest_window(card, nil), true).state isEqualToString:@"unavailable"]);
+ for (NSString *prompt in approval_card_prompts()) {
+ card = approval_card_selftest_fixture();
+ NSMutableDictionary *header = [card[@"children"][0] mutableCopy];
+ review_selftest_set_child(header, 1, @"value", prompt);
+ review_selftest_set_child(card, 0, @"children", header[@"children"]);
+ CARD_CHECK([approval_card_classify_tree(approval_card_selftest_window(card, nil), true).state isEqualToString:@"ready"]);
+ }
+ card = approval_card_selftest_fixture();
+ review_selftest_set_child(card, 0, @"role", @"AXAlert");
+ review_selftest_set_child(card, 0, @"subrole", nil);
+ CARD_CHECK([approval_card_classify_tree(approval_card_selftest_window(card, nil), true).state isEqualToString:@"ready"]);
+ for (NSNumber *index in @[@2,@3]) {
+ NSString *label = index.intValue == 2 ? APPROVAL_CARD_DENY : APPROVAL_CARD_ALLOW;
+ NSArray *names = index.intValue == 2 ? @[@"Deny Esc", @"Deny⎋", @"Deny\n⌃⌥⌘F14"]
+ : @[@"Allow once ⏎", @"Allow once↵", @"Allow once\n⌃⌥⌘F13"];
+ for (NSString *name in names) {
+ card = approval_card_selftest_fixture();
+ review_selftest_set_child(card, index.unsignedIntegerValue, @"title", name);
+ CARD_CHECK([approval_card_classify_tree(approval_card_selftest_window(card, nil), true).state isEqualToString:@"unavailable"]);
+ review_selftest_set_child(card, index.unsignedIntegerValue, @"children", @[
+ @{ @"role": @"AXStaticText", @"value": label }
+ ]);
+ CARD_CHECK([approval_card_classify_tree(approval_card_selftest_window(card, nil), true).state isEqualToString:@"ready"]);
+ }
+ for (NSString *key in @[@"enabled", @"pressable", @"focusable", @"element"]) {
+ card = approval_card_selftest_fixture(); review_selftest_set_child(card, index.unsignedIntegerValue, key, nil);
+ CARD_CHECK([approval_card_classify_tree(approval_card_selftest_window(card, nil), true).state isEqualToString:@"unavailable"]);
+ if (![key isEqualToString:@"element"]) {
+ review_selftest_set_child(card, index.unsignedIntegerValue, key, @NO);
+ CARD_CHECK([approval_card_classify_tree(approval_card_selftest_window(card, nil), true).state isEqualToString:@"unavailable"]);
+ }
+ }
+ for (NSString *name in @[@"Always allow", @"Allow all edits", @"Allow once and more", @"Deny everything", @"Continue", @"allow once"]) {
+ card = approval_card_selftest_fixture(); review_selftest_set_child(card, index.unsignedIntegerValue, @"title", name);
+ CARD_CHECK([approval_card_classify_tree(approval_card_selftest_window(card, nil), true).state isEqualToString:@"unavailable"]);
+ }
+ card = approval_card_selftest_fixture(); review_selftest_set_child(card, index.unsignedIntegerValue, @"hidden", @YES);
+ CARD_CHECK([approval_card_classify_tree(approval_card_selftest_window(card, nil), true).state isEqualToString:@"unavailable"]);
+ }
+ NSArray *blockers = @[
+ @{ @"role": @"AXDialog" }, @{ @"role": @"AXSheet" }, @{ @"role": @"AXAlertDialog" },
+ @{ @"role": @"AXGroup", @"subrole": @"AXApplicationDialog" },
+ @{ @"role": @"AXGroup", @"subrole": @"AXApplicationAlertDialog" },
+ @{ @"role": @"AXGroup", @"subrole": @"AXDialog" },
+ @{ @"role": @"AXGroup", @"subrole": @"AXAlertDialog" },
+ @{ @"role": @"AXMenu" }, @{ @"role": @"AXMenuItem" }
+ ];
+ for (NSDictionary *blocker in blockers) {
+ card = approval_card_selftest_fixture();
+ CARD_CHECK([approval_card_classify_tree(approval_card_selftest_window(card, @[blocker]), true).state isEqualToString:@"unavailable"]);
+ NSMutableDictionary *hidden = [blocker mutableCopy]; hidden[@"hidden"] = @YES;
+ CARD_CHECK([approval_card_classify_tree(approval_card_selftest_window(card, @[hidden]), true).state isEqualToString:@"ready"]);
+ card[@"children"] = [card[@"children"] arrayByAddingObject:blocker];
+ CARD_CHECK([approval_card_classify_tree(approval_card_selftest_window(card, nil), true).state isEqualToString:@"unavailable"]);
+ }
+ card = approval_card_selftest_fixture();
+ CARD_CHECK([approval_card_classify_tree(approval_card_selftest_window(card, @[approval_card_selftest_fixture()]), true).state isEqualToString:@"unavailable"]);
+ CARD_CHECK([approval_card_classify_tree(card, true).state isEqualToString:@"unavailable"]);
+ for (NSNumber *index in @[@0,@2,@3]) {
+ card = approval_card_selftest_fixture(); card[@"children"] = [card[@"children"] arrayByAddingObject:card[@"children"][index.unsignedIntegerValue]];
+ CARD_CHECK([approval_card_classify_tree(approval_card_selftest_window(card, nil), true).state isEqualToString:@"unavailable"]);
+ }
+ for (NSString *role in @[@"AXTextField", @"AXTextArea", @"AXComboBox", @"AXCheckBox", @"AXWebArea", @"AXAlert"]) {
+ card = approval_card_selftest_fixture(); card[@"children"] = [card[@"children"] arrayByAddingObject:@{ @"role": role }];
+ CARD_CHECK([approval_card_classify_tree(approval_card_selftest_window(card, nil), true).state isEqualToString:@"unavailable"]);
+ }
+ card = approval_card_selftest_fixture();
+ card[@"children"] = [card[@"children"] arrayByAddingObjectsFromArray:@[
+ @{ @"role": @"AXButton", @"title": @"Always allow", @"element": @"broader-1" },
+ @{ @"role": @"AXButton", @"title": @"Approval options", @"element": @"broader-2" }
+ ]];
+ ApprovalCardCapture options = approval_card_classify_tree(approval_card_selftest_window(card, nil), true);
+ CARD_CHECK([options.state isEqualToString:@"ready"] && [options.allow isEqual:@"allow-7"] && [options.deny isEqual:@"deny-6"]);
+ card = approval_card_selftest_fixture();
+ review_selftest_set_child(card, 1, @"value", @"Fixture private command B");
+ ApprovalCardCapture changed = approval_card_classify_tree(approval_card_selftest_window(card, nil), true);
+ CARD_CHECK(![changed.token isEqualToString:ready.token]);
+ card = approval_card_selftest_fixture(); review_selftest_set_child(card, 3, @"identity", @999);
+ CARD_CHECK(![approval_card_classify_tree(approval_card_selftest_window(card, nil), true).token isEqualToString:ready.token]);
+ card = approval_card_selftest_fixture(); card[@"path"] = @"0/1/0";
+ CARD_CHECK(![approval_card_classify_tree(approval_card_selftest_window(card, nil), true).token isEqualToString:ready.token]);
+ card = approval_card_selftest_fixture(); card[@"hidden"] = @YES;
+ CARD_CHECK(approval_card_classify_tree(approval_card_selftest_window(card, nil), true).state == nil);
+ card = approval_card_selftest_fixture();
+ NSMutableArray *large = [card[@"children"] mutableCopy];
+ for (unsigned i = 0; i < 180; i++) [large addObject:@{ @"role": @"AXStaticText" }];
+ card[@"children"] = large;
+ CARD_CHECK([approval_card_classify_tree(approval_card_selftest_window(card, nil), true).state isEqualToString:@"unavailable"]);
+
+ ReviewScanBudget budget = { .visited = 20, .complete = true };
+ NSDictionary *diagnostics = approval_card_diagnostics(window, budget);
+ NSData *data = [NSJSONSerialization dataWithJSONObject:diagnostics options:0 error:NULL];
+ NSString *serialized = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
+ CARD_CHECK([diagnostics[@"state"] isEqualToString:@"ready"]);
+ CARD_CHECK([diagnostics[@"knownLabels"][@"Allow once"] isEqual:@1]);
+ CARD_CHECK([serialized rangeOfString:@"Fixture private"].location == NSNotFound);
+ CARD_CHECK([serialized rangeOfString:@"a1:"].location == NSNotFound && [serialized rangeOfString:@"allow-7"].location == NSNotFound);
+ review_scan_stop(&budget, "node-limit");
+ CARD_CHECK([approval_card_diagnostics(window, budget)[@"state"] isEqualToString:@"unavailable"]);
+
+ bool attempted = false;
+ for (NSString *decision in @[@"approve",@"decline"]) {
+ ApprovalCardSelftestState state = approval_card_selftest_state(ready);
+ CARD_CHECK(approval_card_selftest_dispatch(decision.UTF8String, &state, ready.token, &attempted) == NULL);
+ CARD_CHECK(attempted && state.presses == 1 && state.card_reads == 2 && state.context_reads == 4 && state.validations == 2
+ && state.prepared == 1 && state.focuses == 1 && state.focus_checks == 1 && state.events == 2);
+ CARD_CHECK([state.target isEqual:([decision isEqualToString:@"approve"] ? ready.allow : ready.deny)]);
+ CARD_CHECK(state.prepared_key == ([decision isEqualToString:@"approve"] ? 0x24 : 0x31));
+ state = approval_card_selftest_state(ready); state.press_result = kAXErrorCannotComplete;
+ CARD_CHECK(approval_error_is(approval_card_selftest_dispatch(decision.UTF8String, &state, ready.token, &attempted), "delivery-unknown"));
+ CARD_CHECK(attempted && state.presses == 1 && state.events == 1);
+ }
+ ApprovalCardSelftestState state = approval_card_selftest_state(ready);
+ CARD_CHECK(approval_error_is(approval_card_selftest_dispatch("always-allow", &state, ready.token, &attempted), "invalid-arguments"));
+ CARD_CHECK(!attempted && state.presses == 0 && state.context_reads == 0);
+ for (unsigned i = 0; i < 4; i++) {
+ state = approval_card_selftest_state(ready); state.contexts[i].window = 46;
+ CARD_CHECK(approval_error_is(approval_card_selftest_dispatch("approve", &state, ready.token, &attempted), "context-changed"));
+ CARD_CHECK(!attempted && state.presses == 0);
+ state = approval_card_selftest_state(ready); state.context_errors[i] = "not-frontmost";
+ CARD_CHECK(approval_error_is(approval_card_selftest_dispatch("approve", &state, ready.token, &attempted), "not-frontmost"));
+ CARD_CHECK(!attempted && state.presses == 0);
+ }
+ for (unsigned i = 0; i < 2; i++) {
+ state = approval_card_selftest_state(ready); state.cards[i] = changed;
+ CARD_CHECK(approval_error_is(approval_card_selftest_dispatch("approve", &state, ready.token, &attempted), "approval-changed"));
+ CARD_CHECK(!attempted && state.presses == 0);
+ state = approval_card_selftest_state(ready); state.cards[i].state = @"unavailable";
+ CARD_CHECK(approval_error_is(approval_card_selftest_dispatch("approve", &state, ready.token, &attempted), "approval-unavailable"));
+ CARD_CHECK(!attempted && state.presses == 0);
+ }
+ for (unsigned i = 0; i < 4; i++) {
+ state = approval_card_selftest_state(ready);
+ if (i == 0) state.cards[1].card = @"different-card";
+ if (i == 1) state.cards[1].header = @"different-header";
+ if (i == 2) state.cards[1].allow = @"different-allow";
+ if (i == 3) state.cards[1].deny = @"different-deny";
+ CARD_CHECK(approval_error_is(approval_card_selftest_dispatch("approve", &state, ready.token, &attempted), "approval-changed"));
+ CARD_CHECK(!attempted && state.presses == 0);
+ }
+ for (unsigned i = 0; i < 2; i++) {
+ for (NSString *error in @[@"approval-unavailable", @"approval-changed"]) {
+ state = approval_card_selftest_state(ready); state.control_errors[i] = error.UTF8String;
+ CARD_CHECK(approval_error_is(approval_card_selftest_dispatch("approve", &state, ready.token, &attempted), error.UTF8String));
+ CARD_CHECK(!attempted && state.presses == 0 && state.events == 0 && state.card_reads == i + 1 && state.validations == i + 1);
+ }
+ }
+ for (NSString *error in @[@"event-unavailable", @"input-active", @"permission-denied", @"activation-timeout"]) {
+ state = approval_card_selftest_state(ready); state.prepare_error = error.UTF8String;
+ CARD_CHECK(approval_error_is(approval_card_selftest_dispatch("approve", &state, ready.token, &attempted), error.UTF8String));
+ CARD_CHECK(!attempted && state.events == 0 && state.prepared == 1 && state.focuses == 0 && state.card_reads == 0);
+ }
+ for (NSString *error in @[@"focus-unavailable", @"focus-unverified", @"input-active", @"activation-timeout"]) {
+ state = approval_card_selftest_state(ready); state.focus_error = error.UTF8String;
+ CARD_CHECK(approval_error_is(approval_card_selftest_dispatch("approve", &state, ready.token, &attempted), error.UTF8String));
+ CARD_CHECK(!attempted && state.events == 0 && state.focuses == 1 && state.card_reads == 1);
+ }
+ for (NSString *error in @[@"focus-unverified", @"input-active", @"activation-timeout", @"not-frontmost"]) {
+ state = approval_card_selftest_state(ready); state.focus_check_error = error.UTF8String;
+ CARD_CHECK(approval_error_is(approval_card_selftest_dispatch("approve", &state, ready.token, &attempted), error.UTF8String));
+ CARD_CHECK(!attempted && state.events == 0 && state.focuses == 1 && state.card_reads == 2 && state.context_reads == 4);
+ }
+ ApprovalShortcutKeyEvent pair[2];
+ CARD_CHECK(approval_card_event_pair("approve", pair));
+ CARD_CHECK(pair[0].key == 0x24 && pair[1].key == 0x24 && pair[0].down && !pair[1].down
+ && pair[0].flags == 0 && pair[1].flags == 0);
+ CARD_CHECK(approval_card_event_pair("decline", pair));
+ CARD_CHECK(pair[0].key == 0x31 && pair[1].key == 0x31 && pair[0].down && !pair[1].down
+ && pair[0].flags == 0 && pair[1].flags == 0);
+ CARD_CHECK(!approval_card_event_pair("always-allow", pair));
+ CARD_CHECK(!approval_card_event_pair(NULL, pair));
+ NSMutableArray *packet_calls = [NSMutableArray array];
+ ApprovalCardLiveActivation packet = { .down = (__bridge CGEventRef)@"fixture-down", .up = (__bridge CGEventRef)@"fixture-up" };
+ approval_card_emit_pair(&packet, (ApprovalCardPacketOperations){
+ .post = approval_card_selftest_post_event, .wait = approval_card_selftest_wait_event,
+ .context = (__bridge void *)packet_calls
+ });
+ CARD_CHECK(([packet_calls isEqual:@[@"fixture-down", @9000, @"fixture-up", @9000]]));
+ CARD_CHECK(approval_card_packet_tag(0) == UINT64_C(0x5444434100000000));
+ CARD_CHECK(approval_card_packet_tag(UINT32_MAX) == UINT64_C(0x54444341FFFFFFFF));
+ CARD_CHECK(approval_card_input_error(0, false, false, false) == NULL);
+ CARD_CHECK(approval_card_input_error(kCGEventFlagMaskAlphaShift, false, false, false) == NULL);
+ CGEventFlags modifiers[] = {kCGEventFlagMaskShift, kCGEventFlagMaskControl, kCGEventFlagMaskAlternate,
+ kCGEventFlagMaskCommand, kCGEventFlagMaskSecondaryFn};
+ for (unsigned i = 0; i < sizeof(modifiers) / sizeof(modifiers[0]); i++) {
+ CARD_CHECK(approval_error_is(approval_card_input_error(modifiers[i], false, false, false), "input-active"));
+ }
+ CARD_CHECK(approval_error_is(approval_card_input_error(0, true, false, false), "input-active"));
+ CARD_CHECK(approval_error_is(approval_card_input_error(0, false, true, false), "input-active"));
+ CARD_CHECK(approval_error_is(approval_card_input_error(0, false, false, true), "input-active"));
+#undef CARD_CHECK
+ printf("{\"checks\":%u,\"failures\":%u,\"live_io\":false}\n", checks, failures);
+ return failures == 0 ? 0 : 1;
+}
diff --git a/native/approval-shortcuts.h b/native/approval-shortcuts.h
new file mode 100644
index 0000000..704fb29
--- /dev/null
+++ b/native/approval-shortcuts.h
@@ -0,0 +1,636 @@
+#include
+#include
+#include
+#include
+
+// This transport invokes a configured command in the foreground window. A
+// window token is not an approval-request identity or an acknowledgment.
+typedef struct {
+ const char *command;
+ const char *accelerator;
+ CGKeyCode key;
+ CGEventFlags flags;
+} ApprovalShortcutSpec;
+
+typedef struct {
+ pid_t pid;
+ CGWindowID window;
+ uint32_t session_user;
+ uint32_t session_console;
+} ApprovalShortcutContext;
+
+typedef struct {
+ CGKeyCode key;
+ CGEventFlags flags;
+ bool down;
+} ApprovalShortcutKeyEvent;
+
+typedef struct {
+ const char *(*validate_keymap)(const ApprovalShortcutSpec *, void *);
+ const char *(*read_context)(ApprovalShortcutContext *, void *);
+ bool (*prepare_events)(const ApprovalShortcutSpec *, void *);
+ void (*post_pair)(pid_t, void *);
+ void *context;
+} ApprovalShortcutOperations;
+
+static bool approval_shortcut_spec(const char *decision, ApprovalShortcutSpec *spec) {
+ if (decision == NULL || spec == NULL) return false;
+ if (strcmp(decision, "approve") == 0) {
+ *spec = (ApprovalShortcutSpec) {
+ "approval.approve", "Control+Alt+Command+F13", 0x69,
+ kCGEventFlagMaskControl | kCGEventFlagMaskAlternate | kCGEventFlagMaskCommand
+ };
+ return true;
+ }
+ if (strcmp(decision, "decline") == 0) {
+ *spec = (ApprovalShortcutSpec) {
+ "approval.decline", "Control+Alt+Command+F14", 0x6B,
+ kCGEventFlagMaskControl | kCGEventFlagMaskAlternate | kCGEventFlagMaskCommand
+ };
+ return true;
+ }
+ return false;
+}
+
+static void approval_shortcut_event_pair(
+ const ApprovalShortcutSpec *spec, ApprovalShortcutKeyEvent events[2]
+) {
+ events[0] = (ApprovalShortcutKeyEvent) { spec->key, spec->flags, true };
+ events[1] = (ApprovalShortcutKeyEvent) { spec->key, spec->flags, false };
+}
+
+// Normalize just the first stroke: a chord using the dedicated stroke would
+// intercept it too. Whitespace within the stroke is not a Codex accelerator.
+static NSString *approval_shortcut_first_stroke(NSString *key) {
+ NSString *trimmed = [key stringByTrimmingCharactersInSet:
+ NSCharacterSet.whitespaceAndNewlineCharacterSet];
+ NSString *stroke = [trimmed componentsSeparatedByCharactersInSet:
+ NSCharacterSet.whitespaceAndNewlineCharacterSet].firstObject;
+ if (stroke.length == 0) return nil;
+ bool control = false, alt = false, command = false, shift = false;
+ NSString *main_key = nil;
+ for (NSString *part in [stroke.lowercaseString componentsSeparatedByString:@"+"]) {
+ if ([part isEqualToString:@"ctrl"] || [part isEqualToString:@"control"]) {
+ control = true;
+ } else if ([part isEqualToString:@"alt"] || [part isEqualToString:@"option"]
+ || [part isEqualToString:@"opt"]) {
+ alt = true;
+ } else if ([part isEqualToString:@"command"] || [part isEqualToString:@"cmd"]
+ || [part isEqualToString:@"meta"] || [part isEqualToString:@"super"]
+ || [part isEqualToString:@"cmdorctrl"]
+ || [part isEqualToString:@"commandorcontrol"]) {
+ command = true;
+ } else if ([part isEqualToString:@"shift"]) {
+ shift = true;
+ } else {
+ if (main_key != nil || part.length == 0) return nil;
+ main_key = part;
+ }
+ }
+ if (main_key == nil) return nil;
+ return [NSString stringWithFormat:@"%d:%d:%d:%d:%@",
+ control, alt, command, shift, main_key];
+}
+
+// Conflict detection deliberately accepts broad aliases. Establishing that
+// Codex can run our command is stricter: its renderer accepts these modifier
+// spellings case-sensitively, with one function-key name (case-insensitive).
+static bool approval_shortcut_has_codex_syntax(NSString *stroke) {
+ unsigned keys = 0;
+ for (NSString *part in [stroke componentsSeparatedByString:@"+"]) {
+ if ([part isEqualToString:@"CmdOrCtrl"] || [part isEqualToString:@"Command"]
+ || [part isEqualToString:@"Cmd"] || [part isEqualToString:@"Control"]
+ || [part isEqualToString:@"Ctrl"] || [part isEqualToString:@"Alt"]
+ || [part isEqualToString:@"Option"] || [part isEqualToString:@"Shift"]) continue;
+ if ([part.lowercaseString isEqualToString:@"f13"] || [part.lowercaseString isEqualToString:@"f14"]) keys += 1;
+ else return false;
+ }
+ return keys == 1;
+}
+
+static const char *approval_validate_keymap(id keymap, const ApprovalShortcutSpec *spec) {
+ if (![keymap isKindOfClass:NSArray.class]) return "keymap-invalid";
+ NSString *command = [NSString stringWithUTF8String:spec->command];
+ NSString *dedicated = approval_shortcut_first_stroke(
+ [NSString stringWithUTF8String:spec->accelerator]);
+ bool configured = false, disabled = false, conflict = false;
+ for (id item in (NSArray *)keymap) {
+ if (![item isKindOfClass:NSDictionary.class]) return "keymap-invalid";
+ id item_command = item[@"command"];
+ id key = item[@"key"];
+ if (![item_command isKindOfClass:NSString.class]
+ || [item_command length] == 0
+ || (key != NSNull.null && ![key isKindOfClass:NSString.class])) {
+ return "keymap-invalid";
+ }
+ bool selected = [item_command isEqualToString:command];
+ if (key == NSNull.null) {
+ if (selected) disabled = true;
+ continue;
+ }
+ NSString *normalized = approval_shortcut_first_stroke(key);
+ if (![normalized isEqualToString:dedicated]) continue;
+ NSString *trimmed = [key stringByTrimmingCharactersInSet:
+ NSCharacterSet.whitespaceAndNewlineCharacterSet];
+ bool has_chord = [trimmed rangeOfCharacterFromSet:
+ NSCharacterSet.whitespaceAndNewlineCharacterSet].location != NSNotFound;
+ if (!selected) conflict = true;
+ else if (!has_chord && approval_shortcut_has_codex_syntax(trimmed)) configured = true;
+ else if (has_chord) {
+ NSString *first = [trimmed componentsSeparatedByCharactersInSet:
+ NSCharacterSet.whitespaceAndNewlineCharacterSet].firstObject;
+ if (approval_shortcut_has_codex_syntax(first)) conflict = true;
+ }
+ }
+ if (disabled) return "command-disabled";
+ if (conflict) return "shortcut-conflict";
+ return configured ? NULL : "shortcut-unconfigured";
+}
+
+static NSString *approval_context_token(ApprovalShortcutContext context) {
+ if (context.pid <= 1 || context.window == 0) return nil;
+ return [NSString stringWithFormat:@"v2:%d:%u:%u:%u", context.pid, context.window,
+ context.session_user, context.session_console];
+}
+
+static bool approval_context_matches(
+ ApprovalShortcutContext actual, pid_t expected_pid, NSString *expected_token
+) {
+ return actual.pid == expected_pid && expected_pid > 1
+ && expected_token.length > 0
+ && [approval_context_token(actual) isEqualToString:expected_token];
+}
+
+static const char *approval_dispatch_shortcut(
+ const ApprovalShortcutSpec *spec, pid_t expected_pid, NSString *expected_token,
+ ApprovalShortcutOperations operations
+) {
+ const char *error = operations.validate_keymap(spec, operations.context);
+ if (error != NULL) return error;
+ ApprovalShortcutContext current = { 0 };
+ error = operations.read_context(¤t, operations.context);
+ if (error != NULL) return error;
+ if (!approval_context_matches(current, expected_pid, expected_token)) {
+ return "context-changed";
+ }
+ if (!operations.prepare_events(spec, operations.context)) return "event-unavailable";
+ // Recheck both inputs after event allocation, immediately before dispatch.
+ error = operations.validate_keymap(spec, operations.context);
+ if (error != NULL) return error;
+ error = operations.read_context(¤t, operations.context);
+ if (error != NULL) return error;
+ if (!approval_context_matches(current, expected_pid, expected_token)) {
+ return "context-changed";
+ }
+ operations.post_pair(current.pid, operations.context);
+ return NULL;
+}
+
+static const char *approval_session_from_dictionary(
+ NSDictionary *session, uid_t expected_user, uint32_t *user, uint32_t *console
+) {
+ if (![session isKindOfClass:NSDictionary.class]) return "session-unavailable";
+ id on_console = session[(__bridge NSString *)kCGSessionOnConsoleKey];
+ id login_done = session[(__bridge NSString *)kCGSessionLoginDoneKey];
+ id user_value = session[(__bridge NSString *)kCGSessionUserIDKey];
+ id console_value = session[(__bridge NSString *)kCGSessionConsoleSetKey];
+ if (on_console == nil || login_done == nil || user_value == nil
+ || CFGetTypeID((__bridge CFTypeRef)on_console) != CFBooleanGetTypeID()
+ || CFGetTypeID((__bridge CFTypeRef)login_done) != CFBooleanGetTypeID()
+ || CFGetTypeID((__bridge CFTypeRef)user_value) != CFNumberGetTypeID()
+ || (console_value != nil && CFGetTypeID((__bridge CFTypeRef)console_value) != CFNumberGetTypeID())) {
+ return "session-unavailable";
+ }
+ // macOS 26 can omit the console-set key. UID plus the Codex PID/window
+ // still identify this foreground session; include the extra key if present.
+ int64_t user_number = -1, console_number = 0;
+ if (!CFNumberGetValue((__bridge CFNumberRef)user_value, kCFNumberSInt64Type, &user_number)
+ || (console_value != nil && !CFNumberGetValue((__bridge CFNumberRef)console_value,
+ kCFNumberSInt64Type, &console_number))
+ || user_number < 0 || user_number > UINT32_MAX
+ || console_number < 0 || console_number > UINT32_MAX) return "session-unavailable";
+ if (![on_console boolValue] || ![login_done boolValue]
+ || (uid_t)user_number != expected_user) return "session-inactive";
+ // Public CGSession keys establish a logged-in console session, not an
+ // authoritative unlocked state. Some macOS versions additionally publish
+ // this undocumented lock flag; honor explicit true but do not interpret a
+ // missing key as proof of unlocked state or require undocumented false.
+ id locked = session[@"CGSSessionScreenIsLocked"];
+ if (locked != nil) {
+ if (CFGetTypeID((__bridge CFTypeRef)locked) != CFBooleanGetTypeID()
+ && CFGetTypeID((__bridge CFTypeRef)locked) != CFNumberGetTypeID()) {
+ return "session-unavailable";
+ }
+ if ([locked boolValue]) return "session-locked";
+ }
+ *user = (uint32_t)user_number;
+ *console = (uint32_t)console_number;
+ return NULL;
+}
+
+static const char *approval_read_live_session(uint32_t *user, uint32_t *console) {
+ CFDictionaryRef session = CGSessionCopyCurrentDictionary();
+ const char *error = approval_session_from_dictionary(
+ (__bridge NSDictionary *)session, getuid(), user, console);
+ if (session != NULL) CFRelease(session);
+ return error;
+}
+
+static const char *approval_read_live_context(
+ ApprovalShortcutContext *result, void *unused
+) {
+ (void)unused;
+ uint32_t session_user = 0, session_console = 0;
+ const char *session_error = approval_read_live_session(&session_user, &session_console);
+ if (session_error != NULL) return session_error;
+ NSRunningApplication *frontmost = NSWorkspace.sharedWorkspace.frontmostApplication;
+ if (![frontmost.bundleIdentifier isEqualToString:@"com.openai.codex"]
+ || frontmost.terminated || frontmost.processIdentifier <= 1) return "not-frontmost";
+ pid_t pid = frontmost.processIdentifier;
+ AXUIElementRef application = AXUIElementCreateApplication(pid);
+ if (application == NULL) return "context-unavailable";
+ AXUIElementSetMessagingTimeout(application, 0.35);
+ CFTypeRef window_value = NULL;
+ AXError error = AXUIElementCopyAttributeValue(
+ application, kAXFocusedWindowAttribute, &window_value);
+ CFRelease(application);
+ if (error != kAXErrorSuccess || window_value == NULL
+ || CFGetTypeID(window_value) != AXUIElementGetTypeID()) {
+ if (window_value != NULL) CFRelease(window_value);
+ return "context-unavailable";
+ }
+ CFTypeRef position_value = NULL, size_value = NULL;
+ CGPoint position = CGPointZero;
+ CGSize size = CGSizeZero;
+ bool geometry_available = AXUIElementCopyAttributeValue(
+ (AXUIElementRef)window_value, kAXPositionAttribute, &position_value) == kAXErrorSuccess
+ && position_value != NULL && CFGetTypeID(position_value) == AXValueGetTypeID()
+ && AXValueGetValue((AXValueRef)position_value, kAXValueCGPointType, &position)
+ && AXUIElementCopyAttributeValue(
+ (AXUIElementRef)window_value, kAXSizeAttribute, &size_value) == kAXErrorSuccess
+ && size_value != NULL && CFGetTypeID(size_value) == AXValueGetTypeID()
+ && AXValueGetValue((AXValueRef)size_value, kAXValueCGSizeType, &size)
+ && size.width > 0 && size.height > 0;
+ if (position_value != NULL) CFRelease(position_value);
+ if (size_value != NULL) CFRelease(size_value);
+ CFRelease(window_value);
+ if (!geometry_available) return "context-unavailable";
+
+ // Window number, PID and bounds are sufficient; do not read titles, AX text,
+ // screenshots or other content. Ambiguous identical windows fail closed.
+ CFArrayRef windows = CGWindowListCopyWindowInfo(
+ kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements,
+ kCGNullWindowID);
+ if (windows == NULL) return "context-unavailable";
+ CGWindowID selected = 0;
+ unsigned matches = 0;
+ for (NSDictionary *window in (__bridge NSArray *)windows) {
+ if ([window[(__bridge NSString *)kCGWindowOwnerPID] intValue] != pid
+ || [window[(__bridge NSString *)kCGWindowLayer] intValue] != 0) continue;
+ CGRect bounds = CGRectZero;
+ id bounds_value = window[(__bridge NSString *)kCGWindowBounds];
+ if (![bounds_value isKindOfClass:NSDictionary.class]
+ || !CGRectMakeWithDictionaryRepresentation(
+ (__bridge CFDictionaryRef)bounds_value, &bounds)) continue;
+ if (fabs(bounds.origin.x - position.x) > 0.5
+ || fabs(bounds.origin.y - position.y) > 0.5
+ || fabs(bounds.size.width - size.width) > 0.5
+ || fabs(bounds.size.height - size.height) > 0.5) continue;
+ selected = [window[(__bridge NSString *)kCGWindowNumber] unsignedIntValue];
+ matches += 1;
+ }
+ CFRelease(windows);
+ if (matches != 1 || selected == 0) return "context-unavailable";
+ uint32_t final_user = 0, final_console = 0;
+ session_error = approval_read_live_session(&final_user, &final_console);
+ if (session_error != NULL) return session_error;
+ if (final_user != session_user || final_console != session_console) return "context-changed";
+ NSRunningApplication *final_frontmost = NSWorkspace.sharedWorkspace.frontmostApplication;
+ if (final_frontmost.processIdentifier != pid
+ || ![final_frontmost.bundleIdentifier isEqualToString:@"com.openai.codex"]) {
+ return "context-changed";
+ }
+ *result = (ApprovalShortcutContext) { pid, selected, session_user, session_console };
+ return NULL;
+}
+
+typedef struct {
+ const char *keymap_path;
+ CGEventRef down;
+ CGEventRef up;
+} ApprovalShortcutLiveState;
+
+static const char *approval_validate_live_keymap(
+ const ApprovalShortcutSpec *spec, void *context
+) {
+ ApprovalShortcutLiveState *state = context;
+ int descriptor = open(state->keymap_path, O_RDONLY | O_NONBLOCK | O_CLOEXEC);
+ if (descriptor < 0) return "keymap-invalid";
+ struct stat info;
+ const size_t limit = 1024 * 1024;
+ if (fstat(descriptor, &info) != 0 || !S_ISREG(info.st_mode)
+ || info.st_size <= 0 || info.st_size > (off_t)limit) {
+ close(descriptor);
+ return "keymap-invalid";
+ }
+ NSMutableData *data = [NSMutableData dataWithLength:limit + 1];
+ size_t used = 0;
+ while (used <= limit) {
+ ssize_t count = read(descriptor, (uint8_t *)data.mutableBytes + used, limit + 1 - used);
+ if (count < 0 && errno == EINTR) continue;
+ if (count < 0) { close(descriptor); return "keymap-invalid"; }
+ if (count == 0) break;
+ used += (size_t)count;
+ }
+ close(descriptor);
+ if (used > limit) return "keymap-invalid";
+ data.length = used;
+ id keymap = [NSJSONSerialization JSONObjectWithData:data options:0 error:NULL];
+ return approval_validate_keymap(keymap, spec);
+}
+
+static bool approval_prepare_live_events(const ApprovalShortcutSpec *spec, void *context) {
+ ApprovalShortcutLiveState *state = context;
+ ApprovalShortcutKeyEvent events[2];
+ approval_shortcut_event_pair(spec, events);
+ state->down = create_key_event(events[0].key, events[0].down, events[0].flags, NULL, 0);
+ state->up = create_key_event(events[1].key, events[1].down, events[1].flags, NULL, 0);
+ return state->down != NULL && state->up != NULL;
+}
+
+static void approval_post_live_pair(pid_t pid, void *context) {
+ ApprovalShortcutLiveState *state = context;
+ CGEventPostToPid(pid, state->down);
+ CGEventPostToPid(pid, state->up);
+}
+
+static int approval_print_error(const char *error, int code) {
+ printf("{\"sent\":false,\"error\":\"%s\"}\n", error);
+ return code;
+}
+
+static int codex_approval_shortcut(int argc, char **argv) {
+ ApprovalShortcutSpec spec;
+ if (argc != 6 || !approval_shortcut_spec(argv[2], &spec)
+ || argv[3][0] != '/' || strlen(argv[5]) > 64) {
+ return approval_print_error("invalid-arguments", 64);
+ }
+ char *end = NULL;
+ errno = 0;
+ long expected_pid = strtol(argv[4], &end, 10);
+ if (errno != 0 || end == argv[4] || *end != '\0'
+ || expected_pid <= 1 || expected_pid > INT_MAX) {
+ return approval_print_error("invalid-arguments", 64);
+ }
+ NSString *token = [NSString stringWithUTF8String:argv[5]];
+ if (token.length == 0) return approval_print_error("invalid-arguments", 64);
+ ApprovalShortcutLiveState state = { argv[3], NULL, NULL };
+ const char *error = approval_dispatch_shortcut(
+ &spec, (pid_t)expected_pid, token, (ApprovalShortcutOperations) {
+ .validate_keymap = approval_validate_live_keymap,
+ .read_context = approval_read_live_context,
+ .prepare_events = approval_prepare_live_events,
+ .post_pair = approval_post_live_pair,
+ .context = &state
+ });
+ if (state.down != NULL) CFRelease(state.down);
+ if (state.up != NULL) CFRelease(state.up);
+ if (error != NULL) return approval_print_error(error, 1);
+ // Quartz does not acknowledge that a command handler accepted the event.
+ printf("{\"sent\":true}\n");
+ return 0;
+}
+
+typedef struct {
+ ApprovalShortcutContext contexts[2];
+ const char *context_errors[2];
+ const char *keymap_errors[2];
+ unsigned reads, validations, prepares, posts;
+ bool prepared;
+ pid_t posted_pid;
+ CGKeyCode prepared_key;
+ CGEventFlags prepared_flags;
+} ApprovalShortcutSelftestState;
+
+static const char *approval_selftest_validate(const ApprovalShortcutSpec *spec, void *context) {
+ (void)spec;
+ ApprovalShortcutSelftestState *state = context;
+ return state->keymap_errors[state->validations++ > 0 ? 1 : 0];
+}
+
+static const char *approval_selftest_context(ApprovalShortcutContext *result, void *context) {
+ ApprovalShortcutSelftestState *state = context;
+ unsigned index = state->reads++ > 0 ? 1 : 0;
+ *result = state->contexts[index];
+ return state->context_errors[index];
+}
+
+static bool approval_selftest_prepare(const ApprovalShortcutSpec *spec, void *context) {
+ ApprovalShortcutSelftestState *state = context;
+ state->prepares += 1;
+ state->prepared_key = spec->key;
+ state->prepared_flags = spec->flags;
+ return state->prepared;
+}
+
+static void approval_selftest_post(pid_t pid, void *context) {
+ ApprovalShortcutSelftestState *state = context;
+ state->posts += 1;
+ state->posted_pid = pid;
+}
+
+static const char *approval_run_selftest_case(ApprovalShortcutSelftestState *state) {
+ ApprovalShortcutSpec spec;
+ approval_shortcut_spec("approve", &spec);
+ return approval_dispatch_shortcut(&spec, 123, @"v2:123:45:501:1", (ApprovalShortcutOperations) {
+ .validate_keymap = approval_selftest_validate,
+ .read_context = approval_selftest_context,
+ .prepare_events = approval_selftest_prepare,
+ .post_pair = approval_selftest_post,
+ .context = state
+ });
+}
+
+static bool approval_error_is(const char *actual, const char *expected) {
+ return actual != NULL && strcmp(actual, expected) == 0;
+}
+
+static int approval_shortcut_selftest(void) {
+ // All dependencies below are in-memory fakes. Do not probe the host, create
+ // CGEvents, activate an application or post input from this selftest.
+ unsigned checks = 0, failures = 0;
+#define APPROVAL_CHECK(condition) do { checks += 1; if (!(condition)) failures += 1; } while (0)
+ ApprovalShortcutSpec approve, decline;
+ APPROVAL_CHECK(approval_shortcut_spec("approve", &approve));
+ APPROVAL_CHECK(approval_shortcut_spec("decline", &decline));
+ APPROVAL_CHECK(!approval_shortcut_spec("other", &decline));
+ APPROVAL_CHECK(approve.key == 0x69 && decline.key == 0x6B);
+ APPROVAL_CHECK(approve.flags == (kCGEventFlagMaskControl | kCGEventFlagMaskAlternate
+ | kCGEventFlagMaskCommand) && decline.flags == approve.flags);
+ ApprovalShortcutKeyEvent events[2];
+ approval_shortcut_event_pair(&approve, events);
+ APPROVAL_CHECK(events[0].down && !events[1].down && events[0].key == 0x69
+ && events[1].key == 0x69 && events[0].flags == approve.flags
+ && events[1].flags == approve.flags);
+ approval_shortcut_event_pair(&decline, events);
+ APPROVAL_CHECK(events[0].down && !events[1].down && events[0].key == 0x6B
+ && events[1].key == 0x6B && events[0].flags == decline.flags
+ && events[1].flags == decline.flags);
+ NSArray *valid = @[
+ @{ @"command": @"approval.approve", @"key": @"Enter" },
+ @{ @"command": @"approval.approve", @"key": @"Control+Alt+Command+F13" },
+ @{ @"command": @"approval.decline", @"key": @"Escape" },
+ @{ @"command": @"approval.decline", @"key": @"Control+Alt+Command+F14" }
+ ];
+ APPROVAL_CHECK(approval_validate_keymap(valid, &approve) == NULL);
+ APPROVAL_CHECK(approval_validate_keymap(valid, &decline) == NULL);
+ for (NSString *alias in @[@"Cmd+Ctrl+Option+F13", @"Control+Alt+CmdOrCtrl+F13",
+ @"F13+Ctrl+Command+Alt", @"Command+Control+Alt+f13"]) {
+ APPROVAL_CHECK(approval_validate_keymap(@[
+ @{ @"command": @"approval.approve", @"key": alias }
+ ], &approve) == NULL);
+ }
+ for (NSString *invalid_alias in @[@"Meta+Ctrl+Option+F13", @"Super+Ctrl+Alt+F13",
+ @"Cmd+Ctrl+Opt+F13", @"CommandOrControl+Control+Alt+F13",
+ @"command+control+alt+F13",
+ @"Meta+Ctrl+Option+F13 A"]) {
+ NSDictionary *entry = @{ @"command": @"approval.approve", @"key": invalid_alias };
+ APPROVAL_CHECK(approval_error_is(approval_validate_keymap(@[entry], &approve),
+ "shortcut-unconfigured"));
+ APPROVAL_CHECK(approval_validate_keymap([valid arrayByAddingObject:entry], &approve) == NULL);
+ }
+ APPROVAL_CHECK(approval_error_is(approval_validate_keymap(@[], &approve),
+ "shortcut-unconfigured"));
+ APPROVAL_CHECK(approval_error_is(approval_validate_keymap(@{}, &approve), "keymap-invalid"));
+ APPROVAL_CHECK(approval_error_is(approval_validate_keymap(@[@{ @"command": @"x" }], &approve),
+ "keymap-invalid"));
+ APPROVAL_CHECK(approval_error_is(approval_validate_keymap(@[
+ @{ @"command": @"x", @"key": @1 }
+ ], &approve), "keymap-invalid"));
+ APPROVAL_CHECK(approval_error_is(approval_validate_keymap([valid arrayByAddingObject:
+ @{ @"command": @"approval.approve", @"key": NSNull.null }], &approve), "command-disabled"));
+ for (NSString *collision in @[@"Meta+Ctrl+Option+F13", @"F13+Ctrl+Cmd+Alt",
+ @"Control+Alt+CmdOrCtrl+F13", @"CommandOrControl+Alt+Control+F13",
+ @"Control+Alt+Command+F13 A"]) {
+ APPROVAL_CHECK(approval_error_is(approval_validate_keymap([valid arrayByAddingObject:
+ @{ @"command": @"other", @"key": collision }], &approve), "shortcut-conflict"));
+ }
+ APPROVAL_CHECK(approval_error_is(approval_validate_keymap([valid arrayByAddingObject:
+ @{ @"command": @"approval.approve", @"key": @"Control+Alt+Command+F13 A" }],
+ &approve), "shortcut-conflict"));
+ APPROVAL_CHECK(approval_validate_keymap([valid arrayByAddingObject:
+ @{ @"command": @"other", @"key": @"Shift+Control+Alt+Command+F13" }], &approve) == NULL);
+ APPROVAL_CHECK(approval_context_matches((ApprovalShortcutContext){123, 45, 501, 1},
+ 123, @"v2:123:45:501:1"));
+ APPROVAL_CHECK(!approval_context_matches((ApprovalShortcutContext){124, 45, 501, 1},
+ 123, @"v2:123:45:501:1"));
+ APPROVAL_CHECK(!approval_context_matches((ApprovalShortcutContext){123, 46, 501, 1},
+ 123, @"v2:123:45:501:1"));
+ APPROVAL_CHECK(!approval_context_matches((ApprovalShortcutContext){123, 0, 501, 1},
+ 123, @"v2:123:0:501:1"));
+ APPROVAL_CHECK(!approval_context_matches((ApprovalShortcutContext){123, 45, 502, 1},
+ 123, @"v2:123:45:501:1"));
+ APPROVAL_CHECK(!approval_context_matches((ApprovalShortcutContext){123, 45, 501, 2},
+ 123, @"v2:123:45:501:1"));
+ ApprovalShortcutSelftestState success = {
+ .contexts = {{123, 45, 501, 1}, {123, 45, 501, 1}}, .prepared = true
+ };
+ APPROVAL_CHECK(approval_run_selftest_case(&success) == NULL);
+ APPROVAL_CHECK(success.validations == 2 && success.reads == 2 && success.prepares == 1
+ && success.posts == 1 && success.posted_pid == 123
+ && success.prepared_key == approve.key && success.prepared_flags == approve.flags);
+ ApprovalShortcutSelftestState changed = {
+ .contexts = {{123, 45, 501, 1}, {123, 46, 501, 1}}, .prepared = true
+ };
+ APPROVAL_CHECK(approval_error_is(approval_run_selftest_case(&changed), "context-changed"));
+ APPROVAL_CHECK(changed.posts == 0 && changed.reads == 2);
+ ApprovalShortcutSelftestState foreign = {
+ .contexts = {{999, 45, 501, 1}, {999, 45, 501, 1}}, .prepared = true
+ };
+ APPROVAL_CHECK(approval_error_is(approval_run_selftest_case(&foreign), "context-changed"));
+ APPROVAL_CHECK(foreign.posts == 0 && foreign.prepares == 0);
+ ApprovalShortcutSelftestState background = {
+ .context_errors = {"not-frontmost", NULL}, .prepared = true
+ };
+ APPROVAL_CHECK(approval_error_is(approval_run_selftest_case(&background), "not-frontmost"));
+ APPROVAL_CHECK(background.posts == 0 && background.prepares == 0);
+ ApprovalShortcutSelftestState background_during_prepare = {
+ .contexts = {{123, 45, 501, 1}, {123, 45, 501, 1}}, .prepared = true,
+ .context_errors = {NULL, "not-frontmost"}
+ };
+ APPROVAL_CHECK(approval_error_is(approval_run_selftest_case(&background_during_prepare),
+ "not-frontmost"));
+ APPROVAL_CHECK(background_during_prepare.posts == 0 && background_during_prepare.reads == 2);
+ ApprovalShortcutSelftestState disabled = {
+ .contexts = {{123, 45, 501, 1}, {123, 45, 501, 1}}, .prepared = true,
+ .keymap_errors = {"command-disabled", NULL}
+ };
+ APPROVAL_CHECK(approval_error_is(approval_run_selftest_case(&disabled), "command-disabled"));
+ APPROVAL_CHECK(disabled.posts == 0 && disabled.reads == 0 && disabled.prepares == 0);
+ ApprovalShortcutSelftestState changed_binding = {
+ .contexts = {{123, 45, 501, 1}, {123, 45, 501, 1}}, .prepared = true,
+ .keymap_errors = {NULL, "shortcut-conflict"}
+ };
+ APPROVAL_CHECK(approval_error_is(approval_run_selftest_case(&changed_binding), "shortcut-conflict"));
+ APPROVAL_CHECK(changed_binding.posts == 0 && changed_binding.reads == 1);
+ ApprovalShortcutSelftestState no_event = {
+ .contexts = {{123, 45, 501, 1}, {123, 45, 501, 1}}, .prepared = false
+ };
+ APPROVAL_CHECK(approval_error_is(approval_run_selftest_case(&no_event), "event-unavailable"));
+ APPROVAL_CHECK(no_event.posts == 0 && no_event.reads == 1);
+ ApprovalShortcutSelftestState changed_session = {
+ .contexts = {{123, 45, 501, 1}, {123, 45, 501, 2}}, .prepared = true
+ };
+ APPROVAL_CHECK(approval_error_is(approval_run_selftest_case(&changed_session), "context-changed"));
+ APPROVAL_CHECK(changed_session.posts == 0 && changed_session.reads == 2);
+ ApprovalShortcutSelftestState locked_during_prepare = {
+ .contexts = {{123, 45, 501, 1}, {123, 45, 501, 1}}, .prepared = true,
+ .context_errors = {NULL, "session-locked"}
+ };
+ APPROVAL_CHECK(approval_error_is(approval_run_selftest_case(&locked_during_prepare),
+ "session-locked"));
+ APPROVAL_CHECK(locked_during_prepare.posts == 0 && locked_during_prepare.reads == 2);
+ NSDictionary *active_session = @{
+ (__bridge NSString *)kCGSessionOnConsoleKey: @YES,
+ (__bridge NSString *)kCGSessionLoginDoneKey: @YES,
+ (__bridge NSString *)kCGSessionUserIDKey: @501,
+ (__bridge NSString *)kCGSessionConsoleSetKey: @1
+ };
+ uint32_t session_user = 0, session_console = 0;
+ APPROVAL_CHECK(approval_session_from_dictionary(active_session, 501,
+ &session_user, &session_console) == NULL && session_user == 501 && session_console == 1);
+ NSMutableDictionary *modern_session = [active_session mutableCopy];
+ [modern_session removeObjectForKey:(__bridge NSString *)kCGSessionConsoleSetKey];
+ APPROVAL_CHECK(approval_session_from_dictionary(modern_session, 501,
+ &session_user, &session_console) == NULL && session_user == 501 && session_console == 0);
+ modern_session[(__bridge NSString *)kCGSessionConsoleSetKey] = @"invalid";
+ APPROVAL_CHECK(approval_error_is(approval_session_from_dictionary(modern_session, 501,
+ &session_user, &session_console), "session-unavailable"));
+ NSMutableDictionary *inactive_session = [active_session mutableCopy];
+ inactive_session[(__bridge NSString *)kCGSessionOnConsoleKey] = @NO;
+ APPROVAL_CHECK(approval_error_is(approval_session_from_dictionary(inactive_session, 501,
+ &session_user, &session_console), "session-inactive"));
+ inactive_session = [active_session mutableCopy];
+ inactive_session[(__bridge NSString *)kCGSessionLoginDoneKey] = @NO;
+ APPROVAL_CHECK(approval_error_is(approval_session_from_dictionary(inactive_session, 501,
+ &session_user, &session_console), "session-inactive"));
+ APPROVAL_CHECK(approval_error_is(approval_session_from_dictionary(active_session, 502,
+ &session_user, &session_console), "session-inactive"));
+ NSMutableDictionary *locked_session = [active_session mutableCopy];
+ locked_session[@"CGSSessionScreenIsLocked"] = @YES;
+ APPROVAL_CHECK(approval_error_is(approval_session_from_dictionary(locked_session, 501,
+ &session_user, &session_console), "session-locked"));
+ locked_session[@"CGSSessionScreenIsLocked"] = @NO;
+ APPROVAL_CHECK(approval_session_from_dictionary(locked_session, 501,
+ &session_user, &session_console) == NULL);
+ locked_session[@"CGSSessionScreenIsLocked"] = @"false";
+ APPROVAL_CHECK(approval_error_is(approval_session_from_dictionary(locked_session, 501,
+ &session_user, &session_console), "session-unavailable"));
+ APPROVAL_CHECK(approval_error_is(approval_session_from_dictionary(nil, 501,
+ &session_user, &session_console), "session-unavailable"));
+ APPROVAL_CHECK(approval_error_is(approval_session_from_dictionary(@{}, 501,
+ &session_user, &session_console), "session-unavailable"));
+#undef APPROVAL_CHECK
+ printf("{\"checks\":%u,\"failures\":%u,\"live_io\":false}\n", checks, failures);
+ return failures == 0 ? 0 : 1;
+}
diff --git a/native/keybridge.m b/native/keybridge.m
index 2446e12..a486cfc 100644
--- a/native/keybridge.m
+++ b/native/keybridge.m
@@ -77,6 +77,11 @@ static CGEventRef create_key_event(
return event;
}
+#include "approval-shortcuts.h"
+#include "approval-api-context.h"
+#include "review-continue.h"
+#include "approval-card.h"
+
static void post_key(CGKeyCode key, bool down, CGEventFlags flags) {
CGEventRef event = create_key_event(key, down, flags, NULL, 0);
if (event == NULL) return;
@@ -2069,6 +2074,7 @@ static int print_permission_health(bool request) {
static bool command_needs_accessibility(const char *command) {
if (strstr(command, "selftest") != NULL) return false;
+ if (strcmp(command, "codex-approval-api-context") == 0) return false;
if (strncmp(command, "fast-mode-", strlen("fast-mode-")) == 0) return true;
if (strncmp(command, "reasoning-effort-", strlen("reasoning-effort-")) == 0) return true;
if (strncmp(command, "codex-", strlen("codex-")) == 0
@@ -2094,6 +2100,8 @@ static bool command_needs_post_event_access(const char *command) {
|| strncmp(command, "codex-open-side-chat", strlen("codex-open-side-chat")) == 0
|| strncmp(command, "codex-find-thread", strlen("codex-find-thread")) == 0
|| strncmp(command, "codex-search-thread", strlen("codex-search-thread")) == 0
+ || strcmp(command, "codex-approval-shortcut") == 0
+ || strcmp(command, "codex-approval-card") == 0
|| strcmp(command, "codex-dismiss-intelligence-popover") == 0) return true;
return strcmp(command, "voice-down") == 0
|| strcmp(command, "send") == 0
@@ -9247,7 +9255,58 @@ int main(int argc, char **argv) {
return print_permission_health(true);
}
int permission_gate = command_permission_gate(argv[1]);
- if (permission_gate != 0) return permission_gate;
+ if (permission_gate != 0) {
+ if (strcmp(argv[1], "codex-approval-context") == 0
+ || strcmp(argv[1], "codex-approval-shortcut") == 0
+ || strcmp(argv[1], "codex-review-continue") == 0
+ || strcmp(argv[1], "codex-review-diagnostics") == 0
+ || strcmp(argv[1], "codex-approval-card") == 0
+ || strcmp(argv[1], "codex-approval-card-diagnostics") == 0) {
+ return approval_print_error("permission-denied", permission_gate);
+ }
+ return permission_gate;
+ }
+ if (strcmp(argv[1], "codex-approval-api-context") == 0) {
+ if (argc != 2) return approval_api_print_error("invalid-arguments", 64);
+ return print_codex_approval_api_context();
+ }
+ if (strcmp(argv[1], "approval-api-context-selftest") == 0) {
+ if (argc != 2) return 64;
+ return approval_api_context_selftest();
+ }
+ if (strcmp(argv[1], "codex-approval-context") == 0) {
+ if (argc != 2) return approval_print_error("invalid-arguments", 64);
+ return print_codex_task_action_context();
+ }
+ if (strcmp(argv[1], "codex-approval-card") == 0) {
+ return codex_approval_card(argc, argv);
+ }
+ if (strcmp(argv[1], "codex-approval-card-diagnostics") == 0) {
+ if (argc != 2) return approval_print_error("invalid-arguments", 64);
+ return print_codex_approval_card_diagnostics();
+ }
+ if (strcmp(argv[1], "approval-card-selftest") == 0) {
+ if (argc != 2) return 64;
+ return approval_card_selftest();
+ }
+ if (strcmp(argv[1], "codex-approval-shortcut") == 0) {
+ return codex_approval_shortcut(argc, argv);
+ }
+ if (strcmp(argv[1], "approval-shortcut-selftest") == 0) {
+ if (argc != 2) return 64;
+ return approval_shortcut_selftest();
+ }
+ if (strcmp(argv[1], "codex-review-continue") == 0) {
+ return codex_review_continue(argc, argv);
+ }
+ if (strcmp(argv[1], "codex-review-diagnostics") == 0) {
+ if (argc != 2) return approval_print_error("invalid-arguments", 64);
+ return print_codex_review_diagnostics();
+ }
+ if (strcmp(argv[1], "review-continue-selftest") == 0) {
+ if (argc != 2) return 64;
+ return review_continue_selftest();
+ }
if (strcmp(argv[1], "fast-mode-set") == 0) {
if (argc != 3) return 64;
if (strcmp(argv[2], "on") == 0) {
diff --git a/native/review-continue.h b/native/review-continue.h
new file mode 100644
index 0000000..259299b
--- /dev/null
+++ b/native/review-continue.h
@@ -0,0 +1,684 @@
+#include
+
+// The review acknowledgment is owned by the user. This helper only presses
+// the existing enabled button after two complete, matching read-only scans.
+static NSString *const REVIEW_TITLE = @"Chat paused as a precaution";
+static NSString *const REVIEW_HEADING = @"What we detected";
+static NSString *const REVIEW_ACK =
+ @"I confirm I have carefully reviewed and believe it is safe to continue";
+static NSString *const REVIEW_CONTINUE = @"Continue chat";
+
+typedef struct {
+ NSString *__strong state;
+ NSString *__strong token;
+ id __strong target;
+ id __strong checkbox;
+ id __strong dialog;
+} ReviewCapture;
+
+typedef struct {
+ const char *(*read_context)(ApprovalShortcutContext *, void *);
+ ReviewCapture (*read_review)(pid_t, void *);
+ bool (*same_element)(id, id, void *);
+ const char *(*validate_controls)(ReviewCapture, void *);
+ AXError (*press)(id, void *);
+ void *context;
+} ReviewContinueOperations;
+
+static bool review_has_label(NSDictionary *node, NSString *label) {
+ for (NSString *key in @[@"title", @"description", @"value", @"help"]) {
+ if ([node[key] isKindOfClass:NSString.class] && [node[key] isEqualToString:label]) return true;
+ }
+ return false;
+}
+
+static bool review_node_is_dialog(NSDictionary *node) {
+ return [node[@"role"] isEqualToString:@"AXDialog"]
+ || [node[@"role"] isEqualToString:@"AXSheet"]
+ || [node[@"subrole"] isEqualToString:@"AXDialog"]
+ || ([node[@"role"] isEqualToString:@"AXGroup"]
+ && [node[@"subrole"] isEqualToString:@"AXApplicationDialog"]);
+}
+
+static bool review_node_is_modal(NSDictionary *node) {
+ return review_node_is_dialog(node)
+ || [node[@"role"] isEqualToString:@"AXAlert"]
+ || [node[@"role"] isEqualToString:@"AXAlertDialog"]
+ || [node[@"subrole"] isEqualToString:@"AXAlertDialog"]
+ || [node[@"subrole"] isEqualToString:@"AXApplicationDialog"]
+ || [node[@"subrole"] isEqualToString:@"AXApplicationAlertDialog"];
+}
+
+static void review_collect_nodes(NSDictionary *node, NSMutableArray *nodes) {
+ if ([node[@"hidden"] boolValue]) return;
+ [nodes addObject:node];
+ for (NSDictionary *child in node[@"children"]) review_collect_nodes(child, nodes);
+}
+
+static NSString *review_fingerprint(NSDictionary *tree) {
+ // The digest includes accessible identity, control states and findings text,
+ // but neither text nor element handles ever leave the helper process.
+ NSMutableArray *nodes = [NSMutableArray array];
+ review_collect_nodes(tree, nodes);
+ NSMutableArray *fields = [NSMutableArray arrayWithCapacity:nodes.count];
+ NSArray *keys = @[@"identity", @"path", @"role", @"subrole", @"title",
+ @"description", @"help", @"value", @"identifier", @"enabled", @"hidden", @"pressable"];
+ for (NSDictionary *node in nodes) {
+ NSMutableArray *row = [NSMutableArray arrayWithCapacity:keys.count];
+ for (NSString *key in keys) [row addObject:node[key] != nil ? node[key] : NSNull.null];
+ [fields addObject:row];
+ }
+ NSData *data = [NSJSONSerialization dataWithJSONObject:fields options:0 error:NULL];
+ if (data == nil || data.length > UINT_MAX) return nil;
+ unsigned char digest[CC_SHA256_DIGEST_LENGTH];
+ CC_SHA256(data.bytes, (CC_LONG)data.length, digest);
+ NSMutableString *token = [NSMutableString stringWithString:@"r1:"];
+ for (unsigned i = 0; i < sizeof(digest); i++) [token appendFormat:@"%02x", digest[i]];
+ return token;
+}
+
+static ReviewCapture review_classify_tree(NSDictionary *tree, bool complete) {
+ ReviewCapture unavailable = { .state = @"unavailable" };
+ if (!complete || tree == nil) return unavailable;
+ NSMutableArray *nodes = [NSMutableArray array];
+ review_collect_nodes(tree, nodes);
+ NSMutableArray *dialogs = [NSMutableArray array];
+ bool safety_marker = false;
+ unsigned modal_count = 0;
+ for (NSDictionary *node in nodes) {
+ if (review_has_label(node, REVIEW_TITLE) || review_has_label(node, REVIEW_ACK)) safety_marker = true;
+ if (review_node_is_modal(node)) modal_count += 1;
+ if (review_node_is_dialog(node) && review_has_label(node, REVIEW_TITLE)) [dialogs addObject:node];
+ }
+ if (dialogs.count == 0) return safety_marker ? unavailable : (ReviewCapture){ 0 };
+ // An unrelated sibling modal can own input while the review remains in the
+ // AX tree. Require one modal across the entire visible window, not only
+ // within the review subtree.
+ if (dialogs.count != 1 || modal_count != 1) return unavailable;
+ NSDictionary *dialog = dialogs.firstObject;
+ NSMutableArray *inside = [NSMutableArray array];
+ review_collect_nodes(dialog, inside);
+ NSMutableArray *checkboxes = [NSMutableArray array], *buttons = [NSMutableArray array];
+ bool findings_heading = false;
+ unsigned nested_dialogs = 0;
+ for (NSDictionary *node in inside) {
+ if (review_node_is_dialog(node)) nested_dialogs += 1;
+ if (review_has_label(node, REVIEW_HEADING)) findings_heading = true;
+ if ([node[@"role"] isEqualToString:@"AXCheckBox"] && review_has_label(node, REVIEW_ACK)) {
+ [checkboxes addObject:node];
+ }
+ if ([node[@"role"] isEqualToString:@"AXButton"] && review_has_label(node, REVIEW_CONTINUE)) {
+ [buttons addObject:node];
+ }
+ }
+ if (!findings_heading || nested_dialogs != 1 || checkboxes.count != 1 || buttons.count != 1) return unavailable;
+ NSDictionary *checkbox = checkboxes.firstObject, *button = buttons.firstObject;
+ id checked = checkbox[@"value"];
+ if (![checked isKindOfClass:NSNumber.class] || ![checkbox[@"enabled"] isEqual:@YES]) return unavailable;
+ bool is_checked = [checked isEqual:@1];
+ if (!is_checked && ![checked isEqual:@0]) return unavailable;
+ NSString *token = review_fingerprint(dialog);
+ if (token == nil) return unavailable;
+ if (!is_checked) return (ReviewCapture){ .state = @"unchecked", .token = token };
+ if (![button[@"enabled"] isEqual:@YES] || ![button[@"pressable"] isEqual:@YES]) return unavailable;
+ return (ReviewCapture){ .state = @"ready", .token = token,
+ .target = button[@"element"], .checkbox = checkbox[@"element"], .dialog = dialog[@"element"] };
+}
+
+typedef struct {
+ unsigned visited;
+ size_t text_bytes;
+ CFTimeInterval deadline;
+ bool complete;
+ const char *limit_reason;
+ unsigned roles[13];
+ unsigned subroles[6];
+} ReviewScanBudget;
+
+static NSArray *review_diagnostic_roles(void) {
+ return @[@"AXWindow", @"AXGroup", @"AXDialog", @"AXSheet", @"AXAlert", @"AXAlertDialog",
+ @"AXHeading", @"AXStaticText", @"AXCheckBox", @"AXButton", @"AXWebArea", @"missing", @"other"];
+}
+
+static NSArray *review_diagnostic_subroles(void) {
+ return @[@"AXDialog", @"AXApplicationDialog", @"AXAlertDialog", @"AXApplicationAlertDialog", @"missing", @"other"];
+}
+
+static void review_count_diagnostic_value(id value, NSArray *names, unsigned *counts) {
+ NSUInteger index = [value isKindOfClass:NSString.class] ? [names indexOfObject:value] : names.count - 2;
+ if (index == NSNotFound) index = names.count - 1;
+ counts[index] += 1;
+}
+
+static void review_scan_stop(ReviewScanBudget *budget, const char *reason) {
+ budget->complete = false;
+ if (budget->limit_reason == NULL) budget->limit_reason = reason;
+}
+
+static bool review_scan_begin_node(ReviewScanBudget *budget, unsigned depth, CFTimeInterval now) {
+ if (!budget->complete) return false;
+ if (depth > 48) { review_scan_stop(budget, "depth-limit"); return false; }
+ // A real permission card can appear after more than 900 earlier chat nodes.
+ // The existing deadline and text/depth limits still bound this larger count.
+ if (budget->visited >= 4096) { review_scan_stop(budget, "node-limit"); return false; }
+ if (now > budget->deadline) { review_scan_stop(budget, "deadline"); return false; }
+ budget->visited += 1;
+ return true;
+}
+
+static NSDictionary *review_scan_summary(ReviewScanBudget budget) {
+ return @{ @"visited": @(budget.visited), @"complete": @(budget.complete),
+ @"limitReason": budget.limit_reason != NULL ? [NSString stringWithUTF8String:budget.limit_reason] : NSNull.null };
+}
+
+static NSString *review_url_string(id value) {
+ if ([value isKindOfClass:NSString.class]) return value;
+ if (value != nil && CFGetTypeID((__bridge CFTypeRef)value) == CFURLGetTypeID()) {
+ return [(NSURL *)value absoluteString];
+ }
+ return nil;
+}
+
+static NSDictionary *review_scan_diagnostics(ReviewScanBudget budget) {
+ NSMutableDictionary *roles = [NSMutableDictionary dictionary], *subroles = [NSMutableDictionary dictionary];
+ NSArray *role_names = review_diagnostic_roles(), *subrole_names = review_diagnostic_subroles();
+ for (NSUInteger i = 0; i < role_names.count; i++) roles[role_names[i]] = @(budget.roles[i]);
+ for (NSUInteger i = 0; i < subrole_names.count; i++) subroles[subrole_names[i]] = @(budget.subroles[i]);
+ return @{ @"visited": @(budget.visited), @"complete": @(budget.complete),
+ @"limitReason": budget.limit_reason != NULL ? [NSString stringWithUTF8String:budget.limit_reason] : NSNull.null,
+ @"roles": roles, @"subroles": subroles };
+}
+
+static NSDictionary *review_copy_live_tree(AXUIElementRef element, NSString *path,
+ unsigned depth, ReviewScanBudget *budget) {
+ if (!review_scan_begin_node(budget, depth, CFAbsoluteTimeGetCurrent())) return nil;
+ AXUIElementSetMessagingTimeout(element, 0.1);
+ NSArray *attributes = @[(__bridge NSString *)kAXRoleAttribute,
+ (__bridge NSString *)kAXSubroleAttribute, (__bridge NSString *)kAXTitleAttribute,
+ (__bridge NSString *)kAXDescriptionAttribute, (__bridge NSString *)kAXHelpAttribute,
+ (__bridge NSString *)kAXValueAttribute, (__bridge NSString *)kAXIdentifierAttribute,
+ (__bridge NSString *)kAXEnabledAttribute, (__bridge NSString *)kAXHiddenAttribute,
+ (__bridge NSString *)kAXURLAttribute,
+ (__bridge NSString *)kAXChildrenAttribute];
+ NSArray *keys = @[@"role", @"subrole", @"title", @"description", @"help", @"value",
+ @"identifier", @"enabled", @"hidden", @"url", @"children"];
+ CFArrayRef raw = NULL;
+ AXError error = AXUIElementCopyMultipleAttributeValues(element, (__bridge CFArrayRef)attributes,
+ 0, &raw);
+ if (error != kAXErrorSuccess || raw == NULL || CFArrayGetCount(raw) != (CFIndex)keys.count) {
+ if (raw != NULL) CFRelease(raw);
+ review_scan_stop(budget, "accessibility-read");
+ return nil;
+ }
+ NSArray *values = CFBridgingRelease(raw);
+ review_count_diagnostic_value(values[0], review_diagnostic_roles(), budget->roles);
+ review_count_diagnostic_value(values[1], review_diagnostic_subroles(), budget->subroles);
+ NSMutableDictionary *node = [NSMutableDictionary dictionaryWithDictionary:@{
+ @"identity": @((uint64_t)CFHash(element)), @"path": path, @"element": (__bridge id)element
+ }];
+ for (NSUInteger i = 0; i + 1 < keys.count; i++) {
+ id value = values[i];
+ if ([keys[i] isEqualToString:@"url"]) value = review_url_string(value);
+ if ([value isKindOfClass:NSString.class]) {
+ budget->text_bytes += [value lengthOfBytesUsingEncoding:NSUTF8StringEncoding];
+ if (budget->text_bytes > 512 * 1024) { review_scan_stop(budget, "text-limit"); return nil; }
+ node[keys[i]] = value;
+ } else if ([value isKindOfClass:NSNumber.class]) node[keys[i]] = value;
+ }
+ if ([node[@"hidden"] boolValue]) { node[@"children"] = @[]; return node; }
+ if (node[@"role"] == nil) { review_scan_stop(budget, "missing-role"); return nil; }
+ bool approval_button_name = false;
+ for (NSString *key in @[@"title", @"description", @"value"]) {
+ id name = node[key];
+ if ([name isKindOfClass:NSString.class] && ([name hasPrefix:@"Allow once"]
+ || [name hasPrefix:@"Deny"])) approval_button_name = true;
+ }
+ if ([node[@"role"] isEqualToString:@"AXButton"]
+ && (review_has_label(node, REVIEW_CONTINUE) || approval_button_name)) {
+ CFArrayRef actions = NULL;
+ bool pressable = AXUIElementCopyActionNames(element, &actions) == kAXErrorSuccess
+ && actions != NULL && [(__bridge NSArray *)actions containsObject:(__bridge NSString *)kAXPressAction];
+ if (actions != NULL) CFRelease(actions);
+ node[@"pressable"] = @(pressable);
+ if (approval_button_name) {
+ Boolean focusable = false;
+ bool focus_read = AXUIElementIsAttributeSettable(element, kAXFocusedAttribute, &focusable) == kAXErrorSuccess;
+ node[@"focusable"] = @(focus_read && focusable);
+ }
+ }
+ NSMutableArray *children = [NSMutableArray array];
+ id child_values = values.lastObject;
+ if ([child_values isKindOfClass:NSArray.class]) {
+ for (NSUInteger i = 0; i < [child_values count]; i++) {
+ id child = child_values[i];
+ if (CFGetTypeID((__bridge CFTypeRef)child) != AXUIElementGetTypeID()) {
+ review_scan_stop(budget, "invalid-children"); break;
+ }
+ NSDictionary *copied = review_copy_live_tree((__bridge AXUIElementRef)child,
+ [path stringByAppendingFormat:@"/%lu", (unsigned long)i], depth + 1, budget);
+ if (copied != nil) [children addObject:copied];
+ if (!budget->complete) break;
+ }
+ } else if (CFGetTypeID((__bridge CFTypeRef)child_values) == AXValueGetTypeID()) {
+ AXError child_error = kAXErrorFailure;
+ bool missing_children = AXValueGetType((__bridge AXValueRef)child_values) == kAXValueAXErrorType
+ && AXValueGetValue((__bridge AXValueRef)child_values, kAXValueAXErrorType, &child_error)
+ && (child_error == kAXErrorAttributeUnsupported || child_error == kAXErrorNoValue);
+ if (!missing_children) review_scan_stop(budget, "children-read");
+ } else if (child_values != NSNull.null) review_scan_stop(budget, "invalid-children");
+ node[@"children"] = children;
+ return node;
+}
+
+static NSDictionary *review_read_live_snapshot(pid_t pid, ReviewScanBudget *budget) {
+ AXUIElementRef application = AXUIElementCreateApplication(pid);
+ if (application == NULL) { review_scan_stop(budget, "application-unavailable"); return nil; }
+ AXUIElementSetMessagingTimeout(application, 0.2);
+ CFTypeRef window = NULL;
+ AXError error = AXUIElementCopyAttributeValue(application, kAXFocusedWindowAttribute, &window);
+ CFRelease(application);
+ if (error != kAXErrorSuccess || window == NULL || CFGetTypeID(window) != AXUIElementGetTypeID()) {
+ if (window != NULL) CFRelease(window);
+ review_scan_stop(budget, "window-unavailable"); return nil;
+ }
+ budget->deadline = CFAbsoluteTimeGetCurrent() + 0.6;
+ NSDictionary *tree = review_copy_live_tree((AXUIElementRef)window, @"0", 0, budget);
+ CFRelease(window);
+ if (CFAbsoluteTimeGetCurrent() > budget->deadline) review_scan_stop(budget, "deadline");
+ return tree;
+}
+
+static ReviewCapture review_read_live(pid_t pid, void *unused) {
+ (void)unused;
+ ReviewScanBudget budget = { .complete = true };
+ NSDictionary *tree = review_read_live_snapshot(pid, &budget);
+ return review_classify_tree(tree, budget.complete);
+}
+
+static int print_codex_review_diagnostics(void) {
+ ApprovalShortcutContext context = { 0 };
+ const char *error = approval_read_live_context(&context, NULL);
+ if (error != NULL) return approval_print_error(error, 1);
+ ReviewScanBudget budget = { .complete = true };
+ (void)review_read_live_snapshot(context.pid, &budget);
+ ApprovalShortcutContext final_context = { 0 };
+ error = approval_read_live_context(&final_context, NULL);
+ if (error != NULL) return approval_print_error(error, 1);
+ if (!approval_context_matches(final_context, context.pid, approval_context_token(context))) {
+ return approval_print_error("context-changed", 1);
+ }
+ // Deliberately serialize this fixed aggregate allowlist only. Neither the
+ // snapshot nor any identity, path, label, value or findings text is emitted.
+ NSData *data = [NSJSONSerialization dataWithJSONObject:review_scan_diagnostics(budget) options:0 error:NULL];
+ if (data == nil) return approval_print_error("diagnostics-unavailable", 1);
+ printf("%s\n", [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding].UTF8String);
+ return 0;
+}
+
+static bool review_same_live_element(id first, id second, void *unused) {
+ (void)unused;
+ return first != nil && second != nil && CFEqual((__bridge CFTypeRef)first, (__bridge CFTypeRef)second);
+}
+
+static AXError review_press_live(id target, void *unused) {
+ (void)unused;
+ return AXUIElementPerformAction((__bridge AXUIElementRef)target, kAXPressAction);
+}
+
+static NSDictionary *review_read_live_control(id target) {
+ if (target == nil) return nil;
+ AXUIElementSetMessagingTimeout((__bridge AXUIElementRef)target, 0.1);
+ NSArray *attributes = @[(__bridge NSString *)kAXRoleAttribute,
+ (__bridge NSString *)kAXTitleAttribute, (__bridge NSString *)kAXDescriptionAttribute,
+ (__bridge NSString *)kAXHelpAttribute, (__bridge NSString *)kAXValueAttribute,
+ (__bridge NSString *)kAXEnabledAttribute, (__bridge NSString *)kAXHiddenAttribute];
+ NSArray *keys = @[@"role", @"title", @"description", @"help", @"value", @"enabled", @"hidden"];
+ CFArrayRef raw = NULL;
+ if (target == nil || AXUIElementCopyMultipleAttributeValues((__bridge AXUIElementRef)target,
+ (__bridge CFArrayRef)attributes, 0, &raw) != kAXErrorSuccess || raw == NULL) {
+ if (raw != NULL) CFRelease(raw);
+ return nil;
+ }
+ NSArray *values = CFBridgingRelease(raw);
+ if (values.count != keys.count) return nil;
+ NSMutableDictionary *node = [NSMutableDictionary dictionary];
+ for (NSUInteger i = 0; i < keys.count; i++) {
+ if ([values[i] isKindOfClass:NSString.class] || [values[i] isKindOfClass:NSNumber.class]) {
+ node[keys[i]] = values[i];
+ }
+ }
+ return node;
+}
+
+static const char *review_validate_live_controls(ReviewCapture review, void *unused) {
+ (void)unused;
+ NSDictionary *checkbox = review_read_live_control(review.checkbox);
+ NSDictionary *button = review_read_live_control(review.target);
+ if (![checkbox[@"role"] isEqualToString:@"AXCheckBox"] || !review_has_label(checkbox, REVIEW_ACK)
+ || ![checkbox[@"enabled"] isEqual:@YES] || ![button[@"role"] isEqualToString:@"AXButton"]
+ || !review_has_label(button, REVIEW_CONTINUE)) return "review-changed";
+ if (![checkbox[@"value"] isEqual:@1]) return "review-unchecked";
+ if (![button[@"enabled"] isEqual:@YES]) return "review-unavailable";
+ return NULL;
+}
+
+static const char *review_dispatch_continue(pid_t pid, NSString *window_token, NSString *review_token,
+ ReviewContinueOperations ops, bool *attempted) {
+ *attempted = false;
+ ReviewCapture previous = { 0 };
+ for (unsigned i = 0; i < 2; i++) {
+ ApprovalShortcutContext current = { 0 };
+ const char *error = ops.read_context(¤t, ops.context);
+ if (error != NULL) return error;
+ if (!approval_context_matches(current, pid, window_token)) return "context-changed";
+ ReviewCapture review = ops.read_review(pid, ops.context);
+ if ([review.state isEqualToString:@"unchecked"]) return "review-unchecked";
+ if (![review.state isEqualToString:@"ready"] || review.target == nil
+ || review.checkbox == nil || review.dialog == nil) return "review-unavailable";
+ if (![review.token isEqualToString:review_token]) return "review-changed";
+ if (i > 0 && (!ops.same_element(previous.target, review.target, ops.context)
+ || !ops.same_element(previous.checkbox, review.checkbox, ops.context)
+ || !ops.same_element(previous.dialog, review.dialog, ops.context))) return "review-changed";
+ previous = review;
+ }
+ // One final foreground/session check after the potentially slower AX scan.
+ ApprovalShortcutContext current = { 0 };
+ const char *error = ops.read_context(¤t, ops.context);
+ if (error != NULL) return error;
+ if (!approval_context_matches(current, pid, window_token)) return "context-changed";
+ // Re-read only the two held controls after the final window check. Never
+ // mutate the checkbox, even if it was checked in either earlier snapshot.
+ error = ops.validate_controls(previous, ops.context);
+ if (error != NULL) return error;
+ *attempted = true;
+ return ops.press(previous.target, ops.context) == kAXErrorSuccess ? NULL : "delivery-unknown";
+}
+
+static NSDictionary *review_context_metadata(ReviewCapture review) {
+ if (review.state == nil) return nil;
+ return @{ @"state": review.state, @"token": review.token != nil ? review.token : NSNull.null };
+}
+
+static int codex_review_continue(int argc, char **argv) {
+ if (argc != 5 || strlen(argv[3]) > 64 || strlen(argv[4]) != 67
+ || strncmp(argv[4], "r1:", 3) != 0) return approval_print_error("invalid-arguments", 64);
+ for (unsigned i = 3; i < 67; i++) {
+ if (!isxdigit((unsigned char)argv[4][i])) return approval_print_error("invalid-arguments", 64);
+ }
+ char *end = NULL;
+ errno = 0;
+ long pid = strtol(argv[2], &end, 10);
+ if (errno != 0 || end == argv[2] || *end != '\0' || pid <= 1 || pid > INT_MAX) {
+ return approval_print_error("invalid-arguments", 64);
+ }
+ bool attempted = false;
+ const char *error = review_dispatch_continue((pid_t)pid, [NSString stringWithUTF8String:argv[3]],
+ [NSString stringWithUTF8String:argv[4]], (ReviewContinueOperations){
+ .read_context = approval_read_live_context, .read_review = review_read_live,
+ .same_element = review_same_live_element, .validate_controls = review_validate_live_controls,
+ .press = review_press_live
+ }, &attempted);
+ if (error != NULL) {
+ if (attempted) printf("{\"sent\":null,\"error\":\"delivery-unknown\"}\n");
+ else return approval_print_error(error, 1);
+ return 1;
+ }
+ // AXPress success acknowledges action dispatch, not completion of the chat.
+ printf("{\"sent\":true}\n");
+ return 0;
+}
+
+static NSMutableDictionary *review_selftest_dialog(void) {
+ // Chromium maps an ARIA dialog to AXGroup / AXApplicationDialog on macOS.
+ return [@{ @"role": @"AXGroup", @"subrole": @"AXApplicationDialog", @"title": REVIEW_TITLE,
+ @"identity": @10, @"element": @"dialog-10", @"path": @"0/0", @"children": @[
+ @{ @"role": @"AXHeading", @"title": REVIEW_HEADING, @"identity": @11 },
+ @{ @"role": @"AXStaticText", @"value": @"Fixture findings A", @"identity": @12 },
+ @{ @"role": @"AXCheckBox", @"title": REVIEW_ACK, @"value": @1, @"enabled": @YES,
+ @"identity": @13, @"element": @"checkbox-13" },
+ @{ @"role": @"AXButton", @"title": REVIEW_CONTINUE, @"enabled": @YES, @"pressable": @YES,
+ @"identity": @14, @"element": @"button-14" }
+ ] } mutableCopy];
+}
+
+static void review_selftest_set_child(NSMutableDictionary *dialog, NSUInteger index,
+ NSString *key, id value) {
+ NSMutableArray *children = [dialog[@"children"] mutableCopy];
+ NSMutableDictionary *child = [children[index] mutableCopy];
+ if (value == nil) [child removeObjectForKey:key]; else child[key] = value;
+ children[index] = child;
+ dialog[@"children"] = children;
+}
+
+typedef struct {
+ ApprovalShortcutContext contexts[3];
+ const char *context_errors[3];
+ ReviewCapture reviews[2];
+ unsigned context_reads, review_reads, presses;
+ AXError press_result;
+ const char *final_control_error;
+} ReviewContinueSelftestState;
+
+static const char *review_selftest_context(ApprovalShortcutContext *context, void *opaque) {
+ ReviewContinueSelftestState *state = opaque;
+ unsigned index = state->context_reads++;
+ if (index > 2) return "unexpected-read";
+ *context = state->contexts[index];
+ return state->context_errors[index];
+}
+
+static ReviewCapture review_selftest_read(pid_t pid, void *opaque) {
+ (void)pid;
+ ReviewContinueSelftestState *state = opaque;
+ unsigned index = state->review_reads++;
+ return index < 2 ? state->reviews[index] : (ReviewCapture){ .state = @"unavailable" };
+}
+
+static AXError review_selftest_press(id target, void *opaque) {
+ ReviewContinueSelftestState *state = opaque;
+ state->presses += 1;
+ return target != nil ? state->press_result : kAXErrorIllegalArgument;
+}
+
+static const char *review_selftest_validate_controls(ReviewCapture review, void *opaque) {
+ (void)review;
+ ReviewContinueSelftestState *state = opaque;
+ return state->final_control_error;
+}
+
+static const char *review_run_selftest_dispatch(ReviewContinueSelftestState *state,
+ NSString *token, bool *attempted) {
+ return review_dispatch_continue(123, @"v2:123:45:501:1", token, (ReviewContinueOperations){
+ .read_context = review_selftest_context, .read_review = review_selftest_read,
+ .same_element = review_same_live_element, .validate_controls = review_selftest_validate_controls,
+ .press = review_selftest_press, .context = state
+ }, attempted);
+}
+
+static ReviewContinueSelftestState review_selftest_state(ReviewCapture capture) {
+ return (ReviewContinueSelftestState){
+ .contexts = {{123, 45, 501, 1}, {123, 45, 501, 1}, {123, 45, 501, 1}},
+ .reviews = {capture, capture}, .press_result = kAXErrorSuccess
+ };
+}
+
+static int review_continue_selftest(void) {
+ // No host reads, AX traversal, input posting, or UI actions in these tests.
+ unsigned checks = 0, failures = 0;
+#define REVIEW_CHECK(condition) do { checks += 1; if (!(condition)) failures += 1; } while (0)
+ ReviewScanBudget boundary = { .visited = 4095, .deadline = 10, .complete = true };
+ REVIEW_CHECK(review_scan_begin_node(&boundary, 48, 10) && boundary.visited == 4096 && boundary.complete);
+ REVIEW_CHECK(!review_scan_begin_node(&boundary, 48, 10) && boundary.visited == 4096
+ && !boundary.complete && strcmp(boundary.limit_reason, "node-limit") == 0);
+ REVIEW_CHECK(!review_scan_begin_node(&boundary, 0, 0) && boundary.visited == 4096);
+ boundary = (ReviewScanBudget){ .visited = 900, .deadline = 10, .complete = true };
+ REVIEW_CHECK(review_scan_begin_node(&boundary, 0, 9) && boundary.visited == 901 && boundary.complete);
+ boundary = (ReviewScanBudget){ .deadline = 10, .complete = true };
+ REVIEW_CHECK(!review_scan_begin_node(&boundary, 49, 9) && boundary.visited == 0
+ && strcmp(boundary.limit_reason, "depth-limit") == 0);
+ boundary = (ReviewScanBudget){ .deadline = 10, .complete = true };
+ REVIEW_CHECK(!review_scan_begin_node(&boundary, 48, 10.01) && boundary.visited == 0
+ && strcmp(boundary.limit_reason, "deadline") == 0);
+ NSDictionary *summary = review_scan_summary(boundary);
+ REVIEW_CHECK(summary.count == 3 && [summary[@"visited"] isEqual:@0]
+ && [summary[@"complete"] isEqual:@NO] && [summary[@"limitReason"] isEqualToString:@"deadline"]);
+ ReviewScanBudget diagnostic_budget = { .visited = 4, .complete = true };
+ review_count_diagnostic_value(@"AXGroup", review_diagnostic_roles(), diagnostic_budget.roles);
+ review_count_diagnostic_value(@"Fixture private unknown role", review_diagnostic_roles(), diagnostic_budget.roles);
+ review_count_diagnostic_value(nil, review_diagnostic_roles(), diagnostic_budget.roles);
+ review_count_diagnostic_value(@"AXCheckBox", review_diagnostic_roles(), diagnostic_budget.roles);
+ review_count_diagnostic_value(@"AXApplicationDialog", review_diagnostic_subroles(), diagnostic_budget.subroles);
+ review_count_diagnostic_value(@"Fixture private unknown subrole", review_diagnostic_subroles(), diagnostic_budget.subroles);
+ review_count_diagnostic_value(nil, review_diagnostic_subroles(), diagnostic_budget.subroles);
+ NSDictionary *diagnostic = review_scan_diagnostics(diagnostic_budget);
+ NSSet *diagnostic_keys = [NSSet setWithArray:@[@"visited", @"complete", @"limitReason", @"roles", @"subroles"]];
+ REVIEW_CHECK([NSSet setWithArray:diagnostic.allKeys].count == 5
+ && [[NSSet setWithArray:diagnostic.allKeys] isEqualToSet:diagnostic_keys]);
+ REVIEW_CHECK([diagnostic[@"visited"] isEqual:@4] && [diagnostic[@"complete"] isEqual:@YES]
+ && diagnostic[@"limitReason"] == NSNull.null);
+ REVIEW_CHECK([diagnostic[@"roles"] count] == 13 && [diagnostic[@"subroles"] count] == 6);
+ REVIEW_CHECK([diagnostic[@"roles"][@"AXGroup"] isEqual:@1]
+ && [diagnostic[@"roles"][@"AXCheckBox"] isEqual:@1]
+ && [diagnostic[@"roles"][@"other"] isEqual:@1] && [diagnostic[@"roles"][@"missing"] isEqual:@1]);
+ REVIEW_CHECK([diagnostic[@"subroles"][@"AXApplicationDialog"] isEqual:@1]
+ && [diagnostic[@"subroles"][@"other"] isEqual:@1]
+ && [diagnostic[@"subroles"][@"missing"] isEqual:@1]);
+ NSData *diagnostic_data = [NSJSONSerialization dataWithJSONObject:diagnostic options:0 error:NULL];
+ NSString *diagnostic_text = [[NSString alloc] initWithData:diagnostic_data encoding:NSUTF8StringEncoding];
+ REVIEW_CHECK([diagnostic_text rangeOfString:@"Fixture private"].location == NSNotFound);
+ review_scan_stop(&diagnostic_budget, "node-limit");
+ review_scan_stop(&diagnostic_budget, "deadline");
+ diagnostic = review_scan_diagnostics(diagnostic_budget);
+ REVIEW_CHECK([diagnostic[@"complete"] isEqual:@NO] && [diagnostic[@"limitReason"] isEqualToString:@"node-limit"]);
+ NSMutableDictionary *dialog = review_selftest_dialog();
+ ReviewCapture ready = review_classify_tree(dialog, true);
+ REVIEW_CHECK([ready.state isEqualToString:@"ready"] && ready.token.length == 67
+ && ready.target != nil && ready.checkbox != nil && ready.dialog != nil);
+ REVIEW_CHECK([review_classify_tree(dialog, true).token isEqualToString:ready.token]);
+ REVIEW_CHECK(review_classify_tree(@{ @"role": @"AXWindow", @"children": @[] }, true).state == nil);
+ REVIEW_CHECK([review_classify_tree(dialog, false).state isEqualToString:@"unavailable"]);
+ REVIEW_CHECK([review_classify_tree(nil, true).state isEqualToString:@"unavailable"]);
+ REVIEW_CHECK(review_context_metadata((ReviewCapture){0}) == nil);
+ REVIEW_CHECK([review_context_metadata((ReviewCapture){ .state = @"unavailable" })[@"token"] isEqual:NSNull.null]);
+ for (NSString *role in @[@"AXDialog", @"AXSheet"]) {
+ dialog = review_selftest_dialog(); dialog[@"role"] = role;
+ [dialog removeObjectForKey:@"subrole"];
+ REVIEW_CHECK([review_classify_tree(dialog, true).state isEqualToString:@"ready"]);
+ }
+ dialog = review_selftest_dialog(); dialog[@"subrole"] = @"AXDialog";
+ REVIEW_CHECK([review_classify_tree(dialog, true).state isEqualToString:@"ready"]);
+ dialog = review_selftest_dialog(); dialog[@"role"] = @"AXButton";
+ REVIEW_CHECK([review_classify_tree(dialog, true).state isEqualToString:@"unavailable"]);
+ dialog = review_selftest_dialog(); dialog[@"subrole"] = @"AXApplicationAlertDialog";
+ REVIEW_CHECK([review_classify_tree(dialog, true).state isEqualToString:@"unavailable"]);
+ dialog = review_selftest_dialog(); review_selftest_set_child(dialog, 2, @"value", @0);
+ ReviewCapture unchecked = review_classify_tree(dialog, true);
+ REVIEW_CHECK([unchecked.state isEqualToString:@"unchecked"] && unchecked.target == nil);
+ REVIEW_CHECK(![unchecked.token isEqualToString:ready.token]);
+ for (id value in @[@2, @-1, @"1", NSNull.null]) {
+ dialog = review_selftest_dialog(); review_selftest_set_child(dialog, 2, @"value", value);
+ REVIEW_CHECK([review_classify_tree(dialog, true).state isEqualToString:@"unavailable"]);
+ }
+ for (NSNumber *index in @[@2, @3]) {
+ dialog = review_selftest_dialog(); review_selftest_set_child(dialog, index.unsignedIntegerValue, @"enabled", @NO);
+ REVIEW_CHECK([review_classify_tree(dialog, true).state isEqualToString:@"unavailable"]);
+ dialog = review_selftest_dialog(); review_selftest_set_child(dialog, index.unsignedIntegerValue, @"enabled", nil);
+ REVIEW_CHECK([review_classify_tree(dialog, true).state isEqualToString:@"unavailable"]);
+ }
+ dialog = review_selftest_dialog(); review_selftest_set_child(dialog, 3, @"pressable", @NO);
+ REVIEW_CHECK([review_classify_tree(dialog, true).state isEqualToString:@"unavailable"]);
+ dialog = review_selftest_dialog(); dialog[@"title"] = @"Other dialog";
+ REVIEW_CHECK([review_classify_tree(dialog, true).state isEqualToString:@"unavailable"]);
+ dialog = review_selftest_dialog(); [dialog removeObjectForKey:@"subrole"];
+ REVIEW_CHECK([review_classify_tree(dialog, true).state isEqualToString:@"unavailable"]);
+ for (NSNumber *index in @[@0, @2, @3]) {
+ dialog = review_selftest_dialog(); review_selftest_set_child(dialog, index.unsignedIntegerValue, @"title", @"Other label");
+ REVIEW_CHECK([review_classify_tree(dialog, true).state isEqualToString:@"unavailable"]);
+ }
+ for (NSNumber *index in @[@2, @3]) {
+ dialog = review_selftest_dialog();
+ dialog[@"children"] = [dialog[@"children"] arrayByAddingObject:dialog[@"children"][index.unsignedIntegerValue]];
+ REVIEW_CHECK([review_classify_tree(dialog, true).state isEqualToString:@"unavailable"]);
+ }
+ NSDictionary *multiple = @{ @"role": @"AXWindow", @"children": @[review_selftest_dialog(), review_selftest_dialog()] };
+ REVIEW_CHECK([review_classify_tree(multiple, true).state isEqualToString:@"unavailable"]);
+ NSArray *other_modals = @[
+ @{ @"role": @"AXDialog", @"title": @"Other dialog" },
+ @{ @"role": @"AXSheet", @"title": @"Other sheet" },
+ @{ @"role": @"AXGroup", @"subrole": @"AXDialog", @"title": @"Other dialog" },
+ @{ @"role": @"AXGroup", @"subrole": @"AXApplicationDialog", @"title": @"Other dialog" },
+ @{ @"role": @"AXGroup", @"subrole": @"AXApplicationAlertDialog", @"title": @"Other alert" },
+ @{ @"role": @"AXAlertDialog", @"title": @"Other alert" },
+ @{ @"role": @"AXGroup", @"subrole": @"AXAlertDialog", @"title": @"Other alert" },
+ @{ @"role": @"AXAlert", @"title": @"Other alert" }
+ ];
+ for (NSDictionary *other in other_modals) {
+ NSDictionary *window = @{ @"role": @"AXWindow", @"children": @[review_selftest_dialog(), other] };
+ REVIEW_CHECK([review_classify_tree(window, true).state isEqualToString:@"unavailable"]);
+ dialog = review_selftest_dialog();
+ dialog[@"children"] = [dialog[@"children"] arrayByAddingObject:other];
+ REVIEW_CHECK([review_classify_tree(dialog, true).state isEqualToString:@"unavailable"]);
+ NSMutableDictionary *hidden = [other mutableCopy]; hidden[@"hidden"] = @YES;
+ window = @{ @"role": @"AXWindow", @"children": @[review_selftest_dialog(), hidden] };
+ REVIEW_CHECK([review_classify_tree(window, true).state isEqualToString:@"ready"]);
+ }
+ dialog = review_selftest_dialog();
+ dialog[@"children"] = [dialog[@"children"] arrayByAddingObject:@{ @"role": @"AXDialog", @"title": @"Other dialog" }];
+ REVIEW_CHECK([review_classify_tree(dialog, true).state isEqualToString:@"unavailable"]);
+ dialog = review_selftest_dialog(); review_selftest_set_child(dialog, 3, @"hidden", @YES);
+ REVIEW_CHECK([review_classify_tree(dialog, true).state isEqualToString:@"unavailable"]);
+ dialog = review_selftest_dialog(); dialog[@"hidden"] = @YES;
+ REVIEW_CHECK(review_classify_tree(dialog, true).state == nil);
+ dialog = review_selftest_dialog(); review_selftest_set_child(dialog, 1, @"value", @"Fixture findings B");
+ ReviewCapture changed = review_classify_tree(dialog, true);
+ REVIEW_CHECK([changed.state isEqualToString:@"ready"] && ![changed.token isEqualToString:ready.token]);
+ dialog = review_selftest_dialog(); dialog[@"identity"] = @99;
+ REVIEW_CHECK(![review_classify_tree(dialog, true).token isEqualToString:ready.token]);
+ ReviewContinueSelftestState state = review_selftest_state(ready);
+ bool attempted = false;
+ REVIEW_CHECK(review_run_selftest_dispatch(&state, ready.token, &attempted) == NULL);
+ REVIEW_CHECK(attempted && state.presses == 1 && state.context_reads == 3 && state.review_reads == 2);
+ for (unsigned i = 0; i < 3; i++) {
+ state = review_selftest_state(ready); state.contexts[i].window = 46;
+ REVIEW_CHECK(approval_error_is(review_run_selftest_dispatch(&state, ready.token, &attempted), "context-changed"));
+ REVIEW_CHECK(!attempted && state.presses == 0);
+ state = review_selftest_state(ready); state.contexts[i].session_console = 2;
+ REVIEW_CHECK(approval_error_is(review_run_selftest_dispatch(&state, ready.token, &attempted), "context-changed"));
+ REVIEW_CHECK(!attempted && state.presses == 0);
+ state = review_selftest_state(ready); state.context_errors[i] = "not-frontmost";
+ REVIEW_CHECK(approval_error_is(review_run_selftest_dispatch(&state, ready.token, &attempted), "not-frontmost"));
+ REVIEW_CHECK(!attempted && state.presses == 0);
+ }
+ for (unsigned i = 0; i < 2; i++) {
+ state = review_selftest_state(ready); state.reviews[i] = unchecked;
+ REVIEW_CHECK(approval_error_is(review_run_selftest_dispatch(&state, ready.token, &attempted), "review-unchecked"));
+ REVIEW_CHECK(!attempted && state.presses == 0);
+ state = review_selftest_state(ready); state.reviews[i] = changed;
+ REVIEW_CHECK(approval_error_is(review_run_selftest_dispatch(&state, ready.token, &attempted), "review-changed"));
+ REVIEW_CHECK(!attempted && state.presses == 0);
+ state = review_selftest_state(ready); state.reviews[i] = (ReviewCapture){ .state = @"unavailable" };
+ REVIEW_CHECK(approval_error_is(review_run_selftest_dispatch(&state, ready.token, &attempted), "review-unavailable"));
+ REVIEW_CHECK(!attempted && state.presses == 0);
+ }
+ for (unsigned i = 0; i < 3; i++) {
+ state = review_selftest_state(ready);
+ if (i == 0) state.reviews[1].target = @"other-button";
+ if (i == 1) state.reviews[1].checkbox = @"other-checkbox";
+ if (i == 2) state.reviews[1].dialog = @"other-dialog";
+ REVIEW_CHECK(approval_error_is(review_run_selftest_dispatch(&state, ready.token, &attempted), "review-changed"));
+ REVIEW_CHECK(!attempted && state.presses == 0);
+ }
+ state = review_selftest_state(ready); state.press_result = kAXErrorCannotComplete;
+ REVIEW_CHECK(approval_error_is(review_run_selftest_dispatch(&state, ready.token, &attempted), "delivery-unknown"));
+ REVIEW_CHECK(attempted && state.presses == 1);
+ const char *final_errors[] = {"review-unchecked", "review-unavailable", "review-changed"};
+ for (unsigned i = 0; i < sizeof(final_errors) / sizeof(final_errors[0]); i++) {
+ const char *error = final_errors[i];
+ state = review_selftest_state(ready); state.final_control_error = error;
+ REVIEW_CHECK(approval_error_is(review_run_selftest_dispatch(&state, ready.token, &attempted), error));
+ REVIEW_CHECK(!attempted && state.presses == 0 && state.review_reads == 2 && state.context_reads == 3);
+ }
+#undef REVIEW_CHECK
+ printf("{\"checks\":%u,\"failures\":%u,\"live_io\":false}\n", checks, failures);
+ return failures == 0 ? 0 : 1;
+}
diff --git a/scripts/audit-release.mjs b/scripts/audit-release.mjs
index 947a1f3..f5b0cc9 100644
--- a/scripts/audit-release.mjs
+++ b/scripts/audit-release.mjs
@@ -64,16 +64,18 @@ const expectedVisibleActionUuids = [
"com.yechan.threaddeck.weekly",
"com.yechan.threaddeck.thread1",
"com.yechan.threaddeck.newthread",
+ "com.yechan.threaddeck.taskactions",
"com.yechan.threaddeck.voice",
"com.yechan.threaddeck.reasoning",
"com.yechan.threaddeck.page.previous"
];
if (JSON.stringify(visibleActionUuids) !== JSON.stringify(expectedVisibleActionUuids)) {
- failures.push("Marketplace action list is not the six-action Codex-focused layout");
+ failures.push("Marketplace action list is not the seven-action Codex-focused layout");
}
for (const uuid of [
"com.yechan.threaddeck.thread1",
"com.yechan.threaddeck.newthread",
+ "com.yechan.threaddeck.taskactions",
"com.yechan.threaddeck.page.previous"
]) {
const action = pluginManifest.Actions.find((candidate) => candidate.UUID === uuid);
diff --git a/scripts/sync-localizations.mjs b/scripts/sync-localizations.mjs
index 3bbf26a..b95eb8f 100644
--- a/scripts/sync-localizations.mjs
+++ b/scripts/sync-localizations.mjs
@@ -27,7 +27,8 @@ const ROOT_COPY = {
const ENGLISH_ACTIONS = new Map(Object.entries({
"com.yechan.threaddeck.weekly": ["Weekly Codex quota", "Shows remaining weekly Codex capacity as a ring. Press to refresh now."],
"com.yechan.threaddeck.thread1": ["Codex task", "Choose Current or Top 1–8 in the Property Inspector. Tap to open it, or hold for 0.55 seconds to dictate and submit a follow-up."],
- "com.yechan.threaddeck.newthread": ["Codex command", "Choose New task, Side Chat, or Send in the Property Inspector."],
+ "com.yechan.threaddeck.newthread": ["Codex command", "Choose New task, Side Chat, Send, Approve, or Decline in the Property Inspector."],
+ "com.yechan.threaddeck.taskactions": ["Task actions", "Choose Approve or Decline and an approval target in the Property Inspector."],
"com.yechan.threaddeck.voice": ["Codex dictation", "Starts dictation in the current Codex composer while held. Release to leave a draft without submitting it."],
"com.yechan.threaddeck.reasoning": ["Codex effort + Fast mode", "Tap repeatedly to move effort immediately; after you stop, ThreadDeck applies only the final level. Hold for 0.6 seconds to toggle Fast mode immediately."],
"com.yechan.threaddeck.thread.top1": ["Top Codex task 1", "Legacy task slot retained for existing profiles."],
diff --git a/scripts/verify.sh b/scripts/verify.sh
index 4e1e4d3..d7265de 100755
--- a/scripts/verify.sh
+++ b/scripts/verify.sh
@@ -49,6 +49,10 @@ ARCHS="$(lipo -archs "$BRIDGE")"
"$BRIDGE" side-chat-tab-selftest
"$BRIDGE" command-palette-selftest
"$BRIDGE" media-bundle-selftest
+"$BRIDGE" approval-shortcut-selftest
+"$BRIDGE" review-continue-selftest
+"$BRIDGE" approval-card-selftest
+"$BRIDGE" approval-api-context-selftest
node - "$ROOT_DIR/native/keybridge.m" "$ROOT_DIR/src/plugin.js" "$ROOT_DIR/src/micro-prepared-bridge.js" <<'NODE'
const fs = require("node:fs");
const source = fs.readFileSync(process.argv[2], "utf8");
diff --git a/src/action-settings.js b/src/action-settings.js
index 2b25834..2408f0b 100644
--- a/src/action-settings.js
+++ b/src/action-settings.js
@@ -4,7 +4,7 @@ const TASK_SOURCE_TO_SLOT = new Map([
["current", -1],
...Array.from({ length: 8 }, (_, index) => [`top${index + 1}`, index])
]);
-const CODEX_COMMANDS = new Set(["new-task", "side-chat", "send"]);
+const CODEX_COMMANDS = new Set(["new-task", "side-chat", "send", "approve", "decline"]);
function taskSourceFromSettings(settings = {}) {
const value = String(settings?.taskSource ?? "").trim().toLowerCase();
@@ -20,10 +20,16 @@ function codexCommandFromSettings(settings = {}) {
return CODEX_COMMANDS.has(value) ? value : "new-task";
}
+function taskActionFromSettings(settings = {}) {
+ const value = String(settings?.command ?? "approve").trim().toLowerCase();
+ return value === "approve" || value === "decline" ? value : null;
+}
+
module.exports = {
CODEX_COMMANDS,
TASK_SOURCE_TO_SLOT,
codexCommandFromSettings,
+ taskActionFromSettings,
taskSlotFromSettings,
taskSourceFromSettings
};
diff --git a/src/approval-controls.js b/src/approval-controls.js
new file mode 100644
index 0000000..b42cf9a
--- /dev/null
+++ b/src/approval-controls.js
@@ -0,0 +1,257 @@
+"use strict";
+
+const THREAD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+const sharedExecutionState = { tail: Promise.resolve(), attempted: new Set() };
+
+function normalizeApprovalIdentity(value) {
+ if (!value || !THREAD_ID.test(value.threadId ?? "")
+ || !["exec", "patch", "permissionRequest"].includes(value.kind)
+ || !((typeof value.requestId === "string" && value.requestId.length > 0)
+ || (Number.isSafeInteger(value.requestId) && value.requestId >= 0))
+ || typeof value.hostId !== "string" || value.hostId.length === 0) return null;
+ return {
+ threadId: value.threadId.toLowerCase(),
+ hostId: value.hostId,
+ requestId: value.requestId,
+ kind: value.kind
+ };
+}
+
+function approvalIdentityKey(value) {
+ const identity = normalizeApprovalIdentity(value);
+ return identity ? JSON.stringify([
+ identity.threadId, identity.hostId, identity.kind, identity.requestId
+ ]) : null;
+}
+
+function isApprovalContextCurrent(isCurrent) {
+ if (isCurrent == null) return true;
+ try {
+ return typeof isCurrent === "function" && isCurrent() === true;
+ } catch {
+ return false;
+ }
+}
+
+// The queue is shared across action contexts and bridge instances. Capture the
+// request at the original press, before queuing, so a second key cannot approve
+// the next prompt merely because the first action finished while it waited.
+class ApprovalControls {
+ constructor({ readRequest, respond, executionState = sharedExecutionState }) {
+ this.readRequest = readRequest;
+ this.respond = respond;
+ this.executionState = executionState;
+ }
+
+ execute(decision, threadId, options = {}) {
+ if (!["approve", "decline"].includes(decision) || !THREAD_ID.test(threadId ?? "")) {
+ return Promise.resolve({ ok: false, reason: "unavailable", delivery: "none" });
+ }
+ const isCurrent = options.isCurrent;
+ const cancelled = () => ({ ok: false, reason: "cancelled", delivery: "none" });
+ if (!isApprovalContextCurrent(isCurrent)) return Promise.resolve(cancelled());
+ const expectedThreadId = threadId.toLowerCase();
+ const capture = Promise.resolve().then(() => !isApprovalContextCurrent(isCurrent)
+ ? null
+ : Object.hasOwn(options, "request") ? options.request : this.readRequest(expectedThreadId))
+ .then(normalizeApprovalIdentity, () => null);
+ const execute = async () => {
+ if (!isApprovalContextCurrent(isCurrent)) return cancelled();
+ const request = await capture;
+ if (!isApprovalContextCurrent(isCurrent)) return cancelled();
+ if (!request || request.threadId !== expectedThreadId) {
+ return { ok: false, reason: "unavailable", delivery: "none" };
+ }
+ const key = approvalIdentityKey(request);
+ if (this.executionState.attempted.has(key)) {
+ return { ok: false, reason: "already-handled", delivery: "none" };
+ }
+ let fresh;
+ try {
+ fresh = normalizeApprovalIdentity(await this.readRequest(expectedThreadId));
+ } catch {
+ if (!isApprovalContextCurrent(isCurrent)) return cancelled();
+ return { ok: false, reason: "unavailable", delivery: "none" };
+ }
+ // A key context can disappear or change settings while this read awaits
+ // the renderer. Cancellation is authoritative until dispatch begins.
+ if (!isApprovalContextCurrent(isCurrent)) return cancelled();
+ if (approvalIdentityKey(fresh) !== key) {
+ return { ok: false, reason: "changed", delivery: "none" };
+ }
+ this.executionState.attempted.add(key);
+ try {
+ const result = await this.respond(decision, request, { isCurrent });
+ if (result?.delivered === true) {
+ // Codex's card callback starts its own async RPC and returns void.
+ // Acknowledging invocation does not claim the request was resolved.
+ return { ok: true, delivery: "invoked", request };
+ }
+ if (result?.delivered === false && result?.delivery === "none") {
+ // The bridge proved that its synchronous pre-dispatch guard rejected
+ // this request. A later deliberate press may inspect it again.
+ this.executionState.attempted.delete(key);
+ return { ok: false, reason: result.reason ?? "changed", delivery: "none" };
+ }
+ } catch {
+ // Once respond has been called, even a disconnect labelled unavailable
+ // may have followed successful dispatch. Never retry or fall back.
+ }
+ return { ok: false, reason: "delivery-unknown", delivery: "unknown" };
+ };
+ const result = this.executionState.tail.then(execute, execute);
+ this.executionState.tail = result.then(() => {}, () => {});
+ return result;
+ }
+}
+
+// Runs only inside an existing, compatible renderer transport. No module
+// imports, synthetic HID activation, keyboard events, or global command runner
+// are used: the visible card supplies the exact request-bound once/deny action.
+// The semantic card/owner props were inspected in Codex 26.901.51231. Unknown
+// surfaces (including config changes, sharing, and input forms) fail closed.
+function inspectApprovalSurface(expectedThreadId, decision = null) {
+ const normalizeThreadId = (value) => {
+ if (typeof value !== "string") return null;
+ const match = value.match(/(?:^|[:/])([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
+ return match?.[1]?.toLowerCase() ?? null;
+ };
+ const visible = (element) => Boolean(element?.isConnected
+ && element.getClientRects().length > 0
+ && getComputedStyle(element).visibility !== "hidden"
+ && !element.closest('[inert], [aria-hidden="true"]'));
+ const expected = normalizeThreadId(expectedThreadId);
+ if (!expected) return null;
+ const focused = document.activeElement;
+ if (focused?.closest('[data-app-shell-tab-panel-controller="right"], [data-tab-id^="sidechat:"]')) return null;
+ if ([...document.querySelectorAll('[role="dialog"], [role="menu"]')].some(visible)) return null;
+ const composers = [...document.querySelectorAll('[data-codex-composer-root]')]
+ .filter((element) => visible(element)
+ && !element.closest('[data-app-shell-tab-panel-controller="right"], [data-tab-id^="sidechat:"]'));
+ // The above-composer portal is a child of the composer root in current
+ // Codex. Scope the identity read to that main composer, including when the
+ // sidebar is unmounted, and reject conflicting portals during navigation.
+ const portalIds = composers.flatMap((element) => (
+ [...element.querySelectorAll('[data-above-composer-conversation-id]')]
+ .filter((portal) => visible(portal) && portal.closest('[data-codex-composer-root]') === element)
+ .map((portal) => normalizeThreadId(portal.getAttribute('data-above-composer-conversation-id')))
+ ));
+ if (portalIds.some((id) => id === null)) return null;
+ const currentIds = new Set(portalIds);
+ if (currentIds.size !== 1 || !currentIds.has(expected)) return null;
+ for (const selected of document.querySelectorAll(
+ '[data-app-action-sidebar-thread-id][data-app-action-sidebar-thread-active="true"], '
+ + '[data-app-action-sidebar-thread-id][aria-current="page"]'
+ )) {
+ const selectedId = normalizeThreadId(selected.getAttribute('data-app-action-sidebar-thread-id'));
+ if (selectedId) currentIds.add(selectedId);
+ }
+ if (currentIds.size !== 1 || !currentIds.has(expected)) return null;
+
+ const surfaces = [...document.querySelectorAll('[data-codex-approval-surface]')].filter(visible);
+ // Multiple cards include background agents and cannot be disambiguated by a
+ // single current-task command. Do not silently choose the first one.
+ if (surfaces.length !== 1) return null;
+ const surface = surfaces[0];
+ const reactKey = Object.getOwnPropertyNames(surface).find((key) => key.startsWith('__reactFiber$'));
+ let fiber = reactKey ? surface[reactKey] : null;
+ const rootOf = (value) => {
+ let root = value;
+ for (let depth = 0; root?.return && depth < 200; depth++) root = root.return;
+ return root?.return ? null : root;
+ };
+ // React may retain the host element's original fiber after a commit. Resolve
+ // its current branch before reading callbacks; the alternate can still carry
+ // the previous request even though the DOM now displays its replacement.
+ let root = rootOf(fiber);
+ if (!root?.stateNode?.current) return null;
+ if (root.stateNode.current !== root) {
+ fiber = fiber?.alternate;
+ root = rootOf(fiber);
+ if (!root || root.stateNode?.current !== root) return null;
+ }
+ let actions = null;
+ let owner = null;
+ for (let depth = 0; fiber && depth < 24; depth += 1, fiber = fiber.return) {
+ const props = fiber.memoizedProps;
+ if (!props || typeof props !== "object") continue;
+ if (!actions && typeof props.actions?.onApprove === "function"
+ && typeof props.actions?.onDeny === "function") actions = props.actions;
+ if (!actions) continue;
+ if (props.item && ["exec", "patch"].includes(props.item.type)
+ && props.item.approvalRequestId != null) {
+ owner = {
+ threadId: normalizeThreadId(props.conversationId),
+ hostId: props.hostId,
+ requestId: props.item.approvalRequestId,
+ kind: props.item.type
+ };
+ break;
+ }
+ if (props.pendingRequest?.permissions
+ && props.pendingRequest.requestId != null && props.conversationId) {
+ owner = {
+ threadId: normalizeThreadId(props.conversationId),
+ hostId: props.hostId,
+ requestId: props.pendingRequest.requestId,
+ kind: "permissionRequest"
+ };
+ break;
+ }
+ // The immediate card owner must identify its request. Never keep walking
+ // through an unknown owner to reuse an unrelated ancestor's pending item.
+ if (props.conversationId || props.item || props.pendingRequest) return null;
+ }
+ if (!owner || owner.threadId !== expected || typeof owner.hostId !== "string"
+ || owner.hostId.length === 0 || actions.isLoading === true
+ || (decision === "approve" && actions.approveDisabled === true) || actions.disableHotkeys === true
+ || actions.approveLabel != null
+ || !((typeof owner.requestId === "string" && owner.requestId.length > 0)
+ || (Number.isSafeInteger(owner.requestId) && owner.requestId >= 0))) return null;
+ const buttons = [...surface.querySelectorAll('button')].filter(visible);
+ // Optional scope and leading buttons have independent disabled states. The
+ // verified card's primary once/deny callbacks use the action state above;
+ // an unrelated disabled secondary button must not disable those controls.
+ if (buttons.length < 2) return null;
+ return { identity: owner, actions, surface };
+}
+
+function approvalRequestExpression(threadId) {
+ if (!THREAD_ID.test(threadId ?? "")) throw new TypeError("A current task UUID is required.");
+ return `(() => {
+ const inspect = ${inspectApprovalSurface.toString()};
+ return inspect(${JSON.stringify(threadId.toLowerCase())})?.identity ?? null;
+ })()`;
+}
+
+function approvalResponseExpression(decision, value) {
+ const identity = normalizeApprovalIdentity(value);
+ if (!["approve", "decline"].includes(decision) || !identity) {
+ throw new TypeError("A decision and exact approval request are required.");
+ }
+ return `(() => {
+ const inspect = ${inspectApprovalSurface.toString()};
+ const expected = ${JSON.stringify(identity)};
+ const candidate = inspect(expected.threadId, ${JSON.stringify(decision)});
+ if (!candidate || candidate.identity.threadId !== expected.threadId
+ || candidate.identity.hostId !== expected.hostId
+ || candidate.identity.kind !== expected.kind
+ || candidate.identity.requestId !== expected.requestId) {
+ return { delivered: false, delivery: 'none', reason: 'changed' };
+ }
+ // Inspection and this exact callback are synchronous in the same renderer
+ // turn, so navigation/request updates cannot retarget the action in between.
+ const action = candidate.actions[${JSON.stringify(decision === "approve" ? "onApprove" : "onDeny")}];
+ action();
+ return { delivered: true };
+ })()`;
+}
+
+module.exports = {
+ ApprovalControls,
+ approvalIdentityKey,
+ approvalRequestExpression,
+ approvalResponseExpression,
+ isApprovalContextCurrent,
+ normalizeApprovalIdentity
+};
diff --git a/src/approval-shortcuts.js b/src/approval-shortcuts.js
new file mode 100644
index 0000000..0c8a1cc
--- /dev/null
+++ b/src/approval-shortcuts.js
@@ -0,0 +1,100 @@
+"use strict";
+
+function nativeReply(value) {
+ try { return JSON.parse(value?.stdout ?? ""); } catch { return null; }
+}
+
+function current(callback) {
+ try { return callback() === true; } catch { return false; }
+}
+
+function captureDiagnostics(reply) {
+ const known = (value, values) => values.includes(value) ? value : "unknown";
+ const scan = reply.scanDiagnostics;
+ return {
+ scanComplete: typeof scan?.complete === "boolean" ? scan.complete : null,
+ visited: Number.isSafeInteger(scan?.visited) && scan.visited >= 0 && scan.visited <= 4096 ? scan.visited : null,
+ scanLimit: known(scan?.limitReason, [null, "depth-limit", "node-limit", "deadline", "text-limit",
+ "accessibility-read", "missing-role", "invalid-children", "children-read", "window-unavailable", "application-unavailable"]),
+ reviewState: reply.review == null ? "absent" : known(reply.review.state, ["ready", "unchecked", "unavailable"]),
+ cardState: reply.approval == null ? "absent" : known(reply.approval.state, ["ready", "unavailable"]),
+ cardReason: known(reply.approval?.reason ?? null, [null, "scan-incomplete", "blocking-surface", "actions-unavailable",
+ "header-unavailable", "card-structure", "controls-unavailable", "untrusted-surface"])
+ };
+}
+
+class ApprovalShortcutControls {
+ constructor({ run }) {
+ this.run = run;
+ this.active = null;
+ }
+
+ async capture(isCurrent = () => true) {
+ if (this.active) return { ok: false, reason: "busy" };
+ const lease = { context: null, attempted: false };
+ this.active = lease;
+ try {
+ if (!current(isCurrent)) return { ok: false, reason: "context-changed" };
+ const reply = nativeReply(await this.run(["codex-approval-context"]));
+ if (!reply || !Number.isSafeInteger(reply.pid) || reply.pid <= 1
+ || typeof reply.token !== "string" || !/^[a-zA-Z0-9:._-]{1,64}$/.test(reply.token)) {
+ return { ok: false, reason: reply?.error ?? "context-unavailable" };
+ }
+ if (!current(isCurrent)) return { ok: false, reason: "context-changed" };
+ lease.context = reply;
+ return { ok: true, lease, diagnostics: captureDiagnostics(reply) };
+ } catch (error) {
+ return { ok: false, reason: nativeReply(error)?.error ?? "context-unavailable" };
+ } finally {
+ if (!lease.context) this.cancel(lease);
+ }
+ }
+
+ cancel(lease) {
+ if (this.active === lease && !lease.attempted) this.active = null;
+ }
+
+ async execute(decision, lease, isCurrent = () => true) {
+ if (!["approve", "decline"].includes(decision) || this.active !== lease
+ || !lease?.context || lease.attempted || !current(isCurrent)) {
+ this.cancel(lease);
+ return { ok: false, reason: "context-changed", delivery: "none" };
+ }
+ const review = lease.context.review;
+ if (review != null && (decision !== "approve" || review.state !== "ready"
+ || typeof review.token !== "string"
+ || !/^r1:[0-9a-f]{64}$/.test(review.token ?? ""))) {
+ this.cancel(lease);
+ return { ok: false, reason: review.state === "unchecked" ? "review-required" : "review-unavailable", delivery: "none" };
+ }
+ const approval = lease.context.approval;
+ if (review == null && (approval?.state !== "ready"
+ || typeof approval.token !== "string"
+ || !/^a1:[0-9a-f]{64}$/.test(approval.token ?? ""))) {
+ this.cancel(lease);
+ return { ok: false, reason: "approval-unavailable", delivery: "none" };
+ }
+ // A native capture identifies the observed card, not a Codex request ID.
+ // Never queue or retry: an uncertain activation may already have acted.
+ lease.attempted = true;
+ const args = review != null
+ ? ["codex-review-continue", String(lease.context.pid), lease.context.token, review.token]
+ : ["codex-approval-card", decision, String(lease.context.pid),
+ lease.context.token, approval.token];
+ try {
+ const reply = nativeReply(await this.run(args));
+ if (reply?.sent === true) return { ok: true, delivery: "sent" };
+ if (reply?.sent === false) return { ok: false, reason: reply.error, delivery: "none" };
+ return { ok: false, reason: "delivery-unknown", delivery: "unknown" };
+ } catch (error) {
+ const reply = nativeReply(error);
+ return reply?.sent === false
+ ? { ok: false, reason: reply.error, delivery: "none" }
+ : { ok: false, reason: "delivery-unknown", delivery: "unknown" };
+ } finally {
+ if (this.active === lease) this.active = null;
+ }
+ }
+}
+
+module.exports = { ApprovalShortcutControls };
diff --git a/src/codex-ipc.js b/src/codex-ipc.js
new file mode 100644
index 0000000..5b30b56
--- /dev/null
+++ b/src/codex-ipc.js
@@ -0,0 +1,380 @@
+"use strict";
+
+const { EventEmitter } = require("node:events");
+const { randomUUID } = require("node:crypto");
+const fs = require("node:fs/promises");
+const net = require("node:net");
+const os = require("node:os");
+const path = require("node:path");
+const { TextDecoder } = require("node:util");
+
+const MAX_FRAME_BYTES = 64 * 1024 * 1024;
+const MAX_PENDING = 4096;
+const MAX_QUEUED_BROADCASTS = 32;
+const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+const NOT_DISPATCHED = new Set([
+ "no-client-found", "request-version-mismatch", "no-handler-for-request"
+]);
+
+class CodexIpcError extends Error {
+ constructor(code, delivery = "none") {
+ super(`Codex IPC: ${code}`);
+ this.name = "CodexIpcError";
+ this.code = code;
+ this.delivery = delivery;
+ }
+}
+
+function timeoutValue(value) {
+ if (!Number.isInteger(value) || value < 1 || value > 60000) {
+ throw new CodexIpcError("invalid-timeout");
+ }
+ return value;
+}
+
+// Read-only validation: never create, replace, repair, or change socket permissions.
+async function validateCodexSocket(socketPath) {
+ if (!path.isAbsolute(socketPath) || typeof process.getuid !== "function") {
+ throw new CodexIpcError("untrusted-socket");
+ }
+ try {
+ const [parent, socket] = await Promise.all([
+ fs.lstat(path.dirname(socketPath)), fs.lstat(socketPath)
+ ]);
+ const uid = process.getuid();
+ if (!parent.isDirectory() || parent.uid !== uid || (parent.mode & 0o022) !== 0 ||
+ !socket.isSocket() || socket.uid !== uid) {
+ throw new CodexIpcError("untrusted-socket");
+ }
+ return { dev: socket.dev, ino: socket.ino, parentDev: parent.dev, parentIno: parent.ino };
+ } catch (error) {
+ if (error instanceof CodexIpcError) throw error;
+ throw new CodexIpcError("socket-unavailable");
+ }
+}
+
+function validateMethod(method, version) {
+ if (typeof method !== "string" || !method.length || method.length > 256 ||
+ !Number.isSafeInteger(version) || version < 0) {
+ throw new CodexIpcError("invalid-request");
+ }
+}
+
+function sameSocketIdentity(before, after) {
+ return ["dev", "ino", "parentDev", "parentIno"].every(key => before?.[key] === after?.[key]);
+}
+
+/**
+ * Bounded client for the desktop's versioned IPC protocol. A connection must be
+ * explicitly established; request/broadcast never reconnect or retry a decision.
+ * request resolves the correlated full response, including server errors with a
+ * delivery classification. Timeouts/disconnects after writing throw with unknown
+ * delivery. None of these transport acknowledgments prove application success.
+ */
+class CodexIpcClient extends EventEmitter {
+ constructor({
+ socketPath = path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "ipc", "ipc.sock"),
+ connectSocket = file => net.createConnection(file),
+ validateSocket = validateCodexSocket,
+ timeoutMs = 2500,
+ maxFrameBytes = MAX_FRAME_BYTES
+ } = {}) {
+ super();
+ if (typeof socketPath !== "string" || !path.isAbsolute(socketPath) ||
+ typeof connectSocket !== "function" || typeof validateSocket !== "function") {
+ throw new CodexIpcError("invalid-options");
+ }
+ if (!Number.isSafeInteger(maxFrameBytes) || maxFrameBytes < 1 || maxFrameBytes > MAX_FRAME_BYTES) {
+ throw new CodexIpcError("invalid-frame-limit");
+ }
+ this.socketPath = socketPath;
+ this.timeoutMs = timeoutValue(timeoutMs);
+ this.maxFrameBytes = maxFrameBytes;
+ this.epoch = 0;
+ this.clientId = null;
+ this._connectSocket = connectSocket;
+ this._validateSocket = validateSocket;
+ this._state = null;
+ this._connecting = null;
+ }
+
+ isReady() {
+ return Boolean(this._state?.ready && !this._state.socket?.destroyed && this.clientId);
+ }
+
+ connect() {
+ if (this.isReady()) return Promise.resolve(this);
+ if (this._connecting) return this._connecting;
+ const state = {
+ epoch: ++this.epoch, socket: null, ready: false, ended: false,
+ pending: new Map(), header: Buffer.alloc(4), headerBytes: 0,
+ body: null, bodyBytes: 0, broadcasts: [], broadcastBytes: 0,
+ rejectConnect: null, connectTimer: null
+ };
+ this._state = state;
+ let resolveConnect;
+ const promise = new Promise((resolve, reject) => {
+ resolveConnect = resolve;
+ state.rejectConnect = reject;
+ });
+ this._connecting = promise;
+ state.connectTimer = setTimeout(() => this._end(state, "connect-timeout"), this.timeoutMs);
+ this._open(state).then(() => {
+ if (state.ended || this._state !== state) return;
+ clearTimeout(state.connectTimer);
+ state.rejectConnect = null;
+ state.ready = true;
+ this._connecting = null;
+ resolveConnect(this);
+ const broadcasts = state.broadcasts;
+ state.broadcasts = [];
+ state.broadcastBytes = 0;
+ // Let connect callers install their listeners before replaying early frames.
+ queueMicrotask(() => {
+ for (const message of broadcasts) {
+ if (this._state !== state || state.ended) break;
+ this._broadcastReceived(state, message, 0);
+ }
+ });
+ }).catch(error => {
+ this._end(state, error instanceof CodexIpcError ? error.code : "connect-failed");
+ });
+ return promise;
+ }
+
+ async _open(state) {
+ const identity = await this._validateSocket(this.socketPath);
+ if (identity === false) throw new CodexIpcError("untrusted-socket");
+ if (this._state !== state || state.ended) return;
+ const socket = this._connectSocket(this.socketPath);
+ if (!socket || typeof socket.on !== "function" || typeof socket.write !== "function" ||
+ typeof socket.destroy !== "function") throw new CodexIpcError("invalid-socket");
+ state.socket = socket;
+ await new Promise((resolve, reject) => {
+ state.rejectOpening = reject;
+ socket.once("connect", () => {
+ if (this._state !== state || state.ended) return;
+ state.rejectOpening = null;
+ resolve();
+ });
+ socket.on("data", chunk => {
+ if (this._state === state && !state.ended) this._read(state, chunk);
+ });
+ socket.on("error", () => this._end(state, "socket-error"));
+ socket.on("end", () => this._end(state, "disconnected"));
+ socket.on("close", () => this._end(state, "disconnected"));
+ });
+ if (this._state !== state || state.ended) return;
+ const after = await this._validateSocket(this.socketPath);
+ if (after === false || !sameSocketIdentity(identity, after)) {
+ throw new CodexIpcError("socket-changed");
+ }
+ if (this._state !== state || state.ended) return;
+ const response = await this._request(state, "initialize", { clientType: "threaddeck" }, {}, true);
+ if (response.resultType !== "success") throw new CodexIpcError(response.error, response.delivery);
+ if (!UUID.test(response.result?.clientId || "")) throw new CodexIpcError("invalid-initialize");
+ this.clientId = response.result.clientId;
+ }
+
+ request(method, params, options = {}) {
+ try {
+ if (!this.isReady()) throw new CodexIpcError("not-connected");
+ return this._request(this._state, method, params, options);
+ } catch (error) {
+ return Promise.reject(error);
+ }
+ }
+
+ _request(state, method, params, { version = 0, targetClientId, hostId, timeoutMs = this.timeoutMs } = {}, initializing = false) {
+ validateMethod(method, version);
+ timeoutValue(timeoutMs);
+ if (targetClientId !== undefined && (typeof targetClientId !== "string" || !UUID.test(targetClientId))) {
+ throw new CodexIpcError("invalid-target");
+ }
+ if (hostId !== undefined && (typeof hostId !== "string" || !hostId.length || hostId.length > 256)) {
+ throw new CodexIpcError("invalid-host");
+ }
+ if (state.ended || !state.socket || (!initializing && !state.ready)) throw new CodexIpcError("not-connected");
+ if (state.pending.size >= MAX_PENDING) throw new CodexIpcError("pending-limit");
+ const requestId = randomUUID();
+ const message = {
+ type: "request", requestId, sourceClientId: initializing ? "initializing-client" : this.clientId,
+ version, method, params, timeoutMs,
+ ...(targetClientId === undefined ? {} : { targetClientId }),
+ ...(hostId === undefined ? {} : { hostId })
+ };
+ const frame = this._encode(message);
+ this._checkWriteCapacity(state, frame);
+ return new Promise((resolve, reject) => {
+ const pending = { method, targetClientId, resolve, reject, sent: false, timer: null };
+ pending.timer = setTimeout(() => {
+ if (state.pending.get(requestId) !== pending) return;
+ state.pending.delete(requestId);
+ reject(new CodexIpcError("request-timeout", pending.sent ? "unknown" : "none"));
+ }, timeoutMs);
+ state.pending.set(requestId, pending);
+ // Set before write: a throwing or callback-failing write may have emitted bytes.
+ pending.sent = true;
+ this._write(state, frame);
+ });
+ }
+
+ broadcast(method, params, { version = 0, targetClientIds } = {}) {
+ if (!this.isReady()) throw new CodexIpcError("not-connected");
+ validateMethod(method, version);
+ if (targetClientIds !== undefined && (!Array.isArray(targetClientIds) || targetClientIds.length > MAX_PENDING ||
+ targetClientIds.some(id => typeof id !== "string" || !UUID.test(id)))) {
+ throw new CodexIpcError("invalid-target");
+ }
+ const frame = this._encode({
+ type: "broadcast", method, version, sourceClientId: this.clientId, params,
+ ...(targetClientIds === undefined ? {} : { targetClientIds })
+ });
+ const state = this._state;
+ this._checkWriteCapacity(state, frame);
+ this._write(state, frame);
+ if (state.ended) throw new CodexIpcError("socket-write-failed", "unknown");
+ return true;
+ }
+
+ _encode(message) {
+ let body;
+ try { body = Buffer.from(JSON.stringify(message), "utf8"); }
+ catch { throw new CodexIpcError("invalid-json"); }
+ if (!body.length || body.length > this.maxFrameBytes) throw new CodexIpcError("frame-limit");
+ const frame = Buffer.allocUnsafe(body.length + 4);
+ frame.writeUInt32LE(body.length, 0);
+ body.copy(frame, 4);
+ return frame;
+ }
+
+ _write(state, frame) {
+ if (state.ended || this._state !== state) return;
+ try {
+ this._checkWriteCapacity(state, frame);
+ state.socket.write(frame, error => {
+ if (error) this._end(state, "socket-write-failed");
+ });
+ } catch { this._end(state, "socket-write-failed"); }
+ }
+
+ _checkWriteCapacity(state, frame) {
+ // Bound socket backpressure as well as individual frames and request count.
+ // A refusal here is known to precede any write of this particular message.
+ const queued = state.socket?.writableLength || 0;
+ if (queued + frame.length > this.maxFrameBytes + 4) throw new CodexIpcError("write-queue-limit");
+ }
+
+ _read(state, chunk) {
+ if (!Buffer.isBuffer(chunk)) { this._end(state, "invalid-frame"); return; }
+ let offset = 0;
+ while (offset < chunk.length && !state.ended) {
+ if (!state.body) {
+ const count = Math.min(4 - state.headerBytes, chunk.length - offset);
+ chunk.copy(state.header, state.headerBytes, offset, offset + count);
+ state.headerBytes += count;
+ offset += count;
+ if (state.headerBytes !== 4) continue;
+ const length = state.header.readUInt32LE(0);
+ if (length < 1 || length > this.maxFrameBytes) { this._end(state, "frame-limit"); return; }
+ state.body = Buffer.allocUnsafe(length);
+ state.bodyBytes = 0;
+ }
+ const count = Math.min(state.body.length - state.bodyBytes, chunk.length - offset);
+ chunk.copy(state.body, state.bodyBytes, offset, offset + count);
+ state.bodyBytes += count;
+ offset += count;
+ if (state.bodyBytes !== state.body.length) continue;
+ const body = state.body;
+ state.body = null;
+ state.bodyBytes = 0;
+ state.headerBytes = 0;
+ let message;
+ try { message = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body)); }
+ catch { this._end(state, "invalid-json"); return; }
+ if (!message || typeof message !== "object" || Array.isArray(message)) {
+ this._end(state, "invalid-message"); return;
+ }
+ this._message(state, message, body.length);
+ }
+ }
+
+ _message(state, message, bytes) {
+ if (message.type === "response") {
+ const pending = state.pending.get(message.requestId);
+ if (!pending) return; // Expired/previous-generation responses never resolve a new request.
+ if ((message.resultType !== "success" && message.resultType !== "error") ||
+ (message.resultType === "success" && (message.method !== pending.method || !UUID.test(message.handledByClientId || ""))) ||
+ (message.method !== undefined && message.method !== pending.method) ||
+ (message.handledByClientId !== undefined && pending.targetClientId !== undefined && message.handledByClientId !== pending.targetClientId) ||
+ (message.resultType === "error" && (typeof message.error !== "string" || !message.error.length || message.error.length > 256))) {
+ this._end(state, "invalid-response"); return;
+ }
+ state.pending.delete(message.requestId);
+ clearTimeout(pending.timer);
+ pending.resolve(message.resultType === "error"
+ ? { ...message, delivery: NOT_DISPATCHED.has(message.error) ? "none" : "unknown" }
+ : message);
+ } else if (message.type === "broadcast") {
+ this._broadcastReceived(state, message, bytes);
+ } else if (message.type === "client-discovery-request") {
+ if (!UUID.test(message.requestId || "")) { this._end(state, "invalid-discovery"); return; }
+ this._write(state, this._encode({
+ type: "client-discovery-response", requestId: message.requestId, response: { canHandle: false }
+ }));
+ }
+ // This client executes no incoming request or advertised handler.
+ }
+
+ _broadcastReceived(state, message, bytes) {
+ if (typeof message.method !== "string" || !message.method.length || message.method.length > 256 ||
+ !Number.isSafeInteger(message.version) || message.version < 0 || !UUID.test(message.sourceClientId || "") ||
+ (message.targetClientIds !== undefined && (!Array.isArray(message.targetClientIds) ||
+ message.targetClientIds.length > MAX_PENDING || message.targetClientIds.some(id => typeof id !== "string" || !UUID.test(id))))) {
+ this._end(state, "invalid-broadcast"); return;
+ }
+ if (!state.ready) {
+ if (state.broadcasts.length >= MAX_QUEUED_BROADCASTS || state.broadcastBytes + bytes > this.maxFrameBytes) {
+ this._end(state, "broadcast-queue-limit"); return;
+ }
+ state.broadcasts.push(message);
+ state.broadcastBytes += bytes;
+ return;
+ }
+ if (message.targetClientIds !== undefined && !message.targetClientIds.includes(this.clientId)) return;
+ this.emit("broadcast", message);
+ }
+
+ _end(state, code) {
+ if (state.ended) return;
+ state.ended = true;
+ state.ready = false;
+ clearTimeout(state.connectTimer);
+ state.rejectOpening?.(new CodexIpcError(code));
+ state.rejectOpening = null;
+ state.rejectConnect?.(new CodexIpcError(code));
+ state.rejectConnect = null;
+ for (const pending of state.pending.values()) {
+ clearTimeout(pending.timer);
+ pending.reject(new CodexIpcError(code, pending.sent ? "unknown" : "none"));
+ }
+ state.pending.clear();
+ state.body = null;
+ state.broadcasts = [];
+ state.broadcastBytes = 0;
+ if (this._state === state) {
+ this._state = null;
+ this._connecting = null;
+ this.clientId = null;
+ this.epoch++;
+ }
+ state.socket?.destroy();
+ this.emit("disconnect", new CodexIpcError(code));
+ }
+
+ close() {
+ if (this._state) this._end(this._state, "closed");
+ }
+}
+
+module.exports = { CodexIpcClient, CodexIpcError, validateCodexSocket };
diff --git a/src/config.js b/src/config.js
index 11d497a..088b79a 100644
--- a/src/config.js
+++ b/src/config.js
@@ -13,6 +13,7 @@ const ACTIONS = {
thread8: "com.yechan.threaddeck.thread8",
sideChat: "com.yechan.threaddeck.sidechat",
newThread: "com.yechan.threaddeck.newthread",
+ taskActions: "com.yechan.threaddeck.taskactions",
voice: "com.yechan.threaddeck.voice",
send: "com.yechan.threaddeck.send",
fastMode: "com.yechan.threaddeck.fastmode",
diff --git a/src/i18n.js b/src/i18n.js
index c28fe4f..0912666 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -84,6 +84,17 @@ const MESSAGES = Object.freeze({
"activity.reopen": "Open again",
"activity.last": "Last activity",
"activity.waiting": "Waiting",
+ "approval.approve": "Approve",
+ "approval.decline": "Decline",
+ "approval.unavailable": "No approval",
+ "approval.changed": "Check request",
+ "approval.sent": "Sent",
+ "approval.multiple": "Choose request",
+ "approval.task": "Choose task",
+ "approval.current": "Current dialog",
+ "approval.review": "Review first",
+ "approval.permission": "Permission",
+ "approval.focus": "Open Codex",
"voice.recording": "Listening",
"voice.preparing": "Switching",
"voice.transcribing": "Transcribing",
@@ -205,6 +216,17 @@ const MESSAGES = Object.freeze({
"activity.reopen": "다시 열기",
"activity.last": "마지막 활동",
"activity.waiting": "작업 대기",
+ "approval.approve": "승인",
+ "approval.decline": "거절",
+ "approval.unavailable": "승인 요청 없음",
+ "approval.changed": "요청 확인",
+ "approval.sent": "전송됨",
+ "approval.multiple": "요청 선택",
+ "approval.task": "작업 선택",
+ "approval.current": "현재 대화상자",
+ "approval.review": "먼저 검토",
+ "approval.permission": "권한 필요",
+ "approval.focus": "Codex 열기",
"voice.recording": "말하는 중",
"voice.preparing": "전환 준비",
"voice.transcribing": "받아쓰기 중",
@@ -326,6 +348,17 @@ const MESSAGES = Object.freeze({
"activity.reopen": "Открыть",
"activity.last": "Последнее",
"activity.waiting": "Ожидание",
+ "approval.approve": "Одобрить",
+ "approval.decline": "Отклонить",
+ "approval.unavailable": "Нет запроса",
+ "approval.changed": "Проверить",
+ "approval.sent": "Отправлено",
+ "approval.multiple": "Выбрать запрос",
+ "approval.task": "Выбрать задачу",
+ "approval.current": "Текущий диалог",
+ "approval.review": "Проверить",
+ "approval.permission": "Доступ",
+ "approval.focus": "Открыть Codex",
"voice.recording": "Слушаю",
"voice.preparing": "Переключаю",
"voice.transcribing": "Распознаю",
diff --git a/src/ipc-approvals.js b/src/ipc-approvals.js
new file mode 100644
index 0000000..281b324
--- /dev/null
+++ b/src/ipc-approvals.js
@@ -0,0 +1,293 @@
+"use strict";
+
+const { createHash } = require("node:crypto");
+
+const UUID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i;
+const METHODS = {
+ "item/commandExecution/requestApproval": "thread-follower-command-approval-decision",
+ "item/fileChange/requestApproval": "thread-follower-file-approval-decision",
+ "item/permissions/requestApproval": "thread-follower-permissions-request-approval-response",
+ "mcpServer/elicitation/request": "thread-follower-submit-mcp-server-elicitation-response"
+};
+const failure = (reason, delivery = "none") => ({ ok: false, reason, delivery });
+const current = (fn) => { try { return fn() === true; } catch { return false; } };
+const verifiedTarget = async (fn) => { try { return (await fn()) === true; } catch { return false; } };
+const plain = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
+const requestId = (v) => (Number.isSafeInteger(v) && v >= 0)
+ || (typeof v === "string" && v.length > 0 && v.length <= 256 && !/[\x00-\x1f]/.test(v));
+const fingerprint = (v) => createHash("sha256").update(JSON.stringify(v)).digest("hex");
+
+function approvalRequest(request, threadId) {
+ if (!plain(request) || !requestId(request.id) || !Object.hasOwn(METHODS, request.method)
+ || !plain(request.params) || request.params.threadId !== threadId
+ || typeof request.params.turnId !== "string" || !request.params.turnId) return null;
+ const elicitation = request.method === "mcpServer/elicitation/request";
+ if (!elicitation && (typeof request.params.itemId !== "string" || !request.params.itemId)) return null;
+ if (elicitation) {
+ const schema = request.params.requestedSchema;
+ // A simple tool approval needs no form values or acknowledgment checkbox.
+ // Authentication, URL flows, general forms and extended forms stay manual.
+ if (request.params.mode !== "form" || request.params._meta?.codex_approval_kind !== "mcp_tool_call"
+ || !plain(schema) || schema.type !== "object" || !plain(schema.properties)
+ || Object.keys(schema.properties).length !== 0
+ || (schema.required != null && (!Array.isArray(schema.required) || schema.required.length !== 0))
+ || Object.keys(schema).some((k) => !["type", "properties", "required", "additionalProperties", "$schema", "title", "description"].includes(k))) return null;
+ }
+ if (request.method === "item/permissions/requestApproval" && !plain(request.params.permissions)) return null;
+ if (request.params.availableDecisions != null && (!Array.isArray(request.params.availableDecisions)
+ || !request.params.availableDecisions.every((d) => typeof d === "string" || plain(d)))) return null;
+ return { threadId, requestId: request.id, method: request.method,
+ turnId: request.params.turnId, itemId: request.params.itemId ?? null,
+ ...(request.method === "item/permissions/requestApproval" ? { permissions: request.params.permissions } : {}),
+ availableDecisions: request.params.availableDecisions,
+ fingerprint: fingerprint(request) };
+}
+
+function projectSnapshot(value, threadId) {
+ if (!plain(value) || value.id !== threadId || value.hostId !== "local"
+ || !Array.isArray(value.requests) || value.requests.length > 128
+ || Buffer.byteLength(JSON.stringify(value.requests)) > 256 * 1024) return null;
+ const seen = new Set();
+ for (const r of value.requests) {
+ if (!plain(r) || !requestId(r.id) || typeof r.method !== "string") return null;
+ const key = JSON.stringify(r.id);
+ if (seen.has(key)) return null;
+ seen.add(key);
+ }
+ // Retain only the pending requests; conversation content is never cached or
+ // logged by this adapter. Unsupported requests still count as ambiguity.
+ return { requests: value.requests, signature: fingerprint(value.requests) };
+}
+
+function validContext(value) {
+ return value?.ok === true && Number.isSafeInteger(value.pid) && value.pid > 1
+ && typeof value.token === "string" && /^api1:\d+:\d+:\d+:\d+$/.test(value.token);
+}
+
+// An explicitly selected task is the target. The desktop IPC protocol has no
+// complete inventory of ephemeral/hidden tasks, so this adapter deliberately
+// does not infer a global singleton from saved threads or quiet broadcasts.
+class IpcApprovalControls {
+ constructor({ client, readContext, timeoutMs = 2000 }) {
+ this.client = client;
+ this.readContext = readContext;
+ this.timeoutMs = timeoutMs;
+ this.active = null;
+ this.attempted = new Set();
+ this.record = null;
+ this.waiter = null;
+ client.on("broadcast", (m) => this.observe(m));
+ client.on("disconnect", () => {
+ this.record = null;
+ this.rejectWaiter("connection-changed");
+ });
+ }
+
+ rejectWaiter(reason) {
+ this.waiter?.reject(Object.assign(new Error(reason), { code: reason }));
+ }
+
+ observe(message) {
+ const record = this.record;
+ if (!record) return;
+ if (message.method === "client-status-changed" && message.params?.clientId === record.owner
+ && message.params.status === "disconnected") {
+ record.dirty = true;
+ this.rejectWaiter("owner-changed");
+ return;
+ }
+ if (message.method !== "thread-stream-state-changed"
+ || message.sourceClientId !== record.owner
+ || message.params?.conversationId !== record.threadId || message.params.hostId !== "local") return;
+ const change = message.params.change;
+ if (message.version !== 11 || !plain(change) || !Number.isSafeInteger(change.revision) || change.revision < 0) {
+ record.dirty = true;
+ this.rejectWaiter("unsupported-version");
+ return;
+ }
+ if (change.type === "snapshot") {
+ const projected = projectSnapshot(change.conversationState, record.threadId);
+ if (!projected || change.revision < record.revision
+ || (change.revision === record.revision && !record.dirty && record.value
+ && record.value.signature !== projected.signature)) {
+ record.dirty = true;
+ this.rejectWaiter("state-changed");
+ return;
+ }
+ record.revision = change.revision;
+ record.value = projected;
+ record.dirty = false;
+ this.waiter?.resolve(projected);
+ return;
+ }
+ if (change.type !== "patches" || change.baseRevision !== record.revision
+ || change.revision <= change.baseRevision || !Array.isArray(change.patches)) {
+ record.dirty = true;
+ return;
+ }
+ record.revision = change.revision;
+ for (const patch of change.patches) {
+ if (!plain(patch) || !["add", "remove", "replace"].includes(patch.op)
+ || !Array.isArray(patch.path) || patch.path.length === 0 || patch.path[0] === "requests") {
+ // Refresh instead of applying an unfamiliar/root/request patch to a
+ // partial conversation projection. A gap cannot make old data ready.
+ record.dirty = true;
+ }
+ }
+ }
+
+ isCurrent(lease, isCurrent) {
+ return this.active === lease && this.client.isReady() && this.client.epoch === lease.epoch
+ && current(isCurrent);
+ }
+
+ async owner(threadId, targetClientId) {
+ const reply = await this.client.request("thread-owner-discovery", { conversationId: threadId, hostId: "local" },
+ { version: 1, targetClientId, timeoutMs: this.timeoutMs });
+ if (reply.resultType !== "success" || !UUID.test(reply.handledByClientId ?? "")) throw Error("owner-unavailable");
+ return reply.handledByClientId;
+ }
+
+ snapshot(lease) {
+ if (this.waiter || this.record?.owner !== lease.owner || this.record?.threadId !== lease.threadId) {
+ return Promise.reject(Error("state-changed"));
+ }
+ return new Promise((resolve, reject) => {
+ let timer;
+ const waiter = {
+ resolve: (value) => { cleanup(); resolve(value); },
+ reject: (error) => { cleanup(); reject(error); }
+ };
+ const cleanup = () => { clearTimeout(timer); if (this.waiter === waiter) this.waiter = null; };
+ this.waiter = waiter;
+ timer = setTimeout(() => waiter.reject(Error("snapshot-timeout")), this.timeoutMs);
+ try {
+ this.client.broadcast("thread-stream-following-changed",
+ { conversationId: lease.threadId, hostId: "local", following: true },
+ { version: 1, targetClientIds: [lease.owner] });
+ } catch (error) { waiter.reject(error); }
+ });
+ }
+
+ async capture(threadId, isCurrent = () => true, verifyTarget = async () => true) {
+ if (this.active) return failure("busy");
+ if (!UUID.test(threadId ?? "") || !current(isCurrent)) return failure("no-selected-task");
+ const lease = { threadId, attempted: false, verifyTarget };
+ this.active = lease;
+ try {
+ const context = await this.readContext();
+ if (!validContext(context)) return failure(context?.error ?? "context-unavailable");
+ if (!await verifiedTarget(lease.verifyTarget)) return failure("target-changed");
+ if (!current(isCurrent)) return failure("context-changed");
+ lease.context = context;
+ try { await this.client.connect(); } catch {
+ return { ...failure("ipc-unavailable"), canFallback: true };
+ }
+ lease.epoch = this.client.epoch;
+ if (!this.isCurrent(lease, isCurrent)) return failure("context-changed");
+ lease.owner = await this.owner(threadId);
+ if (!this.isCurrent(lease, isCurrent)) return failure("context-changed");
+ this.record = { owner: lease.owner, threadId, revision: -1, dirty: true, value: null };
+ const value = await this.snapshot(lease);
+ if (!this.isCurrent(lease, isCurrent)) return failure("context-changed");
+ if (value.requests.length === 0) return failure("no-requests");
+ if (value.requests.length !== 1) return failure("multiple-requests");
+ const request = approvalRequest(value.requests[0], threadId);
+ if (!request) return failure("unsupported-request");
+ lease.request = request;
+ lease.key = JSON.stringify([context.token, threadId, request.method, request.requestId, request.turnId, request.itemId]);
+ if (this.attempted.has(lease.key)) return failure("already-handled");
+ if (this.attempted.size >= 4096) return failure("attempt-limit");
+ lease.captured = true;
+ return { ok: true, lease };
+ } catch { return failure("ipc-state-unavailable"); }
+ finally { if (!lease.captured) this.cancel(lease); }
+ }
+
+ cancel(lease) {
+ if (this.active !== lease || lease?.attempted) return;
+ this.release(lease);
+ }
+
+ release(lease) {
+ if (this.active !== lease) return;
+ this.rejectWaiter("cancelled");
+ if (lease.owner && this.client.isReady() && this.client.epoch === lease.epoch) {
+ try {
+ this.client.broadcast("thread-stream-following-changed",
+ { conversationId: lease.threadId, hostId: "local", following: false },
+ { version: 1, targetClientIds: [lease.owner] });
+ } catch { /* Disconnect removes the follower subscription at the router. */ }
+ }
+ this.record = null;
+ this.active = null;
+ }
+
+ async execute(decision, lease, isCurrent = () => true) {
+ if (lease?.executing) return failure("busy");
+ if (!["approve", "decline"].includes(decision) || !lease?.captured || lease.attempted
+ || !this.isCurrent(lease, isCurrent)) {
+ this.cancel(lease);
+ return failure("context-changed");
+ }
+ lease.executing = true;
+ const wireDecision = decision === "approve" ? "accept" : "decline";
+ try {
+ if (lease.request.availableDecisions && !lease.request.availableDecisions.includes(wireDecision)) return failure("unsupported-decision");
+ if (await this.owner(lease.threadId, lease.owner) !== lease.owner) return failure("owner-changed");
+ const fresh = await this.snapshot(lease);
+ if (!this.isCurrent(lease, isCurrent) || fresh.requests.length !== 1
+ || fingerprint(fresh.requests[0]) !== lease.request.fingerprint) return failure("request-changed");
+ const context = await this.readContext();
+ if (!validContext(context) || context.pid !== lease.context.pid || context.token !== lease.context.token
+ || !this.isCurrent(lease, isCurrent) || this.record?.dirty) return failure("context-changed");
+ if (!await verifiedTarget(lease.verifyTarget)) return failure("target-changed");
+ if (!this.isCurrent(lease, isCurrent) || this.record?.dirty) return failure("context-changed");
+ // Session and selected-task reads yield to IPC messages. A full snapshot can
+ // replace `fresh` without setting dirty, so validate the latest state
+ // again immediately before committing to this exact request.
+ const latest = this.record;
+ if (latest?.owner !== lease.owner || latest.threadId !== lease.threadId
+ || latest.value?.requests.length !== 1
+ || fingerprint(latest.value.requests[0]) !== lease.request.fingerprint) return failure("request-changed");
+ lease.attempted = true;
+ this.attempted.add(lease.key);
+ const method = METHODS[lease.request.method];
+ let response;
+ try {
+ const params = { conversationId: lease.threadId, requestId: lease.request.requestId };
+ if (lease.request.method === "item/permissions/requestApproval") {
+ params.response = { permissions: decision === "approve" ? lease.request.permissions : {}, scope: "turn" };
+ } else if (lease.request.method === "mcpServer/elicitation/request") {
+ params.response = { action: wireDecision, content: decision === "approve" ? {} : null };
+ } else params.decision = wireDecision;
+ response = await this.client.request(method, params,
+ { version: 1, targetClientId: lease.owner, timeoutMs: this.timeoutMs });
+ } catch { return failure("delivery-unknown", "unknown"); }
+ if (response.resultType === "error" && response.delivery === "none") {
+ this.attempted.delete(lease.key);
+ return failure("request-changed");
+ }
+ if (response.resultType !== "success" || response.method !== method
+ || response.handledByClientId !== lease.owner || response.result?.ok !== true
+ || !this.isCurrent(lease, isCurrent)) return failure("delivery-unknown", "unknown");
+ try {
+ const after = await this.snapshot(lease);
+ if (after.requests.some((r) => r.id === lease.request.requestId)) return failure("delivery-unknown", "unknown");
+ } catch { return failure("delivery-unknown", "unknown"); }
+ // The owner removes requests optimistically. This acknowledges owner-side
+ // handling, not backend completion; retain the same blue Sent feedback.
+ return { ok: true, delivery: "owner-accepted" };
+ } catch { return failure(lease.attempted ? "delivery-unknown" : "ipc-state-unavailable", lease.attempted ? "unknown" : "none"); }
+ finally { this.release(lease); }
+ }
+
+ close() {
+ this.rejectWaiter("closed");
+ this.client.close();
+ this.record = null;
+ this.active = null;
+ }
+}
+
+module.exports = { IpcApprovalControls, approvalRequest, projectSnapshot };
diff --git a/src/micro-cdp.js b/src/micro-cdp.js
index 6495372..1e0caea 100644
--- a/src/micro-cdp.js
+++ b/src/micro-cdp.js
@@ -15,6 +15,7 @@ const { promisify } = require("node:util");
const { CodexMainInspectorEvaluator } = require("./micro-main-inspector");
const { CodexPreparedRendererBridge } = require("./micro-prepared-bridge");
+const { approvalRequestExpression, approvalResponseExpression, isApprovalContextCurrent, normalizeApprovalIdentity } = require("./approval-controls");
const execFileAsync = promisify(execFile);
const DEFAULT_STATE_PATH = path.join(
@@ -1066,6 +1067,12 @@ class CodexMicroBridge {
this.evaluationNamespace = `${process.pid}-${Math.random().toString(36).slice(2)}`;
}
+ isReady() {
+ return Boolean(this.socket && Number.isInteger(this.WebSocket?.OPEN)
+ && this.socket.readyState === this.WebSocket.OPEN)
+ || this.preparedBridge.isReady() === true;
+ }
+
async refreshReadOnly() {
try {
await this.ensureConnected();
@@ -1105,6 +1112,36 @@ class CodexMicroBridge {
}
}
+ async readApprovalRequest(threadId) {
+ const expression = approvalRequestExpression(threadId);
+ try {
+ await this.ensureConnected();
+ return normalizeApprovalIdentity(await this.evaluate(expression));
+ } catch (error) {
+ throw this.normalizeError(error, "approval request inspection");
+ }
+ }
+
+ async respondToApproval(decision, request, options = {}) {
+ const expression = approvalResponseExpression(decision, request);
+ // Reuse the connection established by request inspection. Reconnecting,
+ // activating Micro, or using runKeycap could change the active request.
+ if (!isApprovalContextCurrent(options.isCurrent)) {
+ return { delivered: false, delivery: "none", reason: "cancelled" };
+ }
+ // Every error after this boundary has potentially unknown delivery. Never
+ // reclassify it as cancelled/undelivered if the context changes afterwards.
+ try {
+ return await this.evaluate(expression);
+ } catch (error) {
+ throw new MicroBridgeError("Codex approval delivery could not be confirmed.", {
+ code: "MICRO_APPROVAL_DELIVERY_UNKNOWN",
+ delivery: "unknown",
+ cause: error
+ });
+ }
+ }
+
async activateRuntime() {
if (this.runtimeActivated) return true;
try {
diff --git a/src/plugin.js b/src/plugin.js
index 159956c..bce61eb 100644
--- a/src/plugin.js
+++ b/src/plugin.js
@@ -63,6 +63,10 @@ const {
recordRemoteComposerStateObservation,
} = require("./remote-state");
const { selectTopThreadRows: selectThreadRows } = require("./thread-selection");
+const { ApprovalControls } = require("./approval-controls");
+const { ApprovalShortcutControls } = require("./approval-shortcuts");
+const { CodexIpcClient } = require("./codex-ipc");
+const { IpcApprovalControls } = require("./ipc-approvals");
const { isInternalThreadRecord } = require("./thread-privacy");
const {
parseCodexQueueWindows,
@@ -102,6 +106,7 @@ const {
const { readInstalledProfilePageState } = require("./streamdeck-profile-state");
const {
codexCommandFromSettings,
+ taskActionFromSettings,
taskSlotFromSettings
} = require("./action-settings");
const {
@@ -251,12 +256,20 @@ const RUNTIME_TRACE_FIELDS = new Set([
"accessibility",
"postEvent",
"codexAccess",
- "issue"
+ "issue",
+ "scanComplete",
+ "visited",
+ "scanLimit",
+ "reviewState",
+ "cardState",
+ "cardReason",
+ "decision"
]);
const CURRENT_THREAD_AWARE_ACTIONS = new Set([
ACTIONS.thread1,
ACTIONS.sideChat,
ACTIONS.newThread,
+ ACTIONS.taskActions,
ACTIONS.voice,
ACTIONS.send,
ACTIONS.fastMode,
@@ -363,6 +376,7 @@ const refreshResilienceContractMode = process.argv.includes("--verify-refresh-re
const usageCacheContractMode = process.argv.includes("--verify-usage-cache");
const voiceSubmitContractMode = process.argv.includes("--verify-voice-submit");
const interactionContractMode = process.argv.includes("--verify-interactions");
+const approvalContractMode = process.argv.includes("--verify-approvals");
const keyBridgePermissionContractMode = process.argv.includes("--verify-keybridge-permission");
const demoOutput = argument("--render-demo");
const demoLightOutput = argument("--render-demo-light");
@@ -379,6 +393,7 @@ const runtimeTraceEnabled = !snapshotMode
&& !usageCacheContractMode
&& !voiceSubmitContractMode
&& !interactionContractMode
+ && !approvalContractMode
&& !keyBridgePermissionContractMode
&& !demoOutput
&& !demoLightOutput
@@ -488,6 +503,8 @@ let activeAppearanceRefresh = null;
// independently selectable Current Task action resolves through
// `primaryThreadRow`, so switching tasks never silently renumbers the list.
let threadSlots = Array(THREAD_COUNT).fill(null);
+const approvalPressByContext = new Map();
+let approvalOperation = null;
let primaryThreadId = null;
let primaryThreadRow = null;
let usageState = { remaining: null, failed: false };
@@ -820,6 +837,7 @@ function threadForContext(context) {
}
function codexCommandForContext(context, action = contexts.get(context)) {
+ if (action === ACTIONS.taskActions) return taskActionFromSettings(settingsForContext(context));
if (action === ACTIONS.sideChat) return "side-chat";
if (action === ACTIONS.send) return "send";
if (action === ACTIONS.newThread) {
@@ -874,6 +892,24 @@ const codexMicroBridge = new CodexMicroBridge({
if (process.env.THREADDECK_MICRO_DEBUG === "1") console.log(message);
}
});
+const approvalControls = new ApprovalControls({
+ readRequest: (threadId) => codexMicroBridge.readApprovalRequest(threadId),
+ respond: (decision, request, options) => codexMicroBridge.respondToApproval(decision, request, options)
+});
+const approvalShortcutControls = new ApprovalShortcutControls({
+ run: (args) => execFileAsync(KEY_BRIDGE, args, { timeout: 2200, maxBuffer: 16 * 1024 })
+});
+const ipcApprovalControls = new IpcApprovalControls({
+ client: new CodexIpcClient(),
+ readContext: async () => {
+ try {
+ const result = await execFileAsync(KEY_BRIDGE, ["codex-approval-api-context"], { timeout: 2200, maxBuffer: 4096 });
+ return JSON.parse(result.stdout);
+ } catch (error) {
+ try { return JSON.parse(error.stdout); } catch { return { ok: false, error: "context-unavailable" }; }
+ }
+ }
+});
const codexControlPlane = new CodexControlPlane({
micro: codexMicroBridge,
log: (message) => {
@@ -1122,6 +1158,7 @@ function showFeedback(context, kind, label, durationMs) {
if (contextFeedback.get(context)?.token !== token) return;
dismissFeedback(context, token);
}, duration);
+ return token;
}
function dismissFeedback(context, token = null, nowMs = Date.now()) {
@@ -1966,17 +2003,28 @@ function cancelThreadPress(context, releaseVoice = true) {
if (voiceStateByContext.get(context) === "preparing") setVoiceVisualState(context, "idle");
}
+let approvalTaskTarget = null;
+
+function selectApprovalTask(thread) {
+ // Only an explicit task-key press supplies the UUID for background approval.
+ // A task title, periodic observer or Side Chat header cannot replace it.
+ approvalTaskTarget = thread?.id ? Object.freeze({ id: thread.id,
+ title: thread.title || t("thread.untitled"), remote: thread.remote, hostId: thread.hostId }) : null;
+ renderStaticContexts();
+ return approvalTaskTarget;
+}
+
+
function beginThreadPress(context, slot, options = {}) {
if (threadPressByContext.has(context)) return;
- const thread = options.thread ?? threadForSlot(slot);
+ const thread = options.thread ?? (context ? threadForContext(context) : threadForSlot(slot));
+ const productionControl = !options.openThread && !options.focusComposer && !options.beginVoice;
+ if (productionControl || options.bindApprovalTarget === true) selectApprovalTask(thread);
if (!thread?.id) {
showFeedback(context, "error", "작업 없음");
return;
}
- const productionControl = !options.openThread
- && !options.focusComposer
- && !options.beginVoice;
const microStatus = options.microStatus
?? (productionControl ? microControlThreadStatus : null);
const state = {
@@ -2397,6 +2445,189 @@ function endReasoningControlPress(context, options = {}) {
: stepReasoningEffort(context, options.reasoning ?? options);
}
+function approvalSvg(decision, target = approvalTaskTarget) {
+ const approve = decision === "approve";
+ const accent = approve ? THEME.green : THEME.red;
+ const mark = approve ? "M48 66L65 83L97 49" : "M53 49L91 87M91 49L53 87";
+ const title = target ? [...target.title].slice(0, 12).join("") + ([...target.title].length > 12 ? "…" : "") : "";
+ return shell(accent, `
+ ${title ? `${escapeXml(title)} ` : ""}
+
+
+ ${escapeXml(t(approve ? "approval.approve" : "approval.decline"))} `);
+}
+
+function cancelApprovalPress(context) {
+ const state = approvalPressByContext.get(context);
+ approvalPressByContext.delete(context);
+ if (state) {
+ state.clearPendingFeedback();
+ void state.request.then((capture) => {
+ if (capture?.route === "ipc") state.ipc?.cancel(capture.lease);
+ else state.shortcuts.cancel(capture?.lease);
+ if (!state.released && approvalOperation === state) approvalOperation = null;
+ });
+ }
+}
+
+function updateKeyEventSettings(context, settings) {
+ if (!settings) return false;
+ const hadApprovalPress = approvalPressByContext.has(context);
+ const previousCommand = codexCommandForContext(context);
+ const previousApprovalTarget = approvalTargetMode(context);
+ actionSettingsByContext.set(context, settings);
+ if (hadApprovalPress && (previousCommand !== codexCommandForContext(context)
+ || previousApprovalTarget !== approvalTargetMode(context))) {
+ cancelApprovalPress(context);
+ return true;
+ }
+ return false;
+}
+
+function approvalTargetMode(context) {
+ const settings = settingsForContext(context);
+ const mode = settings.approvalTarget;
+ return !Object.hasOwn(settings, "approvalTarget") ? "task-key" : ["task-key", "current-dialog"].includes(mode) ? mode : null;
+}
+
+function approvalKeySvg(decision, context) {
+ const mode = approvalTargetMode(context);
+ return approvalSvg(decision, mode === "task-key"
+ ? approvalTaskTarget ?? { title: t("approval.task") }
+ : { title: mode === "current-dialog" ? t("approval.current") : "?" });
+}
+
+function beginApprovalPress(context, options = {}) {
+ if (approvalOperation || approvalPressByContext.size > 0) return;
+ const decision = codexCommandForContext(context);
+ if (decision !== "approve" && decision !== "decline") return;
+ const mode = approvalTargetMode(context);
+ if (mode == null) return;
+ const target = mode === "current-dialog" ? null : Object.hasOwn(options, "target") ? options.target
+ : approvalContractMode ? null : approvalTaskTarget;
+ const state = { decision, target, mode, threadId: target?.id ?? currentControlThreadId(), startedAtMs: Date.now(), released: false, request: null,
+ shortcuts: options.shortcuts ?? approvalShortcutControls,
+ ipc: mode === "current-dialog" ? null : Object.hasOwn(options, "ipc") ? options.ipc
+ : approvalContractMode ? null : ipcApprovalControls };
+ approvalPressByContext.set(context, state);
+ approvalOperation = state;
+ const beginFeedback = options.beginFeedback ?? showFeedback;
+ const clearPendingFeedback = options.clearPendingFeedback ?? dismissFeedback;
+ const feedbackToken = beginFeedback(context, "loading", t("feedback.checking"));
+ state.clearPendingFeedback = () => {
+ if (feedbackToken !== null && feedbackToken !== undefined) clearPendingFeedback(context, feedbackToken);
+ };
+ state.isCurrent = () => approvalPressByContext.get(context) === state
+ && codexCommandForContext(context) === state.decision
+ && approvalTargetMode(context) === state.mode
+ && (state.target ? (options.targetIsCurrent?.() ?? approvalTaskTarget === state.target)
+ : currentControlThreadId() === state.threadId)
+ && !activeRemoteNavigation?.promise && !activeDeepLinkNavigation?.promise && !activeComposerCreation;
+ // A command being eligible for Micro does not mean its bridge preparation
+ // succeeded. A physical approval press only inspects an already ready bridge.
+ const available = options.available ?? (() => codexControlPlane.shouldTryMicro() && codexMicroBridge.isReady());
+ const synchronize = options.synchronize ?? (() => synchronizeCurrentCodexThread({ quiet: true, refreshFastMode: false }));
+ const readRequest = options.readRequest ?? ((id) => codexMicroBridge.readApprovalRequest(id));
+ const verifyTarget = async () => state.target?.remote === false
+ && (state.target.hostId == null || state.target.hostId === "local") && state.isCurrent();
+ state.request = (async () => {
+ if (!state.isCurrent()) return null;
+ if (state.ipc) {
+ if (!await verifyTarget()) return { route: "ipc", ok: false, reason: "no-selected-task" };
+ const capture = await state.ipc.capture(state.threadId, state.isCurrent, verifyTarget);
+ // A task selected on the deck never falls through to a different visible
+ // native card, including when IPC is unavailable.
+ return { route: "ipc", ...capture };
+ }
+ if (state.threadId && available()) {
+ try {
+ const current = await synchronize();
+ if (!state.isCurrent()) return null;
+ if (current?.id === state.threadId) {
+ const request = await readRequest(state.threadId);
+ if (!state.isCurrent()) return null;
+ if (request) return { route: "renderer", request };
+ }
+ } catch {
+ // Inspection cannot dispatch a decision. Falling back here is safe;
+ // once renderer delivery starts, its result is final for this press.
+ }
+ }
+ if (!state.isCurrent()) return null;
+ const capture = await state.shortcuts.capture(state.isCurrent);
+ if (capture.diagnostics) runtimeTrace("approval-native-capture", {
+ ...capture.diagnostics, decision: state.decision, elapsedMs: Date.now() - state.startedAtMs
+ });
+ return { route: "native", ...capture };
+ })().catch(() => null);
+}
+
+function approvalShortcutFeedback(reason) {
+ if (["approval-unavailable", "no-requests"].includes(reason)) return t("approval.unavailable");
+ if (reason === "multiple-requests") return t("approval.multiple");
+ if (["no-selected-task", "target-changed"].includes(reason)) return t("approval.task");
+ if (reason === "unsupported-request") return t("approval.review");
+ if (["review-required", "review-unchecked"].includes(reason)) return t("approval.review");
+ if (reason === "review-unavailable") return t("approval.focus");
+ if (["permission-denied"].includes(reason)) return t("approval.permission");
+ if (["not-frontmost", "context-unavailable", "session-unavailable", "session-inactive", "session-locked"].includes(reason)) return t("approval.focus");
+ return t("approval.changed");
+}
+
+async function endApprovalPress(context, options = {}) {
+ const state = approvalPressByContext.get(context);
+ if (!state || state.released) return false;
+ state.released = true;
+ const feedback = options.feedback ?? showFeedback;
+ const execute = options.execute ?? ((decision, id, request, isCurrent) => approvalControls.execute(decision, id, { request, isCurrent }));
+ let capture;
+ try {
+ capture = await state.request;
+ if (approvalPressByContext.get(context) !== state) return false;
+ if (!capture || !state.isCurrent()) {
+ feedback(context, "error", t("approval.changed"));
+ return false;
+ }
+ if (capture.route === "ipc") {
+ const result = capture.ok
+ ? await state.ipc.execute(state.decision, capture.lease, state.isCurrent) : capture;
+ runtimeTrace("approval-ipc", { decision: state.decision, result: result.ok ? "owner-accepted" : "failed",
+ reason: result.reason ?? "none", elapsedMs: Date.now() - state.startedAtMs });
+ if (approvalPressByContext.get(context) !== state) return false;
+ feedback(context, result.ok ? "info" : "error", result.ok ? t("approval.sent") : approvalShortcutFeedback(result.reason));
+ return result.ok === true;
+ }
+ if (capture.route === "native") {
+ const result = capture.ok
+ ? await state.shortcuts.execute(state.decision, capture.lease, state.isCurrent)
+ : capture;
+ runtimeTrace("approval-native", { decision: state.decision, result: result.ok ? "sent" : "failed",
+ reason: result.reason ?? "none", elapsedMs: Date.now() - state.startedAtMs });
+ if (approvalPressByContext.get(context) !== state) return false;
+ // Native activation has no request-resolution acknowledgment from Codex.
+ // Blue Sent means activation was sent, never that the request completed.
+ feedback(context, result.ok ? "info" : "error", result.ok
+ ? t("approval.sent") : approvalShortcutFeedback(result.reason));
+ return result.ok === true;
+ }
+ const result = await execute(state.decision, state.threadId, capture.request, state.isCurrent);
+ if (approvalPressByContext.get(context) !== state) return false;
+ feedback(context, result?.ok ? "info" : "error", result?.ok ? t("approval.sent")
+ : t(result?.reason === "unavailable" ? "approval.unavailable" : "approval.changed"));
+ if (result?.ok && !options.execute) void refreshThreads();
+ return result?.ok === true;
+ } catch {
+ if (approvalPressByContext.get(context) === state) feedback(context, "error", t("approval.changed"));
+ return false;
+ } finally {
+ state.clearPendingFeedback();
+ if (capture?.route === "ipc") state.ipc?.cancel(capture.lease);
+ else state.shortcuts.cancel(capture?.lease);
+ if (approvalPressByContext.get(context) === state) approvalPressByContext.delete(context);
+ if (approvalOperation === state) approvalOperation = null;
+ }
+}
+
function sideChatSvg() {
return shell(THEME.text, `
@@ -2415,9 +2646,14 @@ function pageNavigationSvg(action, settings = {}) {
function staticActionSvg(action, context = null) {
const controlThreadId = currentControlThreadId();
+ if (action === ACTIONS.taskActions) {
+ const decision = context ? codexCommandForContext(context, action) : "approve";
+ return decision ? approvalKeySvg(decision, context) : shell(THEME.red, `? `);
+ }
if (action === ACTIONS.newThread) {
const command = context ? codexCommandForContext(context, action) : "new-task";
if (command === "side-chat") return sideChatSvg();
+ if (command === "approve" || command === "decline") return approvalKeySvg(command, context);
if (command === "send") {
return sendSvg(context ? sendLongPressArmedContexts.has(context) : false);
}
@@ -10200,6 +10436,23 @@ async function switchProfilePage(context, device, action, settings = {}, options
return true;
}
+function applyReceivedSettings(context, settings) {
+ const wasSendControl = contextIsSendControl(context);
+ updateKeyEventSettings(context, settings);
+ if (wasSendControl && !contextIsSendControl(context)) {
+ cancelSendPress(context, false);
+ }
+ const action = contexts.get(context);
+ const slot = threadSlotForContext(context, action);
+ if (slot !== undefined) {
+ setImage(context, threadSvg(displayedThreadSlot(slot), slot));
+ void refreshThreads();
+ } else {
+ const svg = staticActionSvg(action, context);
+ if (svg) setImage(context, svg);
+ }
+}
+
function registerPlugin() {
if (!port || !pluginUUID || !registerEvent) process.exit(1);
socket = new WebSocket(`ws://127.0.0.1:${port}`);
@@ -10233,6 +10486,7 @@ function registerPlugin() {
});
socket.addEventListener("close", () => {
+ for (const context of approvalPressByContext.keys()) cancelApprovalPress(context);
imageDeliveryQueue.clear();
});
@@ -10279,20 +10533,7 @@ function registerPlugin() {
}
if (message.event === "didReceiveSettings" && contexts.has(message.context)) {
- const wasSendControl = contextIsSendControl(message.context);
- actionSettingsByContext.set(message.context, message.payload?.settings ?? {});
- if (wasSendControl && !contextIsSendControl(message.context)) {
- cancelSendPress(message.context, false);
- }
- const action = contexts.get(message.context);
- const slot = threadSlotForContext(message.context, action);
- if (slot !== undefined) {
- setImage(message.context, threadSvg(displayedThreadSlot(slot), slot));
- void refreshThreads();
- } else {
- const svg = staticActionSvg(action, message.context);
- if (svg) setImage(message.context, svg);
- }
+ applyReceivedSettings(message.context, message.payload?.settings ?? {});
} else if (message.event === "willAppear" && Object.values(ACTIONS).includes(message.action)) {
contexts.set(message.context, message.action);
actionSettingsByContext.set(message.context, message.payload?.settings ?? {});
@@ -10342,6 +10583,7 @@ function registerPlugin() {
voiceStateByContext.delete(message.context);
voiceSessionIdByContext.delete(message.context);
contexts.delete(message.context);
+ cancelApprovalPress(message.context);
actionSettingsByContext.delete(message.context);
contextDeviceIds.delete(message.context);
contextImages.delete(message.context);
@@ -10352,12 +10594,12 @@ function registerPlugin() {
permissionAlertedContexts.delete(message.context);
microBridgeAlertedContexts.delete(message.context);
} else if (message.event === "keyDown" && contexts.has(message.context)) {
- if (message.payload?.settings) {
- actionSettingsByContext.set(message.context, message.payload.settings);
- }
+ if (updateKeyEventSettings(message.context, message.payload?.settings)) return;
const action = contexts.get(message.context);
if (action === ACTIONS.voice && !voiceHeldContexts.has(message.context)) {
beginCurrentVoicePress(message.context);
+ } else if (["approve", "decline"].includes(codexCommandForContext(message.context))) {
+ beginApprovalPress(message.context);
} else if (contextIsSendControl(message.context)) {
beginSendPress(message.context);
} else if (action === ACTIONS.fastMode) {
@@ -10368,12 +10610,12 @@ function registerPlugin() {
beginThreadPress(message.context, threadSlotForContext(message.context, action));
}
} else if (message.event === "keyUp" && contexts.has(message.context)) {
- if (message.payload?.settings) {
- actionSettingsByContext.set(message.context, message.payload.settings);
- }
+ if (updateKeyEventSettings(message.context, message.payload?.settings)) return;
const action = contexts.get(message.context);
if (action === ACTIONS.voice) {
endCurrentVoicePress(message.context);
+ } else if (["approve", "decline"].includes(codexCommandForContext(message.context))) {
+ void endApprovalPress(message.context);
} else if (contextIsSendControl(message.context)) {
void endSendPress(message.context);
} else if (threadSlotForContext(message.context, action) !== undefined) {
@@ -15907,19 +16149,419 @@ function installShutdownHandlers() {
releaseVoiceKeysSync();
codexMicroBootstrap.close();
codexControlPlane.close();
+ ipcApprovalControls.close();
process.exit(0);
});
process.once("SIGINT", () => {
releaseVoiceKeysSync();
codexMicroBootstrap.close();
codexControlPlane.close();
+ ipcApprovalControls.close();
process.exit(0);
});
process.on("exit", () => {
releaseVoiceKeysSync();
codexMicroBootstrap.close();
codexControlPlane.close();
+ ipcApprovalControls.close();
+ });
+}
+
+async function verifyApprovalControls() {
+ const assert = require("node:assert/strict");
+ const recent = { id: "10000000-0000-4000-8000-000000000001", title: "Recent task", status: "idle", remote: false };
+ const waiting = { id: "10000000-0000-4000-8000-000000000002", title: "Waiting task", status: "working", remote: false };
+ const messages = [];
+ socket = { readyState: 1, send: (message) => messages.push(JSON.parse(message)) };
+ contexts.set("task", ACTIONS.thread1);
+ actionSettingsByContext.set("task", { taskSource: "top1" });
+ threadSlots = [waiting, recent];
+ primaryThreadId = recent.id;
+ primaryThreadRow = recent;
+ const opened = [];
+ beginThreadPress("task", 0, {
+ bindApprovalTarget: true,
+ openThread: async (_context, _slot, options) => { opened.push(options.thread.id); return true; },
+ focusComposer: async () => false, beginVoice: async () => false,
+ pauseMedia: async () => false, resumeMedia: async () => false,
+ sleep: async () => {}, schedule: () => null
+ });
+ assert.equal(approvalTaskTarget.id, waiting.id, "Top task key binds the displayed UUID");
+ threadSlots = [recent, waiting];
+ endThreadPress("task");
+ await Promise.resolve();
+ assert.deepEqual(opened, [waiting.id], "task reordering does not retarget an in-flight key press");
+ selectApprovalTask(null);
+ actionSettingsByContext.set("task", { taskSource: "current" });
+ beginThreadPress("task", -1, {
+ bindApprovalTarget: true,
+ openThread: async (_context, _slot, options) => { opened.push(options.thread.id); return true; },
+ focusComposer: async () => false, beginVoice: async () => false,
+ pauseMedia: async () => false, resumeMedia: async () => false,
+ sleep: async () => {}, schedule: () => null
+ });
+ assert.equal(approvalTaskTarget.id, recent.id, "Current task key binds the displayed UUID");
+ primaryThreadId = waiting.id;
+ primaryThreadRow = waiting;
+ endThreadPress("task");
+ await Promise.resolve();
+ assert.deepEqual(opened, [waiting.id, recent.id], "Current task changes do not retarget an in-flight key press");
+ primaryThreadId = recent.id;
+ primaryThreadRow = recent;
+ selectApprovalTask(null);
+ const request = { threadId: recent.id, requestId: 42, hostId: "local", kind: "exec" };
+ contexts.set("approval", ACTIONS.newThread);
+ actionSettingsByContext.set("approval", { command: "approve" });
+ const delivered = [];
+ const rendererFeedback = [];
+ const quietApprovalFeedback = { beginFeedback: () => null };
+ const start = () => beginApprovalPress("approval", {
+ ...quietApprovalFeedback,
+ available: () => true, synchronize: async () => recent, readRequest: async () => request
+ });
+ start();
+ const approved = await endApprovalPress("approval", {
+ execute: async (decision, id, identity) => { delivered.push({ decision, id, identity }); return { ok: true }; },
+ feedback: (...args) => rendererFeedback.push(args)
+ });
+ assert.equal(approved, true);
+ assert.deepEqual(rendererFeedback, [["approval", "info", t("approval.sent")]], "renderer dispatch is blue Sent, not completed work");
+ assert.deepEqual(delivered, [{ decision: "approve", id: recent.id, identity: request }]);
+ assert.equal(await endApprovalPress("approval", { execute: () => assert.fail("duplicate release"), feedback: () => {} }), false);
+
+ start();
+ await approvalPressByContext.get("approval").request;
+ primaryThreadId = waiting.id;
+ primaryThreadRow = waiting;
+ assert.equal(await endApprovalPress("approval", { execute: () => assert.fail("changed task"), feedback: () => {} }), false);
+ primaryThreadId = recent.id;
+ primaryThreadRow = recent;
+ start();
+ await approvalPressByContext.get("approval").request;
+ cancelApprovalPress("approval");
+ assert.equal(await endApprovalPress("approval", { execute: () => assert.fail("cancelled context"), feedback: () => {} }), false);
+ beginApprovalPress("approval", { ...quietApprovalFeedback, available: () => false, readRequest: () => assert.fail("unavailable bridge"),
+ shortcuts: { capture: async () => ({ ok: false, reason: "not-frontmost" }), cancel: () => {} } });
+ assert.equal(await endApprovalPress("approval", { execute: () => assert.fail("unavailable request"), feedback: () => {} }), false);
+ // The native card route remains available on builds with no renderer bridge.
+ // Fixtures inject the helper; no live input is emitted by verification.
+ const shortcutCalls = [];
+ const shortcuts = new ApprovalShortcutControls({
+ run: async (args) => {
+ shortcutCalls.push(args);
+ return { stdout: JSON.stringify(args.length === 1
+ ? { pid: 123, token: "v2:123:45:501:1",
+ approval: { state: "ready", token: `a1:${"d".repeat(64)}` } } : { sent: true }) };
+ }
+ });
+ beginApprovalPress("approval", { ...quietApprovalFeedback, available: () => false, shortcuts });
+ const nativeFeedback = [];
+ assert.equal(await endApprovalPress("approval", {
+ execute: () => assert.fail("renderer must not run for shortcut capture"),
+ feedback: (...args) => nativeFeedback.push(args)
+ }), true);
+ assert.equal(shortcutCalls.length, 2);
+ assert.deepEqual(nativeFeedback, [["approval", "info", t("approval.sent")]]);
+ beginApprovalPress("approval", { ...quietApprovalFeedback, available: () => false, shortcuts });
+ await approvalPressByContext.get("approval").request;
+ cancelApprovalPress("approval");
+ await Promise.resolve();
+ assert.equal(shortcuts.active, null);
+ assert.equal(await endApprovalPress("approval", { feedback: () => {} }), false);
+ assert.equal(shortcutCalls.length, 3, "cancelled capture must not dispatch");
+ beginApprovalPress("approval", { ...quietApprovalFeedback, available: () => false, shortcuts });
+ await approvalPressByContext.get("approval").request;
+ assert.equal(updateKeyEventSettings("approval", { command: "new-task" }), true);
+ await Promise.resolve();
+ assert.equal(shortcuts.active, null);
+ assert.equal(approvalPressByContext.has("approval"), false);
+ actionSettingsByContext.set("approval", { command: "approve" });
+ start();
+ assert.equal(await endApprovalPress("approval", {
+ execute: async () => ({ ok: false, reason: "delivery-unknown", delivery: "unknown" }), feedback: () => {}
+ }), false);
+ assert.equal(shortcutCalls.length, 4, "renderer uncertainty must not trigger a shortcut");
+ let releaseRead;
+ let readStarted;
+ const startedRead = new Promise((resolve) => { readStarted = resolve; });
+ const waitingRead = new Promise((resolve) => { releaseRead = resolve; });
+ let responses = 0;
+ const delayedControls = new ApprovalControls({
+ readRequest: async () => { readStarted(); return waitingRead; },
+ respond: async () => { responses += 1; return { delivered: true }; },
+ executionState: { tail: Promise.resolve(), attempted: new Set() }
+ });
+ start();
+ const pendingApproval = endApprovalPress("approval", {
+ execute: (decision, id, captured, isCurrent) => delayedControls.execute(decision, id, { request: captured, isCurrent }),
+ feedback: () => {}
+ });
+ await startedRead;
+ cancelApprovalPress("approval");
+ releaseRead(request);
+ assert.equal(await pendingApproval, false);
+ assert.equal(responses, 0, "settings/context cancellation while queued must prevent delivery");
+ contexts.set("approval", ACTIONS.taskActions);
+ for (const [settings, decision] of [[{}, "approve"], [{ command: "decline" }, "decline"]]) {
+ actionSettingsByContext.set("approval", settings);
+ assert.equal(codexCommandForContext("approval"), decision);
+ start();
+ assert.equal(await endApprovalPress("approval", {
+ execute: async (actual, id, identity) => {
+ assert.equal(actual, decision);
+ assert.equal(id, recent.id);
+ assert.deepEqual(identity, request);
+ return { ok: true };
+ },
+ feedback: () => {}
+ }), true);
+ assert.equal(staticActionSvg(ACTIONS.taskActions, "approval"), approvalKeySvg(decision, "approval"));
+ }
+ actionSettingsByContext.set("approval", { command: "approve" });
+ const originalApprovalReadiness = codexMicroBridge.isReady;
+ const originalShouldTryMicro = codexControlPlane.shouldTryMicro;
+ try {
+ codexControlPlane.shouldTryMicro = () => true;
+ codexMicroBridge.isReady = () => false;
+ const pendingFeedback = new Map();
+ const feedbackCalls = [];
+ let feedbackToken = 0;
+ const fixtureFeedback = (context, kind, label) => {
+ const token = ++feedbackToken;
+ pendingFeedback.set(context, { token, kind, label });
+ feedbackCalls.push({ context, kind, label });
+ return token;
+ };
+ const pendingFeedbackOptions = {
+ beginFeedback: fixtureFeedback,
+ clearPendingFeedback: (context, token) => {
+ if (pendingFeedback.get(context)?.token === token) pendingFeedback.delete(context);
+ }
+ };
+ let releaseCapture;
+ const capturePromise = new Promise((resolve) => { releaseCapture = resolve; });
+ let captures = 0;
+ let nativeDispatches = 0;
+ const delayedShortcuts = {
+ capture: () => { captures += 1; return capturePromise; },
+ execute: async (decision, lease, isCurrent) => {
+ assert.equal(decision, "approve");
+ assert.equal(lease, "deferred-native");
+ assert.equal(isCurrent(), true);
+ nativeDispatches += 1;
+ return { ok: true };
+ },
+ cancel: () => {}
+ };
+ const beginDelayedNative = () => beginApprovalPress("approval", {
+ ...pendingFeedbackOptions,
+ synchronize: () => assert.fail("a cold bridge must not synchronize before native capture"),
+ readRequest: () => assert.fail("a cold bridge must not inspect or prepare the renderer"),
+ shortcuts: delayedShortcuts
+ });
+ beginDelayedNative();
+ assert.deepEqual(feedbackCalls, [{ context: "approval", kind: "loading", label: t("feedback.checking") }]);
+ assert.equal(captures, 1, "native capture starts before any asynchronous bridge operation");
+ const shortPress = endApprovalPress("approval", {
+ execute: () => assert.fail("native capture must not dispatch through the renderer"),
+ feedback: fixtureFeedback
+ });
+ beginDelayedNative();
+ assert.equal(await endApprovalPress("approval", { feedback: fixtureFeedback }), false);
+ assert.equal(captures, 1);
+ assert.equal(nativeDispatches, 0);
+ assert.equal(feedbackCalls.length, 1, "duplicate presses cannot replace the pending indicator");
+ releaseCapture({ ok: true, lease: "deferred-native" });
+ assert.equal(await shortPress, true);
+ assert.equal(nativeDispatches, 1, "a release before capture completes still dispatches exactly once");
+ assert.deepEqual(pendingFeedback.get("approval"), {
+ token: 2, kind: "info", label: t("approval.sent")
+ });
+ assert.equal(approvalPressByContext.has("approval"), false);
+
+ for (const newerFeedback of [false, true]) {
+ let finishCancelledCapture;
+ const cancelledCapture = new Promise((resolve) => { finishCancelledCapture = resolve; });
+ beginApprovalPress("approval", {
+ ...pendingFeedbackOptions,
+ shortcuts: {
+ capture: () => cancelledCapture,
+ execute: () => assert.fail("a cancelled short press cannot dispatch"),
+ cancel: () => {}
+ }
+ });
+ const cancelledState = approvalPressByContext.get("approval");
+ if (newerFeedback) fixtureFeedback("approval", "info", "Newer feedback");
+ cancelApprovalPress("approval");
+ if (newerFeedback) assert.equal(pendingFeedback.get("approval")?.label, "Newer feedback");
+ else assert.equal(pendingFeedback.has("approval"), false, "cancellation clears its pending indicator immediately");
+ finishCancelledCapture({ ok: true, lease: "cancelled-native" });
+ await cancelledState.request;
+ assert.equal(await endApprovalPress("approval", { feedback: fixtureFeedback }), false);
+ assert.equal(nativeDispatches, 1);
+ }
+
+ codexMicroBridge.isReady = () => true;
+ beginApprovalPress("approval", {
+ ...quietApprovalFeedback,
+ synchronize: async () => recent,
+ readRequest: async () => request,
+ shortcuts: { capture: () => assert.fail("a ready renderer keeps its existing route"), cancel: () => {} }
+ });
+ assert.equal(await endApprovalPress("approval", {
+ execute: async (decision, id, identity) => {
+ assert.equal(decision, "approve");
+ assert.equal(id, recent.id);
+ assert.deepEqual(identity, request);
+ return { ok: true };
+ },
+ feedback: () => {}
+ }), true);
+ } finally {
+ codexMicroBridge.isReady = originalApprovalReadiness;
+ codexControlPlane.shouldTryMicro = originalShouldTryMicro;
+ }
+ for (const result of [{ ok: true, delivery: "owner-accepted" }, { ok: false, reason: "delivery-unknown", delivery: "unknown" }]) {
+ let captured = 0, dispatched = 0;
+ const ipc = {
+ capture: async (id, isCurrent) => {
+ assert.equal(id, recent.id);
+ assert.equal(isCurrent(), true);
+ captured++;
+ return { ok: true, lease: "ipc-fixture" };
+ },
+ execute: async (decision, lease, isCurrent) => {
+ assert.equal(decision, "approve");
+ assert.equal(lease, "ipc-fixture");
+ assert.equal(isCurrent(), true);
+ dispatched++;
+ return result;
+ }, cancel: () => {}
+ };
+ beginApprovalPress("approval", { ...quietApprovalFeedback, ipc,
+ target: recent, targetIsCurrent: () => true,
+ synchronize: () => assert.fail("IPC does not focus or synchronize the renderer"),
+ shortcuts: { capture: () => assert.fail("IPC must not send keyboard input"), cancel: () => {} } });
+ beginApprovalPress("approval", { ipc });
+ const events = [];
+ assert.equal(await endApprovalPress("approval", {
+ execute: () => assert.fail("IPC cannot fall back after dispatch"), feedback: (...args) => events.push(args)
+ }), result.ok);
+ assert.equal(captured, 1);
+ assert.equal(dispatched, 1);
+ assert.equal(events[0][1], result.ok ? "info" : "error");
+ assert.equal(await endApprovalPress("approval", { feedback: () => {} }), false);
+ }
+ beginApprovalPress("approval", { ...quietApprovalFeedback,
+ target: recent, targetIsCurrent: () => true,
+ ipc: { capture: async () => ({ ok: false, reason: "multiple-requests" }), cancel: () => {} },
+ shortcuts: { capture: () => assert.fail("ambiguous IPC requests cannot use a keyboard fallback"), cancel: () => {} } });
+ assert.equal(await endApprovalPress("approval", { feedback: () => {} }), false);
+ for (const target of [{ ...recent, remote: true }, { ...recent, hostId: "remote-host" }]) {
+ beginApprovalPress("approval", { ...quietApprovalFeedback, target, targetIsCurrent: () => true,
+ ipc: { capture: () => assert.fail("host mismatch must not query IPC"), cancel: () => {} },
+ shortcuts: { capture: () => assert.fail("bound target must not become a native approval"), cancel: () => {} }
+ });
+ assert.equal(await endApprovalPress("approval", { feedback: () => {} }), false);
+ }
+ for (const reason of ["no-requests", "ipc-unavailable"]) {
+ beginApprovalPress("approval", { ...quietApprovalFeedback, target: recent, targetIsCurrent: () => true,
+ ipc: { capture: async () => ({ ok: false, reason, canFallback: true }), cancel: () => {} },
+ shortcuts: { capture: () => assert.fail("bound tasks cannot fall through to any other visible dialog"), cancel: () => {} }
+ });
+ assert.equal(await endApprovalPress("approval", { feedback: () => {} }), false);
+ }
+ actionSettingsByContext.set("approval", { command: "approve", approvalTarget: "current-dialog" });
+ beginApprovalPress("approval", { ...quietApprovalFeedback, target: recent,
+ ipc: { capture: () => assert.fail("current dialog mode must not query a bound task") },
+ available: () => false,
+ shortcuts: { capture: async () => ({ ok: true, lease: "manual-review" }),
+ execute: async (_decision, lease) => { assert.equal(lease, "manual-review"); return { ok: true }; }, cancel: () => {} }
+ });
+ assert.equal(await endApprovalPress("approval", { feedback: () => {} }), true);
+ actionSettingsByContext.set("approval", { command: "approve", approvalTarget: "unknown" });
+ beginApprovalPress("approval", quietApprovalFeedback);
+ assert.equal(approvalPressByContext.has("approval"), false);
+ actionSettingsByContext.set("approval", { command: "approve" });
+ const targetA = selectApprovalTask(recent);
+ primaryThreadId = waiting.id;
+ let releaseTargetCapture;
+ beginApprovalPress("approval", { ...quietApprovalFeedback, target: targetA,
+ ipc: { capture: async (id, valid) => {
+ assert.equal(id, recent.id, "explicit task-key target outranks a manually selected task");
+ assert.equal(valid(), true);
+ return new Promise((resolve) => { releaseTargetCapture = resolve; });
+ }, execute: () => assert.fail("a later task-key selection cancels captured approval"), cancel: () => {} }
+ });
+ contexts.set("other-approval", ACTIONS.taskActions);
+ actionSettingsByContext.set("other-approval", { command: "decline", approvalTarget: "current-dialog" });
+ beginApprovalPress("other-approval", { ...quietApprovalFeedback,
+ shortcuts: { capture: () => assert.fail("concurrent target modes cannot capture another decision") }
+ });
+ assert.equal(approvalPressByContext.has("other-approval"), false);
+ contexts.delete("other-approval");
+ actionSettingsByContext.delete("other-approval");
+ const targetPending = endApprovalPress("approval", { feedback: () => {} });
+ await Promise.resolve();
+ const targetB = selectApprovalTask({ ...recent, id: waiting.id });
+ assert.notEqual(targetA, targetB, "same task titles do not substitute UUIDs");
+ releaseTargetCapture({ ok: true, lease: "cancelled-target" });
+ assert.equal(await targetPending, false);
+ primaryThreadId = recent.id;
+ selectApprovalTask(null);
+ beginApprovalPress("approval", { ...quietApprovalFeedback, target: recent, targetIsCurrent: () => true,
+ ipc: { capture: async () => ({ ok: true, lease: "mode-change" }), cancel: () => {},
+ execute: () => assert.fail("changing target mode must cancel a captured decision") }
});
+ await approvalPressByContext.get("approval").request;
+ applyReceivedSettings("approval", { command: "approve", approvalTarget: "current-dialog" });
+ applyReceivedSettings("approval", { command: "approve", approvalTarget: "task-key" });
+ assert.equal(await endApprovalPress("approval", { feedback: () => {} }), false);
+ actionSettingsByContext.set("approval", { command: "send" });
+ assert.equal(codexCommandForContext("approval"), null);
+ start();
+ assert.equal(approvalPressByContext.has("approval"), false);
+ assert.equal(contextIsSendControl("approval"), false);
+ assert.match(staticActionSvg(ACTIONS.taskActions, "approval"), />\?<\/text>/);
+ assert.equal(CURRENT_THREAD_AWARE_ACTIONS.has(ACTIONS.taskActions), true);
+ // The two visible entry points must use the same target semantics for both decisions.
+ for (const action of [ACTIONS.newThread, ACTIONS.taskActions]) {
+ contexts.set("approval", action);
+ for (const decision of ["approve", "decline"]) {
+ for (const mode of ["task-key", "current-dialog"]) {
+ actionSettingsByContext.set("approval", { command: decision, approvalTarget: mode });
+ assert.equal(codexCommandForContext("approval"), decision);
+ const target = selectApprovalTask(recent);
+ const calls = [];
+ const ipc = { capture: async (id) => { calls.push(["capture", id]); return { ok: true, lease: "ipc-parity" }; },
+ execute: async (choice, lease) => { calls.push([choice, lease]); return { ok: true }; }, cancel: () => {} };
+ const shortcuts = { capture: async () => { calls.push(["capture-native"]); return { ok: true, lease: "native-parity" }; },
+ execute: async (choice, lease) => { calls.push([choice, lease]); return { ok: true }; }, cancel: () => {} };
+ beginApprovalPress("approval", { ...quietApprovalFeedback, target, ipc, shortcuts, available: () => false });
+ assert.equal(await endApprovalPress("approval", { feedback: () => {} }), true);
+ assert.deepEqual(calls, mode === "task-key"
+ ? [["capture", recent.id], [decision, "ipc-parity"]]
+ : [["capture-native"], [decision, "native-parity"]]);
+ assert.equal(await endApprovalPress("approval", { feedback: () => {} }), false);
+ beginApprovalPress("approval", { ...quietApprovalFeedback, target, ipc, shortcuts, available: () => false });
+ await approvalPressByContext.get("approval").request;
+ const count = calls.length;
+ applyReceivedSettings("approval", { command: decision, approvalTarget: mode === "task-key" ? "current-dialog" : "task-key" });
+ applyReceivedSettings("approval", { command: decision, approvalTarget: mode });
+ assert.equal(await endApprovalPress("approval", { feedback: () => {} }), false);
+ assert.equal(calls.length, count, "settings round-trip cancels both entry points without dispatch");
+ await Promise.resolve();
+ }
+ }
+ }
+ selectApprovalTask(null);
+ const previews = {};
+ for (const appearance of ["light", "dark"]) {
+ THEME = appearance === "light" ? LIGHT_THEME : DARK_THEME;
+ for (const decision of ["approve", "decline"]) previews[`${decision}-${appearance}`] = approvalSvg(decision);
+ previews[`bound-${appearance}`] = approvalSvg("approve", { title: "Создание плагина" });
+ }
+ console.log(JSON.stringify({ passed: true, frozenPress: true, approvalCaptureCancellation: true, approvalReadyOnly: true, approvalShortPress: true, approvalPendingFeedback: true, approvalIpcNoInput: true, previews }));
}
function runSelectedMode() {
@@ -15948,6 +16590,11 @@ function runSelectedMode() {
console.error(error);
process.exitCode = 1;
});
+ } else if (approvalContractMode) {
+ verifyApprovalControls().then(() => process.exit(process.exitCode ?? 0)).catch((error) => {
+ console.error(error);
+ process.exit(1);
+ });
} else if (interactionContractMode) {
verifyInteractionPolicy()
.then(() => process.exit(process.exitCode ?? 0))
@@ -15999,7 +16646,8 @@ function main() {
|| refreshResilienceContractMode
|| usageCacheContractMode
|| voiceSubmitContractMode
- || interactionContractMode;
+ || interactionContractMode
+ || approvalContractMode;
if (verificationOnly) {
runSelectedMode();
return;
diff --git a/test/action-settings-approval.test.js b/test/action-settings-approval.test.js
new file mode 100644
index 0000000..e192cc4
--- /dev/null
+++ b/test/action-settings-approval.test.js
@@ -0,0 +1,54 @@
+"use strict";
+
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const { CODEX_COMMANDS, codexCommandFromSettings, taskActionFromSettings, taskSourceFromSettings, taskSlotFromSettings } = require("../src/action-settings");
+const { setLanguage, t } = require("../src/i18n");
+
+test("both approval action entry points interpret supported decisions consistently", () => {
+ for (const command of ["approve", "decline", "APPROVE", " Decline "]) {
+ const settings = Object.freeze({ command, customSetting: "kept" });
+ const expected = command.trim().toLowerCase();
+ assert.equal(taskActionFromSettings(settings), expected);
+ assert.equal(codexCommandFromSettings(settings), expected);
+ assert.equal(settings.customSetting, "kept");
+ }
+ assert.equal(CODEX_COMMANDS.has("approve"), true);
+ assert.equal(CODEX_COMMANDS.has("decline"), true);
+});
+
+test("Task actions defaults to approve and rejects unsupported commands", () => {
+ assert.equal(taskActionFromSettings(), "approve");
+ assert.equal(taskActionFromSettings({}), "approve");
+ for (const command of ["send", "new-task", "side-chat", "", "future", false, 0, {}, []]) {
+ assert.equal(taskActionFromSettings({ command }), null);
+ }
+});
+
+test("existing command and task defaults remain compatible", () => {
+ assert.equal(codexCommandFromSettings(), "new-task");
+ assert.equal(codexCommandFromSettings({ command: "future" }), "new-task");
+ for (const command of ["new-task", "side-chat", "send"]) {
+ assert.equal(codexCommandFromSettings({ command }), command);
+ }
+ assert.equal(taskSourceFromSettings({}), "current");
+ assert.equal(taskSlotFromSettings({}), -1);
+ for (let index = 1; index <= 8; index += 1) {
+ assert.equal(taskSlotFromSettings({ taskSource: `top${index}` }), index - 1);
+ }
+});
+
+test("approval key feedback uses the selected locale", () => {
+ try {
+ for (const [language, approve, decline, sent] of [
+ ["en", "Approve", "Decline", "Sent"],
+ ["ko", "승인", "거절", "전송됨"],
+ ["ru", "Одобрить", "Отклонить", "Отправлено"]
+ ]) {
+ setLanguage(language);
+ assert.equal(t("approval.approve"), approve);
+ assert.equal(t("approval.decline"), decline);
+ assert.equal(t("approval.sent"), sent);
+ }
+ } finally { setLanguage("en"); }
+});
diff --git a/test/approval-controls.test.js b/test/approval-controls.test.js
new file mode 100644
index 0000000..50803ee
--- /dev/null
+++ b/test/approval-controls.test.js
@@ -0,0 +1,384 @@
+"use strict";
+
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const vm = require("node:vm");
+const {
+ ApprovalControls,
+ approvalRequestExpression,
+ approvalResponseExpression,
+ normalizeApprovalIdentity
+} = require("../src/approval-controls");
+const { CodexMicroBridge, MicroBridgeError } = require("../src/micro-cdp");
+
+const THREAD = "019f8442-7025-7b42-8fc0-0b93f0be2073";
+const OTHER = "019f8442-7025-7b42-8fc0-0b93f0be2074";
+const request = (overrides = {}) => ({
+ threadId: THREAD, hostId: "local", requestId: 17, kind: "exec", ...overrides
+});
+const state = () => ({ tail: Promise.resolve(), attempted: new Set() });
+const deferred = () => {
+ let resolve;
+ const promise = new Promise((complete) => { resolve = complete; });
+ return { promise, resolve };
+};
+
+function controls(options = {}) {
+ const sent = [];
+ const controller = new ApprovalControls({
+ readRequest: async () => request(),
+ respond: async (...args) => { sent.push(args.slice(0, 2)); return { delivered: true }; },
+ executionState: state(),
+ ...options
+ });
+ return { controller, sent };
+}
+
+test("approval identities require exact UUID, request type, host, and typed request ID", () => {
+ for (const value of [null, {}, request({ threadId: "task title" }), request({ hostId: null }),
+ request({ requestId: "" }), request({ requestId: NaN }), request({ kind: "userInput" })]) {
+ assert.equal(normalizeApprovalIdentity(value), null);
+ }
+ assert.deepEqual(normalizeApprovalIdentity(request({ requestId: 0 })), request({ requestId: 0 }));
+});
+
+test("absent request, wrong task, invalid commands and read failures never dispatch", async () => {
+ for (const readRequest of [async () => null, async () => request({ threadId: OTHER }),
+ async () => { throw new Error("offline"); }]) {
+ const { controller, sent } = controls({ readRequest });
+ assert.equal((await controller.execute("approve", THREAD)).delivery, "none");
+ assert.deepEqual(sent, []);
+ }
+ const { controller, sent } = controls();
+ assert.equal((await controller.execute("always-approve", THREAD)).ok, false);
+ assert.equal((await controller.execute("approve", "title")).ok, false);
+ assert.deepEqual(sent, []);
+});
+
+test("request/task/host/type changes between capture and dispatch are refused", async () => {
+ for (const replacement of [null, request({ requestId: 18 }), request({ requestId: "17" }),
+ request({ threadId: OTHER }), request({ hostId: "remote" }), request({ kind: "patch" })]) {
+ let reads = 0;
+ const { controller, sent } = controls({ readRequest: async () => ++reads === 1 ? request() : replacement });
+ assert.equal((await controller.execute("approve", THREAD)).reason, "changed");
+ assert.deepEqual(sent, []);
+ }
+});
+
+test("a verified request is delivered once, with the selected decision only", async () => {
+ for (const decision of ["approve", "decline"]) {
+ const { controller, sent } = controls();
+ const result = await controller.execute(decision, THREAD);
+ assert.equal(result.ok, true);
+ assert.equal(result.delivery, "invoked");
+ assert.equal((await controller.execute(decision, THREAD)).reason, "already-handled");
+ assert.deepEqual(sent, [[decision, request()]]);
+ }
+});
+
+test("a key-down request is retained through key-up and never replaced after a hold", async () => {
+ let reads = 0;
+ const { controller, sent } = controls({ readRequest: async () => {
+ reads++;
+ return request({ requestId: 18 });
+ } });
+ assert.equal((await controller.execute("approve", THREAD, { request: request() })).reason, "changed");
+ assert.equal(reads, 1);
+ assert.deepEqual(sent, []);
+ assert.equal((await controller.execute("approve", THREAD, { request: null })).reason, "unavailable");
+ assert.equal(reads, 1);
+});
+
+test("context disappearance or settings change during a fresh read cancels before respond", async () => {
+ const readStarted = deferred();
+ const readFinished = deferred();
+ let current = true;
+ const { controller, sent } = controls({ readRequest: async () => {
+ readStarted.resolve();
+ return readFinished.promise;
+ } });
+ const result = controller.execute("approve", THREAD, { request: request(), isCurrent: () => current });
+ await readStarted.promise;
+ current = false;
+ readFinished.resolve(request());
+ assert.deepEqual(await result, { ok: false, reason: "cancelled", delivery: "none" });
+ assert.deepEqual(sent, []);
+});
+
+test("cancellation while queued skips fresh inspection and dispatch", async () => {
+ const queueFinished = deferred();
+ const executionState = { tail: queueFinished.promise, attempted: new Set() };
+ let current = true;
+ let reads = 0;
+ const { controller, sent } = controls({
+ executionState, readRequest: async () => { reads++; return request(); }
+ });
+ const result = controller.execute("decline", THREAD, { request: request(), isCurrent: () => current });
+ await Promise.resolve();
+ current = false;
+ queueFinished.resolve();
+ assert.equal((await result).reason, "cancelled");
+ assert.equal(reads, 0);
+ assert.deepEqual(sent, []);
+});
+
+test("cancellation after capture awaits or a throwing predicate never dispatches", async () => {
+ const readStarted = deferred();
+ const readFinished = deferred();
+ let current = true;
+ let reads = 0;
+ const { controller, sent } = controls({ readRequest: async () => {
+ reads++;
+ readStarted.resolve();
+ return readFinished.promise;
+ } });
+ const result = controller.execute("approve", THREAD, { isCurrent: () => current });
+ await readStarted.promise;
+ current = false;
+ readFinished.resolve(request());
+ assert.equal((await result).reason, "cancelled");
+ assert.equal(reads, 1);
+ assert.deepEqual(sent, []);
+ assert.equal((await controller.execute("approve", THREAD, {
+ request: request(), isCurrent: () => { throw new Error("context removed"); }
+ })).reason, "cancelled");
+});
+
+test("the bridge receives and rechecks cancellation immediately before evaluation", async () => {
+ const bridge = new CodexMicroBridge();
+ let evaluations = 0;
+ bridge.evaluate = async () => { evaluations++; return { delivered: true }; };
+ let current = true;
+ const isCurrent = () => current;
+ const { controller } = controls({ respond: async (decision, identity, options) => {
+ assert.equal(options.isCurrent, isCurrent);
+ current = false;
+ return bridge.respondToApproval(decision, identity, options);
+ } });
+ assert.equal((await controller.execute("approve", THREAD, { request: request(), isCurrent })).reason, "cancelled");
+ assert.equal(evaluations, 0);
+});
+
+test("context changes after possible dispatch never become a no-delivery cancellation", async () => {
+ for (const ambiguous of [false, true]) {
+ let current = true;
+ const { controller } = controls({ respond: async () => {
+ current = false;
+ if (ambiguous) throw new Error("response lost");
+ return { delivered: true };
+ } });
+ const result = await controller.execute("approve", THREAD, {
+ request: request(), isCurrent: () => current
+ });
+ assert.equal(result.delivery, ambiguous ? "unknown" : "invoked");
+ }
+});
+
+test("concurrent contexts capture the original prompt and serialize all dispatches", async () => {
+ const executionState = state();
+ let current = request();
+ let active = 0;
+ let maximum = 0;
+ const sent = [];
+ const options = {
+ executionState,
+ readRequest: async () => ({ ...current }),
+ respond: async (decision, identity) => {
+ active += 1;
+ maximum = Math.max(maximum, active);
+ sent.push([decision, identity.requestId]);
+ await Promise.resolve();
+ current = request({ requestId: 18 });
+ active -= 1;
+ return { delivered: true };
+ }
+ };
+ const first = new ApprovalControls(options);
+ const second = new ApprovalControls(options);
+ const results = await Promise.all([first.execute("approve", THREAD), second.execute("decline", THREAD)]);
+ assert.equal(results[0].ok, true);
+ assert.equal(results[1].reason, "already-handled");
+ assert.equal(maximum, 1);
+ assert.deepEqual(sent, [["approve", 17]]);
+});
+
+test("ambiguous delivery is consumed even if transport claims disconnected/unavailable", async () => {
+ for (const respond of [async () => undefined, async () => ({ delivered: false }),
+ async () => { throw new MicroBridgeError("disconnected", { delivery: "none" }); }]) {
+ let attempts = 0;
+ const { controller } = controls({ respond: async (...args) => { attempts++; return respond(...args); } });
+ assert.equal((await controller.execute("approve", THREAD)).delivery, "unknown");
+ assert.equal((await controller.execute("decline", THREAD)).reason, "already-handled");
+ assert.equal(attempts, 1);
+ }
+});
+
+test("a definite atomic guard rejection permits a later deliberate press, with no automatic retry", async () => {
+ let attempts = 0;
+ const { controller } = controls({ respond: async () => {
+ attempts++;
+ return { delivered: false, delivery: "none", reason: "changed" };
+ } });
+ assert.equal((await controller.execute("approve", THREAD)).reason, "changed");
+ assert.equal(attempts, 1);
+ await controller.execute("approve", THREAD);
+ assert.equal(attempts, 2);
+});
+
+// A synthetic renderer model, never an installed Codex document or its code.
+function rendererFixture(options = {}) {
+ const hits = [];
+ const identity = request(options.identity);
+ const actions = {
+ onApprove: () => hits.push("once"), onDeny: () => hits.push("deny"),
+ scopedApproveAction: { onClick: () => hits.push("session") },
+ leadingAction: { onClick: () => hits.push("always") },
+ ...options.actions
+ };
+ const node = (extra = {}) => ({
+ isConnected: true, getClientRects: () => [{}], closest: () => null,
+ getAttribute: () => null, ...extra
+ });
+ const owner = options.unknownOwner ? { conversationId: THREAD } : identity.kind === "permissionRequest"
+ ? { conversationId: identity.threadId, hostId: identity.hostId,
+ pendingRequest: { requestId: identity.requestId, permissions: { network: { enabled: true } } } }
+ : { conversationId: identity.threadId, hostId: identity.hostId,
+ item: { type: identity.kind, approvalRequestId: identity.requestId } };
+ const root = { return: null, stateNode: {} };
+ root.stateNode.current = root;
+ const surface = node({
+ __reactFiber$test: {
+ memoizedProps: {}, return: {
+ memoizedProps: { actions }, return: { memoizedProps: owner, return: root }
+ }
+ },
+ querySelectorAll: () => [node({ disabled: options.actions?.approveDisabled }), node(),
+ ...options.disabledSecondary ? [node({ disabled: true })] : []]
+ });
+ const composer = node();
+ const portals = (options.portals ?? [options.currentThread ?? THREAD]).map((threadId) => node({
+ getAttribute: () => threadId,
+ closest: (selector) => selector === '[data-codex-composer-root]' ? composer : null
+ }));
+ composer.querySelectorAll = (selector) => selector === '[data-above-composer-conversation-id]' ? portals : [];
+ const context = {
+ getComputedStyle: () => ({ visibility: "visible" }),
+ document: {
+ activeElement: node(),
+ querySelector: () => null,
+ querySelectorAll: (selector) => {
+ if (selector === '[data-codex-composer-root]') return [composer];
+ if (selector === '[data-codex-approval-surface]') return options.noSurface ? []
+ : options.ambiguous ? [surface, node()] : [surface];
+ if (selector.startsWith('[data-app-action-sidebar-thread-id]')) return options.sidebarThread
+ ? [node({ getAttribute: () => options.sidebarThread })] : [];
+ if (selector.includes('[role="dialog"]')) return options.dialog ? [node()] : [];
+ return [];
+ }
+ }
+ };
+ return { context, hits, surface, actions, composer, portals };
+}
+
+test("renderer reads only exact actionable exec, patch, and turn-permission identities", () => {
+ for (const kind of ["exec", "patch", "permissionRequest"]) {
+ const fixture = rendererFixture({ identity: { kind } });
+ assert.deepEqual(JSON.parse(JSON.stringify(vm.runInNewContext(
+ approvalRequestExpression(THREAD), fixture.context
+ ))), request({ kind }));
+ assert.deepEqual(fixture.hits, []);
+ }
+});
+
+test("renderer fails closed for hidden, disabled, ambiguous, unknown or different-task cards", () => {
+ for (const options of [{ noSurface: true }, { ambiguous: true }, { unknownOwner: true },
+ { currentThread: OTHER }, { identity: { threadId: OTHER } }, { actions: { isLoading: true } },
+ { actions: { disableHotkeys: true } },
+ { actions: { approveLabel: "Always allow" } }, { dialog: true }]) {
+ const fixture = rendererFixture(options);
+ assert.equal(vm.runInNewContext(approvalRequestExpression(THREAD), fixture.context), null);
+ assert.equal(vm.runInNewContext(approvalResponseExpression("approve", request()), fixture.context).delivered, false);
+ assert.deepEqual(fixture.hits, []);
+ }
+ const fixture = rendererFixture();
+ fixture.surface.isConnected = false;
+ assert.equal(vm.runInNewContext(approvalRequestExpression(THREAD), fixture.context), null);
+});
+
+test("renderer derives current task from the composer's child portal with the sidebar unmounted", () => {
+ const fixture = rendererFixture();
+ assert.equal(fixture.composer.closest('[data-above-composer-conversation-id]'), null);
+ assert.equal(vm.runInNewContext(approvalRequestExpression(THREAD), fixture.context).threadId, THREAD);
+ assert.equal(vm.runInNewContext(approvalResponseExpression("approve", request()), fixture.context).delivered, true);
+ assert.deepEqual(fixture.hits, ["once"]);
+});
+
+test("renderer refuses conflicting visible portals or active sidebar identities", () => {
+ for (const options of [{ portals: [THREAD, OTHER] }, { portals: [THREAD, ""] },
+ { sidebarThread: OTHER }, { portals: [] }]) {
+ const fixture = rendererFixture(options);
+ assert.equal(vm.runInNewContext(approvalRequestExpression(THREAD), fixture.context), null);
+ assert.deepEqual(fixture.hits, []);
+ }
+ const fixture = rendererFixture({ portals: [], sidebarThread: THREAD });
+ assert.equal(vm.runInNewContext(approvalRequestExpression(THREAD), fixture.context), null);
+});
+
+test("disabled scope buttons do not block once/deny, and approve-only state does not block denial", () => {
+ for (const decision of ["approve", "decline"]) {
+ const fixture = rendererFixture({ disabledSecondary: true });
+ assert.equal(vm.runInNewContext(approvalResponseExpression(decision, request()), fixture.context).delivered, true);
+ assert.deepEqual(fixture.hits, [decision === "approve" ? "once" : "deny"]);
+ }
+ const fixture = rendererFixture({ actions: { approveDisabled: true } });
+ assert.equal(vm.runInNewContext(approvalRequestExpression(THREAD), fixture.context).requestId, 17);
+ assert.equal(vm.runInNewContext(approvalResponseExpression("approve", request()), fixture.context).delivered, false);
+ assert.deepEqual(fixture.hits, []);
+ assert.equal(vm.runInNewContext(approvalResponseExpression("decline", request()), fixture.context).delivered, true);
+ assert.deepEqual(fixture.hits, ["deny"]);
+});
+
+test("renderer rechecks request identity synchronously and selects only once/deny callbacks", () => {
+ for (const decision of ["approve", "decline"]) {
+ const fixture = rendererFixture();
+ assert.equal(vm.runInNewContext(approvalResponseExpression(decision, request({ requestId: 18 })), fixture.context).delivered, false);
+ assert.deepEqual(fixture.hits, []);
+ assert.equal(vm.runInNewContext(approvalResponseExpression(decision, request()), fixture.context).delivered, true);
+ assert.deepEqual(fixture.hits, [decision === "approve" ? "once" : "deny"]);
+ }
+});
+
+test("renderer resolves React's committed fiber and refuses callbacks from a previous request", () => {
+ const fixture = rendererFixture();
+ const replacement = rendererFixture({ identity: { requestId: 18 } });
+ const oldFiber = fixture.surface.__reactFiber$test;
+ const newFiber = replacement.surface.__reactFiber$test;
+ oldFiber.alternate = newFiber;
+ oldFiber.return.return.return.stateNode.current = newFiber.return.return.return;
+ assert.equal(vm.runInNewContext(approvalRequestExpression(THREAD), fixture.context).requestId, 18);
+ assert.equal(vm.runInNewContext(approvalResponseExpression("approve", request()), fixture.context).delivered, false);
+ assert.deepEqual(fixture.hits, []);
+ assert.deepEqual(replacement.hits, []);
+ oldFiber.alternate = null;
+ assert.equal(vm.runInNewContext(approvalRequestExpression(THREAD), fixture.context), null);
+});
+
+test("bridge approval dispatch never reconnects, activates Micro, or retries", async () => {
+ const bridge = new CodexMicroBridge();
+ const calls = [];
+ bridge.ensureConnected = async () => { calls.push("connect"); };
+ bridge.activateRuntime = async () => { throw new Error("must not activate"); };
+ bridge.evaluate = async (expression) => {
+ calls.push(expression);
+ return request();
+ };
+ assert.deepEqual(await bridge.readApprovalRequest(THREAD), request());
+ assert.equal(calls.length, 2);
+ calls.length = 0;
+ bridge.evaluate = async () => { calls.push("evaluate"); throw new Error("socket closed after dispatch"); };
+ await assert.rejects(() => bridge.respondToApproval("approve", request()),
+ (error) => error.delivery === "unknown");
+ assert.deepEqual(calls, ["evaluate"]);
+ await assert.rejects(() => bridge.respondToApproval("acceptForSession", request()), TypeError);
+ assert.deepEqual(calls, ["evaluate"]);
+});
diff --git a/test/approval-shortcuts.test.js b/test/approval-shortcuts.test.js
new file mode 100644
index 0000000..0b3fddc
--- /dev/null
+++ b/test/approval-shortcuts.test.js
@@ -0,0 +1,155 @@
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const { ApprovalShortcutControls } = require("../src/approval-shortcuts");
+
+const cardToken = `a1:${"d".repeat(64)}`;
+const context = { pid: 123, token: "v2:123:45:501:1", approval: { state: "ready", token: cardToken } };
+const output = (value) => ({ stdout: JSON.stringify(value) });
+const deferred = () => { let resolve; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; };
+
+test("a native press is captured once and sends one decision; repeated release does not send again", async () => {
+ const calls = [];
+ const controls = new ApprovalShortcutControls({ run: async (args) => { calls.push(args); return output(args.length === 1 ? context : { sent: true }); } });
+ const capture = await controls.capture();
+ assert.equal(capture.ok, true);
+ assert.equal((await controls.capture()).reason, "busy");
+ assert.deepEqual(await controls.execute("approve", capture.lease), { ok: true, delivery: "sent" });
+ assert.equal((await controls.execute("approve", capture.lease)).delivery, "none");
+ assert.deepEqual(calls, [["codex-approval-context"], ["codex-approval-card", "approve",
+ "123", context.token, cardToken]]);
+});
+
+test("cancellation before/during capture and before dispatch emits no input and releases reservation", async () => {
+ let isCurrent = true;
+ const pending = deferred();
+ const calls = [];
+ const controls = new ApprovalShortcutControls({ run: async (args) => { calls.push(args); return pending.promise; } });
+ assert.equal((await controls.capture(() => false)).ok, false);
+ assert.equal(calls.length, 0);
+ const capture = controls.capture(() => isCurrent);
+ isCurrent = false;
+ pending.resolve(output(context));
+ assert.equal((await capture).reason, "context-changed");
+ assert.equal(controls.active, null);
+ isCurrent = true;
+ const ready = await controls.capture();
+ assert.equal((await controls.execute("decline", ready.lease, () => false)).delivery, "none");
+ assert.equal(controls.active, null);
+ const cancelled = await controls.capture();
+ controls.cancel(cancelled.lease);
+ assert.equal((await controls.execute("decline", cancelled.lease)).delivery, "none");
+ assert.equal(calls.every((args) => args.length === 1), true);
+});
+
+test("concurrent native presses are rejected, including while delivery is unresolved", async () => {
+ const pending = deferred();
+ let sent = 0;
+ const controls = new ApprovalShortcutControls({ run: async (args) => args.length === 1 ? output(context) : (sent++, pending.promise) });
+ const capture = await controls.capture();
+ const dispatch = controls.execute("decline", capture.lease);
+ controls.cancel(capture.lease);
+ assert.equal((await controls.capture()).reason, "busy");
+ pending.resolve(output({ sent: true }));
+ assert.equal((await dispatch).delivery, "sent");
+ assert.equal(sent, 1);
+});
+
+test("native rejection remains a no-delivery error; transport loss is unknown and never retried", async () => {
+ for (const response of [output({ sent: false, error: "approval-changed" }), { stdout: "bad" }, new Error("timeout")]) {
+ let sends = 0;
+ const controls = new ApprovalShortcutControls({ run: async (args) => {
+ if (args.length === 1) return output(context);
+ sends++;
+ if (response instanceof Error) throw response;
+ return response;
+ } });
+ const capture = await controls.capture();
+ const result = await controls.execute("approve", capture.lease);
+ assert.equal(result.delivery, response.stdout?.includes("approval-changed") ? "none" : "unknown");
+ await controls.execute("approve", capture.lease);
+ assert.equal(sends, 1);
+ }
+ const denied = Object.assign(new Error("denied"), output({ sent: false, error: "permission-denied" }));
+ const controls = new ApprovalShortcutControls({ run: async () => { throw denied; } });
+ assert.equal((await controls.capture()).reason, "permission-denied");
+ assert.equal(controls.active, null);
+});
+
+test("review continuation invokes only the captured checked dialog and does not require shortcut bindings", async () => {
+ const token = `r1:${"a".repeat(64)}`;
+ const calls = [];
+ const controls = new ApprovalShortcutControls({ run: async (args) => { calls.push(args); return output(args.length === 1
+ ? { ...context, review: { state: "ready", token } } : { sent: true }); } });
+ const capture = await controls.capture();
+ assert.deepEqual(await controls.execute("approve", capture.lease), { ok: true, delivery: "sent" });
+ assert.deepEqual(calls[1], ["codex-review-continue", "123", context.token, token]);
+ assert.equal((await controls.execute("approve", capture.lease)).delivery, "none");
+ assert.equal(calls.length, 2);
+});
+
+test("unchecked, ambiguous, malformed reviews and Decline never fall back to a shortcut", async () => {
+ for (const [decision, review] of [
+ ["approve", { state: "unchecked", token: `r1:${"b".repeat(64)}` }],
+ ["approve", { state: "unavailable" }],
+ ["approve", { state: "ready", token: "invalid" }],
+ ["decline", { state: "ready", token: `r1:${"b".repeat(64)}` }]
+ ]) {
+ let calls = 0;
+ const controls = new ApprovalShortcutControls({ run: async () => { calls++; return output({ ...context, review }); } });
+ const capture = await controls.capture();
+ assert.equal((await controls.execute(decision, capture.lease)).delivery, "none");
+ assert.equal(calls, 1);
+ assert.equal(controls.active, null);
+ }
+});
+
+test("an uncertain review AXPress is never retried or replaced with a shortcut", async () => {
+ let calls = 0;
+ const controls = new ApprovalShortcutControls({ run: async () => ++calls === 1
+ ? output({ ...context, review: { state: "ready", token: `r1:${"c".repeat(64)}` } })
+ : Promise.reject(Object.assign(new Error("AXPress failed"), output({ sent: null, error: "delivery-unknown" }))) });
+ const capture = await controls.capture();
+ assert.equal((await controls.execute("approve", capture.lease)).delivery, "unknown");
+ await controls.execute("approve", capture.lease);
+ assert.equal(calls, 2);
+});
+
+
+test("Decline targets the same captured permission card without a keymap", async () => {
+ const calls = [];
+ const controls = new ApprovalShortcutControls({ run: async (args) => {
+ calls.push(args); return output(args.length === 1 ? context : { sent: true });
+ } });
+ const captured = await controls.capture();
+ assert.deepEqual(await controls.execute("decline", captured.lease), { ok: true, delivery: "sent" });
+ assert.deepEqual(calls[1], ["codex-approval-card", "decline", "123", context.token, cardToken]);
+});
+
+test("absent, ambiguous and malformed permission cards never dispatch keys or actions", async () => {
+ for (const approval of [undefined, null, {}, { state: "unavailable" },
+ { state: "ready" }, { state: "ready", token: "r1:" + "d".repeat(64) },
+ { state: "ready", token: "a1:" + "d".repeat(63) }]) {
+ for (const decision of ["approve", "decline"]) {
+ const calls = [];
+ const controls = new ApprovalShortcutControls({ run: async (args) => {
+ calls.push(args); return output({ ...context, approval });
+ } });
+ const captured = await controls.capture();
+ assert.deepEqual(await controls.execute(decision, captured.lease),
+ { ok: false, reason: "approval-unavailable", delivery: "none" });
+ assert.deepEqual(calls, [["codex-approval-context"]]);
+ assert.equal(controls.active, null);
+ }
+ }
+});
+
+test("capture diagnostics expose only fixed states and counts, never request text or tokens", async () => {
+ const controls = new ApprovalShortcutControls({ run: async () => output({ ...context,
+ scanDiagnostics: { visited: 579, complete: true, limitReason: "private text" },
+ approval: { state: "ready", token: cardToken, reason: "private justification" },
+ title: "private task title" }) });
+ const capture = await controls.capture();
+ assert.deepEqual(capture.diagnostics, { scanComplete: true, visited: 579,
+ scanLimit: "unknown", reviewState: "absent", cardState: "ready", cardReason: "unknown" });
+ controls.cancel(capture.lease);
+});
diff --git a/test/codex-ipc.test.js b/test/codex-ipc.test.js
new file mode 100644
index 0000000..2f09307
--- /dev/null
+++ b/test/codex-ipc.test.js
@@ -0,0 +1,414 @@
+"use strict";
+
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const { EventEmitter } = require("node:events");
+const { randomUUID } = require("node:crypto");
+const fs = require("node:fs/promises");
+const net = require("node:net");
+const os = require("node:os");
+const path = require("node:path");
+const { CodexIpcClient, validateCodexSocket } = require("../src/codex-ipc");
+
+const CLIENT = "10000000-0000-4000-8000-000000000001";
+const OWNER = "20000000-0000-4000-8000-000000000002";
+const OTHER = "30000000-0000-4000-8000-000000000003";
+
+function frame(message) {
+ const body = Buffer.isBuffer(message) ? message : Buffer.from(JSON.stringify(message));
+ const header = Buffer.alloc(4);
+ header.writeUInt32LE(body.length);
+ return Buffer.concat([header, body]);
+}
+
+function success(request, result = {}, handledByClientId = OWNER) {
+ return { type: "response", requestId: request.requestId, resultType: "success", method: request.method, handledByClientId, result };
+}
+
+function broadcast(params = {}, targetClientIds) {
+ return { type: "broadcast", method: "snapshot", version: 11, sourceClientId: OWNER, params,
+ ...(targetClientIds === undefined ? {} : { targetClientIds }) };
+}
+
+class FakeSocket extends EventEmitter {
+ constructor({ initialize = true } = {}) {
+ super();
+ this.destroyed = false;
+ this.writes = [];
+ this.initialize = initialize;
+ }
+ write(bytes, callback) {
+ const message = JSON.parse(bytes.subarray(4).toString("utf8"));
+ this.writes.push(message);
+ if (message.method === "initialize" && this.initialize) {
+ queueMicrotask(() => this.emit("data", frame(success(message, { clientId: CLIENT }))));
+ }
+ callback?.();
+ return true;
+ }
+ destroy() {
+ if (this.destroyed) return;
+ this.destroyed = true;
+ this.emit("close");
+ }
+ receive(message) { this.emit("data", frame(message)); }
+}
+
+function fixture(options = {}, socketOptions = {}) {
+ const sockets = [];
+ const client = new CodexIpcClient({
+ socketPath: "/tmp/threaddeck-ipc-fixture.sock", timeoutMs: 300,
+ validateSocket: async () => true,
+ connectSocket: () => {
+ const socket = new FakeSocket(socketOptions);
+ sockets.push(socket);
+ queueMicrotask(() => socket.emit("connect"));
+ return socket;
+ },
+ ...options
+ });
+ return { client, sockets, socket: () => sockets.at(-1) };
+}
+
+test("explicit connection shares initialization and assigns the server's client identity", async t => {
+ const { client, sockets, socket } = fixture();
+ t.after(() => client.close());
+ assert.equal(client.isReady(), false);
+ await assert.rejects(client.request("approval", {}), { code: "not-connected", delivery: "none" });
+ const first = client.connect();
+ assert.equal(client.connect(), first);
+ await first;
+ assert.equal(sockets.length, 1);
+ assert.equal(client.isReady(), true);
+ assert.equal(client.clientId, CLIENT);
+ assert.equal(socket().writes[0].sourceClientId, "initializing-client");
+ assert.deepEqual(socket().writes[0].params, { clientType: "threaddeck" });
+ assert.equal(socket().writes[0].version, 0);
+ await client.connect();
+ assert.equal(socket().writes.length, 1);
+ const epoch = client.epoch;
+ client.close();
+ assert.equal(client.isReady(), false);
+ assert.ok(client.epoch > epoch);
+ await client.connect();
+ assert.equal(sockets.length, 2);
+ assert.ok(client.epoch > epoch + 1);
+});
+
+test("request preserves routing, version, timeout, and correlates out-of-order responses", async t => {
+ const { client, socket } = fixture();
+ t.after(() => client.close());
+ await client.connect();
+ const first = client.request("owner", { first: true }, { version: 1, targetClientId: OWNER, hostId: "local", timeoutMs: 500 });
+ const second = client.request("owner", { second: true });
+ const [one, two] = socket().writes.slice(1);
+ assert.equal(one.sourceClientId, CLIENT);
+ assert.equal(one.targetClientId, OWNER);
+ assert.equal(one.hostId, "local");
+ assert.equal(one.version, 1);
+ assert.equal(one.timeoutMs, 500);
+ assert.notEqual(one.requestId, two.requestId);
+ socket().receive(success(two, { ordinal: 2 }));
+ socket().receive(success(one, { ordinal: 1 }));
+ assert.equal((await second).result.ordinal, 2);
+ assert.equal((await first).result.ordinal, 1);
+});
+
+test("split headers, split UTF-8 bodies, and multiple frames retain exact broadcast values", async t => {
+ const { client, socket } = fixture();
+ t.after(() => client.close());
+ await client.connect();
+ const values = [];
+ client.on("broadcast", message => values.push(message.params.text));
+ const one = frame(broadcast({ text: "Привет 🟡" }));
+ for (const byte of one) socket().emit("data", Buffer.from([byte]));
+ socket().emit("data", Buffer.concat([frame(broadcast({ text: "two" })), frame(broadcast({ text: "three" }))]));
+ assert.deepEqual(values, ["Привет 🟡", "two", "three"]);
+});
+
+test("early broadcasts wait for initialize and honor explicit target lists", async t => {
+ const { client, socket } = fixture({}, { initialize: false });
+ t.after(() => client.close());
+ const connecting = client.connect();
+ await new Promise(resolve => setImmediate(resolve));
+ const init = socket().writes[0];
+ socket().receive(broadcast({ text: "ours" }, [CLIENT]));
+ socket().receive(broadcast({ text: "other" }, [OTHER]));
+ socket().receive(broadcast({ text: "empty" }, []));
+ socket().receive(broadcast({ text: "all" }));
+ socket().receive(success(init, { clientId: CLIENT }));
+ await connecting;
+ const values = [];
+ client.on("broadcast", message => values.push(message.params.text));
+ await new Promise(resolve => setImmediate(resolve));
+ assert.deepEqual(values, ["ours", "all"]);
+});
+
+test("client advertises no request handlers and never executes incoming requests", async t => {
+ const { client, socket } = fixture();
+ t.after(() => client.close());
+ await client.connect();
+ const requestId = randomUUID();
+ socket().receive({ type: "client-discovery-request", requestId, request: { method: "arbitrary" } });
+ socket().receive({ type: "request", requestId: randomUUID(), method: "arbitrary" });
+ assert.equal(socket().writes.length, 2);
+ assert.deepEqual(socket().writes[1], { type: "client-discovery-response", requestId, response: { canHandle: false } });
+});
+
+test("broadcast uses one targeted frame and performs no implicit connection", async t => {
+ const { client, socket } = fixture();
+ t.after(() => client.close());
+ assert.throws(() => client.broadcast("follow", {}), { code: "not-connected", delivery: "none" });
+ await client.connect();
+ assert.equal(client.broadcast("follow", { following: true }, { version: 1, targetClientIds: [OWNER] }), true);
+ assert.deepEqual(socket().writes[1], {
+ type: "broadcast", method: "follow", sourceClientId: CLIENT, version: 1,
+ targetClientIds: [OWNER], params: { following: true }
+ });
+});
+
+for (const [error, delivery] of [
+ ["no-client-found", "none"], ["request-version-mismatch", "none"], ["no-handler-for-request", "none"],
+ ["request-timeout", "unknown"], ["client-disconnected", "unknown"], ["server-closed", "unknown"], ["future-error", "unknown"]
+]) {
+ test(`server ${error} response preserves the full error with ${delivery} delivery`, async t => {
+ const { client, socket } = fixture();
+ t.after(() => client.close());
+ await client.connect();
+ const request = client.request("approval", {}, { targetClientId: OWNER });
+ const requestId = socket().writes.at(-1).requestId;
+ socket().receive({ type: "response", requestId, resultType: "error", error });
+ assert.deepEqual(await request, { type: "response", requestId, resultType: "error", error, delivery });
+ assert.equal(client.isReady(), true);
+ });
+}
+
+test("timeout has unknown delivery, releases pending state, and ignores late responses", async t => {
+ const { client, socket } = fixture();
+ t.after(() => client.close());
+ await client.connect();
+ const timed = client.request("approval", {}, { timeoutMs: 10 });
+ const expired = socket().writes.at(-1);
+ await assert.rejects(timed, { code: "request-timeout", delivery: "unknown" });
+ assert.equal(client._state.pending.size, 0);
+ const current = client.request("approval", {});
+ socket().receive(success(expired, { expired: true }));
+ assert.equal(client._state.pending.size, 1);
+ socket().receive(success(socket().writes.at(-1), { current: true }));
+ assert.deepEqual((await current).result, { current: true });
+ assert.equal(socket().writes.length, 3);
+});
+
+test("disconnect invalidates all in-flight requests and ignores data from an older socket", async t => {
+ const { client, socket } = fixture();
+ t.after(() => client.close());
+ await client.connect();
+ const old = socket();
+ const pending = client.request("approval", {});
+ const rejected = assert.rejects(pending, { code: "disconnected", delivery: "unknown" });
+ old.emit("end");
+ await rejected;
+ assert.equal(client.isReady(), false);
+ await client.connect();
+ const messages = [];
+ client.on("broadcast", message => messages.push(message));
+ old.receive(broadcast());
+ old.emit("error", Error("stale error"));
+ assert.equal(client.isReady(), true);
+ assert.equal(messages.length, 0);
+});
+
+for (const [name, mutate] of [
+ ["method mismatch", response => { response.method = "different"; }],
+ ["target mismatch", response => { response.handledByClientId = OTHER; }],
+ ["missing handler", response => { delete response.handledByClientId; }],
+ ["invalid result type", response => { response.resultType = "unknown"; }]
+]) {
+ test(`correlated ${name} closes the connection without retry`, async t => {
+ const { client, socket } = fixture();
+ t.after(() => client.close());
+ await client.connect();
+ const pending = client.request("approval", {}, { targetClientId: OWNER });
+ const rejected = assert.rejects(pending, { code: "invalid-response", delivery: "unknown" });
+ const response = success(socket().writes.at(-1));
+ mutate(response);
+ socket().receive(response);
+ await rejected;
+ assert.equal(client.isReady(), false);
+ assert.equal(socket().writes.length, 2);
+ });
+}
+
+for (const [name, payload] of [
+ ["zero-length", Buffer.alloc(4)],
+ ["oversized", Buffer.from([1, 0, 0, 4])],
+ ["malformed JSON", frame(Buffer.from("{"))],
+ ["invalid UTF-8", frame(Buffer.from([0x22, 0xff, 0x22]))],
+ ["array envelope", frame([])]
+]) {
+ test(`${name} frame closes without retaining request state`, async t => {
+ const { client, socket } = fixture();
+ t.after(() => client.close());
+ await client.connect();
+ const state = client._state;
+ const pending = client.request("approval", {});
+ const rejected = assert.rejects(pending, error => error.delivery === "unknown");
+ socket().emit("data", payload);
+ await rejected;
+ assert.equal(client.isReady(), false);
+ assert.equal(state.pending.size, 0);
+ assert.equal(state.body, null);
+ });
+}
+
+test("bad initialization cannot establish client identity", async t => {
+ const { client, socket } = fixture({}, { initialize: false });
+ t.after(() => client.close());
+ const connecting = client.connect();
+ const rejected = assert.rejects(connecting, { code: "invalid-initialize" });
+ await new Promise(resolve => setImmediate(resolve));
+ socket().receive(success(socket().writes[0], { clientId: "not-a-uuid" }));
+ await rejected;
+ assert.equal(client.isReady(), false);
+ assert.equal(client.clientId, null);
+});
+
+test("connect timeout also bounds a silent initialize", async t => {
+ const { client, socket } = fixture({ timeoutMs: 15 }, { initialize: false });
+ t.after(() => client.close());
+ await assert.rejects(client.connect(), { code: "connect-timeout", delivery: "none" });
+ assert.equal(socket().destroyed, true);
+ assert.equal(client._state, null);
+});
+
+test("close during asynchronous validation prevents any later socket connection", async () => {
+ let release;
+ const validation = new Promise(resolve => { release = resolve; });
+ const { client, sockets } = fixture({ validateSocket: () => validation });
+ const connecting = client.connect();
+ const rejected = assert.rejects(connecting, { code: "closed", delivery: "none" });
+ client.close();
+ release(true);
+ await rejected;
+ await new Promise(resolve => setImmediate(resolve));
+ assert.equal(sockets.length, 0);
+});
+
+test("validator failure opens no socket and a changed identity sends no initialize", async () => {
+ const denied = fixture({ validateSocket: async () => false });
+ await assert.rejects(denied.client.connect(), { code: "untrusted-socket" });
+ assert.equal(denied.sockets.length, 0);
+ let validations = 0;
+ const changed = fixture({ validateSocket: async () => ({ dev: 1, ino: ++validations, parentDev: 1, parentIno: 3 }) });
+ await assert.rejects(changed.client.connect(), { code: "socket-changed" });
+ assert.equal(changed.socket().writes.length, 0);
+ assert.equal(changed.socket().destroyed, true);
+});
+
+test("failed writes are uncertain and never reissued", async t => {
+ const { client, socket } = fixture();
+ t.after(() => client.close());
+ await client.connect();
+ let writes = 0;
+ socket().write = () => { writes++; throw Error("partial write possible"); };
+ await assert.rejects(client.request("approval", {}), { code: "socket-write-failed", delivery: "unknown" });
+ assert.equal(writes, 1);
+ assert.equal(client.isReady(), false);
+});
+
+test("invalid outgoing routing and serialization fail before sending", async t => {
+ const { client, socket } = fixture({ maxFrameBytes: 512 });
+ t.after(() => client.close());
+ await client.connect();
+ await assert.rejects(client.request("approval", {}, { targetClientId: "wrong" }), { code: "invalid-target", delivery: "none" });
+ await assert.rejects(client.request("approval", {}, { timeoutMs: Infinity }), { code: "invalid-timeout", delivery: "none" });
+ await assert.rejects(client.request("approval", {}, { version: -1 }), { code: "invalid-request", delivery: "none" });
+ const circular = {}; circular.self = circular;
+ await assert.rejects(client.request("approval", circular), { code: "invalid-json", delivery: "none" });
+ await assert.rejects(client.request("approval", { huge: "x".repeat(512) }), { code: "frame-limit", delivery: "none" });
+ assert.equal(socket().writes.length, 1);
+ assert.equal(client._state.pending.size, 0);
+});
+
+test("pending request limit is enforced before another request is sent", async t => {
+ const { client, socket } = fixture({ timeoutMs: 5000 });
+ t.after(() => client.close());
+ await client.connect();
+ const pending = Array.from({ length: 4096 }, () => client.request("discovery", {}).catch(error => error));
+ await assert.rejects(client.request("approval", {}), { code: "pending-limit", delivery: "none" });
+ assert.equal(socket().writes.length, 4097);
+ client.close();
+ assert.equal((await Promise.all(pending)).every(error => error.delivery === "unknown"), true);
+});
+
+test("socket backpressure refuses another frame before dispatch", async t => {
+ const { client, socket } = fixture({ maxFrameBytes: 1024 });
+ t.after(() => client.close());
+ await client.connect();
+ socket().writableLength = 1024;
+ await assert.rejects(client.request("approval", {}), { code: "write-queue-limit", delivery: "none" });
+ assert.throws(() => client.broadcast("follow", {}), { code: "write-queue-limit", delivery: "none" });
+ assert.equal(socket().writes.length, 1);
+ assert.equal(client._state.pending.size, 0);
+ assert.equal(client.isReady(), true);
+});
+
+test("early broadcast queue is bounded", async t => {
+ const { client, socket } = fixture({}, { initialize: false });
+ t.after(() => client.close());
+ const connecting = client.connect();
+ const rejected = assert.rejects(connecting, { code: "broadcast-queue-limit" });
+ await new Promise(resolve => setImmediate(resolve));
+ for (let index = 0; index < 33; index++) socket().receive(broadcast({ index }));
+ await rejected;
+ assert.equal(client.isReady(), false);
+});
+
+test("real isolated Unix socket validates permissions and completes framed requests", async t => {
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "threaddeck-ipc-test-"));
+ await fs.chmod(directory, 0o700);
+ const socketPath = path.join(directory, "fixture.sock");
+ const peers = new Set();
+ const server = net.createServer(socket => {
+ peers.add(socket);
+ socket.on("close", () => peers.delete(socket));
+ let buffer = Buffer.alloc(0);
+ socket.on("data", chunk => {
+ buffer = Buffer.concat([buffer, chunk]);
+ while (buffer.length >= 4 && buffer.length >= buffer.readUInt32LE(0) + 4) {
+ const length = buffer.readUInt32LE(0);
+ const request = JSON.parse(buffer.subarray(4, length + 4));
+ buffer = buffer.subarray(length + 4);
+ if (request.type === "request") {
+ const response = frame(success(request, request.method === "initialize" ? { clientId: CLIENT } : { received: true }));
+ socket.write(response.subarray(0, 2));
+ socket.write(response.subarray(2));
+ }
+ }
+ });
+ });
+ const client = new CodexIpcClient({ socketPath });
+ t.after(async () => {
+ client.close();
+ for (const peer of peers) peer.destroy();
+ await new Promise(resolve => server.close(resolve));
+ await fs.rm(directory, { recursive: true, force: true });
+ });
+ await new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen(socketPath, resolve);
+ });
+ assert.equal(typeof (await validateCodexSocket(socketPath)).ino, "number");
+ await client.connect();
+ assert.deepEqual((await client.request("fixture-read", {}, { targetClientId: OWNER })).result, { received: true });
+ client.close();
+ const link = path.join(directory, "link.sock");
+ await fs.symlink(socketPath, link);
+ await assert.rejects(validateCodexSocket(link), { code: "untrusted-socket" });
+ const regular = path.join(directory, "regular");
+ await fs.writeFile(regular, "fixture");
+ await assert.rejects(validateCodexSocket(regular), { code: "untrusted-socket" });
+ await fs.chmod(directory, 0o770);
+ await assert.rejects(validateCodexSocket(socketPath), { code: "untrusted-socket" });
+ await fs.chmod(directory, 0o700);
+});
diff --git a/test/domain.test.js b/test/domain.test.js
index eadc1ef..f873c00 100644
--- a/test/domain.test.js
+++ b/test/domain.test.js
@@ -80,8 +80,9 @@ const {
test("configuration exposes a complete and internally consistent action contract", () => {
const actionValues = Object.values(ACTIONS);
- assert.equal(actionValues.length, 17);
- assert.equal(new Set(actionValues).size, 17);
+ assert.equal(actionValues.length, 18);
+ assert.equal(new Set(actionValues).size, 18);
+ assert.equal(ACTIONS.taskActions, "com.yechan.threaddeck.taskactions");
assert.equal(ACTIONS.fastMode, "com.yechan.threaddeck.fastmode");
assert.equal(ACTIONS.reasoning, "com.yechan.threaddeck.reasoning");
assert.equal(ACTIONS.topThread1, "com.yechan.threaddeck.thread.top1");
diff --git a/test/ipc-approvals.test.js b/test/ipc-approvals.test.js
new file mode 100644
index 0000000..ec4f7d2
--- /dev/null
+++ b/test/ipc-approvals.test.js
@@ -0,0 +1,329 @@
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const { EventEmitter } = require("node:events");
+const { IpcApprovalControls, approvalRequest, projectSnapshot } = require("../src/ipc-approvals");
+
+const threadId = "10000000-0000-4000-8000-000000000001";
+const owner = "20000000-0000-4000-8000-000000000001";
+const other = "30000000-0000-4000-8000-000000000001";
+const context = { ok: true, pid: 123, token: "api1:123:123456789:501:0", frontmost: false };
+const request = (overrides = {}) => ({ id: 7, method: "item/commandExecution/requestApproval",
+ params: { threadId, turnId: "turn-1", itemId: "item-1", command: "fixture-only" }, ...overrides });
+const clone = (v) => structuredClone(v);
+
+class Client extends EventEmitter {
+ constructor(requests = [request()]) { super(); this.epoch = 1; this.ready = false; this.requests = requests; this.revision = 1; this.calls = []; this.broadcasts = []; }
+ async connect() { this.ready = true; }
+ isReady() { return this.ready; }
+ close() { this.ready = false; this.epoch++; this.emit("disconnect"); }
+ async request(method, params, options) {
+ this.calls.push({ method, params: clone(params), options });
+ if (this.override) { const value = await this.override(method, params, options); if (value) return value; }
+ if (method === "thread-owner-discovery") return { resultType: "success", method, handledByClientId: owner, result: {} };
+ if (!this.keepRequest) { this.requests = []; this.revision++; }
+ return { resultType: "success", method, handledByClientId: owner, result: { ok: true } };
+ }
+ broadcast(method, params, options) {
+ this.broadcasts.push({ method, params, options });
+ if (!params.following || this.noSnapshot) return;
+ const change = { type: "snapshot", revision: this.revision,
+ conversationState: { id: threadId, hostId: "local", requests: clone(this.requests), turns: ["not cached"] } };
+ queueMicrotask(() => this.emit("broadcast", { method: "thread-stream-state-changed", version: this.version ?? 11,
+ sourceClientId: this.snapshotOwner ?? owner, params: { conversationId: threadId, hostId: "local", change } }));
+ }
+ patch(patches, baseRevision = this.revision) {
+ this.emit("broadcast", { method: "thread-stream-state-changed", version: 11, sourceClientId: owner,
+ params: { conversationId: threadId, hostId: "local", change: { type: "patches", baseRevision, revision: ++this.revision, patches } } });
+ }
+}
+function fixture(requests, readContext = async () => clone(context)) {
+ const client = new Client(requests);
+ return { client, controls: new IpcApprovalControls({ client, readContext, timeoutMs: 30 }) };
+}
+const decisions = (client) => client.calls.filter((c) => c.method !== "thread-owner-discovery");
+
+test("selected task uses exact IPC request ID while Codex is backgrounded, with no broader grant", async () => {
+ for (const decision of ["approve", "decline"]) {
+ const { controls, client } = fixture();
+ const capture = await controls.capture(threadId);
+ assert.equal(capture.ok, true);
+ assert.equal(await controls.capture(threadId).then((v) => v.reason), "busy");
+ assert.deepEqual(await controls.execute(decision, capture.lease), { ok: true, delivery: "owner-accepted" });
+ assert.deepEqual(decisions(client), [{ method: "thread-follower-command-approval-decision",
+ params: { conversationId: threadId, requestId: 7, decision: decision === "approve" ? "accept" : "decline" },
+ options: { version: 1, targetClientId: owner, timeoutMs: 30 } }]);
+ assert.equal(client.broadcasts.at(-1).params.following, false);
+ assert.equal(controls.active, null);
+ assert.equal(controls.record, null);
+ }
+});
+
+test("read-only capture never responds, including empty, unsupported and multiple pending requests", async () => {
+ for (const [requests, reason] of [
+ [[], "no-requests"], [[request(), request({ id: 8 })], "multiple-requests"],
+ [[request({ method: "item/tool/requestUserInput" })], "unsupported-request"],
+ [[request(), request({ id: 8, method: "item/tool/requestUserInput" })], "multiple-requests"]
+ ]) {
+ const { client, controls } = fixture(requests);
+ const result = await controls.capture(threadId);
+ assert.equal(result.reason, reason);
+ assert.notEqual(result.canFallback, true);
+ assert.equal(decisions(client).length, 0);
+ assert.equal(controls.active, null);
+ }
+});
+
+test("missing selection, invalid session and context cancellation cannot dispatch", async () => {
+ for (const invalid of [null, { ok: false, error: "session-locked" }, { ...context, token: "unknown" }, { ...context, pid: 0 }]) {
+ const { client, controls } = fixture(undefined, async () => invalid);
+ assert.equal((await controls.capture(threadId)).ok, false);
+ assert.equal(client.calls.length, 0);
+ }
+ const { client, controls } = fixture();
+ assert.equal((await controls.capture(null)).reason, "no-selected-task");
+ assert.equal((await controls.capture(threadId, () => false)).ok, false);
+ const capture = await controls.capture(threadId);
+ assert.equal((await controls.execute("approve", capture.lease, () => false)).delivery, "none");
+ assert.equal(decisions(client).length, 0);
+});
+
+test("changed request, second request and owner failure before dispatch all fail closed", async () => {
+ for (const change of [
+ (c) => { c.requests = [request({ id: 8 })]; c.revision++; },
+ (c) => { c.requests[0].params.command = "changed"; c.revision++; },
+ (c) => { c.requests.push(request({ id: 8 })); c.revision++; },
+ (c) => { c.override = async () => ({ resultType: "error", error: "no-client-found" }); }
+ ]) {
+ const { client, controls } = fixture();
+ const capture = await controls.capture(threadId);
+ change(client);
+ assert.equal((await controls.execute("approve", capture.lease)).delivery, "none");
+ assert.equal(decisions(client).length, 0);
+ }
+});
+
+test("numeric and string request IDs are distinct; a changed type cannot reuse a capture", async () => {
+ const { client, controls } = fixture();
+ const capture = await controls.capture(threadId);
+ client.requests[0].id = "7"; client.revision++;
+ assert.equal((await controls.execute("approve", capture.lease)).delivery, "none");
+ assert.equal(decisions(client).length, 0);
+ assert.notEqual(approvalRequest(request(), threadId).fingerprint,
+ approvalRequest(request({ id: "7" }), threadId).fingerprint);
+});
+
+test("unsupported protocol, wrong owner and malformed request snapshots cannot become ready", async () => {
+ for (const change of [
+ (c) => { c.version = 12; }, (c) => { c.snapshotOwner = other; },
+ (c) => { c.requests = [request({ id: null })]; }, (c) => { c.requests = [request(), request()]; }
+ ]) {
+ const { client, controls } = fixture(); change(client);
+ assert.equal((await controls.capture(threadId)).ok, false);
+ assert.equal(decisions(client).length, 0);
+ }
+});
+
+test("revision gaps, root/request patches and session changes during final check cannot dispatch", async () => {
+ for (const mutation of [
+ (c) => c.patch([{ op: "replace", path: ["requests"], value: [] }]),
+ (c) => c.patch([{ op: "replace", path: [], value: {} }]),
+ (c) => c.patch([{ op: "replace", path: ["turns", 0], value: {} }], -10),
+ (c) => c.close()
+ ]) {
+ let reads = 0;
+ const { client, controls } = fixture(undefined, async () => { if (++reads === 2) mutation(client); return context; });
+ const capture = await controls.capture(threadId);
+ assert.equal((await controls.execute("approve", capture.lease)).delivery, "none");
+ assert.equal(decisions(client).length, 0);
+ }
+ let reads = 0;
+ const { client, controls } = fixture(undefined, async () => ++reads === 1 ? context : { ...context, token: "api1:123:987654321:501:0" });
+ const capture = await controls.capture(threadId);
+ assert.equal((await controls.execute("approve", capture.lease)).delivery, "none");
+ assert.equal(decisions(client).length, 0);
+});
+
+test("unrelated valid patches do not confuse pending identity", async () => {
+ let reads = 0;
+ const { client, controls } = fixture(undefined, async () => {
+ if (++reads === 2) client.patch([{ op: "replace", path: ["turns", 0, "durationMs"], value: 5 }]);
+ return context;
+ });
+ const capture = await controls.capture(threadId);
+ assert.equal((await controls.execute("approve", capture.lease)).ok, true);
+});
+
+test("new owner snapshots during the session read cannot change the captured request or its uniqueness", async () => {
+ for (const mutate of [
+ (c) => { c.requests[0].params.command = "changed after refresh"; },
+ (c) => { c.requests.push(request({ id: 8 })); },
+ (c) => { c.requests = []; },
+ (c) => { c.requests[0].id = "7"; }
+ ]) {
+ let reads = 0;
+ const { client, controls } = fixture(undefined, async () => {
+ if (++reads === 2) {
+ mutate(client);
+ client.emit("broadcast", { method: "thread-stream-state-changed", version: 11,
+ sourceClientId: owner, params: { conversationId: threadId, hostId: "local",
+ change: { type: "snapshot", revision: ++client.revision,
+ conversationState: { id: threadId, hostId: "local", requests: clone(client.requests) } } } });
+ }
+ return context;
+ });
+ const capture = await controls.capture(threadId);
+ assert.deepEqual(await controls.execute("approve", capture.lease),
+ { ok: false, reason: "request-changed", delivery: "none" });
+ assert.equal(decisions(client).length, 0);
+ assert.equal(controls.active, null);
+ }
+});
+
+test("selected task must be verified strictly at capture and again before dispatch", async () => {
+ for (const verifyTarget of [async () => false, async () => 1, async () => undefined,
+ async () => { throw Error("selection unavailable"); }]) {
+ const { client, controls } = fixture();
+ assert.equal((await controls.capture(threadId, () => true, verifyTarget)).reason, "target-changed");
+ assert.equal(client.calls.length, 0);
+ assert.equal(controls.active, null);
+ }
+ const { client, controls } = fixture();
+ let verifications = 0;
+ const capture = await controls.capture(threadId, () => true, async () => ++verifications === 1);
+ assert.equal(capture.ok, true);
+ assert.deepEqual(await controls.execute("approve", capture.lease),
+ { ok: false, reason: "target-changed", delivery: "none" });
+ assert.equal(verifications, 2);
+ assert.equal(decisions(client).length, 0);
+});
+
+test("a snapshot changing during selected-task verification cannot dispatch", async () => {
+ const { client, controls } = fixture();
+ let verifications = 0;
+ const capture = await controls.capture(threadId, () => true, async () => {
+ if (++verifications === 2) {
+ client.requests[0].params.command = "changed during selection verification";
+ client.emit("broadcast", { method: "thread-stream-state-changed", version: 11,
+ sourceClientId: owner, params: { conversationId: threadId, hostId: "local",
+ change: { type: "snapshot", revision: ++client.revision,
+ conversationState: { id: threadId, hostId: "local", requests: clone(client.requests) } } } });
+ }
+ return true;
+ });
+ assert.deepEqual(await controls.execute("approve", capture.lease),
+ { ok: false, reason: "request-changed", delivery: "none" });
+ assert.equal(decisions(client).length, 0);
+});
+
+test("concurrent decisions share one lease and cancellation during the final read prevents dispatch", async () => {
+ const { client, controls } = fixture();
+ const capture = await controls.capture(threadId);
+ let resumeOwner, enteredOwner;
+ const ownerEntered = new Promise((resolve) => { enteredOwner = resolve; });
+ client.override = async (method) => {
+ if (method !== "thread-owner-discovery") return;
+ enteredOwner();
+ await new Promise((resolve) => { resumeOwner = resolve; });
+ };
+ const execution = controls.execute("approve", capture.lease);
+ await ownerEntered;
+ assert.equal((await controls.execute("decline", capture.lease)).reason, "busy");
+ resumeOwner();
+ assert.equal((await execution).ok, true);
+ assert.equal(decisions(client).length, 1);
+
+ let reads = 0, cancelledLease;
+ const cancelled = fixture(undefined, async () => {
+ if (++reads === 2) cancelled.controls.cancel(cancelledLease);
+ return context;
+ });
+ cancelledLease = (await cancelled.controls.capture(threadId)).lease;
+ assert.equal((await cancelled.controls.execute("approve", cancelledLease)).delivery, "none");
+ assert.equal(decisions(cancelled.client).length, 0);
+ assert.equal(cancelled.controls.active, null);
+});
+
+test("acknowledgment without disappearance and disappearance without matching ack are unknown, never retried", async () => {
+ for (const mutate of [
+ (c) => { c.keepRequest = true; },
+ (c) => { c.override = async (method) => {
+ if (method === "thread-owner-discovery") return;
+ c.requests = []; c.revision++; return { resultType: "success", method, handledByClientId: other, result: { ok: true } };
+ }; },
+ (c) => { c.override = async (method) => { if (method !== "thread-owner-discovery") throw Error("disconnect"); }; }
+ ]) {
+ const { client, controls } = fixture();
+ const capture = await controls.capture(threadId); mutate(client);
+ assert.equal((await controls.execute("approve", capture.lease)).delivery, "unknown");
+ client.requests = [request()]; client.revision++;
+ assert.equal((await controls.capture(threadId)).reason, "already-handled");
+ assert.equal((await controls.execute("approve", capture.lease)).ok, false);
+ assert.equal(decisions(client).length, 1);
+ }
+});
+
+test("a definitely unrouted decision allows a later deliberate attempt", async () => {
+ const { client, controls } = fixture();
+ const first = await controls.capture(threadId);
+ client.override = async (method) => method === "thread-owner-discovery" ? undefined
+ : { resultType: "error", error: "no-client-found", delivery: "none" };
+ assert.equal((await controls.execute("approve", first.lease)).delivery, "none");
+ client.override = null;
+ const next = await controls.capture(threadId);
+ assert.equal((await controls.execute("approve", next.lease)).ok, true);
+ assert.equal(decisions(client).length, 2);
+});
+
+test("permission grant is limited to the requested subset and current turn", async () => {
+ for (const decision of ["approve", "decline"]) {
+ const permissions = { network: { enabled: true } };
+ const r = request({ method: "item/permissions/requestApproval", params: { threadId, turnId: "turn", itemId: "item", permissions } });
+ const { client, controls } = fixture([r]);
+ const capture = await controls.capture(threadId);
+ assert.equal((await controls.execute(decision, capture.lease)).ok, true);
+ assert.deepEqual(decisions(client)[0].params.response, { permissions: decision === "approve" ? permissions : {}, scope: "turn" });
+ }
+});
+
+test("only empty tool-approval elicitation forms accept a one-press response", async () => {
+ const r = request({ method: "mcpServer/elicitation/request", params: { threadId, turnId: "turn", mode: "form",
+ _meta: { codex_approval_kind: "mcp_tool_call" }, requestedSchema: { type: "object", properties: {} } } });
+ for (const decision of ["approve", "decline"]) {
+ const { client, controls } = fixture([clone(r)]);
+ const capture = await controls.capture(threadId);
+ assert.equal((await controls.execute(decision, capture.lease)).ok, true);
+ assert.deepEqual(decisions(client)[0].params.response,
+ { action: decision === "approve" ? "accept" : "decline", content: decision === "approve" ? {} : null });
+ assert.equal(Object.hasOwn(decisions(client)[0].params.response, "_meta"), false);
+ }
+ for (const mutate of [
+ (p) => { p.mode = "url"; }, (p) => { p.mode = "openai/form"; },
+ (p) => { p._meta.codex_approval_kind = "connector_auth"; },
+ (p) => { p.requestedSchema.properties.agree = { type: "boolean" }; },
+ (p) => { p.requestedSchema.required = ["agree"]; },
+ (p) => { p.requestedSchema.allOf = []; }
+ ]) {
+ const value = clone(r); mutate(value.params);
+ assert.equal(approvalRequest(value, threadId), null);
+ }
+});
+
+test("file decisions preserve routing and unsupported session-only choices do not broaden approval", async () => {
+ const file = fixture([request({ method: "item/fileChange/requestApproval" })]);
+ const capture = await file.controls.capture(threadId);
+ assert.equal((await file.controls.execute("decline", capture.lease)).ok, true);
+ assert.equal(decisions(file.client)[0].method, "thread-follower-file-approval-decision");
+ const r = request(); r.params.availableDecisions = ["acceptForSession", "decline"];
+ const { controls, client } = fixture([r]);
+ const onlySession = await controls.capture(threadId);
+ assert.equal((await controls.execute("approve", onlySession.lease)).reason, "unsupported-decision");
+ assert.equal(decisions(client).length, 0);
+});
+
+test("only pending state is retained, malformed identity and oversized state are rejected", () => {
+ const projected = projectSnapshot({ id: threadId, hostId: "local", requests: [request()], turns: ["private content"] }, threadId);
+ assert.deepEqual(Object.keys(projected), ["requests", "signature"]);
+ assert.equal(projectSnapshot({ id: other, hostId: "local", requests: [] }, threadId), null);
+ assert.equal(projectSnapshot({ id: threadId, hostId: "remote", requests: [] }, threadId), null);
+ assert.equal(projectSnapshot({ id: threadId, hostId: "local", requests: [request({ oversized: "x".repeat(300000) })] }, threadId), null);
+});
diff --git a/test/micro-cdp.test.js b/test/micro-cdp.test.js
index 5e6383e..58cb8b5 100644
--- a/test/micro-cdp.test.js
+++ b/test/micro-cdp.test.js
@@ -519,3 +519,43 @@ test("task switching matches canonical ids but sends the exact Micro slot key",
assert.match(expression, new RegExp(`local:${threadId}`));
assert.match(expression, /AG02/);
});
+
+test("approval readiness reads only an existing open or prepared connection", () => {
+ const unexpected = () => assert.fail("readiness must not prepare or perform I/O");
+ let prepared = false;
+ class ReadySocket {
+ static OPEN = 1;
+ constructor() { unexpected(); }
+ }
+ const bridge = new CodexMicroBridge({
+ WebSocket: ReadySocket, fetch: unexpected, execFile: unexpected, readFile: unexpected,
+ preparedBridge: { isReady: () => prepared, prepare: unexpected, canAttach: unexpected }
+ });
+ bridge.connect = unexpected;
+ bridge.ensureConnected = unexpected;
+ bridge.prepareCommandBridge = unexpected;
+ assert.equal(bridge.isReady(), false);
+ bridge.connecting = Promise.resolve();
+ bridge.onDemandAttached = true;
+ bridge.lastSnapshot = { connected: true };
+ assert.equal(bridge.isReady(), false, "pending preparation and stale state are not readiness");
+ for (const readyState of [0, 2, 3]) {
+ bridge.socket = { readyState };
+ assert.equal(bridge.isReady(), false);
+ }
+ bridge.socket = { readyState: 1 };
+ assert.equal(bridge.isReady(), true);
+ bridge.socket = null;
+ prepared = true;
+ assert.equal(bridge.isReady(), true, "a prepared command-only bridge remains available");
+ prepared = false;
+ assert.equal(bridge.isReady(), false);
+ bridge.socket = {};
+ for (const WebSocket of [undefined, {}]) {
+ bridge.WebSocket = WebSocket;
+ assert.equal(bridge.isReady(), false, "missing socket constants cannot match undefined state");
+ prepared = true;
+ assert.equal(bridge.isReady(), true);
+ prepared = false;
+ }
+});
diff --git a/test/plugin-verification.test.js b/test/plugin-verification.test.js
index de1197b..9399f77 100644
--- a/test/plugin-verification.test.js
+++ b/test/plugin-verification.test.js
@@ -10,7 +10,7 @@ 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"]) {
+for (const mode of ["completion", "refresh-resilience", "usage-cache", "voice-submit", "interactions", "approvals"]) {
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}`
diff --git a/test/property-inspector.test.js b/test/property-inspector.test.js
index b66f7f5..dbbfe04 100644
--- a/test/property-inspector.test.js
+++ b/test/property-inspector.test.js
@@ -12,6 +12,7 @@ const SCRIPT = fs.readFileSync(
class FakeElement {
constructor(dataset = {}) {
+ this.children = [];
this.attributes = {};
this.dataset = dataset;
this.hidden = false;
@@ -36,6 +37,13 @@ function createHost(navigatorLanguage = "en-US") {
["task-settings", new FakeElement()],
["command-settings", new FakeElement()],
["navigation-settings", new FakeElement()],
+ ["task-actions-settings", new FakeElement()],
+ ["approval-target-settings", new FakeElement()],
+ ["command-help", new FakeElement({ copy: "commandHelp" })],
+ ["task-action-help", new FakeElement({ copy: "approvalHelp" })],
+ ["approval-target-help", new FakeElement()],
+ ["task-action", new FakeElement({ setting: "command" })],
+ ["approval-target", new FakeElement({ setting: "approvalTarget" })],
["task-source", new FakeElement({ setting: "taskSource" })],
["command", new FakeElement({ setting: "command" })],
["page-direction", new FakeElement({ setting: "pageDirection" })],
@@ -45,6 +53,17 @@ function createHost(navigatorLanguage = "en-US") {
const html = fs.readFileSync(path.join(ROOT, "com.yechan.threaddeck.sdPlugin/property-inspector/index.html"), "utf8");
const localizedElements = new Map([...html.matchAll(/data-copy="([^"]+)"/g)]
.map((match) => [match[1], new FakeElement({ copy: match[1] })]));
+ for (const element of elements.values()) {
+ if (element.dataset.copy) localizedElements.set(element.dataset.copy, element);
+ }
+ for (const [, id, body] of html.matchAll(/]*>([\s\S]*?)<\/select>/g)) {
+ for (const [, value, copy, text] of body.matchAll(/]*>([^<]*)<\/option>/g)) {
+ const option = new FakeElement(copy ? { copy } : {});
+ option.value = value;
+ option.textContent = text;
+ elements.get(id).children.push(option);
+ }
+ }
const sockets = [];
class FakeWebSocket {
@@ -66,6 +85,12 @@ function createHost(navigatorLanguage = "en-US") {
this.sent.push(JSON.parse(message));
}
+ receive(message) {
+ this.listeners.get("message")?.({ data: typeof message === "string" ? message : JSON.stringify(message) });
+ }
+
+ close() { this.readyState = 3; }
+
open() {
this.readyState = FakeWebSocket.OPEN;
this.listeners.get("open")?.();
@@ -87,11 +112,13 @@ function createHost(navigatorLanguage = "en-US") {
return optionElements.get(match[1]);
},
querySelectorAll(selector) {
- if (selector === "[data-copy]") return [...localizedElements.values()];
+ if (selector === "[data-copy]") return [...localizedElements.values(), ...[...elements.values()].flatMap((element) => element.children).filter((option) => option.dataset.copy)];
if (selector === "select[data-setting]") {
return [
elements.get("task-source"),
elements.get("command"),
+ elements.get("task-action"),
+ elements.get("approval-target"),
elements.get("page-direction")
];
}
@@ -228,3 +255,225 @@ test("Property Inspector can relocalize all task slots between supported languag
assert.equal(localizedElements.get("help").textContent, help);
}
});
+
+
+const TASK_ACTION = "com.yechan.threaddeck.thread1";
+const COMMAND_ACTION = "com.yechan.threaddeck.newthread";
+const TASK_ACTIONS_ACTION = "com.yechan.threaddeck.taskactions";
+const NAVIGATION_ACTION = "com.yechan.threaddeck.page.previous";
+
+function createInspector(language = "en-US") {
+ const host = createHost(language);
+ return {
+ ...host,
+ connect(settings, action = TASK_ACTION, context = "task-context") {
+ host.sandbox.connectElgatoStreamDeckSocket("28196", context, "registerPropertyInspector", {}, {
+ action, context, payload: { settings }
+ });
+ return host.sockets.at(-1);
+ },
+ change(id, value) {
+ const element = host.elements.get(id);
+ element.value = value;
+ element.listeners.get("change")?.();
+ }
+ };
+}
+function saved(socket) { return socket.sent.filter((message) => message.event === "setSettings"); }
+function requests(socket) { return socket.sent.filter((message) => message.event === "sendToPlugin"); }
+
+test("Approve and Decline autosave and explain pending approval behavior", () => {
+ const ui = createInspector();
+ const socket = ui.connect({ command: "approve", customSetting: "kept" }, COMMAND_ACTION, "command-context");
+ socket.open();
+ assert.equal(ui.elements.get("command").value, "approve");
+ assert.equal(ui.elements.get("command-settings").hidden, false);
+ assert.match(ui.elements.get("command-help").textContent, /pending request/);
+ assert.match(ui.elements.get("command-help").textContent, /Blue Sent means the action was sent/);
+ assert.equal(ui.elements.get("approval-target-settings").hidden, false);
+ assert.equal(ui.elements.get("approval-target").value, "task-key");
+ assert.doesNotMatch(ui.elements.get("command-help").textContent, /Command\+Return/);
+ ui.change("command", "decline");
+ assert.deepEqual(saved(socket)[0].payload, { command: "decline", customSetting: "kept" });
+ assert.match(ui.elements.get("command-help").textContent, /Blue Sent means the action was sent/);
+ ui.change("command", "send");
+ assert.match(ui.elements.get("command-help").textContent, /Command\+Return/);
+ assert.equal(ui.elements.get("approval-target-settings").hidden, true);
+ assert.deepEqual(requests(socket), []);
+});
+
+test("Task actions exposes only approval decisions and preserves settings on save", () => {
+ const ui = createInspector();
+ const socket = ui.connect({ customSetting: "kept" }, TASK_ACTIONS_ACTION, "actions-context");
+ socket.open();
+ assert.equal(ui.elements.get("task-actions-settings").hidden, false);
+ assert.equal(ui.elements.get("command-settings").hidden, true);
+ assert.equal(ui.elements.get("task-settings").hidden, true);
+ assert.equal(ui.elements.get("task-action").value, "approve");
+ assert.deepEqual(ui.elements.get("task-action").children.map((option) => option.value), ["approve", "decline"]);
+ assert.match(ui.elements.get("task-action-help").textContent, /Blue Sent means the action was sent/);
+ assert.equal(ui.elements.get("approval-target-settings").hidden, false);
+ assert.equal(saved(socket).length, 0, "opening settings must not change the profile");
+ ui.change("task-action", "decline");
+ assert.deepEqual(saved(socket)[0].payload, { customSetting: "kept", command: "decline" });
+ assert.equal(saved(socket)[0].context, "actions-context");
+ ui.change("task-action", "send");
+ assert.equal(saved(socket).length, 1, "unrelated commands must not be saved");
+ assert.deepEqual(requests(socket), []);
+ socket.receive({ event: "didReceiveSettings", context: "actions-context", payload: { settings: { command: "send" } } });
+ assert.equal(ui.elements.get("task-action").value, "", "invalid settings must not look like approval");
+ assert.equal(ui.elements.get("approval-target-settings").hidden, true);
+});
+
+test("approval target autosaves for both approval actions without altering unrelated settings", () => {
+ for (const action of [TASK_ACTIONS_ACTION, COMMAND_ACTION]) {
+ const ui = createInspector();
+ const socket = ui.connect({ command: "approve", customSetting: "kept" }, action, "approval-context");
+ socket.open();
+ assert.equal(ui.elements.get("approval-target").value, "task-key");
+ assert.deepEqual(saved(socket), [], "showing the default must not rewrite settings");
+ assert.match(ui.elements.get("approval-target-help").textContent, /name appears on Approve and Decline/);
+ assert.match(ui.elements.get("approval-target-help").textContent, /stays selected when you switch tasks manually in Codex/);
+ assert.match(ui.elements.get("approval-target-help").textContent, /another app has focus/);
+ ui.change("approval-target", "current-dialog");
+ assert.deepEqual(saved(socket).at(-1).payload, { command: "approve", customSetting: "kept", approvalTarget: "current-dialog" });
+ assert.match(ui.elements.get("approval-target-help").textContent, /intended task and request in the foreground/);
+ assert.match(ui.elements.get("approval-target-help").textContent, /Review findings/);
+ assert.match(ui.elements.get("approval-target-help").textContent, /check the acknowledgment in Codex first/);
+ ui.change(action === TASK_ACTIONS_ACTION ? "task-action" : "command", "decline");
+ assert.equal(saved(socket).at(-1).payload.approvalTarget, "current-dialog");
+ ui.change("approval-target", "task-key");
+ assert.deepEqual(saved(socket).at(-1).payload, { command: "decline", customSetting: "kept", approvalTarget: "task-key" });
+ assert.deepEqual(requests(socket), [], "approval target changes only settings");
+ }
+});
+
+test("unknown approval targets stay unselected and preserved until explicitly replaced", () => {
+ for (const approvalTarget of [null, "", "future-mode", false, 0, "TASK-KEY", " current-dialog "]) {
+ const ui = createInspector();
+ const socket = ui.connect({ command: "approve", approvalTarget }, TASK_ACTIONS_ACTION, "approval-context");
+ socket.open();
+ assert.equal(ui.elements.get("approval-target").value, "");
+ assert.match(ui.elements.get("approval-target-help").textContent, /not supported/);
+ assert.equal(saved(socket).length, 0);
+ ui.change("task-action", "decline");
+ assert.deepEqual(saved(socket).at(-1).payload, { command: "decline", approvalTarget });
+ ui.change("approval-target", "invalid");
+ assert.equal(saved(socket).length, 1, "unknown or placeholder values cannot be saved as a mode");
+ ui.change("approval-target", "current-dialog");
+ assert.deepEqual(saved(socket).at(-1).payload, { command: "decline", approvalTarget: "current-dialog" });
+ }
+});
+
+test("non-approval actions hide target settings and cannot save a target through the hidden control", () => {
+ for (const [action, settings] of [
+ [TASK_ACTION, { taskSource: "current" }], [NAVIGATION_ACTION, {}],
+ [COMMAND_ACTION, { command: "send" }], [COMMAND_ACTION, { command: "new-task" }],
+ [COMMAND_ACTION, { command: "side-chat" }], [TASK_ACTIONS_ACTION, { command: "send" }]
+ ]) {
+ const ui = createInspector();
+ const socket = ui.connect(settings, action);
+ socket.open();
+ assert.equal(ui.elements.get("approval-target-settings").hidden, true);
+ ui.change("approval-target", "current-dialog");
+ assert.equal(saved(socket).length, 0);
+ }
+});
+
+test("approval target reflects external settings and survives command switches", () => {
+ const ui = createInspector();
+ const socket = ui.connect({ command: "send", approvalTarget: "current-dialog" }, COMMAND_ACTION, "approval-context");
+ socket.open();
+ ui.change("command", "approve");
+ assert.equal(ui.elements.get("approval-target-settings").hidden, false);
+ assert.equal(ui.elements.get("approval-target").value, "current-dialog");
+ socket.receive({ event: "didReceiveSettings", context: "approval-context", payload: {
+ settings: { command: "decline", approvalTarget: "task-key" }
+ } });
+ assert.equal(ui.elements.get("approval-target").value, "task-key");
+ ui.change("command", "new-task");
+ assert.equal(ui.elements.get("approval-target-settings").hidden, true);
+ assert.equal(saved(socket).at(-1).payload.approvalTarget, "task-key");
+ ui.change("command", "decline");
+ assert.equal(ui.elements.get("approval-target").value, "task-key");
+});
+
+test("approval keys explain the visible-card action without changing Codex shortcuts", () => {
+ const ui = createInspector();
+ ui.sandbox.connectElgatoStreamDeckSocket("28196", "inspector-session", "registerPropertyInspector", {}, {
+ action: TASK_ACTIONS_ACTION, context: "task-instance", payload: { settings: { command: "approve", approvalTarget: "current-dialog" } }
+ });
+ const socket = ui.sockets.at(-1);
+ socket.open();
+ assert.deepEqual(requests(socket), []);
+ assert.equal(ui.elements.has("prepare-approval-shortcuts"), false);
+ assert.match(ui.elements.get("approval-target-help").textContent, /visible permission request/);
+ assert.match(ui.elements.get("approval-target-help").textContent, /No keyboard shortcut setup/);
+ assert.deepEqual(saved(socket), []);
+});
+
+test("approval copy is localized in every supported host language", () => {
+ for (const [language, approve, decline, target] of [
+ ["en", "Approve", "Decline", "Task selected on Stream Deck"],
+ ["ko", "승인", "거절", "Stream Deck에서 선택한 작업"],
+ ["ru", "Одобрить", "Отклонить", "Задача, выбранная на Stream Deck"]
+ ]) {
+ const ui = createInspector();
+ ui.sandbox.connectElgatoStreamDeckSocket("28196", "context", "registerPropertyInspector",
+ { application: { language } }, { action: TASK_ACTIONS_ACTION, payload: { settings: {} } });
+ const label = (id, value) => ui.elements.get(id).children.find((option) => option.value === value).textContent;
+ assert.equal(label("command", "approve"), approve);
+ assert.equal(label("command", "decline"), decline);
+ assert.equal(label("task-action", "approve"), approve);
+ assert.equal(label("task-action", "decline"), decline);
+ assert.equal(label("approval-target", "task-key"), target);
+ }
+});
+
+test("external settings target the active inspector without replacing pending user edits", () => {
+ const ui = createInspector();
+ const socket = ui.connect({ command: "approve" }, TASK_ACTIONS_ACTION, "active-context");
+ const message = (context, settings, action = TASK_ACTIONS_ACTION) => ({
+ event: "didReceiveSettings", context, action, payload: { settings }
+ });
+ socket.open();
+ socket.receive(message("other-context", { command: "decline" }));
+ socket.receive(message("active-context", { command: "decline" }, COMMAND_ACTION));
+ socket.receive("{invalid");
+ socket.receive("null");
+ assert.equal(ui.elements.get("task-action").value, "approve");
+ socket.receive(message("active-context", { command: "decline", approvalTarget: "current-dialog" }));
+ assert.equal(ui.elements.get("task-action").value, "decline");
+ const next = ui.connect({ command: "approve" }, TASK_ACTIONS_ACTION, "new-context");
+ ui.change("approval-target", "current-dialog");
+ next.receive(message("new-context", { command: "decline" }));
+ socket.receive(message("new-context", { command: "decline" }));
+ socket.open();
+ assert.equal(ui.elements.get("task-action").value, "approve");
+ next.open();
+ assert.deepEqual(saved(next).map((entry) => entry.payload), [{ command: "approve", approvalTarget: "current-dialog" }]);
+});
+
+test("settings messages may identify the action instance while saves use the inspector session", () => {
+ const ui = createInspector();
+ ui.sandbox.connectElgatoStreamDeckSocket("28196", "inspector-session", "registerPropertyInspector", {}, {
+ action: TASK_ACTIONS_ACTION, context: "action-instance", payload: { settings: {} }
+ });
+ const socket = ui.sockets.at(-1);
+ socket.open();
+ socket.receive({ event: "didReceiveSettings", context: "action-instance", action: TASK_ACTIONS_ACTION,
+ payload: { settings: { command: "decline", approvalTarget: "current-dialog" } } });
+ assert.equal(ui.elements.get("task-action").value, "decline");
+ ui.change("approval-target", "task-key");
+ assert.equal(saved(socket).at(-1).context, "inspector-session");
+});
+
+test("approval extraction leaves the existing task sources and profiles unchanged", () => {
+ const ui = createInspector();
+ const socket = ui.connect({ taskSource: "top2", customSetting: "kept" });
+ socket.open();
+ assert.deepEqual(ui.elements.get("task-source").children.map((option) => option.value),
+ ["current", "top1", "top2", "top3", "top4", "top5", "top6", "top7", "top8"]);
+ assert.deepEqual(saved(socket), []);
+ assert.deepEqual(requests(socket), []);
+});