From 6e97d4d5c9031521594233af3c158e4c9a11ef6b Mon Sep 17 00:00:00 2001 From: Dazer <47606394+dazer1234@users.noreply.github.com> Date: Sat, 18 Jul 2026 06:05:53 +0200 Subject: [PATCH 1/3] Keep the Windows Codex Deck bridge available --- CHANGELOG.md | 8 ++ README.md | 13 ++- docs/ARCHITECTURE.md | 14 +++ docs/TROUBLESHOOTING.md | 4 +- launcher/README.txt | 15 ++- launcher/Start-CodexDeck.ps1 | 27 ++++- launcher/Watch-CodexDeck.ps1 | 219 +++++++++++++++++++++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- scripts/build-launcher.mjs | 2 +- static/manifest.json | 2 +- test/launcher.test.ts | 35 ++++++ 12 files changed, 330 insertions(+), 15 deletions(-) create mode 100644 launcher/Watch-CodexDeck.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dbe80a..70fb1a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.5.1 - Unreleased + +- Replaced the one-shot Windows-login launcher with a persistent, single-instance bridge watcher. +- Automatically recovers the bridge after Codex updates, crashes, and normal restarts. +- Detects rapid Codex restarts by main-process generation even when no stopped interval is observed. +- Avoids touching a normal Codex session that was already open when the watcher is first installed. +- Removes stale bridge-port files and records bounded diagnostics in `%LOCALAPPDATA%\CodexDeck\watcher.log`. + ## 0.5.0 - 2026-07-17 - The launcher reuses a healthy existing loopback debug session instead of restarting Codex on every run. diff --git a/README.md b/README.md index 7179939..645a675 100644 --- a/README.md +++ b/README.md @@ -45,11 +45,15 @@ Other 15-key Stream Deck models may work, but have not been verified yet. 5. Open **Codex Settings > Codex Micro** and choose the agent source, action assignments, joystick actions, and encoder behavior you want. 6. In Stream Deck, drag the Codex Deck actions onto your keys using the layout below. -Use **Start Codex Deck.cmd** whenever you want to use the bridge. You do not need to restart an already launcher-started Codex session; after a normal Codex restart, run the launcher again. +Without the optional watcher, use **Start Codex Deck.cmd** whenever you want to use the bridge. You do not need to restart an already launcher-started Codex session. -To remove that manual step, run `Start-CodexDeck.ps1 -InstallStartup` once. -This creates a hidden shortcut in the Windows Startup folder and starts Codex -with the bridge after you sign in. Remove it with `-UninstallStartup`. +To remove that manual step permanently, run `Start-CodexDeck.ps1 -InstallStartup` once. +This installs a single hidden watcher that stays active after Windows sign-in, +tracks Codex across app updates and restarts, removes stale bridge ports, and +automatically restores the bridge when Codex launches again. Installing it +does not restart a normal Codex session that is already open; that session is +picked up after its next normal close and reopen. Remove the watcher with +`-UninstallStartup`. ## Recommended 15-key layout @@ -117,6 +121,7 @@ See [Architecture and security](docs/ARCHITECTURE.md) for the full boundary. - The public runtime does not read Codex task databases, rollout files, or desktop logs. - Local icon SVGs are read only from `%LOCALAPPDATA%\CodexDeck\icons`. - Closing the launcher-started Codex session closes the debug endpoint. +- The optional watcher remains local, single-instance, and records only bounded diagnostics in `%LOCALAPPDATA%\CodexDeck\watcher.log`. Do not use the launcher on a machine where you run untrusted local software. See [SECURITY.md](SECURITY.md) before reporting a vulnerability. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d77de8d..4edbf17 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -15,6 +15,20 @@ The bundled runtime helper connects to that renderer and enables the Micro featu The launcher does not edit the Codex installation, Codex LevelDB, task database, rollout files, or logs. +When startup monitoring is installed, `Watch-CodexDeck.ps1` remains active as +a single hidden PowerShell process. It dynamically resolves the newest Codex +Microsoft Store package on every check, so an app update can change the install +path without invalidating the watcher. A named mutex prevents duplicates. + +The watcher follows three safety rules: + +1. A healthy debug-enabled Codex session is reused and never restarted. +2. A normal session that was already open when monitoring was installed is left untouched until its next normal restart. +3. A later Codex launch, update restart, or crash recovery without the required loopback port receives at most one recovery restart for that process generation. + +Stale port metadata is removed automatically. The bounded watcher log lives at +`%LOCALAPPDATA%\CodexDeck\watcher.log`. + ### Stream Deck plugin The plugin discovers the loopback port from the state file or from the command line of a running Codex process. It then uses Chrome DevTools Protocol `Runtime.evaluate` calls to: diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index c612cd8..2e357da 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -4,7 +4,9 @@ - You can safely run the launcher again while a launcher-started Codex session is open; it now reuses the existing debug session instead of restarting Codex. - Use `Start-CodexDeck.ps1 -ForceRestart` only when you explicitly want to restart Codex. -- To start it automatically after Windows sign-in, run `Start-CodexDeck.ps1 -InstallStartup` once. Remove that behavior with `-UninstallStartup`. +- To keep it available across Windows sign-in, Codex restarts, and Codex app updates, run `Start-CodexDeck.ps1 -InstallStartup` once. This installs a persistent single-instance watcher. Remove it with `-UninstallStartup`. +- Installing monitoring leaves an already-open normal Codex session untouched. Close and reopen Codex once, or run the launcher manually when you are ready for that one recovery restart. +- Watcher diagnostics are stored in `%LOCALAPPDATA%\CodexDeck\watcher.log` and rotate automatically. - Close all Codex windows. - Start Codex with `Start Codex Deck.cmd`. - Keep the launcher folder intact; `runtime-override.mjs` must remain next to the PowerShell script. diff --git a/launcher/README.txt b/launcher/README.txt index f6210e5..47a928a 100644 --- a/launcher/README.txt +++ b/launcher/README.txt @@ -14,9 +14,18 @@ that session once. It then starts the installed Codex Windows app with a loopback-only Chrome DevTools port and enables the Codex Micro UI for that session. It does not patch the Codex installation or upload any data. -Optional: run `Start-CodexDeck.ps1 -InstallStartup` once to add a hidden -Windows-login shortcut. That starts the bridge automatically after sign-in. -Remove it again with `Start-CodexDeck.ps1 -UninstallStartup`. +Recommended: run `Start-CodexDeck.ps1 -InstallStartup` once. This installs a +single hidden background watcher that stays active after Windows sign-in. It +detects Codex restarts and app updates, removes stale bridge data, and restores +the bridge automatically whenever Codex starts again. + +Installing the watcher never restarts an already-open normal Codex session. +That session is recovered after you next close and reopen Codex. At later +Windows logins or after Codex updates, the watcher may perform one immediate +recovery restart when Codex launches without its required loopback port. + +Remove the watcher with `Start-CodexDeck.ps1 -UninstallStartup`. Diagnostics +are written to `%LOCALAPPDATA%\CodexDeck\watcher.log`. This is an unofficial compatibility bridge and may need an update after a Codex desktop release. diff --git a/launcher/Start-CodexDeck.ps1 b/launcher/Start-CodexDeck.ps1 index d06c4c1..2904155 100644 --- a/launcher/Start-CodexDeck.ps1 +++ b/launcher/Start-CodexDeck.ps1 @@ -15,17 +15,37 @@ function Get-StartupShortcutPath { Join-Path ([Environment]::GetFolderPath('Startup')) 'Codex Deck.lnk' } +function Get-WatcherStopPath { + Join-Path (Join-Path $env:LOCALAPPDATA 'CodexDeck') 'watcher.stop' +} + +function Start-BridgeWatcher { + $watcherPath = Join-Path $PSScriptRoot 'Watch-CodexDeck.ps1' + if (-not (Test-Path -LiteralPath $watcherPath)) { throw "Codex Deck watcher not found: $watcherPath" } + $powerShellPath = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + Start-Process -FilePath $powerShellPath -WindowStyle Hidden -ArgumentList @( + '-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', "`"$watcherPath`"" + ) +} + function Set-StartupShortcut { $shortcutPath = Get-StartupShortcutPath + $watcherPath = Join-Path $PSScriptRoot 'Watch-CodexDeck.ps1' + if (-not (Test-Path -LiteralPath $watcherPath)) { throw "Codex Deck watcher not found: $watcherPath" } + $stopPath = Get-WatcherStopPath + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $stopPath) | Out-Null + Remove-Item -LiteralPath $stopPath -Force -ErrorAction SilentlyContinue $shell = New-Object -ComObject WScript.Shell $shortcut = $shell.CreateShortcut($shortcutPath) $shortcut.TargetPath = (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') - $shortcut.Arguments = "-NoLogo -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$PSCommandPath`"" + $shortcut.Arguments = "-NoLogo -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$watcherPath`" -RecoverExistingSession" $shortcut.WorkingDirectory = $PSScriptRoot - $shortcut.Description = 'Start Codex Deck bridge at Windows sign-in' + $shortcut.Description = 'Keep the Codex Deck bridge available while Codex is running' $shortcut.IconLocation = "$env:SystemRoot\System32\shell32.dll,44" $shortcut.Save() + Start-BridgeWatcher Write-Host "Startup shortcut installed: $shortcutPath" + Write-Host 'The background watcher is running. An existing normal Codex session was not restarted.' } if ($InstallStartup) { @@ -35,6 +55,9 @@ if ($InstallStartup) { if ($UninstallStartup) { $shortcutPath = Get-StartupShortcutPath + $stopPath = Get-WatcherStopPath + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $stopPath) | Out-Null + [IO.File]::WriteAllText($stopPath, [DateTimeOffset]::UtcNow.ToString('o'), [Text.UTF8Encoding]::new($false)) if (Test-Path -LiteralPath $shortcutPath) { Remove-Item -LiteralPath $shortcutPath -Force Write-Host "Startup shortcut removed: $shortcutPath" diff --git a/launcher/Watch-CodexDeck.ps1 b/launcher/Watch-CodexDeck.ps1 new file mode 100644 index 0000000..a5949f3 --- /dev/null +++ b/launcher/Watch-CodexDeck.ps1 @@ -0,0 +1,219 @@ +param( + [switch]$Once, + [switch]$SelfTest, + [switch]$RecoverExistingSession, + [ValidateRange(1, 30)] + [int]$PollSeconds = 2 +) + +$ErrorActionPreference = 'Stop' + +$launcherPath = Join-Path $PSScriptRoot 'Start-CodexDeck.ps1' +$powerShellPath = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' +$stateRoot = Join-Path $env:LOCALAPPDATA 'CodexDeck' +$statePath = Join-Path $stateRoot 'codex-micro-bridge.json' +$stopPath = Join-Path $stateRoot 'watcher.stop' +$logPath = Join-Path $stateRoot 'watcher.log' +$mutexName = 'Local\CodexDeckBridgeWatcher' + +function Test-RecoveryAllowed( + [string]$Generation, + [string]$HandledGeneration, + [bool]$RecoverExisting, + [bool]$SawStopped, + [bool]$HadHealthy +) { + $generationChanged = -not [string]::IsNullOrWhiteSpace($HandledGeneration) -and $Generation -ne $HandledGeneration + $RecoverExisting -or $SawStopped -or $HadHealthy -or $generationChanged +} + +if ($SelfTest) { + $cases = @( + @{ Name = 'initial existing session remains untouched'; Expected = $false; Actual = Test-RecoveryAllowed 'v1:100' '' $false $false $false }, + @{ Name = 'same process remains untouched'; Expected = $false; Actual = Test-RecoveryAllowed 'v1:100' 'v1:100' $false $false $false }, + @{ Name = 'rapid main-process replacement recovers'; Expected = $true; Actual = Test-RecoveryAllowed 'v1:101' 'v1:100' $false $false $false }, + @{ Name = 'observed stopped interval recovers'; Expected = $true; Actual = Test-RecoveryAllowed 'v1:101' '' $false $true $false }, + @{ Name = 'previous healthy bridge recovers'; Expected = $true; Actual = Test-RecoveryAllowed 'v2:200' 'v1:100' $false $false $true }, + @{ Name = 'login recovery handles startup race'; Expected = $true; Actual = Test-RecoveryAllowed 'v1:100' '' $true $false $false } + ) + $failures = @($cases | Where-Object { $_.Actual -ne $_.Expected }) + if ($failures.Count -gt 0) { throw "Watcher self-test failed: $($failures.Name -join ', ')" } + Write-Host "Codex Deck watcher self-test passed ($($cases.Count) cases)." + exit 0 +} + +New-Item -ItemType Directory -Force -Path $stateRoot | Out-Null + +function Write-WatcherLog([string]$Message) { + if (Test-Path -LiteralPath $logPath) { + $log = Get-Item -LiteralPath $logPath -ErrorAction SilentlyContinue + if ($null -ne $log -and $log.Length -gt 524288) { + Move-Item -LiteralPath $logPath -Destination "$logPath.previous" -Force + } + } + $line = "[$([DateTimeOffset]::Now.ToString('o'))] $Message" + Add-Content -LiteralPath $logPath -Value $line -Encoding UTF8 + Write-Host $line +} + +function Get-CodexInstallation { + $package = Get-AppxPackage -Name 'OpenAI.Codex' -ErrorAction SilentlyContinue | + Sort-Object Version -Descending | + Select-Object -First 1 + if ($null -eq $package -or [string]::IsNullOrWhiteSpace($package.InstallLocation)) { return $null } + $appRoot = Join-Path $package.InstallLocation 'app' + [pscustomobject]@{ + Root = [IO.Path]::GetFullPath($appRoot).TrimEnd('\') + Version = $package.Version.ToString() + } +} + +function Get-CodexProcesses([string]$AppRoot) { + $prefix = $AppRoot.TrimEnd('\') + '\' + @(Get-CimInstance Win32_Process -ErrorAction Stop | Where-Object { + -not [string]::IsNullOrWhiteSpace($_.ExecutablePath) -and + $_.ExecutablePath.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase) + }) +} + +function Get-CodexGeneration([string]$Version, $Processes) { + $main = $Processes | + Where-Object { $_.Name -ieq 'ChatGPT.exe' -and $_.CommandLine -notmatch '--type=' } | + Sort-Object ProcessId | + Select-Object -First 1 + if ($null -eq $main) { $main = $Processes | Sort-Object ProcessId | Select-Object -First 1 } + "${Version}:$($main.ProcessId)" +} + +function Get-HealthyDebugPort($Processes) { + foreach ($process in $Processes) { + if ([string]::IsNullOrWhiteSpace($process.CommandLine)) { continue } + if ($process.CommandLine -match '--remote-debugging-port=(\d+)') { + $candidate = [int]$Matches[1] + try { + $response = Invoke-WebRequest -UseBasicParsing -Uri "http://127.0.0.1:$candidate/json/version" -TimeoutSec 1 + if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 300) { return $candidate } + } + catch { } + } + } + return $null +} + +function Clear-StalePortFile { + if (-not (Test-Path -LiteralPath $statePath)) { return } + try { + $state = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json + $port = [int]$state.port + $response = Invoke-WebRequest -UseBasicParsing -Uri "http://127.0.0.1:$port/json/version" -TimeoutSec 1 + if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 300) { return } + } + catch { } + Remove-Item -LiteralPath $statePath -Force -ErrorAction SilentlyContinue + Write-WatcherLog 'Removed a stale Codex Deck bridge port file.' +} + +function Test-LauncherReady { + if (-not (Test-Path -LiteralPath $launcherPath)) { return $false } + if ($null -eq (Get-Command node -ErrorAction SilentlyContinue)) { return $false } + $runtimeCandidates = @( + (Join-Path $PSScriptRoot 'runtime-override.mjs'), + (Join-Path $PSScriptRoot '..\release\codex-deck-launcher\runtime-override.mjs') + ) + @($runtimeCandidates | Where-Object { Test-Path -LiteralPath $_ }).Count -gt 0 +} + +function Invoke-CodexDeckLauncher([switch]$ForceRestart) { + if (-not (Test-LauncherReady)) { + throw 'The Codex Deck launcher bundle or Node.js is unavailable; Codex was not restarted.' + } + $arguments = @('-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $launcherPath) + if ($ForceRestart) { $arguments += '-ForceRestart' } + $output = & $powerShellPath @arguments 2>&1 + foreach ($line in $output) { Write-WatcherLog "Launcher: $line" } + if ($LASTEXITCODE -ne 0) { throw "Codex Deck launcher failed with exit code $LASTEXITCODE." } +} + +$createdNew = $false +$mutex = [Threading.Mutex]::new($true, $mutexName, [ref]$createdNew) +if (-not $createdNew) { + Write-Host 'A Codex Deck watcher is already running.' + $mutex.Dispose() + exit 0 +} + +$sawStoppedSession = $false +$hadHealthyBridge = $false +$handledGeneration = '' +$lastHealthyGeneration = '' +$lastState = '' + +try { + Write-WatcherLog "Watcher started (recoverExisting=$($RecoverExistingSession.IsPresent))." + while ($true) { + if (Test-Path -LiteralPath $stopPath) { + Write-WatcherLog 'Watcher stop requested.' + break + } + + try { + $codex = Get-CodexInstallation + $processes = if ($null -eq $codex) { @() } else { Get-CodexProcesses $codex.Root } + + if ($processes.Count -eq 0) { + if ($lastState -ne 'stopped') { Write-WatcherLog 'Codex is not running; waiting for its next launch.' } + $lastState = 'stopped' + $sawStoppedSession = $true + $handledGeneration = '' + Clear-StalePortFile + } + else { + $generation = Get-CodexGeneration $codex.Version $processes + $port = Get-HealthyDebugPort $processes + + if ($port) { + $hadHealthyBridge = $true + $sawStoppedSession = $false + $handledGeneration = $generation + if ($lastHealthyGeneration -ne $generation) { + Write-WatcherLog "Healthy Codex Deck bridge detected for Codex $($codex.Version) on port $port." + Invoke-CodexDeckLauncher + $lastHealthyGeneration = $generation + } + $lastState = "healthy:$generation" + } + else { + Clear-StalePortFile + $mayRecover = Test-RecoveryAllowed $generation $handledGeneration $RecoverExistingSession $sawStoppedSession $hadHealthyBridge + if ($generation -ne $handledGeneration -and $mayRecover) { + $handledGeneration = $generation + Write-WatcherLog "Codex $($codex.Version) started without the bridge; performing one automatic recovery restart." + Start-Sleep -Milliseconds 1500 + Invoke-CodexDeckLauncher -ForceRestart + $hadHealthyBridge = $true + $sawStoppedSession = $false + $lastState = "recovered:$generation" + } + elseif ($lastState -ne "unmanaged:$generation") { + Write-WatcherLog "Codex generation $generation is already running without the bridge. It will be recovered when the main process changes; the current session was left untouched." + $lastState = "unmanaged:$generation" + $handledGeneration = $generation + } + } + } + } + catch { + $message = $_.Exception.Message + if ($lastState -ne "error:$message") { Write-WatcherLog "Watcher check failed: $message" } + $lastState = "error:$message" + } + + if ($Once) { break } + Start-Sleep -Seconds $PollSeconds + } +} +finally { + $mutex.ReleaseMutex() + $mutex.Dispose() + Write-WatcherLog 'Watcher stopped.' +} diff --git a/package-lock.json b/package-lock.json index e818971..933b0d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-stream-deck", - "version": "0.5.0", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-stream-deck", - "version": "0.5.0", + "version": "0.5.1", "license": "MIT", "dependencies": { "@elgato/streamdeck": "2.1.0", diff --git a/package.json b/package.json index a389f02..e666758 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-stream-deck", - "version": "0.5.0", + "version": "0.5.1", "private": false, "type": "module", "description": "Unofficial Windows bridge between the Codex desktop app and Elgato Stream Deck", diff --git a/scripts/build-launcher.mjs b/scripts/build-launcher.mjs index e6c7471..58554a5 100644 --- a/scripts/build-launcher.mjs +++ b/scripts/build-launcher.mjs @@ -18,6 +18,6 @@ await build({ await cp(resolve("node_modules/ws"), resolve(output, "node_modules/ws"), { recursive: true }); -for (const filename of ["Start Codex Deck.cmd", "Start-CodexDeck.ps1", "README.txt"]) { +for (const filename of ["Start Codex Deck.cmd", "Start-CodexDeck.ps1", "Watch-CodexDeck.ps1", "README.txt"]) { await cp(resolve("launcher", filename), resolve(output, filename)); } diff --git a/static/manifest.json b/static/manifest.json index c894d61..3681b12 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -1,7 +1,7 @@ { "$schema": "https://schemas.elgato.com/streamdeck/plugins/manifest.json", "Name": "Codex Deck", - "Version": "0.5.0.0", + "Version": "0.5.1.0", "Author": "Dazer", "Description": "Unofficial local bridge between Codex for Windows and Stream Deck.", "UUID": "com.simeo.codex-deck", diff --git a/test/launcher.test.ts b/test/launcher.test.ts index 3e3c2b3..1ea52bd 100644 --- a/test/launcher.test.ts +++ b/test/launcher.test.ts @@ -1,7 +1,13 @@ import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; import { buildRuntimeOverrideExpression } from "../launcher/runtime-override.js"; +const execFileAsync = promisify(execFile); + test("launcher discovers the persisted-signal module without a build hash", () => { const expression = buildRuntimeOverrideExpression(); assert.match(expression, /\/assets\/persisted-signal-/); @@ -12,3 +18,32 @@ test("launcher discovers the persisted-signal module without a build hash", () = test("launcher rejects an unsafe feature-gate expression", () => { assert.throws(() => buildRuntimeOverrideExpression("1);alert(1)//"), /digits only/); }); + +test("startup monitoring survives Codex updates without duplicate watchers", async () => { + const [watcher, launcher, build] = await Promise.all([ + readFile(new URL("../launcher/Watch-CodexDeck.ps1", import.meta.url), "utf8"), + readFile(new URL("../launcher/Start-CodexDeck.ps1", import.meta.url), "utf8"), + readFile(new URL("../scripts/build-launcher.mjs", import.meta.url), "utf8") + ]); + + assert.match(watcher, /Local\\CodexDeckBridgeWatcher/); + assert.match(watcher, /Get-AppxPackage -Name 'OpenAI\.Codex'/); + assert.match(watcher, /Test-RecoveryAllowed/); + assert.match(watcher, /rapid main-process replacement recovers/); + assert.match(watcher, /current session was left untouched/i); + assert.match(watcher, /Clear-StalePortFile/); + assert.equal(watcher.match(/Invoke-CodexDeckLauncher -ForceRestart/g)?.length, 1); + + assert.match(launcher, /Watch-CodexDeck\.ps1/); + assert.match(launcher, /-RecoverExistingSession/); + assert.match(launcher, /Start-BridgeWatcher/); + assert.match(build, /Watch-CodexDeck\.ps1/); +}); + +test("watcher recovery decision self-test passes in PowerShell", async () => { + const watcherPath = fileURLToPath(new URL("../launcher/Watch-CodexDeck.ps1", import.meta.url)); + const { stdout } = await execFileAsync("powershell.exe", [ + "-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", watcherPath, "-SelfTest" + ]); + assert.match(stdout, /self-test passed \(6 cases\)/i); +}); From 8b376093fb4e07509d5beb371dd5369461a8c489 Mon Sep 17 00:00:00 2001 From: dazer1234 <47606394+dazer1234@users.noreply.github.com> Date: Sat, 18 Jul 2026 05:58:00 +0200 Subject: [PATCH 2/3] Add macOS Codex Micro launcher and watcher --- README.md | 11 +- docs/ARCHITECTURE.md | 13 + docs/MACOS.md | 126 ++++++ launcher/Start Codex Deck.command | 25 ++ launcher/macos/codex-deck-macos.ts | 632 +++++++++++++++++++++++++++++ launcher/macos/watcher-policy.ts | 147 +++++++ launcher/runtime-override.ts | 122 +++++- launcher/start-codex-deck.sh | 12 + scripts/build-launcher.mjs | 19 + src/codex-deck-paths.ts | 11 + src/codex-micro-renderer-bridge.ts | 131 ++++-- src/controller.ts | 8 +- test/launcher.test.ts | 19 +- test/macos-launcher.test.ts | 47 +++ test/macos-watcher.test.ts | 117 ++++++ test/micro-bridge.test.ts | 9 +- 16 files changed, 1390 insertions(+), 59 deletions(-) create mode 100644 docs/MACOS.md create mode 100755 launcher/Start Codex Deck.command create mode 100644 launcher/macos/codex-deck-macos.ts create mode 100644 launcher/macos/watcher-policy.ts create mode 100755 launcher/start-codex-deck.sh create mode 100644 src/codex-deck-paths.ts create mode 100644 test/macos-launcher.test.ts create mode 100644 test/macos-watcher.test.ts diff --git a/README.md b/README.md index 645a675..45aacce 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Codex Deck -An unofficial, Windows-only bridge that brings the Codex Micro control model to an Elgato Stream Deck. +An unofficial bridge that brings the Codex Micro control model to an Elgato Stream Deck. The released Stream Deck workflow is Windows-based; the repository also contains a local-only macOS launcher and persistent bridge watcher for the future multi-host hub. Codex Deck mirrors the six native agent slots, their live states, the six configurable Micro action slots, joystick directions, encoder click, and reasoning-effort controls. It sends Codex's own renderer events instead of typing text or relying on global hotkeys. @@ -36,6 +36,14 @@ There is deliberately no legacy task-database reader, log scraper, or hotkey fal Other 15-key Stream Deck models may work, but have not been verified yet. +### macOS foundation + +The macOS launcher enables the same native Codex Micro renderer event path in +the installed Codex app and can install a persistent per-user LaunchAgent. It +does not yet relay the Mac to a Windows/Stream Deck host. See +[macOS launcher and watcher](docs/MACOS.md) for install, diagnostics, safety, +and uninstall commands. + ## Install 1. Open the latest [GitHub release](https://github.com/dazer1234/codex-stream-deck/releases/latest). @@ -171,6 +179,7 @@ Outputs: - Plugin bundle: `dist/com.simeo.codex-deck.sdPlugin` - Launcher folder: `release/codex-deck-launcher` - Installable plugin package: `com.simeo.codex-deck.streamDeckPlugin` +- macOS launcher folder: `release/codex-deck-launcher-macos` See [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4edbf17..58f9df8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -29,6 +29,19 @@ The watcher follows three safety rules: Stale port metadata is removed automatically. The bounded watcher log lives at `%LOCALAPPDATA%\CodexDeck\watcher.log`. +On macOS, the launcher discovers the running or installed app by its signed +bundle metadata, reads `CFBundleExecutable`, and launches the app bundle through +LaunchServices with the same loopback-only debugging arguments. The per-user +LaunchAgent watcher stores a main-process generation (PID, start time, and +executable path), reuses healthy bridges, and performs at most one graceful +recovery restart for a later unbridged generation. The generation and recovery +policy is persisted atomically and guarded by a PID-directory lock. + +The macOS bridge state adds `platform`, `hostId`, `hostName`, and +`codexVersion` while retaining the Windows-compatible `port` and `updatedAt` +fields. `hostId` is intended as the future relay node identity; the CDP port is +never a relay endpoint. + ### Stream Deck plugin The plugin discovers the loopback port from the state file or from the command line of a running Codex process. It then uses Chrome DevTools Protocol `Runtime.evaluate` calls to: diff --git a/docs/MACOS.md b/docs/MACOS.md new file mode 100644 index 0000000..834f969 --- /dev/null +++ b/docs/MACOS.md @@ -0,0 +1,126 @@ +# macOS launcher and watcher + +The macOS support is the local Codex Micro foundation for a future secure +multi-host relay. It enables the existing Codex renderer integration and keeps +the loopback bridge available after later Codex launches, crashes, and app +updates. It does not expose a network relay or connect to a Windows hub yet. + +## Safety boundary + +- Codex is discovered from its bundle metadata and running process path. The + app filename and version are not hardcoded. +- The executable named by `CFBundleExecutable` is validated, while launches go + through macOS LaunchServices so Input Monitoring/TCC permissions remain tied + to the signed app bundle. +- Nothing inside `Codex.app` (or the currently named app bundle) is edited, + patched, replaced, or re-signed. +- Chrome DevTools is always requested on `127.0.0.1`; `0.0.0.0` is never used. +- A normal Codex session that is already running when the watcher is first + installed is recorded and left untouched. +- After installation, a later normal launch or update replacement can require + one automatic recovery restart. The watcher performs no more than one such + restart for a single main-process generation and never force-kills Codex. +- Official OpenAI keycap SVGs are not included. User-local icons remain in the + `icons` directory during uninstall. + +## Commands + +From the extracted macOS launcher directory: + +```zsh +./start-codex-deck.sh dry-run +./start-codex-deck.sh self-test +./start-codex-deck.sh start +./start-codex-deck.sh install +./start-codex-deck.sh uninstall +``` + +`Start Codex Deck.command` provides the double-clickable equivalent of +`start`. If Codex is already running without a healthy bridge, it displays the +restart warning and requires the user to type `yes` before continuing. + +`install` copies only the bundled watcher runtime into Application Support, +writes the LaunchAgent, and loads it in the current GUI session. It does not +restart an already-running normal session during first installation. + +## Files + +```text +~/Library/Application Support/CodexDeck/ + codex-deck-macos.mjs + watcher-launch.sh + codex-micro-bridge.json + host.json + watcher-state.json + watcher.log + watcher.log.1 + watcher.log.2 + watcher.log.3 + icons/ # optional user-owned files + +~/Library/LaunchAgents/com.simeo.codex-deck.watcher.plist +``` + +`watcher.log` rotates at approximately 1 MB with three retained generations. +The PID-directory lock guarantees one watcher instance. Stale locks are +reclaimed only when the recorded process no longer exists. + +The bridge state remains compatible with the Windows reader's required `port` +and `updatedAt` fields and adds stable Mac identity: + +```json +{ + "port": 12345, + "updatedAt": "2026-07-18T12:34:56.000Z", + "platform": "darwin", + "hostId": "generated-stable-uuid", + "hostName": "User-visible Mac name", + "codexVersion": "detected-version" +} +``` + +The state file is written atomically. An unhealthy or mismatched port file is +removed before recovery. + +## Recovery model + +The watcher compares the Codex main PID, process start time, and executable +path (which includes the bundle path). This identifies a new generation even +when Codex stops and is replaced between two polls; each iteration separately +rediscovers the bundle version for state metadata. + +1. A healthy loopback bridge is reused and the runtime override is verified. +2. A first-install normal session is preserved. +3. A stopped interval is confirmed before starting Codex with the bridge. +4. A later normal generation is gracefully terminated once and relaunched + through LaunchServices with loopback-only debugging. +5. A 30-second startup window prevents repeated launches or restarts while the + renderer and CDP endpoint are becoming ready. + +The self-test simulates first-install preservation, repeat suppression, rapid +replacement, observed stops, app updates, LaunchAgent races, stale state, and +duplicate watcher instances. It does not launch or stop Codex. + +## Diagnostics + +```zsh +./start-codex-deck.sh dry-run +tail -n 100 "$HOME/Library/Application Support/CodexDeck/watcher.log" +launchctl print "gui/$(id -u)/com.simeo.codex-deck.watcher" +plutil -lint "$HOME/Library/LaunchAgents/com.simeo.codex-deck.watcher.plist" +``` + +## Uninstall + +`uninstall` unloads the LaunchAgent and removes its runtime, policy state, +bridge port file, lock, and rotating logs. It deliberately preserves +`host.json` (stable future relay identity) and `icons/` (user-owned assets). +No Codex application data is removed. + +## Future multi-host handoff + +A future authenticated relay can treat `hostId` as the stable Mac node key, +publish the six renderer agent snapshots, and forward authenticated commands to +the existing native Micro dispatcher. The relay must use its own authenticated, +encrypted transport and must never expose or forward the Chrome DevTools port. +CDP remains loopback-only on the Mac. diff --git a/launcher/Start Codex Deck.command b/launcher/Start Codex Deck.command new file mode 100755 index 0000000..220372e --- /dev/null +++ b/launcher/Start Codex Deck.command @@ -0,0 +1,25 @@ +#!/bin/zsh +set -u + +script_dir="${0:A:h}" +"$script_dir/start-codex-deck.sh" start +status=$? + +if [[ $status -eq 2 ]]; then + print + print "May I restart Codex once to verify the macOS bridge? Any unsent composer text should be saved first." + read "answer?Type yes to continue: " + if [[ "$answer" == "yes" ]]; then + "$script_dir/start-codex-deck.sh" start --restart + status=$? + else + print "Codex was left running and unchanged." + status=0 + fi +fi + +if [[ -t 0 ]]; then + print + read "_?Press Return to close this window." +fi +exit $status diff --git a/launcher/macos/codex-deck-macos.ts b/launcher/macos/codex-deck-macos.ts new file mode 100644 index 0000000..044789b --- /dev/null +++ b/launcher/macos/codex-deck-macos.ts @@ -0,0 +1,632 @@ +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { + chmod, copyFile, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, writeFile +} from "node:fs/promises"; +import { createServer } from "node:net"; +import { homedir, hostname, platform, tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { applyRuntimeOverride, verifyMicroRuntime } from "../runtime-override.js"; +import { + createWatcherPolicyState, + evaluateWatcherPolicy, + resumeWatcherPolicyState, + type WatcherPolicyState +} from "./watcher-policy.js"; + +const CODEX_BUNDLE_ID = "com.openai.codex"; +const AGENT_LABEL = "com.simeo.codex-deck.watcher"; +const STATE_ROOT = join(homedir(), "Library", "Application Support", "CodexDeck"); +const BRIDGE_STATE_PATH = join(STATE_ROOT, "codex-micro-bridge.json"); +const HOST_STATE_PATH = join(STATE_ROOT, "host.json"); +const WATCHER_STATE_PATH = join(STATE_ROOT, "watcher-state.json"); +const WATCHER_LOG_PATH = join(STATE_ROOT, "watcher.log"); +const WATCHER_LOCK_PATH = join(STATE_ROOT, "watcher.lock"); +const INSTALLED_RUNTIME_PATH = join(STATE_ROOT, "codex-deck-macos.mjs"); +const WATCHER_LAUNCHER_PATH = join(STATE_ROOT, "watcher-launch.sh"); +const LAUNCH_AGENT_PATH = join(homedir(), "Library", "LaunchAgents", `${AGENT_LABEL}.plist`); +const POLL_MS = 1_000; +const LOG_LIMIT_BYTES = 1_000_000; + +type CodexInstallation = { + appPath: string; + bundleId: string; + version: string; + buildVersion: string; + executableName: string; + executablePath: string; +}; + +type MainProcess = { + pid: number; + ppid: number; + startedAt: string; + command: string; + generation: string; + installation: CodexInstallation; +}; + +type HostState = { hostId: string; hostName: string }; +type BridgeState = HostState & { + port: number; + updatedAt: string; + platform: "darwin"; + codexVersion: string; +}; + +function run(command: string, args: string[], options: { allowFailure?: boolean } = {}): string { + const result = spawnSync(command, args, { encoding: "utf8" }); + if (result.status !== 0 && !options.allowFailure) { + const detail = (result.stderr || result.stdout || `${command} exited ${result.status}`).trim(); + throw new Error(detail); + } + return result.stdout.trim(); +} + +function plistValue(infoPath: string, key: string): string { + return run("/usr/bin/plutil", ["-extract", key, "raw", "-o", "-", infoPath]); +} + +async function isDirectory(path: string): Promise { + try { return (await stat(path)).isDirectory(); } + catch { return false; } +} + +function parseProcessRows(output: string): Array<{ pid: number; ppid: number; startedAt: string; command: string }> { + const rows: Array<{ pid: number; ppid: number; startedAt: string; command: string }> = []; + const pattern = /^\s*(\d+)\s+(\d+)\s+([A-Z][a-z]{2}\s+[A-Z][a-z]{2}\s+\d+\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(.+)$/; + for (const line of output.split("\n")) { + const match = line.match(pattern); + if (!match) continue; + rows.push({ pid: Number(match[1]), ppid: Number(match[2]), startedAt: match[3]!, command: match[4]! }); + } + return rows; +} + +function processRows(): ReturnType { + return parseProcessRows(run("/bin/ps", ["-axo", "pid=,ppid=,lstart=,command="])); +} + +function appPathFromExecutable(command: string): string | null { + const match = command.match(/^(.+?\.app)\/Contents\/MacOS\/[^/]+(?:\s|$)/); + return match?.[1] ?? null; +} + +async function installationFromApp(appPath: string): Promise { + const infoPath = join(appPath, "Contents", "Info.plist"); + try { + const bundleId = plistValue(infoPath, "CFBundleIdentifier"); + if (bundleId !== CODEX_BUNDLE_ID) return null; + const executableName = plistValue(infoPath, "CFBundleExecutable"); + const executablePath = join(appPath, "Contents", "MacOS", executableName); + const executable = await stat(executablePath); + if (!executable.isFile()) throw new Error(`Codex executable is not a file: ${executablePath}`); + return { + appPath, + bundleId, + version: plistValue(infoPath, "CFBundleShortVersionString"), + buildVersion: plistValue(infoPath, "CFBundleVersion"), + executableName, + executablePath + }; + } catch { return null; } +} + +async function standardAppCandidates(): Promise { + const roots = ["/Applications", join(homedir(), "Applications")]; + const found: string[] = []; + for (const root of roots) { + try { + for (const entry of await readdir(root, { withFileTypes: true })) { + if (entry.isDirectory() && entry.name.endsWith(".app")) found.push(join(root, entry.name)); + } + } catch { /* The optional app directory may not exist. */ } + } + return found; +} + +export async function discoverCodexInstallation(): Promise { + if (platform() !== "darwin") throw new Error("The macOS launcher only runs on macOS."); + const candidates: string[] = []; + if (process.env.CODEX_DECK_APP_PATH) candidates.push(resolve(process.env.CODEX_DECK_APP_PATH)); + + for (const row of processRows()) { + if (row.ppid !== 1) continue; + const appPath = appPathFromExecutable(row.command); + if (appPath) candidates.push(appPath); + } + + const spotlight = run("/usr/bin/mdfind", [`kMDItemCFBundleIdentifier == '${CODEX_BUNDLE_ID}'`], { allowFailure: true }); + if (spotlight) candidates.push(...spotlight.split("\n").filter((path) => path.endsWith(".app"))); + candidates.push(...await standardAppCandidates()); + + const installations: CodexInstallation[] = []; + for (const candidate of [...new Set(candidates)]) { + if (!await isDirectory(candidate)) continue; + const installation = await installationFromApp(candidate); + if (installation) installations.push(installation); + } + if (installations.length === 0) { + throw new Error(`No installed Codex app with bundle identifier ${CODEX_BUNDLE_ID} was found.`); + } + + const runningPaths = new Set(processRows().filter((row) => row.ppid === 1).map((row) => appPathFromExecutable(row.command))); + installations.sort((left, right) => { + const running = Number(runningPaths.has(right.appPath)) - Number(runningPaths.has(left.appPath)); + return running || right.version.localeCompare(left.version, undefined, { numeric: true }); + }); + return installations[0]!; +} + +function findMainProcess(installation: CodexInstallation): MainProcess | null { + const row = processRows().find((candidate) => + candidate.ppid === 1 && + (candidate.command === installation.executablePath || candidate.command.startsWith(`${installation.executablePath} `)) + ); + if (!row) return null; + return { + ...row, + generation: `${row.pid}:${row.startedAt}:${installation.executablePath}`, + installation + }; +} + +export function parseDebugPort(command: string): number | null { + const port = Number(command.match(/(?:^|\s)--remote-debugging-port(?:=|\s+)(\d+)(?:\s|$)/)?.[1]); + return Number.isInteger(port) && port > 0 && port <= 65_535 ? port : null; +} + +function hasLoopbackDebugAddress(command: string): boolean { + return /(?:^|\s)--remote-debugging-address(?:=|\s+)127\.0\.0\.1(?:\s|$)/.test(command); +} + +async function fetchJson(url: string, timeout = 1_000): Promise { + const response = await fetch(url, { signal: AbortSignal.timeout(timeout) }); + if (!response.ok) throw new Error(`${url} returned HTTP ${response.status}.`); + return await response.json() as T; +} + +async function healthyDebugPort(main: MainProcess | null): Promise { + if (!main || !hasLoopbackDebugAddress(main.command)) return null; + const port = parseDebugPort(main.command); + if (!port) return null; + try { + await fetchJson(`http://127.0.0.1:${port}/json/version`, 750); + const targets = await fetchJson>(`http://127.0.0.1:${port}/json/list`, 750); + return targets.some((target) => target.type === "page" && target.url?.startsWith("app://")) ? port : null; + } catch { return null; } +} + +async function chooseLoopbackPort(): Promise { + return await new Promise((resolvePort, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + server.close((error) => error ? reject(error) : resolvePort(port)); + }); + }); +} + +async function atomicWrite(path: string, contents: string, mode = 0o600): Promise { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`); + await writeFile(temporary, contents, { encoding: "utf8", mode }); + await chmod(temporary, mode); + await rename(temporary, path); +} + +async function atomicWriteJson(path: string, value: unknown): Promise { + await atomicWrite(path, `${JSON.stringify(value, null, 2)}\n`); +} + +async function readJson(path: string): Promise { + try { return JSON.parse(await readFile(path, "utf8")) as T; } + catch { return null; } +} + +async function computerName(): Promise { + const name = run("/usr/sbin/scutil", ["--get", "ComputerName"], { allowFailure: true }); + return name || hostname(); +} + +async function hostState(): Promise { + const existing = await readJson>(HOST_STATE_PATH); + const hostId = typeof existing?.hostId === "string" && /^[0-9a-f-]{36}$/i.test(existing.hostId) + ? existing.hostId + : randomUUID(); + const value = { hostId, hostName: await computerName() }; + if (existing?.hostId !== value.hostId || existing.hostName !== value.hostName) await atomicWriteJson(HOST_STATE_PATH, value); + return value; +} + +async function writeBridgeState(port: number, installation: CodexInstallation): Promise { + const host = await hostState(); + const state: BridgeState = { + port, + updatedAt: new Date().toISOString(), + platform: "darwin", + ...host, + codexVersion: installation.version + }; + await atomicWriteJson(BRIDGE_STATE_PATH, state); + return state; +} + +export function isBridgeStateStale(statePort: unknown, activePort: number | null): boolean { + const port = Number(statePort); + return !Number.isInteger(port) || port < 1 || port > 65_535 || activePort == null || port !== activePort; +} + +async function removeStaleBridgeState(activePort: number | null, log?: (message: string) => Promise): Promise { + const removed = await removeStaleBridgeStateFile(BRIDGE_STATE_PATH, activePort); + if (removed && log) await log("Removed stale bridge state."); + return removed; +} + +export async function removeStaleBridgeStateFile(path: string, activePort: number | null): Promise { + const state = await readJson<{ port?: unknown }>(path); + if (!state) return false; + if (!isBridgeStateStale(state.port, activePort)) return false; + await rm(path, { force: true }); + return true; +} + +export function buildCodexLaunchSpec(installation: Pick, port: number): { command: string; args: string[] } { + if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new Error(`Invalid debugging port: ${port}`); + return { + command: "/usr/bin/open", + args: [ + "-n", + "-a", + installation.appPath, + "--args", + "--remote-debugging-address=127.0.0.1", + `--remote-debugging-port=${port}` + ] + }; +} + +async function launchCodex(installation: CodexInstallation, port: number): Promise { + // Launch through LaunchServices so macOS associates TCC/Input Monitoring + // state with the signed app bundle, while still passing Electron's CDP flags. + const spec = buildCodexLaunchSpec(installation, port); + const child = spawn(spec.command, spec.args, { detached: true, stdio: "ignore" }); + child.unref(); +} + +async function terminateCodex(main: MainProcess): Promise { + process.kill(main.pid, "SIGTERM"); + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + try { process.kill(main.pid, 0); } + catch { return; } + await delay(250); + } + throw new Error(`Codex main process ${main.pid} did not exit after SIGTERM; it was not force-killed.`); +} + +const delay = (milliseconds: number) => new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)); + +async function enableBridge(installation: CodexInstallation, port: number): Promise<{ override: unknown; verification: unknown }> { + await writeBridgeState(port, installation); + const override = await applyRuntimeOverride(port, 30_000); + const verification = await verifyMicroRuntime(port, 30_000); + await writeBridgeState(port, installation); + return { override, verification }; +} + +async function rotateLog(): Promise { + try { + if ((await stat(WATCHER_LOG_PATH)).size < LOG_LIMIT_BYTES) return; + } catch { return; } + await rm(`${WATCHER_LOG_PATH}.3`, { force: true }); + for (const [from, to] of [[`${WATCHER_LOG_PATH}.2`, `${WATCHER_LOG_PATH}.3`], [`${WATCHER_LOG_PATH}.1`, `${WATCHER_LOG_PATH}.2`], [WATCHER_LOG_PATH, `${WATCHER_LOG_PATH}.1`]] as const) { + try { await rename(from, to); } catch { /* A rotation source may not exist. */ } + } +} + +async function log(message: string): Promise { + await mkdir(STATE_ROOT, { recursive: true, mode: 0o700 }); + await rotateLog(); + const file = await open(WATCHER_LOG_PATH, "a", 0o600); + try { await file.write(`${new Date().toISOString()} [${process.pid}] ${message}\n`); } + finally { await file.close(); } +} + +export async function acquirePidLock(lockPath = WATCHER_LOCK_PATH): Promise<(() => Promise) | null> { + await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 }); + try { + await mkdir(lockPath, { mode: 0o700 }); + } catch { + const existing = Number((await readFile(join(lockPath, "pid"), "utf8").catch(() => "")).trim()); + if (Number.isInteger(existing)) { + try { process.kill(existing, 0); return null; } + catch { /* Reclaim a stale lock below. */ } + } + const stale = `${lockPath}.stale.${process.pid}.${Date.now()}`; + try { await rename(lockPath, stale); } + catch { return null; } + await rm(stale, { recursive: true, force: true }); + try { await mkdir(lockPath, { mode: 0o700 }); } + catch { return null; } + } + await writeFile(join(lockPath, "pid"), `${process.pid}\n`, { mode: 0o600 }); + return async () => { await rm(lockPath, { recursive: true, force: true }); }; +} + +async function runWatcher(): Promise { + const release = await acquirePidLock(); + if (!release) { + await log("A watcher instance is already active; duplicate exiting safely."); + return 0; + } + let released = false; + const cleanup = async () => { + if (!released) { released = true; await release(); } + }; + process.once("SIGTERM", () => { void cleanup().finally(() => process.exit(0)); }); + process.once("SIGINT", () => { void cleanup().finally(() => process.exit(0)); }); + + await log("Watcher started."); + let policy = resumeWatcherPolicyState(await readJson(WATCHER_STATE_PATH)); + let enabledSignature = ""; + try { + while (true) { + try { + const installation = await discoverCodexInstallation(); + const main = findMainProcess(installation); + const port = await healthyDebugPort(main); + const decision = evaluateWatcherPolicy(policy, { + now: Date.now(), generation: main?.generation ?? null, bridgeHealthy: port != null + }); + policy = decision.state; + await atomicWriteJson(WATCHER_STATE_PATH, policy); + + if (port != null) { + const signature = `${main!.generation}:${port}`; + if (signature !== enabledSignature) { + const result = await enableBridge(installation, port); + enabledSignature = signature; + await log(`Reused healthy loopback bridge on port ${port}: ${JSON.stringify(result.verification)}`); + } + } else { + enabledSignature = ""; + await removeStaleBridgeState(null, log); + if (decision.action.type === "launch-bridge") { + const selectedPort = await chooseLoopbackPort(); + await log(`Starting Codex with a loopback bridge (${decision.action.reason}).`); + await launchCodex(installation, selectedPort); + } else if (decision.action.type === "restart-for-recovery" && main) { + await log(`Recovering Codex bridge once for generation ${main.generation} (${decision.action.reason}).`); + await terminateCodex(main); + const refreshed = await discoverCodexInstallation(); + await launchCodex(refreshed, await chooseLoopbackPort()); + } + } + } catch (error) { + await log(`Watcher iteration failed: ${String(error)}`); + } + await delay(POLL_MS); + } + } finally { + await cleanup(); + } +} + +export function buildWatcherLaunchScript(runtimePath = INSTALLED_RUNTIME_PATH): string { + const shellQuote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`; + return `#!/bin/zsh +set -u + +runtime=${shellQuote(runtimePath)} +typeset -a candidates +candidates=(/opt/homebrew/bin/node /usr/local/bin/node) +for node_candidate in "$HOME"/.nvm/versions/node/*/bin/node(N); do + candidates+=("$node_candidate") +done +for app_candidate in \${(f)"$(/usr/bin/mdfind 'kMDItemCFBundleIdentifier == "com.openai.codex"' 2>/dev/null)"}; do + candidates+=("$app_candidate/Contents/Resources/cua_node/bin/node") +done + +for node_candidate in "\${candidates[@]}"; do + [[ -x "$node_candidate" ]] || continue + node_version=$("$node_candidate" --version 2>/dev/null) || continue + node_major=\${\${node_version#v}%%.*} + [[ "$node_major" == <-> && "$node_major" -ge 20 ]] || continue + exec "$node_candidate" "$runtime" watch +done + +print -r -- "$(/bin/date -u +%Y-%m-%dT%H:%M:%SZ) [launcher] Node.js 20 or newer was not found; watcher did not start." >> ${shellQuote(WATCHER_LOG_PATH)} +exit 78 +`; +} + +export function buildLaunchAgentPlist(watcherLauncherPath = WATCHER_LAUNCHER_PATH): string { + const xml = (value: string) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); + return ` + + + + Label + ${AGENT_LABEL} + ProgramArguments + + /bin/zsh + ${xml(watcherLauncherPath)} + + RunAtLoad + + KeepAlive + + ProcessType + Background + ThrottleInterval + 10 + StandardOutPath + /dev/null + StandardErrorPath + /dev/null + + +`; +} + +function builtRuntimeSource(): string { + const current = resolve(process.argv[1]!); + if (current.endsWith(".mjs")) return current; + return resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "release", "codex-deck-launcher-macos", "codex-deck-macos.mjs"); +} + +function currentUserId(): number { + if (typeof process.getuid !== "function") throw new Error("A POSIX user ID is required to manage the macOS LaunchAgent."); + return process.getuid(); +} + +async function installLaunchAgent(): Promise { + const source = builtRuntimeSource(); + if (!await stat(source).then((value) => value.isFile()).catch(() => false)) { + throw new Error("Build the macOS launcher first with `npm run build`."); + } + await mkdir(STATE_ROOT, { recursive: true, mode: 0o700 }); + await mkdir(dirname(LAUNCH_AGENT_PATH), { recursive: true }); + const temporaryRuntime = `${INSTALLED_RUNTIME_PATH}.${process.pid}.tmp`; + await copyFile(source, temporaryRuntime); + await chmod(temporaryRuntime, 0o700); + await rename(temporaryRuntime, INSTALLED_RUNTIME_PATH); + await atomicWrite(WATCHER_LAUNCHER_PATH, buildWatcherLaunchScript(), 0o700); + await atomicWrite(LAUNCH_AGENT_PATH, buildLaunchAgentPlist(), 0o644); + await hostState(); + run("/bin/launchctl", ["bootout", `gui/${currentUserId()}`, LAUNCH_AGENT_PATH], { allowFailure: true }); + run("/bin/launchctl", ["bootstrap", `gui/${currentUserId()}`, LAUNCH_AGENT_PATH]); + console.log(`LaunchAgent installed: ${LAUNCH_AGENT_PATH}`); + console.log("An already-running normal Codex session is recorded and left untouched."); +} + +async function uninstallLaunchAgent(): Promise { + run("/bin/launchctl", ["bootout", `gui/${currentUserId()}`, LAUNCH_AGENT_PATH], { allowFailure: true }); + await rm(LAUNCH_AGENT_PATH, { force: true }); + for (const path of [INSTALLED_RUNTIME_PATH, WATCHER_LAUNCHER_PATH, BRIDGE_STATE_PATH, WATCHER_STATE_PATH, WATCHER_LOG_PATH, `${WATCHER_LOG_PATH}.1`, `${WATCHER_LOG_PATH}.2`, `${WATCHER_LOG_PATH}.3`]) { + await rm(path, { force: true }); + } + await rm(WATCHER_LOCK_PATH, { recursive: true, force: true }); + console.log("Codex Deck LaunchAgent removed. host.json and the icons directory were preserved."); +} + +async function dryRun(): Promise { + const installation = await discoverCodexInstallation(); + const main = findMainProcess(installation); + const port = await healthyDebugPort(main); + const staleState = await readJson<{ port?: unknown }>(BRIDGE_STATE_PATH); + console.log(`Codex app: ${installation.appPath}`); + console.log(`Bundle ID: ${installation.bundleId}`); + console.log(`Version: ${installation.version} (${installation.buildVersion})`); + console.log(`Executable: ${installation.executablePath}`); + console.log(`Main process: ${main ? `${main.pid} (${main.generation})` : "not running"}`); + console.log(`Reusable loopback bridge: ${port ?? "none"}`); + console.log(`Bridge state file: ${staleState ? "present (not modified in dry-run)" : "absent"}`); + if (main && !port) console.log("Action: a real restart would be required; dry-run left Codex untouched."); + else if (!main) console.log("Action: start Codex with a random loopback port."); + else console.log("Action: reuse the current bridge and apply the runtime override."); +} + +async function startOnce(allowRestart: boolean): Promise { + let installation = await discoverCodexInstallation(); + const main = findMainProcess(installation); + let port = await healthyDebugPort(main); + if (main && !port && !allowRestart) { + console.error("Codex is already running without a reusable loopback bridge."); + console.error("A restart requires explicit permission. Re-run with --restart only after saving unsent composer text."); + return 2; + } + if (main && !port) { + await terminateCodex(main); + installation = await discoverCodexInstallation(); + } + if (!port) { + port = await chooseLoopbackPort(); + await launchCodex(installation, port); + } + const result = await enableBridge(installation, port); + console.log(`Codex Deck ready on 127.0.0.1:${port}.`); + console.log(JSON.stringify(result, null, 2)); + return 0; +} + +async function selfTest(): Promise { + let state = createWatcherPolicyState(0); + let result = evaluateWatcherPolicy(state, { now: 0, generation: "A", bridgeHealthy: false }); + assert.equal(result.action.type, "preserve-initial-session", "initial existing session remains untouched"); + state = result.state; + result = evaluateWatcherPolicy(state, { now: 10_000, generation: "A", bridgeHealthy: false }); + assert.equal(result.action.type, "preserve-initial-session", "same initial process is never restarted"); + + result = evaluateWatcherPolicy(state, { now: 10_001, generation: "B", bridgeHealthy: false }); + assert.equal(result.action.type, "restart-for-recovery", "rapid main-process replacement is detected"); + state = result.state; + result = evaluateWatcherPolicy(state, { now: 41_000, generation: "B", bridgeHealthy: false }); + assert.equal(result.action.type, "wait", "the same generation is not restarted repeatedly"); + + state = createWatcherPolicyState(0); + result = evaluateWatcherPolicy(state, { now: 0, generation: null, bridgeHealthy: false }); + state = result.state; + result = evaluateWatcherPolicy(state, { now: 8_000, generation: null, bridgeHealthy: false }); + assert.equal(result.action.type, "launch-bridge", "an observed stopped interval triggers recovery"); + + state = createWatcherPolicyState(0); + result = evaluateWatcherPolicy(state, { now: 0, generation: "A", bridgeHealthy: true }); + state = result.state; + result = evaluateWatcherPolicy(state, { now: 10_000, generation: "B", bridgeHealthy: false }); + assert.equal(result.action.type, "restart-for-recovery", "a previous healthy bridge recovers after replacement/update"); + + state = createWatcherPolicyState(0); + result = evaluateWatcherPolicy(state, { now: 0, generation: null, bridgeHealthy: false }); + state = result.state; + result = evaluateWatcherPolicy(state, { now: 2_000, generation: "RACE", bridgeHealthy: false }); + assert.equal(result.action.type, "preserve-initial-session", "LaunchAgent startup race preserves the first normal session"); + + const temporaryRoot = await mkdtemp(join(tmpdir(), "codex-deck-self-test-")); + const lockPath = join(temporaryRoot, "watcher.lock"); + const firstLock = await acquirePidLock(lockPath); + assert.ok(firstLock, "the first watcher acquires its PID lock"); + assert.equal(await acquirePidLock(lockPath), null, "a duplicate watcher exits safely"); + await firstLock(); + const reclaimedLock = await acquirePidLock(lockPath); + assert.ok(reclaimedLock, "the lock is available after clean shutdown"); + await reclaimedLock(); + + const staleStatePath = join(temporaryRoot, "codex-micro-bridge.json"); + await writeFile(staleStatePath, `${JSON.stringify({ port: 70_000 })}\n`); + assert.equal(await removeStaleBridgeStateFile(staleStatePath, null), true, "stale port state is removed"); + assert.equal(await stat(staleStatePath).then(() => true).catch(() => false), false, "stale state file no longer exists"); + await rm(temporaryRoot, { recursive: true, force: true }); + + assert.equal(isBridgeStateStale(70_000, null), true, "stale/invalid port state is rejected"); + assert.equal(isBridgeStateStale(43123, 43123), false, "the active bridge state is retained"); + console.log("macOS self-test passed: 8 recovery, race, stale-state, and single-instance scenarios."); +} + +async function main(): Promise { + const command = process.argv[2] ?? "start"; + if (command === "--dry-run" || command === "dry-run") { await dryRun(); return 0; } + if (command === "--self-test" || command === "self-test") { await selfTest(); return 0; } + if (command === "--print-launch-agent" || command === "print-launch-agent") { + process.stdout.write(buildLaunchAgentPlist()); return 0; + } + if (command === "install") { await installLaunchAgent(); return 0; } + if (command === "uninstall") { await uninstallLaunchAgent(); return 0; } + if (command === "watch") return await runWatcher(); + if (command === "start") return await startOnce(process.argv.includes("--restart")); + if (command === "--restart") return await startOnce(true); + throw new Error("Usage: start-codex-deck.sh [start [--restart]|dry-run|self-test|install|uninstall|watch|print-launch-agent]"); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().then((code) => { process.exitCode = code; }).catch((error) => { + console.error(`Codex Deck: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + }); +} diff --git a/launcher/macos/watcher-policy.ts b/launcher/macos/watcher-policy.ts new file mode 100644 index 0000000..d8a3a71 --- /dev/null +++ b/launcher/macos/watcher-policy.ts @@ -0,0 +1,147 @@ +export type WatcherObservation = { + now: number; + generation: string | null; + bridgeHealthy: boolean; +}; + +export type WatcherAction = + | { type: "preserve-initial-session" } + | { type: "reuse-bridge" } + | { type: "wait"; reason: string } + | { type: "launch-bridge"; reason: string } + | { type: "restart-for-recovery"; generation: string; reason: string }; + +export type WatcherPolicyState = { + initialized: boolean; + startupGraceUntil: number; + lastGeneration: string | null; + suppressedInitialGeneration: string | null; + stoppedSince: number | null; + hadHealthyBridge: boolean; + recoveryPendingUntil: number; + recoveryAttempts: string[]; +}; + +export const DEFAULT_STARTUP_GRACE_MS = 5_000; +export const DEFAULT_STOPPED_GRACE_MS = 2_000; +export const DEFAULT_RECOVERY_STARTUP_MS = 30_000; + +export function createWatcherPolicyState(now = Date.now()): WatcherPolicyState { + return { + initialized: false, + startupGraceUntil: now + DEFAULT_STARTUP_GRACE_MS, + lastGeneration: null, + suppressedInitialGeneration: null, + stoppedSince: null, + hadHealthyBridge: false, + recoveryPendingUntil: 0, + recoveryAttempts: [] + }; +} + +export function resumeWatcherPolicyState( + stored: WatcherPolicyState | null, + now = Date.now() +): WatcherPolicyState { + if (!stored) return createWatcherPolicyState(now); + return { + ...stored, + startupGraceUntil: now + DEFAULT_STARTUP_GRACE_MS, + stoppedSince: null, + recoveryPendingUntil: 0, + recoveryAttempts: [...stored.recoveryAttempts].slice(-16) + }; +} + +export function evaluateWatcherPolicy( + state: WatcherPolicyState, + observation: WatcherObservation +): { state: WatcherPolicyState; action: WatcherAction } { + const next: WatcherPolicyState = { + ...state, + recoveryAttempts: [...state.recoveryAttempts] + }; + const { now, generation, bridgeHealthy } = observation; + + if (!state.initialized) { + next.initialized = true; + next.lastGeneration = generation; + if (generation != null) { + next.stoppedSince = null; + if (bridgeHealthy) { + next.hadHealthyBridge = true; + return { state: next, action: { type: "reuse-bridge" } }; + } + next.suppressedInitialGeneration = generation; + return { state: next, action: { type: "preserve-initial-session" } }; + } + next.stoppedSince = now; + return { state: next, action: { type: "wait", reason: "launch-agent-startup-grace" } }; + } + + if (generation == null) { + if (next.stoppedSince == null) next.stoppedSince = now; + if (now < next.recoveryPendingUntil) { + return { state: next, action: { type: "wait", reason: "bridge-startup-pending" } }; + } + if (now < next.startupGraceUntil) { + return { state: next, action: { type: "wait", reason: "launch-agent-startup-grace" } }; + } + if (now - next.stoppedSince < DEFAULT_STOPPED_GRACE_MS) { + return { state: next, action: { type: "wait", reason: "confirm-stopped-interval" } }; + } + next.recoveryPendingUntil = now + DEFAULT_RECOVERY_STARTUP_MS; + next.stoppedSince = now; + return { state: next, action: { type: "launch-bridge", reason: "observed-stopped-interval" } }; + } + + const previousGeneration = next.lastGeneration; + const observedStoppedInterval = next.stoppedSince != null; + const generationChanged = previousGeneration != null && previousGeneration !== generation; + next.lastGeneration = generation; + next.stoppedSince = null; + + if (bridgeHealthy) { + next.hadHealthyBridge = true; + next.recoveryPendingUntil = 0; + next.suppressedInitialGeneration = null; + return { state: next, action: { type: "reuse-bridge" } }; + } + + if (now < next.recoveryPendingUntil) { + return { state: next, action: { type: "wait", reason: "bridge-startup-pending" } }; + } + + if (previousGeneration == null && next.suppressedInitialGeneration == null && !next.hadHealthyBridge && now < next.startupGraceUntil) { + next.suppressedInitialGeneration = generation; + return { state: next, action: { type: "preserve-initial-session" } }; + } + + if (now < next.startupGraceUntil && (generationChanged || observedStoppedInterval)) { + return { state: next, action: { type: "wait", reason: "launch-agent-startup-grace" } }; + } + + if (generation === next.suppressedInitialGeneration && !generationChanged && !observedStoppedInterval) { + return { state: next, action: { type: "preserve-initial-session" } }; + } + + if (next.recoveryAttempts.includes(generation)) { + return { state: next, action: { type: "wait", reason: "recovery-already-attempted-for-generation" } }; + } + + const shouldRecover = generationChanged || observedStoppedInterval || next.hadHealthyBridge || now >= next.startupGraceUntil; + if (!shouldRecover) { + return { state: next, action: { type: "wait", reason: "launch-agent-startup-grace" } }; + } + + next.recoveryAttempts = [...next.recoveryAttempts.slice(-15), generation]; + next.recoveryPendingUntil = now + DEFAULT_RECOVERY_STARTUP_MS; + const reason = generationChanged + ? "main-process-generation-changed" + : observedStoppedInterval + ? "normal-launch-after-stopped-interval" + : next.hadHealthyBridge + ? "previous-healthy-bridge-missing" + : "normal-launch-after-startup-grace"; + return { state: next, action: { type: "restart-for-recovery", generation, reason } }; +} diff --git a/launcher/runtime-override.ts b/launcher/runtime-override.ts index 310acbe..c38316d 100644 --- a/launcher/runtime-override.ts +++ b/launcher/runtime-override.ts @@ -1,5 +1,5 @@ import WebSocket from "ws"; -import { pathToFileURL } from "node:url"; +import { basename } from "node:path"; const MICRO_GATE = "3207467860"; const DETECTION_KEY = "codex-micro-has-ever-been-detected"; @@ -41,19 +41,101 @@ export function buildRuntimeOverrideExpression(gateName = MICRO_GATE): string { ...[...document.querySelectorAll('link[href], script[src]')].map((element) => element.href || element.src), ...performance.getEntriesByType('resource').map((entry) => entry.name) ]; - const persistedUrl = [...new Set(urls)].find((url) => url.includes('/assets/persisted-signal-')); - if (!persistedUrl) return { ready: false, reason: 'persisted-signal-module-unavailable' }; - - const persisted = await import(persistedUrl); - if (typeof persisted.p !== 'function' || typeof persisted.b !== 'function') { - return { ready: false, reason: 'persisted-signal-api-changed' }; + const uniqueUrls = [...new Set(urls)].filter((url) => url.includes('/assets/') && url.endsWith('.js')); + const persistedUrl = uniqueUrls.find((url) => url.includes('/assets/persisted-signal-')); + let detected = null; + let detectionMethod = 'native-device-event'; + if (persistedUrl) { + const persisted = await import(persistedUrl); + if (typeof persisted.p !== 'function' || typeof persisted.b !== 'function') { + return { ready: false, reason: 'persisted-signal-api-changed' }; + } + persisted.b(${JSON.stringify(DETECTION_KEY)}, true); + detected = Boolean(persisted.p(${JSON.stringify(DETECTION_KEY)}, false)); + detectionMethod = 'persisted-signal'; } - persisted.b(${JSON.stringify(DETECTION_KEY)}, true); for (const client of clients) client.$emt?.({ name: 'values_updated' }); + // Newer Codex builds moved the persisted signal into a shared renderer + // chunk. Announcing the native device through the already-loaded event bus + // preserves the same Codex-owned detection path without naming that chunk. + const likelyModules = uniqueUrls.filter((url) => + /(?:vscode-api|codex-micro|app-initial|artifact-tab-content)/.test(url) + ).slice(0, 120); + let nativeEventBus = false; + let deviceHandlers = 0; + let deviceEventDispatched = false; + const eventDeadline = Date.now() + 5000; + while (Date.now() < eventDeadline && !deviceEventDispatched) { + for (const url of likelyModules) { + try { + const module = await import(url); + const bus = Object.values(module).find((candidate) => candidate && typeof candidate === 'object' && + (typeof candidate.dispatchHostMessage === 'function' || typeof candidate.dispatchMessage === 'function')); + if (!bus) continue; + nativeEventBus = true; + deviceHandlers = bus.handlers instanceof Map + ? (bus.handlers.get('codex-micro-device-state-changed')?.size ?? 0) + : 1; + if (deviceHandlers === 0) continue; + const dispatch = bus.dispatchHostMessage ?? bus.dispatchMessage; + dispatch.call(bus, ${JSON.stringify({ + type: "codex-micro-device-state-changed", + state: { status: "connected", error: null, battery: { percentage: 100, isCharging: true } } + })}); + deviceEventDispatched = true; + break; + } catch { /* Ignore unrelated already-loaded renderer chunks. */ } + } + if (!deviceEventDispatched) await new Promise((resolve) => setTimeout(resolve, 100)); + } + const enabled = clients.map((client) => Boolean(client.checkGate?.(gateName))); - const detected = Boolean(persisted.p(${JSON.stringify(DETECTION_KEY)}, false)); - return { ready: enabled.every(Boolean) && detected, enabled, detected, clients: clients.length }; + return { + ready: enabled.every(Boolean) && (detected === true || deviceEventDispatched), + enabled, + detected, + detectionMethod, + nativeEventBus, + deviceHandlers, + deviceEventDispatched, + clients: clients.length + }; + })()`; +} + +export function buildRuntimeVerificationExpression(): string { + return `(async () => { + const urls = [...new Set([ + ...[...document.querySelectorAll('link[href], script[src]')].map((element) => element.href || element.src), + ...performance.getEntriesByType('resource').map((entry) => entry.name) + ])].filter((url) => url.includes('/assets/') && url.endsWith('.js')); + const likelyModules = urls.filter((url) => + /(?:vscode-api|codex-micro|app-initial|artifact-tab-content)/.test(url) + ).slice(0, 120); + let bus = null; + for (const url of likelyModules) { + try { + const module = await import(url); + bus = Object.values(module).find((candidate) => candidate && typeof candidate === 'object' && + (typeof candidate.dispatchHostMessage === 'function' || typeof candidate.dispatchMessage === 'function')) ?? null; + if (bus?.handlers instanceof Map) break; + } catch { /* Ignore unrelated already-loaded renderer chunks. */ } + } + const hidHandlers = bus?.handlers instanceof Map ? (bus.handlers.get('codex-micro-hid-event')?.size ?? 0) : 0; + const joystickHandlers = bus?.handlers instanceof Map ? (bus.handlers.get('codex-micro-joystick-event')?.size ?? 0) : 0; + const settingsLink = Boolean(document.querySelector('[href*="/settings/codex-micro"]')); + const statsig = globalThis.__STATSIG__; + const clients = [...new Set([statsig?.firstInstance, ...Object.values(statsig?.instances ?? {})].filter(Boolean))]; + const menuEnabled = settingsLink || (clients.length > 0 && clients.every((client) => Boolean(client.checkGate?.(${JSON.stringify(MICRO_GATE)})))); + return { + ready: menuEnabled && Boolean(bus) && hidHandlers > 0 && joystickHandlers > 0, + menuEnabled, + nativeEventBus: Boolean(bus), + hidHandlers, + joystickHandlers, + modulesInspected: likelyModules.length + }; })()`; } @@ -135,7 +217,25 @@ export async function applyRuntimeOverride(port: number, timeout = 20_000): Prom } } -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { +export async function verifyMicroRuntime(port: number, timeout = 20_000): Promise { + const target = await findTarget(port, timeout); + const client = new CdpClient(target.webSocketDebuggerUrl!); + await client.connect(); + try { + const deadline = Date.now() + timeout; + let result: unknown; + while (Date.now() < deadline) { + result = await client.evaluate(buildRuntimeVerificationExpression()); + if ((result as { ready?: boolean } | null)?.ready) return result; + await delay(250); + } + throw new Error(`Timed out verifying the Codex Micro runtime: ${JSON.stringify(result)}`); + } finally { + client.close(); + } +} + +if (process.argv[1] && ["runtime-override.mjs", "runtime-override.ts"].includes(basename(process.argv[1]))) { const port = Number.parseInt(process.argv[2] ?? "", 10); if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new Error("Usage: node runtime-override.mjs "); const result = await applyRuntimeOverride(port); diff --git a/launcher/start-codex-deck.sh b/launcher/start-codex-deck.sh new file mode 100755 index 0000000..8350c4a --- /dev/null +++ b/launcher/start-codex-deck.sh @@ -0,0 +1,12 @@ +#!/bin/zsh +set -euo pipefail + +script_dir="${0:A:h}" +runtime="$script_dir/codex-deck-macos.mjs" + +if [[ ! -f "$runtime" ]]; then + print -u2 "Codex Deck: bundled macOS runtime is missing. Run npm run build first." + exit 1 +fi + +exec /usr/bin/env node "$runtime" "${@:-start}" diff --git a/scripts/build-launcher.mjs b/scripts/build-launcher.mjs index 58554a5..2a35569 100644 --- a/scripts/build-launcher.mjs +++ b/scripts/build-launcher.mjs @@ -3,8 +3,11 @@ import { cp, mkdir, rm } from "node:fs/promises"; import { resolve } from "node:path"; const output = resolve("release/codex-deck-launcher"); +const macOutput = resolve("release/codex-deck-launcher-macos"); await rm(output, { recursive: true, force: true }); +await rm(macOutput, { recursive: true, force: true }); await mkdir(output, { recursive: true }); +await mkdir(macOutput, { recursive: true }); await build({ entryPoints: [resolve("launcher/runtime-override.ts")], @@ -21,3 +24,19 @@ await cp(resolve("node_modules/ws"), resolve(output, "node_modules/ws"), { recur for (const filename of ["Start Codex Deck.cmd", "Start-CodexDeck.ps1", "Watch-CodexDeck.ps1", "README.txt"]) { await cp(resolve("launcher", filename), resolve(output, filename)); } + +await build({ + entryPoints: [resolve("launcher/macos/codex-deck-macos.ts")], + outfile: resolve(macOutput, "codex-deck-macos.mjs"), + bundle: true, + platform: "node", + format: "esm", + target: "node20", + minify: false, + banner: { js: "import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);" } +}); + +for (const filename of ["start-codex-deck.sh", "Start Codex Deck.command"]) { + await cp(resolve("launcher", filename), resolve(macOutput, filename)); +} +await cp(resolve("docs/MACOS.md"), resolve(macOutput, "README.md")); diff --git a/src/codex-deck-paths.ts b/src/codex-deck-paths.ts new file mode 100644 index 0000000..48f8cf6 --- /dev/null +++ b/src/codex-deck-paths.ts @@ -0,0 +1,11 @@ +import { homedir } from "node:os"; +import { posix, win32 } from "node:path"; + +export function codexDeckStateRoot( + targetPlatform = process.platform, + home = homedir(), + localAppData = process.env.LOCALAPPDATA +): string { + if (targetPlatform === "darwin") return posix.join(home, "Library", "Application Support", "CodexDeck"); + return win32.join(localAppData ?? win32.join(home, "AppData", "Local"), "CodexDeck"); +} diff --git a/src/codex-micro-renderer-bridge.ts b/src/codex-micro-renderer-bridge.ts index ad507e6..706d7c8 100644 --- a/src/codex-micro-renderer-bridge.ts +++ b/src/codex-micro-renderer-bridge.ts @@ -1,9 +1,9 @@ import { execFile } from "node:child_process"; import { readFile } from "node:fs/promises"; -import { homedir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import WebSocket from "ws"; +import { codexDeckStateRoot } from "./codex-deck-paths.js"; import { OFFICIAL_KEYCAP_IDS, type OfficialKeycapId } from "./keycaps.js"; import type { MicroActionSlot, MicroDirection, MicroSnapshot, ReasoningAdjustment } from "./types.js"; @@ -20,7 +20,7 @@ type CdpResponse = { }; const execFileAsync = promisify(execFile); -const PORT_FILE = join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "CodexDeck", "codex-micro-bridge.json"); +const PORT_FILE = join(codexDeckStateRoot(), "codex-micro-bridge.json"); const DEVICE_STATE = { type: "codex-micro-device-state-changed", state: { status: "connected", error: null, battery: { percentage: 100, isCharging: true } } @@ -32,26 +32,26 @@ export const REASONING_COMMANDS: Record = { }; const SNAPSHOT_EXPRESSION = `(async () => { - const hrefs = [...document.querySelectorAll('link[rel="modulepreload"]')].map((link) => link.href); - const moduleUrl = (prefix) => hrefs.find((href) => href.includes('/assets/' + prefix)); - const required = ['app-scope-', 'codex-micro-slot-signals-', 'vscode-api-', 'src-', 'use-reduced-motion-']; - const urls = Object.fromEntries(required.map((prefix) => [prefix, moduleUrl(prefix)])); - if (Object.values(urls).some((value) => !value)) throw new Error('Codex Micro renderer modules are not loaded.'); - - const [appScope, slotSignals, vscode, source, settings] = await Promise.all([ - import(urls['app-scope-']), - import(urls['codex-micro-slot-signals-']), - import(urls['vscode-api-']), - import(urls['src-']), - import(urls['use-reduced-motion-']) - ]); - const settingStorageUrl = moduleUrl('setting-storage-'); - const settingStorage = settingStorageUrl ? await import(settingStorageUrl) : null; - const definitions = vscode.Et ?? source.P; - const getSetting = settingStorage?.r ?? settings.u; - if (!definitions || typeof getSetting !== 'function') throw new Error('Codex Micro settings API was not found.'); - - const bus = [vscode.g, vscode.m, ...Object.values(vscode)].find((candidate) => candidate && typeof candidate === 'object' && candidate.handlers instanceof Map && (typeof candidate.dispatchHostMessage === 'function' || typeof candidate.dispatchMessage === 'function')); + const urls = [...new Set([ + ...[...document.querySelectorAll('link[href], script[src]')].map((element) => element.href || element.src), + ...performance.getEntriesByType('resource').map((entry) => entry.name) + ])].filter((url) => url.includes('/assets/') && url.endsWith('.js')); + const slotSignalsUrl = urls.find((url) => url.includes('/assets/codex-micro-slot-signals-')); + if (!slotSignalsUrl) throw new Error('Codex Micro slot signals are not loaded.'); + + const namespaces = []; + for (const url of urls) { + try { namespaces.push(await import(url)); } catch {} + } + const exportedValues = namespaces.flatMap((namespace) => Object.values(namespace)); + const definitions = exportedValues.find((candidate) => + candidate && typeof candidate === 'object' && + candidate.layout?.key === 'codex-micro-layout' && + candidate.agentSource?.key === 'codex-micro-agent-source' + ); + if (!definitions) throw new Error('Codex Micro settings definitions were not found.'); + + const bus = exportedValues.find((candidate) => candidate && typeof candidate === 'object' && candidate.handlers instanceof Map && (typeof candidate.dispatchHostMessage === 'function' || typeof candidate.dispatchMessage === 'function')); if (!bus) throw new Error('Codex VS Code event bus was not found.'); const dispatch = bus.dispatchHostMessage ?? bus.dispatchMessage; if ((bus.handlers.get('codex-micro-hid-event')?.size ?? 0) === 0) { @@ -61,6 +61,14 @@ const SNAPSHOT_EXPRESSION = `(async () => { const reactKey = root && Object.getOwnPropertyNames(root).find((key) => key.startsWith('__reactContainer$')); if (!root || !reactKey) throw new Error('Codex React root was not found.'); + const slotSignals = await import(slotSignalsUrl); + const resolvers = Object.values(slotSignals).filter((candidate) => + candidate && typeof candidate === 'object' && + typeof candidate.resolve === 'function' && + typeof candidate.createSubscriberAtom === 'function' + ); + if (resolvers.length === 0) throw new Error('Codex Micro slot resolver was not found.'); + let queue = [root[reactKey]]; const seen = new Set(); let found = null; @@ -76,20 +84,50 @@ const SNAPSHOT_EXPRESSION = `(async () => { dependency = dependency.next; } for (const chain of maps) { - const node = [...chain.values()].find((candidate) => candidate?.token === appScope.t); - if (node) { found = { chain, node }; break; } + for (const node of chain.values()) { + if (!node?.store || typeof node.store.get !== 'function') continue; + for (const resolver of resolvers) { + try { + const atom = resolver.resolve(node, chain); + const slots = node.store.get(atom); + if (Array.isArray(slots) && slots.length === 6 && slots.every((slot, index) => slot?.id === index)) { + found = { chain, node, slots }; + break; + } + } catch {} + } + if (found) break; + } + if (found) break; } queue.push(fiber.child, fiber.sibling); } - if (!found) throw new Error('Codex AppScope store was not found.'); - - const atom = slotSignals.n.resolve(found.node, found.chain); - const slots = found.node.store.get(atom); - const [layout, agentSource, lightingAutoOff] = await Promise.all([ - getSetting(definitions.layout), - getSetting(definitions.agentSource), - getSetting(definitions.lightingAutoOff) - ]); + if (!found) throw new Error('Codex Micro slot store was not found.'); + + let layout = definitions.layout.default; + let agentSource = definitions.agentSource.default; + let lightingAutoOff = definitions.lightingAutoOff?.default ?? '3-minutes'; + const settingReaders = exportedValues.filter((candidate) => { + if (typeof candidate !== 'function' || candidate.length !== 2) return false; + const source = Function.prototype.toString.call(candidate); + return source.includes('.key') && source.includes('.default'); + }); + for (const readSetting of settingReaders) { + try { + const candidateLayout = await readSetting(found.node.store.get, definitions.layout); + const candidateAgentSource = await readSetting(found.node.store.get, definitions.agentSource); + const candidateLightingAutoOff = definitions.lightingAutoOff + ? await readSetting(found.node.store.get, definitions.lightingAutoOff) + : lightingAutoOff; + if (candidateLayout?.version !== 1 || typeof candidateLayout.slots !== 'object') continue; + if (!['pinned', 'recent', 'priority', 'custom'].includes(candidateAgentSource)) continue; + layout = candidateLayout; + agentSource = candidateAgentSource; + if (typeof candidateLightingAutoOff === 'string') lightingAutoOff = candidateLightingAutoOff; + break; + } catch {} + } + const slots = found.slots; const html = document.documentElement; const body = document.body; @@ -245,10 +283,18 @@ export class CodexMicroRendererBridge { await this.ensureConnected(); const message = { type, ...payload }; const expression = `(async () => { - const href = [...document.querySelectorAll('link[rel="modulepreload"]')].map((link) => link.href).find((value) => value.includes('/assets/vscode-api-')); - if (!href) throw new Error('Codex VS Code bridge module is unavailable.'); - const vscode = await import(href); - const bus = [vscode.g, vscode.m, ...Object.values(vscode)].find((candidate) => candidate && typeof candidate === 'object' && candidate.handlers instanceof Map && (typeof candidate.dispatchHostMessage === 'function' || typeof candidate.dispatchMessage === 'function')); + const urls = [...new Set([ + ...[...document.querySelectorAll('link[href], script[src]')].map((element) => element.href || element.src), + ...performance.getEntriesByType('resource').map((entry) => entry.name) + ])].filter((url) => url.includes('/assets/') && url.endsWith('.js')); + let bus = null; + for (const url of urls) { + try { + const namespace = await import(url); + bus = Object.values(namespace).find((candidate) => candidate && typeof candidate === 'object' && candidate.handlers instanceof Map && (typeof candidate.dispatchHostMessage === 'function' || typeof candidate.dispatchMessage === 'function')); + if (bus) break; + } catch {} + } if (!bus) throw new Error('Codex VS Code event bus was not found.'); const dispatch = bus.dispatchHostMessage ?? bus.dispatchMessage; if ((bus.handlers.get(${JSON.stringify(requiredHandler)})?.size ?? 0) === 0) { @@ -349,7 +395,16 @@ export class CodexMicroRendererBridge { async function discoverDebugPort(): Promise { const fromFile = await readPortFile(); if (fromFile && await isDebugPort(fromFile)) return fromFile; - if (process.platform !== "win32") throw new Error("Die native Codex-Micro-Brücke ist derzeit für Windows eingerichtet."); + if (process.platform === "darwin") { + const { stdout } = await execFileAsync("/bin/ps", ["-axo", "command="], { timeout: 4000 }); + for (const line of stdout.split("\n")) { + if (!line.includes(".app/Contents/MacOS/") || !line.includes("--remote-debugging-address=127.0.0.1")) continue; + const port = Number.parseInt(line.match(/--remote-debugging-port(?:=|\s+)(\d+)/)?.[1] ?? "", 10); + if (Number.isInteger(port) && await isDebugPort(port)) return port; + } + throw new Error("Codex wurde nicht über den macOS-Micro-Aktivierungsstarter geöffnet."); + } + if (process.platform !== "win32") throw new Error("Die native Codex-Micro-Brücke wird auf dieser Plattform nicht unterstützt."); const command = "$ports = Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'ChatGPT.exe' -and $_.CommandLine -match '--remote-debugging-port=(\\d+)' } | ForEach-Object { if ($_.CommandLine -match '--remote-debugging-port=(\\d+)') { $Matches[1] } }; $ports | Select-Object -Unique"; const { stdout } = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], { windowsHide: true, timeout: 4000 }); diff --git a/src/controller.ts b/src/controller.ts index 7d2cc74..a7e10e2 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -1,7 +1,7 @@ import streamDeck, { type KeyAction } from "@elgato/streamdeck"; import { readFile } from "node:fs/promises"; -import { homedir } from "node:os"; import { join } from "node:path"; +import { codexDeckStateRoot } from "./codex-deck-paths.js"; import { CodexMicroRendererBridge } from "./codex-micro-renderer-bridge.js"; import type { OfficialKeycapId } from "./keycaps.js"; import { renderAgentKey, renderBuiltinKeycap, renderFallbackKeycap, renderImportedKeycap, type BuiltinIconName } from "./render.js"; @@ -18,11 +18,7 @@ type AgentRegistration = { action: KeyAction; slot: number }; type MicroActionRegistration = { action: KeyAction; slot: MicroActionSlot }; type ActionIdentity = { id: string }; -const USER_ICON_ROOT = join( - process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), - "CodexDeck", - "icons" -); +const USER_ICON_ROOT = join(codexDeckStateRoot(), "icons"); export class DeckController { private readonly microBridge = new CodexMicroRendererBridge((message) => streamDeck.logger.info(message)); diff --git a/test/launcher.test.ts b/test/launcher.test.ts index 1ea52bd..82a1edb 100644 --- a/test/launcher.test.ts +++ b/test/launcher.test.ts @@ -4,7 +4,7 @@ import { readFile } from "node:fs/promises"; import test from "node:test"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; -import { buildRuntimeOverrideExpression } from "../launcher/runtime-override.js"; +import { buildRuntimeOverrideExpression, buildRuntimeVerificationExpression } from "../launcher/runtime-override.js"; const execFileAsync = promisify(execFile); @@ -47,3 +47,20 @@ test("watcher recovery decision self-test passes in PowerShell", async () => { ]); assert.match(stdout, /self-test passed \(6 cases\)/i); }); + +test("launcher supports the current shared-chunk native detection path", () => { + const expression = buildRuntimeOverrideExpression(); + assert.match(expression, /native-device-event/); + assert.match(expression, /codex-micro-device-state-changed/); + assert.match(expression, /dispatchHostMessage/); + assert.match(expression, /deviceEventDispatched/); + assert.match(expression, /3207467860/); +}); + +test("launcher verifies the settings gate and native Micro handlers", () => { + const expression = buildRuntimeVerificationExpression(); + assert.match(expression, /settings\/codex-micro/); + assert.match(expression, /codex-micro-hid-event/); + assert.match(expression, /codex-micro-joystick-event/); + assert.match(expression, /nativeEventBus/); +}); diff --git a/test/macos-launcher.test.ts b/test/macos-launcher.test.ts new file mode 100644 index 0000000..50b568c --- /dev/null +++ b/test/macos-launcher.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildCodexLaunchSpec, buildLaunchAgentPlist, buildWatcherLaunchScript, parseDebugPort } from "../launcher/macos/codex-deck-macos.js"; +import { codexDeckStateRoot } from "../src/codex-deck-paths.js"; + +test("macOS launcher uses LaunchServices and passes loopback-only CDP arguments", () => { + const spec = buildCodexLaunchSpec({ appPath: "/Applications/Unexpected Codex Name.app" }, 43123); + assert.equal(spec.command, "/usr/bin/open"); + assert.deepEqual(spec.args, [ + "-n", + "-a", + "/Applications/Unexpected Codex Name.app", + "--args", + "--remote-debugging-address=127.0.0.1", + "--remote-debugging-port=43123" + ]); + assert.doesNotMatch(spec.args.join(" "), /0\.0\.0\.0/); +}); + +test("macOS launcher validates ports and parses both supported flag forms", () => { + assert.throws(() => buildCodexLaunchSpec({ appPath: "/Applications/Codex.app" }, 0), /Invalid debugging port/); + assert.equal(parseDebugPort("Codex --remote-debugging-port=43123"), 43123); + assert.equal(parseDebugPort("Codex --remote-debugging-port 43124"), 43124); + assert.equal(parseDebugPort("Codex --remote-debugging-port=70000"), null); +}); + +test("bridge and user icon state use the native macOS Application Support root", () => { + assert.equal( + codexDeckStateRoot("darwin", "/Users/tester"), + "/Users/tester/Library/Application Support/CodexDeck" + ); + assert.equal( + codexDeckStateRoot("win32", "C:\\Users\\tester", "C:\\Users\\tester\\AppData\\Local"), + "C:\\Users\\tester\\AppData\\Local\\CodexDeck" + ); +}); + +test("LaunchAgent uses a dynamic Node resolver instead of pinning an NVM version", () => { + const launcher = buildWatcherLaunchScript("/tmp/Codex Deck/runtime.mjs"); + const plist = buildLaunchAgentPlist("/tmp/Codex Deck/watcher-launch.sh"); + assert.match(launcher, /\.nvm\/versions\/node\/\*\/bin\/node/); + assert.match(launcher, /Contents\/Resources\/cua_node\/bin\/node/); + assert.match(launcher, /Node\.js 20 or newer/); + assert.match(plist, /\/bin\/zsh<\/string>/); + assert.match(plist, /watcher-launch\.sh/); + assert.doesNotMatch(plist, /\.nvm\/versions\/node\/v\d/); +}); diff --git a/test/macos-watcher.test.ts b/test/macos-watcher.test.ts new file mode 100644 index 0000000..fc76825 --- /dev/null +++ b/test/macos-watcher.test.ts @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { acquirePidLock, isBridgeStateStale, removeStaleBridgeStateFile } from "../launcher/macos/codex-deck-macos.js"; +import { + createWatcherPolicyState, + evaluateWatcherPolicy, + resumeWatcherPolicyState +} from "../launcher/macos/watcher-policy.js"; + +test("initial existing normal session remains untouched", () => { + const result = evaluateWatcherPolicy(createWatcherPolicyState(0), { + now: 0, generation: "A", bridgeHealthy: false + }); + assert.equal(result.action.type, "preserve-initial-session"); + assert.equal(result.state.suppressedInitialGeneration, "A"); +}); + +test("same process generation is never recovery-restarted repeatedly", () => { + let result = evaluateWatcherPolicy(createWatcherPolicyState(0), { + now: 0, generation: "A", bridgeHealthy: false + }); + result = evaluateWatcherPolicy(result.state, { now: 6_000, generation: "B", bridgeHealthy: false }); + assert.equal(result.action.type, "restart-for-recovery"); + result = evaluateWatcherPolicy(result.state, { now: 37_000, generation: "B", bridgeHealthy: false }); + assert.deepEqual(result.action, { type: "wait", reason: "recovery-already-attempted-for-generation" }); +}); + +test("rapid main-process replacement is detected without observing a stopped poll", () => { + let result = evaluateWatcherPolicy(createWatcherPolicyState(0), { + now: 0, generation: "A", bridgeHealthy: false + }); + result = evaluateWatcherPolicy(result.state, { now: 6_000, generation: "B", bridgeHealthy: false }); + assert.deepEqual(result.action, { + type: "restart-for-recovery", + generation: "B", + reason: "main-process-generation-changed" + }); +}); + +test("observed stopped interval triggers one bridge launch and waits for startup", () => { + let result = evaluateWatcherPolicy(createWatcherPolicyState(0), { + now: 0, generation: "A", bridgeHealthy: true + }); + result = evaluateWatcherPolicy(result.state, { now: 6_000, generation: null, bridgeHealthy: false }); + assert.deepEqual(result.action, { type: "wait", reason: "confirm-stopped-interval" }); + result = evaluateWatcherPolicy(result.state, { now: 8_001, generation: null, bridgeHealthy: false }); + assert.equal(result.action.type, "launch-bridge"); + result = evaluateWatcherPolicy(result.state, { now: 9_000, generation: null, bridgeHealthy: false }); + assert.deepEqual(result.action, { type: "wait", reason: "bridge-startup-pending" }); +}); + +test("previous healthy bridge triggers recovery after app update replacement", () => { + let result = evaluateWatcherPolicy(createWatcherPolicyState(0), { + now: 0, generation: "A:/Applications/Old.app", bridgeHealthy: true + }); + result = evaluateWatcherPolicy(result.state, { + now: 6_000, generation: "B:/Applications/New.app", bridgeHealthy: false + }); + assert.equal(result.action.type, "restart-for-recovery"); +}); + +test("LaunchAgent startup race waits, preserves a fresh install, and recovers prior bridge state", () => { + let fresh = evaluateWatcherPolicy(createWatcherPolicyState(0), { + now: 0, generation: null, bridgeHealthy: false + }); + fresh = evaluateWatcherPolicy(fresh.state, { now: 2_000, generation: "LOGIN", bridgeHealthy: false }); + assert.equal(fresh.action.type, "preserve-initial-session"); + + let prior = evaluateWatcherPolicy(createWatcherPolicyState(0), { + now: 0, generation: "OLD", bridgeHealthy: true + }).state; + prior = resumeWatcherPolicyState(prior, 100_000); + let resumed = evaluateWatcherPolicy(prior, { now: 101_000, generation: "LOGIN", bridgeHealthy: false }); + assert.deepEqual(resumed.action, { type: "wait", reason: "launch-agent-startup-grace" }); + resumed = evaluateWatcherPolicy(resumed.state, { now: 106_000, generation: "LOGIN", bridgeHealthy: false }); + assert.equal(resumed.action.type, "restart-for-recovery"); +}); + +test("stale port state is identified while the live port is retained", () => { + assert.equal(isBridgeStateStale(56_871, 56_871), false); + assert.equal(isBridgeStateStale(56_871, 56_872), true); + assert.equal(isBridgeStateStale(70_000, null), true); + assert.equal(isBridgeStateStale("not-a-port", null), true); +}); + +test("stale bridge-port file is removed but the active one is preserved", async () => { + const root = await mkdtemp(join(tmpdir(), "codex-deck-state-test-")); + try { + const path = join(root, "codex-micro-bridge.json"); + await writeFile(path, `${JSON.stringify({ port: 56_871 })}\n`); + assert.equal(await removeStaleBridgeStateFile(path, 56_871), false); + assert.match(await readFile(path, "utf8"), /56871/); + assert.equal(await removeStaleBridgeStateFile(path, null), true); + await assert.rejects(readFile(path, "utf8"), /ENOENT/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("duplicate watcher instances exit safely under a PID lock", async () => { + const root = await mkdtemp(join(tmpdir(), "codex-deck-lock-test-")); + try { + const lockPath = join(root, "watcher.lock"); + const release = await acquirePidLock(lockPath); + assert.ok(release); + assert.equal(await acquirePidLock(lockPath), null); + await release(); + const reacquired = await acquirePidLock(lockPath); + assert.ok(reacquired); + await reacquired(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/micro-bridge.test.ts b/test/micro-bridge.test.ts index e6cd112..2e1e3a5 100644 --- a/test/micro-bridge.test.ts +++ b/test/micro-bridge.test.ts @@ -18,7 +18,7 @@ test("official Micro statuses map to the Stream Deck color states", () => { test("official keycap SVG contents are not bundled in the public source", async () => { const controller = await readFile(new URL("../src/controller.ts", import.meta.url), "utf8"); - assert.match(controller, /CodexDeck[\s\S]*icons/); + assert.match(controller, /codexDeckStateRoot\(\)[\s\S]*icons/); assert.doesNotMatch(controller, /static\/imgs\/official/); }); @@ -27,7 +27,12 @@ test("renderer bridge uses native Micro events and discovers hashed modules at r for (const eventName of ["codex-micro-device-state-changed", "codex-micro-hid-event", "codex-micro-joystick-event"]) { assert.match(source, new RegExp(eventName)); } - assert.match(source, /modulepreload/); + assert.match(source, /link\[href\], script\[src\]/); + assert.match(source, /performance\.getEntriesByType\('resource'\)/); + assert.match(source, /createSubscriberAtom/); + assert.match(source, /slots\.length === 6/); + assert.match(source, /codex-micro-agent-source/); + assert.doesNotMatch(source, /candidate\?\.token === appScope/); assert.doesNotMatch(source, /D90_rd6W|SFcKxWqG|DJFcGyy5/); }); From 02321aeebc143f390cb4e0bae5861968364a43c3 Mon Sep 17 00:00:00 2001 From: Dazer <47606394+dazer1234@users.noreply.github.com> Date: Sat, 18 Jul 2026 07:47:43 +0200 Subject: [PATCH 3/3] Prepare Codex Deck 0.6.0 multi-host release --- .gitattributes | 3 + CHANGELOG.md | 11 +- CONTRIBUTING.md | 5 +- README.md | 191 ++++++++---------- SECURITY.md | 6 +- docs/ARCHITECTURE.md | 42 +++- docs/ICON_SETUP.md | 13 +- docs/MACOS.md | 135 +++++-------- docs/MULTI_HOST.md | 93 +++++++++ docs/TROUBLESHOOTING.md | 37 +++- docs/WINDOWS.md | 63 ++++++ launcher/Configure-CodexDeckRelay.ps1 | 54 ++++++ launcher/README.txt | 8 + launcher/Start-CodexDeck.ps1 | 69 ++++++- launcher/Watch-CodexDeck.ps1 | 98 +++++++++- launcher/macos/codex-deck-macos.ts | 60 +++++- launcher/start-codex-deck.sh | 21 +- package-lock.json | 4 +- package.json | 9 +- scripts/audit-release.mjs | 47 +++++ scripts/build-launcher.mjs | 20 +- scripts/package-macos-release.sh | 11 ++ scripts/prepare-release.ps1 | 38 ++++ scripts/relay-smoke.ts | 21 ++ src/actions.ts | 21 ++ src/codex-micro-renderer-bridge.ts | 27 ++- src/codex-open.ts | 43 ++++ src/codex-relay-client.ts | 145 ++++++++++++++ src/codex-relay-server.ts | 166 ++++++++++++++++ src/control-target.ts | 33 ++++ src/controller.ts | 202 +++++++++++++++---- src/host-identity.ts | 38 ++++ src/plugin.ts | 2 + src/relay-network.ts | 11 ++ src/relay-protocol.ts | 173 +++++++++++++++++ src/render.ts | 7 +- src/session-ownership.ts | 71 +++++++ src/types.ts | 15 ++ src/windows-open.ts | 36 ---- static/manifest.json | 12 +- test/codex-open.test.ts | 18 ++ test/control-target.test.ts | 28 +++ test/launcher.test.ts | 7 +- test/macos-launcher.test.ts | 24 +++ test/micro-bridge.test.ts | 13 +- test/relay.test.ts | 270 ++++++++++++++++++++++++++ test/session-ownership.test.ts | 54 ++++++ test/windows-open.test.ts | 9 - 48 files changed, 2131 insertions(+), 353 deletions(-) create mode 100644 docs/MULTI_HOST.md create mode 100644 docs/WINDOWS.md create mode 100644 launcher/Configure-CodexDeckRelay.ps1 create mode 100644 scripts/audit-release.mjs create mode 100755 scripts/package-macos-release.sh create mode 100644 scripts/prepare-release.ps1 create mode 100644 scripts/relay-smoke.ts create mode 100644 src/codex-open.ts create mode 100644 src/codex-relay-client.ts create mode 100644 src/codex-relay-server.ts create mode 100644 src/control-target.ts create mode 100644 src/host-identity.ts create mode 100644 src/relay-network.ts create mode 100644 src/relay-protocol.ts create mode 100644 src/session-ownership.ts delete mode 100644 src/windows-open.ts create mode 100644 test/codex-open.test.ts create mode 100644 test/control-target.test.ts create mode 100644 test/relay.test.ts create mode 100644 test/session-ownership.test.ts delete mode 100644 test/windows-open.test.ts diff --git a/.gitattributes b/.gitattributes index d06581f..5179831 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,3 +5,6 @@ *.ts text eol=lf *.mjs text eol=lf *.json text eol=lf +*.md text eol=lf +*.sh text eol=lf +*.command text eol=lf diff --git a/CHANGELOG.md b/CHANGELOG.md index 70fb1a5..e794147 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,21 @@ # Changelog -## 0.5.1 - Unreleased +## 0.6.0 - 2026-07-18 +- Added the local macOS Codex Micro launcher and persistent LaunchAgent watcher. +- Added an opt-in authenticated SSH/Tailscale relay for mixed Windows/macOS agent slots and native command routing. +- Added a Windows/Mac target action while keeping agent keys bound to each task's originating host. +- Added host badges and stable `(hostId, threadKey)` routing for the six global agent keys. - Replaced the one-shot Windows-login launcher with a persistent, single-instance bridge watcher. - Automatically recovers the bridge after Codex updates, crashes, and normal restarts. - Detects rapid Codex restarts by main-process generation even when no stopped interval is observed. - Avoids touching a normal Codex session that was already open when the watcher is first installed. - Removes stale bridge-port files and records bounded diagnostics in `%LOCALAPPDATA%\CodexDeck\watcher.log`. +- Added independent Windows-only and macOS-only operation from the same Stream Deck plugin package. +- Added host-generic task ownership and global recent-activity ordering for mixed Mac/Windows agent keys. +- Restricted relay listeners and clients to loopback or explicit Tailscale addresses and added hidden token entry on Windows. +- Installed the Windows watcher into a durable per-user location instead of depending on the extracted ZIP folder. +- Added separated release archives, checksums, and an automated audit for private state, personal setup markers, and protected keycap SVGs. ## 0.5.0 - 2026-07-17 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 82a19ff..72bc073 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Thanks for helping improve Codex Deck. ## Before opening a pull request -1. Keep the project Windows-only unless the new platform path is tested end to end. +1. Preserve independent Windows-only, macOS-only, and optional multi-host operation. State clearly which paths received automated, live-app, and physical-device testing. 2. Do not commit OpenAI/Elgato proprietary assets, Codex installation files, databases, logs, rollout files, personal paths, or generated release bundles. 3. Do not add hotkey or task-database fallbacks to the native bridge without a separate design discussion. 4. Update compatibility notes when changing renderer integration behavior. @@ -15,6 +15,7 @@ npm ci npm run check npm test npm run validate +npm run audit:release ``` -Pull requests should explain the tested Codex version, Stream Deck version, hardware model, and manual verification performed. +Pull requests should explain the tested Codex version, Stream Deck version, operating system, hardware model, and manual verification performed. Never describe fixture or build validation as physical-device verification. diff --git a/README.md b/README.md index 45aacce..d6faf11 100644 --- a/README.md +++ b/README.md @@ -1,107 +1,95 @@ # Codex Deck -An unofficial bridge that brings the Codex Micro control model to an Elgato Stream Deck. The released Stream Deck workflow is Windows-based; the repository also contains a local-only macOS launcher and persistent bridge watcher for the future multi-host hub. - -Codex Deck mirrors the six native agent slots, their live states, the six configurable Micro action slots, joystick directions, encoder click, and reasoning-effort controls. It sends Codex's own renderer events instead of typing text or relying on global hotkeys. +Codex Deck brings the Codex Micro control model to an Elgato Stream Deck. It mirrors Codex's six native agent slots and sends Codex's own Micro events for actions, joystick directions, encoder clicks, reasoning effort, and official keycap commands. It does not type text or depend on global hotkeys. > [!IMPORTANT] -> This is an independent community project. It is not made, supported, or endorsed by OpenAI or Elgato. It relies on undocumented Codex desktop internals and may need an update after a Codex release. +> This is an independent community project. It is not made, supported, or endorsed by OpenAI or Elgato. It uses undocumented Codex desktop internals and may need an update after a Codex release. ![Six public agent-tile states in Codex-aligned dark mode](docs/assets/agent-status-preview-dark.svg) -## What works +## Choose your setup -- Six dynamic agent keys using the assignments selected in **Codex Settings > Codex Micro**. -- Live states: idle, working, unread completion, approval/input required, error, and empty. -- Automatic light/dark rendering that follows the active Codex appearance. -- Native key-down and key-up events for Micro action slots `ACT06` through `ACT12`. -- Native joystick events for up, right, down, and left. -- Native encoder click. -- Dedicated reasoning-effort up/down buttons with press-and-hold repeat. -- A direct `codex://threads/new` action for starting a new task. -- Standalone actions for all 29 official single-size keycaps, resolved from Codex's live Micro registry. -- The official microphone keycap as a true native press/release action. -- Optional local loading of Codex Micro keycap SVGs without redistributing those files. -- A readable Codex-aligned fallback when a local keycap SVG is missing. +The same Stream Deck plugin package works in all three modes. Install only the launcher and configuration needed for your setup. -There is deliberately no legacy task-database reader, log scraper, or hotkey fallback in the public runtime. +| Setup | Stream Deck software | Codex controlled | Guide | +|---|---|---|---| +| Windows only | Windows | Local Windows Codex | [Windows setup](docs/WINDOWS.md) | +| Mac only | macOS | Local Mac Codex | [macOS setup](docs/MACOS.md) | +| Windows + Mac | Windows | Both apps; six agents are merged | [Multi-host setup](docs/MULTI_HOST.md) | -## Requirements +Windows-only and Mac-only mode have no relay, no second computer dependency, and no host badges. Multi-host mode is optional and can be disabled without changing the local bridge on either machine. -- Windows 10 or newer. -- The Codex desktop app for Windows. -- Elgato Stream Deck 6.6 or newer. -- Node.js 20 or newer for the launcher. -- Tested hardware: the standard 15-key Stream Deck MK.2. +## Features -Other 15-key Stream Deck models may work, but have not been verified yet. - -### macOS foundation +- Six dynamic agent keys using the source and assignments selected in **Codex Settings > Codex Micro**. +- Live idle, working, unread completion, approval/input, error, and empty states. +- Codex-aligned light and dark rendering with restrained status animation. +- Native key-down/key-up handling for Micro slots `ACT06` through `ACT12`. +- Native joystick up, right, down, left, and encoder click. +- Dedicated reasoning-effort up/down buttons with press-and-hold repeat. +- A local `codex://threads/new` action for a new task. +- Standalone actions for all official single-size keycaps, resolved from the installed Codex build at runtime. +- Optional local loading of official keycap SVGs; those protected files are never included in this repository or its releases. +- Optional authenticated SSH/Tailscale relay for one Stream Deck controlling Windows and Mac Codex together. -The macOS launcher enables the same native Codex Micro renderer event path in -the installed Codex app and can install a persistent per-user LaunchAgent. It -does not yet relay the Mac to a Windows/Stream Deck host. See -[macOS launcher and watcher](docs/MACOS.md) for install, diagnostics, safety, -and uninstall commands. +## Requirements -## Install +- Codex desktop on the computer being controlled. +- Elgato Stream Deck 6.6 or newer on the computer connected to the Stream Deck. +- Node.js 20 or newer for the platform launcher. +- Windows 10+ or macOS 13+. +- Tested hardware: standard 15-key Stream Deck MK.2. -1. Open the latest [GitHub release](https://github.com/dazer1234/codex-stream-deck/releases/latest). -2. Download and double-click `com.simeo.codex-deck.streamDeckPlugin`. -3. Download and extract the matching `codex-deck-launcher` ZIP. -4. Double-click **Start Codex Deck.cmd** from the extracted launcher folder. If Codex is already running from the launcher, it reuses that session; if it was launched normally, it restarts it once with the bridge enabled. -5. Open **Codex Settings > Codex Micro** and choose the agent source, action assignments, joystick actions, and encoder behavior you want. -6. In Stream Deck, drag the Codex Deck actions onto your keys using the layout below. +Other Stream Deck models may work, but the included layout and physical-device testing target the normal 5×3 MK.2. -Without the optional watcher, use **Start Codex Deck.cmd** whenever you want to use the bridge. You do not need to restart an already launcher-started Codex session. +## Quick install -To remove that manual step permanently, run `Start-CodexDeck.ps1 -InstallStartup` once. -This installs a single hidden watcher that stays active after Windows sign-in, -tracks Codex across app updates and restarts, removes stale bridge ports, and -automatically restores the bridge when Codex launches again. Installing it -does not restart a normal Codex session that is already open; that session is -picked up after its next normal close and reopen. Remove the watcher with -`-UninstallStartup`. +1. Download `com.simeo.codex-deck.streamDeckPlugin` from the matching [GitHub release](https://github.com/dazer1234/codex-stream-deck/releases/latest) and open it on the computer running Stream Deck. +2. Download only the launcher for that computer: + - Windows: `codex-deck-launcher-windows-vX.Y.Z.zip` + - macOS: `codex-deck-launcher-macos-vX.Y.Z.zip` +3. Follow [Windows](docs/WINDOWS.md), [macOS](docs/MACOS.md), or [Windows + Mac](docs/MULTI_HOST.md). +4. In **Codex Settings > Codex Micro**, choose the agent source, action assignments, joystick actions, and encoder behavior. +5. Build the two Stream Deck pages below. ## Recommended 15-key layout -Page 1: +This is the actual polished two-page layout used for the MK.2. It keeps the six live agents on the main page and puts lower-frequency navigation/reasoning controls on page 2. + +> This layout is only a recommendation and a practical starting point. Every action, position, page, and profile can be customized freely to match your own workflow; Codex Deck does not require this exact arrangement. + +### Page 1 — agents and daily actions | Agent 1 | Agent 2 | Agent 3 | Agent 4 | Agent 5 | |---|---|---|---|---| -| Agent 6 | Action 1 | Action 2 | Action 3 | Action 4 | -| Action 5 | Action 6 | Stream Deck: Next Page | Encoder Click | New Task | +| Agent 6 | Action 1 / Fast | Action 2 / Approve | Action 3 / Reject | Action 4 / Fork | +| Action 5 / Push-to-talk | Keycap · Browser¹ | Stream Deck: Next Page | Reasoning Encoder Click | New Task | -The six action keys follow whatever is assigned to `ACT06`, `ACT07`, `ACT08`, `ACT09`, `ACT10/11`, and `ACT12` in the Codex Micro settings. Names such as Fast, Approve, Reject, Fork, Push-to-talk, and Send are defaults, not hardcoded behavior. +The action names describe the default Codex Micro setup. The keys always follow the live `ACT06`, `ACT07`, `ACT08`, `ACT09`, and `ACT10/11` assignments selected in Codex. ¹If you use `ACT12` / Send more often than Browser, put **Action 6 / Send** in that position instead. -Page 2: +### Page 2 — navigation and reasoning -| Joystick Up | Reasoning Down | Encoder Click | Reasoning Up | New Task | +| Windows / Mac Target² | Empty | Joystick Up / Plan | Reasoning Down | Reasoning Up | |---|---|---|---|---| -| Empty | Joystick Left | Stream Deck: Previous Page | Joystick Right | Empty | -| Empty | Empty | Joystick Down | Empty | Empty | +| Empty | Joystick Left / Back | Stream Deck: Previous Page | Joystick Right / Forward | Reasoning Encoder Click | +| Stream Deck: Switch Profile³ | Empty | Joystick Down / Sidebar | Empty | New Task | -The page-navigation keys are built-in Stream Deck actions, not Codex Deck actions. +²Use the target key only in Windows + Mac mode. In a single-computer setup, leave it empty or replace it with another keycap action. ³Configure Stream Deck's built-in **Switch Profile** action to return to your own standard profile; no user-specific profile ID is distributed. -The Stream Deck action list also exposes every official Codex Micro keycap as a -standalone action. These execute the command currently associated with that -keycap in the installed Codex build, independently of the six physical action -slots. This makes the full keycap set practical on extra pages without changing -your primary Micro layout. +The page-navigation and profile-switch keys are built-in Stream Deck actions. All other named controls come from Codex Deck. Every official Codex Micro keycap is also exposed as a standalone action, so extra pages can be customized without changing the six synchronized Micro action slots. ## Official keycap SVGs are not included -The repository and release intentionally do **not** contain OpenAI's Codex Micro command/keycap SVG files. The original agent-tile renderer, glow system, animations, and status marks are included under the repository license. +The public source and release intentionally exclude OpenAI's Codex Micro keycap SVG files. The original agent tiles, status marks, glow system, animations, fallback labels, and plugin artwork are included. -If you have the right to use the official keycap files from your own local Codex installation, place them in: +If you have the right to use the files already present in your own Codex installation, copy them outside the repository to: ```text -%LOCALAPPDATA%\CodexDeck\icons +Windows: %LOCALAPPDATA%\CodexDeck\icons +macOS: ~/Library/Application Support/CodexDeck/icons ``` -Rename each file to its Codex keycap ID, for example `FAST.svg`, `APPR.svg`, `REJ.svg`, `SPLIT.svg`, or `MIC.svg`. Codex Deck renders matching files automatically on both synchronized Micro slots and standalone keycap actions. Nothing from this folder is uploaded or committed. - -For a careful local extraction workflow and a ready-to-copy Codex prompt, see [Local icon setup](docs/ICON_SETUP.md). +Name each copy after its Codex keycap ID, such as `FAST.svg`, `APPR.svg`, `REJ.svg`, `SPLIT.svg`, or `MIC.svg`. Codex can inspect your local installation and copy the exact existing SVG files for you when explicitly instructed not to redraw, download, upload, publish, or commit them. See [Local icon setup](docs/ICON_SETUP.md) for the guarded workflow and complete filename list. ## How it works @@ -113,58 +101,44 @@ Stream Deck key -> native Codex Micro handler ``` -The launcher starts the installed Codex app with a random debug port bound to `127.0.0.1`, records that port in `%LOCALAPPDATA%\CodexDeck\codex-micro-bridge.json`, and enables the Micro UI for that session. The plugin discovers version-hashed renderer modules at runtime, reads the native Micro slot/layout state, and dispatches the same three event families used by the Micro integration: +The launcher enables a random Chrome DevTools port bound to `127.0.0.1`. The plugin discovers version-hashed renderer modules, reads the native Micro layout/state, and dispatches the same event families used by the Micro integration: - `codex-micro-device-state-changed` - `codex-micro-hid-event` - `codex-micro-joystick-event` -See [Architecture and security](docs/ARCHITECTURE.md) for the full boundary. +No virtual HID driver is installed and no Codex application file is patched. See [Architecture and security](docs/ARCHITECTURE.md). ## Security and privacy -- The bridge listens on loopback only; it is not intentionally exposed to your network. -- Chrome DevTools access is powerful. Any untrusted process running as your Windows user could attempt to access the local port while that Codex session is open. -- The plugin sends no telemetry and has no cloud service. -- The public runtime does not read Codex task databases, rollout files, or desktop logs. -- Local icon SVGs are read only from `%LOCALAPPDATA%\CodexDeck\icons`. -- Closing the launcher-started Codex session closes the debug endpoint. -- The optional watcher remains local, single-instance, and records only bounded diagnostics in `%LOCALAPPDATA%\CodexDeck\watcher.log`. +- The Codex debug endpoint remains loopback-only and is never the multi-host relay endpoint. +- CDP is privileged: another untrusted process running as the same local user could try to access it. +- Codex Deck has no telemetry, cloud service, or update service. +- Single-host mode reads no rollout data. Multi-host mode reads only exact local rollout **filenames**, never their contents, to distinguish a task's owning desktop from a cloud/SSH mirror. +- Optional SVGs stay in the user-local icons directory and are never uploaded. +- Multi-host mode accepts only authenticated, typed Codex Deck commands over SSH or Tailscale; wildcard and arbitrary public-IP listeners are rejected. +- Private relay tokens, local host state, logs, and personal paths are excluded by the release audit. -Do not use the launcher on a machine where you run untrusted local software. See [SECURITY.md](SECURITY.md) before reporting a vulnerability. +Do not use the launcher while running untrusted local software. See [SECURITY.md](SECURITY.md). ## Compatibility -The current local build was verified with: +Release 0.6.0 was locally validated against: -- Codex for Windows `26.715.2305.0` +- Codex for Windows `26.715.3651.0` +- Codex for macOS `26.715.31251` (build `5538`) - Stream Deck `7.4.2.22730` -- Windows build `10.0.26220.0` +- Windows `10.0.26220.0` - Node.js `24.13.0` - Standard 15-key Stream Deck MK.2 -These are tested versions, not strict minimums. Because Codex Deck uses undocumented renderer internals, newer Codex versions can break compatibility even when the Stream Deck plugin itself still loads. +The Windows physical-device path and the Windows+Mac relay were exercised on the real setup. The macOS launcher, watcher, native bridge, and plugin package are validated; a Stream Deck physically attached to the Mac has not yet been hardware-tested. These are tested versions, not strict maximums. ## Troubleshooting -Start with [Troubleshooting](docs/TROUBLESHOOTING.md). The quickest checks are: +Start with [Troubleshooting](docs/TROUBLESHOOTING.md). The important rule is: restart only the Stream Deck plugin/app for plugin updates. Do not restart Codex unless the launcher explicitly says an unbridged Codex generation needs one recovery restart and you choose to proceed. -1. Run `Start-CodexDeck.ps1 -DryRun` from PowerShell. -2. Confirm `%LOCALAPPDATA%\CodexDeck\codex-micro-bridge.json` exists after launch. -3. Confirm Codex Settings shows **Codex Micro**. -4. Restart Stream Deck after installing or updating the plugin. -5. Check Stream Deck logs for `Native Codex-Micro bridge connected`. - -## Uninstall - -1. Remove **Codex Deck** from Stream Deck's plugin settings. -2. Close Codex and launch it normally. -3. Delete `%LOCALAPPDATA%\CodexDeck` if you also want to remove the port file and local icon copies. -4. Delete the extracted launcher folder. - -The launcher does not patch files inside the Codex installation. - -## Build from source +## Build and release validation ```powershell npm ci @@ -172,24 +146,13 @@ npm run check npm test npm run validate npm run pack +npm run audit:release ``` -Outputs: - -- Plugin bundle: `dist/com.simeo.codex-deck.sdPlugin` -- Launcher folder: `release/codex-deck-launcher` -- Installable plugin package: `com.simeo.codex-deck.streamDeckPlugin` -- macOS launcher folder: `release/codex-deck-launcher-macos` - -See [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. - -## Trademarks and assets - -OpenAI, Codex, ChatGPT, and related marks and assets belong to OpenAI. Elgato and Stream Deck belong to their respective owner. This repository's code and original project artwork are licensed under MIT; third-party marks and user-supplied assets are not relicensed. +`npm run release:prepare` creates a versioned local release-candidate directory with the plugin package, Windows launcher ZIP, and SHA-256 checksums. The macOS ZIP must be created on macOS with `scripts/package-macos-release.sh` so executable bits survive; pass that ZIP to `scripts/prepare-release.ps1 -MacArchivePath ...`. -- [OpenAI brand guidelines](https://openai.com/brand/) -- [Elgato Stream Deck plugin distribution documentation](https://docs.elgato.com/streamdeck/sdk/v1/introduction/distribution/) +Nothing is published automatically. See [CONTRIBUTING.md](CONTRIBUTING.md). -## License +## License and trademarks -[MIT](LICENSE) +Code and original artwork are licensed under [MIT](LICENSE). OpenAI, Codex, ChatGPT, Elgato, Stream Deck, and their marks/assets belong to their respective owners; third-party and user-supplied assets are not relicensed. diff --git a/SECURITY.md b/SECURITY.md index 81ad78f..6cd5e05 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,4 +10,8 @@ Do not publish a working exploit, authentication data, Codex databases, rollout ## Important boundary -Codex Deck starts Codex with a Chrome DevTools endpoint bound to `127.0.0.1`. This is intentionally local but remains accessible to processes running on the same Windows account. Do not expose, forward, or rebind that port to a network interface. +Codex Deck starts Codex with a Chrome DevTools endpoint bound to `127.0.0.1`. This is intentionally local but remains accessible to processes running as the same Windows or macOS user. Do not expose, forward, or rebind that port to a network interface. + +The optional multi-host relay is a separate authenticated, typed protocol. Use only its loopback SSH tunnel or an explicit Tailscale address. Never forward CDP, use wildcard/public listeners, commit relay state, or share pairing tokens in commands, issues, logs, or screenshots. + +Release artifacts are audited for private runtime state, known personal setup markers, and protected Codex keycap SVG files. This reduces accidental packaging risk but does not replace review. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 58f9df8..7373793 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,7 +4,7 @@ ### Launcher -`Start-CodexDeck.ps1` finds the installed Microsoft Store Codex package. If a healthy debug-enabled Codex process already exists, it reuses its loopback port. Otherwise it closes the normal Codex processes, chooses an unused loopback port, writes the port number to `%LOCALAPPDATA%\CodexDeck\codex-micro-bridge.json`, and starts `ChatGPT.exe` with: +`Start-CodexDeck.ps1` finds the installed Microsoft Store Codex package. If a healthy debug-enabled Codex process already exists, it reuses its loopback port. Starting an already-running normal session requires an explicit launcher/recovery path; a read-only `-DryRun` never changes it. The launcher chooses an unused loopback port, writes it to `%LOCALAPPDATA%\CodexDeck\codex-micro-bridge.json`, and starts `ChatGPT.exe` with: ```text --remote-debugging-address=127.0.0.1 @@ -15,7 +15,7 @@ The bundled runtime helper connects to that renderer and enables the Micro featu The launcher does not edit the Codex installation, Codex LevelDB, task database, rollout files, or logs. -When startup monitoring is installed, `Watch-CodexDeck.ps1` remains active as +When startup monitoring is installed, a durable copy under `%LOCALAPPDATA%\CodexDeck\launcher` runs `Watch-CodexDeck.ps1` as a single hidden PowerShell process. It dynamically resolves the newest Codex Microsoft Store package on every check, so an app update can change the install path without invalidating the watcher. A named mutex prevents duplicates. @@ -37,14 +37,12 @@ executable path), reuses healthy bridges, and performs at most one graceful recovery restart for a later unbridged generation. The generation and recovery policy is persisted atomically and guarded by a PID-directory lock. -The macOS bridge state adds `platform`, `hostId`, `hostName`, and -`codexVersion` while retaining the Windows-compatible `port` and `updatedAt` -fields. `hostId` is intended as the future relay node identity; the CDP port is -never a relay endpoint. +Both platforms persist a stable `hostId`, `hostName`, and platform identifier. +The relay uses that identity; the CDP port is never a relay endpoint. ### Stream Deck plugin -The plugin discovers the loopback port from the state file or from the command line of a running Codex process. It then uses Chrome DevTools Protocol `Runtime.evaluate` calls to: +The same plugin runs on Windows and macOS. It discovers the local loopback port from the platform state file or from a running Codex process. It then uses Chrome DevTools Protocol `Runtime.evaluate` calls to: 1. discover the current version-hashed Codex renderer modules; 2. announce a connected Micro device state; @@ -55,6 +53,25 @@ The plugin discovers the loopback port from the state file or from the command l The bridge does not emulate a USB HID device and installs no driver. +### Optional multi-host relay + +The Mac watcher can host an authenticated WebSocket relay on loopback behind an +SSH tunnel or on one explicitly configured Tailscale address. Wildcard listeners are rejected. The Windows +Stream Deck plugin connects as a client, merges typed Mac and Windows snapshots, +and routes agent presses by stable `(hostId, threadKey)` identity. Other controls +target the host selected by the Windows/Mac toggle. + +Host ownership is resolved from exact local rollout filenames, not from a +renderer's mirrored recent list. This distinguishes a Mac desktop task mirrored +through Windows remote SSH from a genuinely Windows-owned task. File contents, +prompts, responses, and project names are never read. The relay never reads or +proxies the remote CLI app-server stream. + +The relay protocol has no arbitrary-evaluation, filesystem, shell, or raw-CDP +operation. Payloads are capped at 64 KiB, authentication is required before a +snapshot or command is accepted, and command results use request IDs with +bounded timeouts. + ### Rendering Agent keys are original deterministic SVGs generated in memory from task title and state. The status palette is: @@ -70,7 +87,7 @@ Agent keys are original deterministic SVGs generated in memory from task title a The renderer derives the active Codex appearance from explicit theme tokens when available and falls back to the computed renderer surface luminance. Dark mode uses layered charcoal surfaces rather than pure black, with off-white text and slightly lifted status colors for the Stream Deck display. -Official Codex Micro keycap SVG contents are not part of the source or release. Optional user-local files are loaded from `%LOCALAPPDATA%\CodexDeck\icons` and wrapped in the project's neutral key surface at runtime. +Official Codex Micro keycap SVG contents are not part of the source or release. Optional user-local files are loaded from `%LOCALAPPDATA%\CodexDeck\icons` on Windows or `~/Library/Application Support/CodexDeck/icons` on macOS and wrapped in the project's neutral key surface at runtime. The controller uses non-overlapping self-scheduled refreshes and caches the last image sent to each action instance. Unchanged keys therefore produce no repeated @@ -78,7 +95,7 @@ USB image writes. Animated frames are limited to working and approval states. ## Trust boundary -CDP provides privileged access to the Codex renderer. Binding to `127.0.0.1` prevents direct access from another machine, but not from another process running under the local user account. Treat the launcher-started session like any other local debugging session: +CDP provides privileged access to the Codex renderer. Binding to `127.0.0.1` prevents direct access from another machine, but not from another process running as the same local user. Treat the launcher-started session like any other local debugging session: - do not run untrusted software at the same time; - do not change the debug address to `0.0.0.0`; @@ -87,7 +104,12 @@ CDP provides privileged access to the Codex renderer. Binding to `127.0.0.1` pre ## Data flow -Codex Deck has no server, API key, analytics endpoint, or update service. Runtime data stays between Stream Deck, the local plugin process, and the local Codex renderer. +In single-host mode Codex Deck has no server, API key, analytics endpoint, or +update service. Runtime data stays between Stream Deck, the local plugin +process, and the local Codex renderer. Optional multi-host mode adds one +user-configured Mac listener reachable through SSH or inside the encrypted +tailnet; titles, task IDs, states, ownership metadata, and typed commands pass +between the paired machines and nowhere else. ## Compatibility boundary diff --git a/docs/ICON_SETUP.md b/docs/ICON_SETUP.md index a4dbc1e..83bbfbe 100644 --- a/docs/ICON_SETUP.md +++ b/docs/ICON_SETUP.md @@ -1,13 +1,14 @@ # Local Codex Micro icon setup -Codex Deck does not distribute the official Codex Micro keycap SVGs. This workflow keeps them local to your Windows account. +Codex Deck does not distribute the official Codex Micro keycap SVGs. This workflow keeps exact local copies outside the repository and release. ## Destination -Create this directory: +Use the directory for the computer running Stream Deck: ```text -%LOCALAPPDATA%\CodexDeck\icons +Windows: %LOCALAPPDATA%\CodexDeck\icons +macOS: ~/Library/Application Support/CodexDeck/icons ``` Each SVG must be named after its Codex keycap ID. The known IDs in the tested Codex build are: @@ -29,12 +30,12 @@ The plugin reads matching files for the six synchronized physical action slots and for the standalone keycap actions. If a file is unavailable, the key still receives a readable themed label instead of remaining blank. -## Prompt Codex to copy the files locally +## Ask Codex to copy the existing files locally -Run this prompt in a Codex task on the Windows PC where Codex is installed: +Run this on the same computer as the Codex installation. Replace `` with the platform destination above: ```text -Inspect my locally installed Codex Windows app and locate the official Codex Micro keycap SVG files that are already present on this machine. Do not redraw, modify, download, upload, publish, or commit them. Copy the exact local SVG files into %LOCALAPPDATA%\CodexDeck\icons and rename each copy to its Codex keycap ID, such as FAST.svg, APPR.svg, REJ.svg, SPLIT.svg, MIC.svg, MIND+.svg, and MIND-.svg. Keep the source files unchanged. Verify that every copied file is a valid SVG with a viewBox, confirm that the destination is outside the Git repository, and report only the source location, destination location, filenames, and validation result. If the assets cannot be confirmed as files from my local Codex installation, stop without copying anything. +Inspect my locally installed Codex desktop app and locate the official Codex Micro keycap SVG files that are already present on this machine. Do not redraw, modify, generate, download, upload, publish, or commit them. Copy the exact local SVG files into and rename each copy to its Codex keycap ID, such as FAST.svg, APPR.svg, REJ.svg, SPLIT.svg, MIC.svg, MIND+.svg, and MIND-.svg. Keep the source files unchanged. Verify that every copied file is a valid SVG with a viewBox, confirm that the destination is outside the Git repository, and report only the source location, destination location, filenames, and validation result. If the assets cannot be confirmed as files from my local Codex installation, stop without copying anything. ``` This prompt intentionally limits the work to files already present in your installation and explicitly prevents them from entering the public repository. diff --git a/docs/MACOS.md b/docs/MACOS.md index 834f969..595a572 100644 --- a/docs/MACOS.md +++ b/docs/MACOS.md @@ -1,31 +1,40 @@ -# macOS launcher and watcher - -The macOS support is the local Codex Micro foundation for a future secure -multi-host relay. It enables the existing Codex renderer integration and keeps -the loopback bridge available after later Codex launches, crashes, and app -updates. It does not expose a network relay or connect to a Windows hub yet. - -## Safety boundary - -- Codex is discovered from its bundle metadata and running process path. The - app filename and version are not hardcoded. -- The executable named by `CFBundleExecutable` is validated, while launches go - through macOS LaunchServices so Input Monitoring/TCC permissions remain tied - to the signed app bundle. -- Nothing inside `Codex.app` (or the currently named app bundle) is edited, - patched, replaced, or re-signed. -- Chrome DevTools is always requested on `127.0.0.1`; `0.0.0.0` is never used. -- A normal Codex session that is already running when the watcher is first - installed is recorded and left untouched. -- After installation, a later normal launch or update replacement can require - one automatic recovery restart. The watcher performs no more than one such - restart for a single main-process generation and never force-kills Codex. -- Official OpenAI keycap SVGs are not included. User-local icons remain in the - `icons` directory during uninstall. +# macOS-only setup -## Commands +This mode runs Stream Deck and Codex on the same Mac. It needs no Windows PC, relay, SSH, Tailscale, or host-target key. The same plugin package used on Windows launches new tasks and agent links locally through macOS. + +## Install + +1. Install `com.simeo.codex-deck.streamDeckPlugin` in Stream Deck for macOS. +2. Extract `codex-deck-launcher-macos-vX.Y.Z.zip`. The official release ZIP is created on macOS so its executable bits are preserved. +3. Install Node.js 20 or newer if `node --version` is unavailable. +4. From Terminal in the extracted launcher directory, run: + + ```zsh + ./start-codex-deck.sh dry-run + ./start-codex-deck.sh self-test + ./start-codex-deck.sh start + ``` + + **Start Codex Deck.command** is the double-clickable equivalent of `start`. +5. Open **Codex Settings > Codex Micro**, configure the native slots, and add the actions from the [recommended layout](../README.md#recommended-15-key-layout). Leave the Windows/Mac target position empty or replace it with another action. + +If an archive tool removed executable permissions, restore only the two launcher files: + +```zsh +chmod +x start-codex-deck.sh "Start Codex Deck.command" +``` + +## Keep the bridge available + +```zsh +./start-codex-deck.sh install +``` + +`install` copies the watcher runtime into Application Support and installs a per-user LaunchAgent. It does not restart a normal Codex session already open during first installation. A later unbridged Codex generation may receive one graceful recovery restart; the same generation is never restarted repeatedly. -From the extracted macOS launcher directory: +Update by extracting the new launcher and running `install` again. The stable host identity, optional relay configuration, and user-owned icons are preserved. + +## Commands ```zsh ./start-codex-deck.sh dry-run @@ -35,13 +44,7 @@ From the extracted macOS launcher directory: ./start-codex-deck.sh uninstall ``` -`Start Codex Deck.command` provides the double-clickable equivalent of -`start`. If Codex is already running without a healthy bridge, it displays the -restart warning and requires the user to type `yes` before continuing. - -`install` copies only the bundled watcher runtime into Application Support, -writes the LaunchAgent, and loads it in the current GUI session. It does not -restart an already-running normal session during first installation. +`start` asks for an explicit `yes` before restarting an already-running normal Codex session. Codex launches through LaunchServices so Input Monitoring/TCC permissions remain attached to the signed app bundle. ## Files @@ -52,54 +55,13 @@ restart an already-running normal session during first installation. codex-micro-bridge.json host.json watcher-state.json - watcher.log - watcher.log.1 - watcher.log.2 - watcher.log.3 - icons/ # optional user-owned files + watcher.log, watcher.log.1 ... + icons/ # optional user-owned SVG copies ~/Library/LaunchAgents/com.simeo.codex-deck.watcher.plist ``` -`watcher.log` rotates at approximately 1 MB with three retained generations. -The PID-directory lock guarantees one watcher instance. Stale locks are -reclaimed only when the recorded process no longer exists. - -The bridge state remains compatible with the Windows reader's required `port` -and `updatedAt` fields and adds stable Mac identity: - -```json -{ - "port": 12345, - "updatedAt": "2026-07-18T12:34:56.000Z", - "platform": "darwin", - "hostId": "generated-stable-uuid", - "hostName": "User-visible Mac name", - "codexVersion": "detected-version" -} -``` - -The state file is written atomically. An unhealthy or mismatched port file is -removed before recovery. - -## Recovery model - -The watcher compares the Codex main PID, process start time, and executable -path (which includes the bundle path). This identifies a new generation even -when Codex stops and is replaced between two polls; each iteration separately -rediscovers the bundle version for state metadata. - -1. A healthy loopback bridge is reused and the runtime override is verified. -2. A first-install normal session is preserved. -3. A stopped interval is confirmed before starting Codex with the bridge. -4. A later normal generation is gracefully terminated once and relaunched - through LaunchServices with loopback-only debugging. -5. A 30-second startup window prevents repeated launches or restarts while the - renderer and CDP endpoint are becoming ready. - -The self-test simulates first-install preservation, repeat suppression, rapid -replacement, observed stops, app updates, LaunchAgent races, stale state, and -duplicate watcher instances. It does not launch or stop Codex. +State writes are atomic, a PID-directory lock prevents duplicate watchers, and logs rotate at approximately 1 MB with three retained generations. Nothing inside the Codex app bundle is modified or re-signed. ## Diagnostics @@ -110,17 +72,14 @@ launchctl print "gui/$(id -u)/com.simeo.codex-deck.watcher" plutil -lint "$HOME/Library/LaunchAgents/com.simeo.codex-deck.watcher.plist" ``` -## Uninstall +## Optional multi-host mode -`uninstall` unloads the LaunchAgent and removes its runtime, policy state, -bridge port file, lock, and rotating logs. It deliberately preserves -`host.json` (stable future relay identity) and `icons/` (user-owned assets). -No Codex application data is removed. +Only configure `relay-config` when one Windows-connected Stream Deck should also control this Mac. The relay is disabled in Mac-only mode. See [Windows + Mac multi-host relay](MULTI_HOST.md). + +## Uninstall -## Future multi-host handoff +```zsh +./start-codex-deck.sh uninstall +``` -A future authenticated relay can treat `hostId` as the stable Mac node key, -publish the six renderer agent snapshots, and forward authenticated commands to -the existing native Micro dispatcher. The relay must use its own authenticated, -encrypted transport and must never expose or forward the Chrome DevTools port. -CDP remains loopback-only on the Mac. +This unloads the LaunchAgent and removes its runtime, bridge state, policy state, lock, and logs. It deliberately preserves `host.json`, optional relay configuration, and `icons/`. No Codex application data is removed and Codex is not restarted. diff --git a/docs/MULTI_HOST.md b/docs/MULTI_HOST.md new file mode 100644 index 0000000..b15e8d8 --- /dev/null +++ b/docs/MULTI_HOST.md @@ -0,0 +1,93 @@ +# Windows + Mac multi-host relay + +This optional mode keeps the physical Stream Deck and Stream Deck software on Windows while controlling both desktop Codex apps. Each computer still has a fully independent local bridge; disabling the relay returns them to normal single-host behavior. + +```text +Stream Deck -> Windows plugin -> local Windows Codex + \-> authenticated relay -> local Mac Codex +``` + +The relay never exposes Chrome DevTools. It forwards only typed Codex Deck commands and six native agent snapshots. + +## Before pairing + +1. Complete [Windows setup](WINDOWS.md), including `-InstallStartup`. +2. Complete [macOS setup](MACOS.md), including `install`. +3. Confirm both local bridges work independently. +4. Use either: + - **SSH tunnel (recommended when SSH already works):** Mac listens on loopback and the Windows watcher maintains a dedicated `ssh -N` tunnel. + - **Tailscale:** Mac listens on its explicit tailnet address. + +Never use `0.0.0.0`, a public IP, or router port forwarding. Codex Deck rejects wildcard and arbitrary public-IP relay addresses. + +## Pair with SSH + +On the Mac: + +```zsh +./start-codex-deck.sh relay-config 127.0.0.1 +``` + +This creates a random 256-bit token in `~/Library/Application Support/CodexDeck/relay-server.json` with user-only permissions. Treat the printed token as a password. + +On Windows, run the matching configurator and omit `-Token` so the secret is entered through a hidden prompt rather than appearing on the command line: + +```powershell +.\Configure-CodexDeckRelay.ps1 ` + -MacAddress 127.0.0.1 ` + -SshHost '' +``` + +The persistent Windows watcher maintains this dedicated tunnel after sign-in and reconnects it after network interruptions. It does not adopt or depend on Codex desktop's remote-CLI SSH process. + +Restart only the Stream Deck plugin or Stream Deck app after pairing. Do not restart Codex. + +## Pair with Tailscale + +On the Mac, substitute its specific Tailscale IP or `*.ts.net` name: + +```zsh +./start-codex-deck.sh relay-config 100.x.y.z +``` + +On Windows: + +```powershell +.\Configure-CodexDeckRelay.ps1 -MacAddress 100.x.y.z +``` + +Enter the token in the hidden prompt. The relay accepts loopback, Tailscale IPv4 (`100.64.0.0/10`), Tailscale IPv6, and `*.ts.net` targets only. + +## Stream Deck behavior + +- The six agent keys are one globally ordered Windows+Mac list. +- Each visible tile receives a small `W` or `M` badge and routes to its owning desktop. +- Add **Windows / Mac Target** to page 2. It switches action slots, joystick, encoder, reasoning, standalone keycaps, and New Task between computers. +- Agent keys ignore the selected target because each task already knows its owner. +- The selected target survives plugin and relay restarts. If Mac is selected while offline, the key visibly fails instead of silently executing on Windows. + +### Ownership and SSH mirrors + +Codex's built-in remote-SSH feature can mirror a Mac-backed task into the Windows renderer. Codex Deck does not confuse that CLI connection with the Mac desktop app. In multi-host mode it compares exact local rollout **filenames** on both hosts to find the owning desktop; it never reads rollout contents, prompts, responses, or project names. + +For the same cloud task visible on both hosts, live status and selection are merged while commands route to the rollout owner. Ownership is host-generic and contains no hard-coded task IDs or project names. + +### Ordering boundary + +Native activity timestamps are used when available. Otherwise Codex Deck retains the last observed assignment, title, selection, or status change. Connecting a second host does not make old idle tasks appear recent. Immediately after first pairing, historical ordering between already-idle mirrored tasks remains best effort when Codex exposes no timestamp; activity observed after pairing is ordered exactly. + +## Disable or rotate + +Mac: + +```zsh +./start-codex-deck.sh relay-disable +``` + +Windows: + +```powershell +.\Configure-CodexDeckRelay.ps1 -Disable +``` + +Disabling Windows also removes the persisted Mac control target so single-host mode resumes locally. Run `relay-config` again to rotate the token, then reconfigure Windows. Never commit either relay JSON file or paste its token into an issue, log, shell command, or screenshot. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 2e357da..12c14e5 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -1,6 +1,6 @@ # Troubleshooting -## Codex Micro is missing from Settings +## Codex Micro is missing from Settings on Windows - You can safely run the launcher again while a launcher-started Codex session is open; it now reuses the existing debug session instead of restarting Codex. - Use `Start-CodexDeck.ps1 -ForceRestart` only when you explicitly want to restart Codex. @@ -9,7 +9,7 @@ - Watcher diagnostics are stored in `%LOCALAPPDATA%\CodexDeck\watcher.log` and rotate automatically. - Close all Codex windows. - Start Codex with `Start Codex Deck.cmd`. -- Keep the launcher folder intact; `runtime-override.mjs` must remain next to the PowerShell script. +- After `-InstallStartup`, the watcher uses its durable copy under `%LOCALAPPDATA%\CodexDeck\launcher`; the extracted ZIP is no longer required. - Run this diagnostic from the launcher folder: ```powershell @@ -18,10 +18,24 @@ If the launcher times out after a Codex update, open an issue with the exact Codex version and launcher output. +## Codex Micro is missing from Settings on macOS + +Run these from the extracted matching launcher: + +```zsh +./start-codex-deck.sh dry-run +./start-codex-deck.sh self-test +tail -n 100 "$HOME/Library/Application Support/CodexDeck/watcher.log" +``` + +Do not replace, re-sign, or edit the Codex app bundle. If `start` says an existing normal session needs a restart, it waits for your explicit `yes`. + ## Agent keys say Bridge offline - Confirm Codex was started through the launcher. -- Confirm `%LOCALAPPDATA%\CodexDeck\codex-micro-bridge.json` exists and contains a port number. +- Confirm the platform bridge state exists and contains a port number: + - Windows: `%LOCALAPPDATA%\CodexDeck\codex-micro-bridge.json` + - macOS: `~/Library/Application Support/CodexDeck/codex-micro-bridge.json` - Restart Stream Deck. - Do not run two launcher-started Codex instances at the same time. @@ -31,11 +45,11 @@ The native handler was unavailable or the action is not valid in the current com ## Agent assignments are unexpected -Codex Deck does not choose the six tasks. Open **Codex Settings > Codex Micro > Agent keys** and select pinned, recently updated, priority, or custom assignments. The Stream Deck mirrors those native slots. +Codex Deck does not choose the six native tasks. Open **Codex Settings > Codex Micro > Agent keys** and select pinned, recently updated, priority, or custom assignments. In multi-host mode, both native six-slot lists are de-duplicated and globally ordered; mirrored tasks route to the host owning the exact local rollout filename. ## Local command icon does not appear -- Verify the file is in `%LOCALAPPDATA%\CodexDeck\icons`. +- Verify the file is in `%LOCALAPPDATA%\CodexDeck\icons` on Windows or `~/Library/Application Support/CodexDeck/icons` on macOS. - Verify the filename exactly matches the keycap ID reported by Codex, including `+` or `-`. - Verify the SVG has a numeric `viewBox`. - Restart Stream Deck after changing icon files. @@ -44,13 +58,22 @@ Codex Deck does not choose the six tasks. Open **Codex Settings > Codex Micro > Restart Stream Deck. Elgato notes that plugins can fail to appear when the Stream Deck app is still running with elevated state after an install or update. +## Mac relay is offline + +- First confirm both local bridges work independently. +- SSH mode: confirm the Windows watcher is installed and the SSH alias works outside Codex's remote-CLI connection. +- Inspect `%LOCALAPPDATA%\CodexDeck\watcher.log` for the dedicated relay tunnel state. +- Confirm the Windows relay URL is `ws://127.0.0.1:` for SSH, or an explicit Tailscale address. +- Restart only the Stream Deck plugin/app after configuration. Do not restart Codex. +- Run `Configure-CodexDeckRelay.ps1 -Disable` to return cleanly to Windows-only mode. + ## What to include in a bug report -- Codex app version (`Get-AppxPackage OpenAI.Codex | Select-Object Version`). +- Codex app version and platform (`Get-AppxPackage OpenAI.Codex | Select-Object Version` on Windows; bundle version on macOS). - Stream Deck version and device model. - Windows version. - Whether `Start-CodexDeck.ps1 -DryRun` succeeds. - The relevant Stream Deck plugin log excerpt. - The exact action that failed. -Do not attach Codex databases, rollout files, authentication data, or the official SVG asset files. +Do not attach Codex databases, rollout files, relay JSON, authentication data, personal paths, or official SVG asset files. diff --git a/docs/WINDOWS.md b/docs/WINDOWS.md new file mode 100644 index 0000000..c20ed08 --- /dev/null +++ b/docs/WINDOWS.md @@ -0,0 +1,63 @@ +# Windows-only setup + +This mode runs Stream Deck and Codex on the same Windows PC. It needs no relay, SSH, Tailscale, Mac, or host-target key. + +## Install + +1. Install `com.simeo.codex-deck.streamDeckPlugin` by opening it. +2. Extract `codex-deck-launcher-windows-vX.Y.Z.zip` to a normal folder. +3. Install Node.js 20 or newer if `node --version` is unavailable. +4. Inspect the current state without changing Codex: + + ```powershell + .\Start-CodexDeck.ps1 -DryRun + ``` + +5. Double-click **Start Codex Deck.cmd**. A bridge-enabled Codex session is reused. If Codex is already running normally without the bridge, the launcher explains that one restart is required before doing it. +6. Open **Codex Settings > Codex Micro**, configure the native slots, and add the actions from the [recommended layout](../README.md#recommended-15-key-layout). + +## Keep the bridge available + +Run once from the extracted launcher folder: + +```powershell +.\Start-CodexDeck.ps1 -InstallStartup +``` + +This installs a durable private launcher copy under `%LOCALAPPDATA%\CodexDeck\launcher` and creates one hidden sign-in watcher. The extracted ZIP can then be moved or deleted. The watcher dynamically follows Codex Store updates, prevents duplicate instances, removes stale port state, and keeps an optional SSH relay tunnel alive. + +Installing the watcher does **not** restart a normal Codex session that is already open. That generation remains untouched. After the next normal Codex close/reopen or an app update, the watcher may perform one recovery restart if the new generation launched without the bridge. + +To update the watcher, extract a newer Windows launcher and run `-InstallStartup` again. User icons, relay settings, host identity, and other state are not overwritten. + +## Useful commands + +```powershell +.\Start-CodexDeck.ps1 -DryRun # read-only diagnosis +.\Start-CodexDeck.ps1 # start or reuse the bridge +.\Start-CodexDeck.ps1 -InstallStartup # install/update persistent watcher +.\Start-CodexDeck.ps1 -UninstallStartup +``` + +`-ForceRestart` exists for an explicit clean restart, but is not a normal update or troubleshooting step. + +## Files + +```text +%LOCALAPPDATA%\CodexDeck\ + launcher\ # durable watcher runtime + codex-micro-bridge.json # current local loopback port + host.json # stable local host identity + watcher.log # bounded diagnostics + icons\ # optional user-owned SVG copies +``` + +The launcher does not patch the installed Codex package. + +## Uninstall + +1. Run `Start-CodexDeck.ps1 -UninstallStartup` before deleting the launcher. +2. Remove Codex Deck in Stream Deck's plugin settings. +3. Delete `%LOCALAPPDATA%\CodexDeck` only if you also want to remove local icons, identity, relay configuration, and diagnostics. + +Uninstalling the watcher does not close or restart Codex. diff --git a/launcher/Configure-CodexDeckRelay.ps1 b/launcher/Configure-CodexDeckRelay.ps1 new file mode 100644 index 0000000..66080db --- /dev/null +++ b/launcher/Configure-CodexDeckRelay.ps1 @@ -0,0 +1,54 @@ +[CmdletBinding()] +param( + [string]$MacAddress, + [string]$Token, + [string]$SshHost, + [ValidateRange(1024, 65535)][int]$Port = 47651, + [ValidateRange(1024, 65535)][int]$RemotePort = 47651, + [switch]$Disable +) + +$stateRoot = Join-Path $env:LOCALAPPDATA 'CodexDeck' +$configPath = Join-Path $stateRoot 'relay-client.json' + +if ($Disable) { + Remove-Item -LiteralPath $configPath -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath (Join-Path $stateRoot 'control-target.json') -Force -ErrorAction SilentlyContinue + Write-Host 'Mac relay removed. Restart only the Stream Deck plugin to apply the change.' + exit 0 +} + +if ([string]::IsNullOrWhiteSpace($MacAddress)) { throw 'MacAddress is required.' } +if ([string]::IsNullOrWhiteSpace($Token)) { + $secureToken = Read-Host 'Paste the Mac relay token (input is hidden)' -AsSecureString + $pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureToken) + try { $Token = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) } + finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) } +} +if ([Text.Encoding]::UTF8.GetByteCount($Token) -lt 32) { throw 'Token must contain at least 32 bytes.' } +if ($MacAddress -match '^(0\.0\.0\.0|::|\[::\]|\*)$') { throw 'Use 127.0.0.1 for an SSH tunnel or the specific Mac Tailscale address, never a wildcard.' } +if (-not [string]::IsNullOrWhiteSpace($SshHost) -and $SshHost -notmatch '^[A-Za-z0-9._-]+$') { + throw 'SshHost must be a safe SSH hostname or config alias without spaces.' +} +if (-not [string]::IsNullOrWhiteSpace($SshHost) -and $MacAddress -notmatch '^(127\.0\.0\.1|ws://127\.0\.0\.1(?::\d+)?)$') { + throw 'An automatically managed SSH tunnel must use the Windows loopback address.' +} + +$url = if ($MacAddress -match '^ws://') { $MacAddress } else { "ws://${MacAddress}:$Port" } +if ($url -notmatch '^ws://[^/]+(?::\d+)?$') { throw 'MacAddress must be a Tailscale IP/hostname or a ws:// URL without a path.' } + +New-Item -ItemType Directory -Force -Path $stateRoot | Out-Null +$config = [ordered]@{ enabled = $true; url = $url; token = $Token } +if (-not [string]::IsNullOrWhiteSpace($SshHost)) { + $config.sshHost = $SshHost + $config.localPort = $Port + $config.remotePort = $RemotePort +} +$temporary = "$configPath.$PID.tmp" +[IO.File]::WriteAllText($temporary, (($config | ConvertTo-Json) + "`n"), [Text.UTF8Encoding]::new($false)) +Move-Item -LiteralPath $temporary -Destination $configPath -Force +Write-Host "Mac relay configured: $url" +if (-not [string]::IsNullOrWhiteSpace($SshHost)) { + Write-Host "The Codex Deck watcher will maintain a separate SSH relay tunnel through '$SshHost'." +} +Write-Host 'Restart only the Stream Deck plugin to connect. Codex does not need to restart.' diff --git a/launcher/README.txt b/launcher/README.txt index 47a928a..6728d54 100644 --- a/launcher/README.txt +++ b/launcher/README.txt @@ -15,6 +15,7 @@ loopback-only Chrome DevTools port and enables the Codex Micro UI for that session. It does not patch the Codex installation or upload any data. Recommended: run `Start-CodexDeck.ps1 -InstallStartup` once. This installs a +durable private launcher copy under `%LOCALAPPDATA%\CodexDeck\launcher` plus a single hidden background watcher that stays active after Windows sign-in. It detects Codex restarts and app updates, removes stale bridge data, and restores the bridge automatically whenever Codex starts again. @@ -27,5 +28,12 @@ recovery restart when Codex launches without its required loopback port. Remove the watcher with `Start-CodexDeck.ps1 -UninstallStartup`. Diagnostics are written to `%LOCALAPPDATA%\CodexDeck\watcher.log`. +Optional Mac pairing: after configuring the relay on the Mac, run +`Configure-CodexDeckRelay.ps1 -MacAddress 127.0.0.1 -SshHost `. +Paste the token into the hidden prompt instead of placing it on the command +line. The watcher keeps this dedicated SSH relay tunnel alive and +does not reuse Codex desktop's remote-CLI SSH process. Restart the Stream Deck +plugin, not Codex. Remove the relay with `-Disable`. + This is an unofficial compatibility bridge and may need an update after a Codex desktop release. diff --git a/launcher/Start-CodexDeck.ps1 b/launcher/Start-CodexDeck.ps1 index 2904155..e42b2cd 100644 --- a/launcher/Start-CodexDeck.ps1 +++ b/launcher/Start-CodexDeck.ps1 @@ -19,8 +19,54 @@ function Get-WatcherStopPath { Join-Path (Join-Path $env:LOCALAPPDATA 'CodexDeck') 'watcher.stop' } -function Start-BridgeWatcher { - $watcherPath = Join-Path $PSScriptRoot 'Watch-CodexDeck.ps1' +function Get-InstalledLauncherRoot { + Join-Path (Join-Path $env:LOCALAPPDATA 'CodexDeck') 'launcher' +} + +function Request-WatcherStop { + $stopPath = Get-WatcherStopPath + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $stopPath) | Out-Null + [IO.File]::WriteAllText($stopPath, [DateTimeOffset]::UtcNow.ToString('o'), [Text.UTF8Encoding]::new($false)) + Start-Sleep -Seconds 3 +} + +function Install-WatcherBundle { + $destinationRoot = Get-InstalledLauncherRoot + $sourceRoot = [IO.Path]::GetFullPath($PSScriptRoot).TrimEnd('\') + if ($sourceRoot.Equals([IO.Path]::GetFullPath($destinationRoot).TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase)) { + return $destinationRoot + } + + $runtimeSource = Join-Path $sourceRoot 'runtime-override.mjs' + if (-not (Test-Path -LiteralPath $runtimeSource)) { + $runtimeSource = Join-Path $sourceRoot '..\release\codex-deck-launcher\runtime-override.mjs' + } + $wsSource = Join-Path $sourceRoot 'node_modules\ws' + if (-not (Test-Path -LiteralPath $wsSource)) { $wsSource = Join-Path $sourceRoot '..\node_modules\ws' } + foreach ($required in @( + (Join-Path $sourceRoot 'Start-CodexDeck.ps1'), + (Join-Path $sourceRoot 'Watch-CodexDeck.ps1'), + (Join-Path $sourceRoot 'Configure-CodexDeckRelay.ps1'), + $runtimeSource, + $wsSource + )) { + if (-not (Test-Path -LiteralPath $required)) { throw "Required launcher component not found: $required" } + } + + New-Item -ItemType Directory -Force -Path (Join-Path $destinationRoot 'node_modules') | Out-Null + foreach ($filename in @('Start-CodexDeck.ps1', 'Watch-CodexDeck.ps1', 'Configure-CodexDeckRelay.ps1', 'README.txt')) { + $source = Join-Path $sourceRoot $filename + if (Test-Path -LiteralPath $source) { Copy-Item -LiteralPath $source -Destination (Join-Path $destinationRoot $filename) -Force } + } + Copy-Item -LiteralPath $runtimeSource -Destination (Join-Path $destinationRoot 'runtime-override.mjs') -Force + $wsDestination = Join-Path $destinationRoot 'node_modules\ws' + Remove-Item -LiteralPath $wsDestination -Recurse -Force -ErrorAction SilentlyContinue + Copy-Item -LiteralPath $wsSource -Destination $wsDestination -Recurse -Force + return $destinationRoot +} + +function Start-BridgeWatcher([string]$LauncherRoot = $PSScriptRoot) { + $watcherPath = Join-Path $LauncherRoot 'Watch-CodexDeck.ps1' if (-not (Test-Path -LiteralPath $watcherPath)) { throw "Codex Deck watcher not found: $watcherPath" } $powerShellPath = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' Start-Process -FilePath $powerShellPath -WindowStyle Hidden -ArgumentList @( @@ -29,22 +75,24 @@ function Start-BridgeWatcher { } function Set-StartupShortcut { + Request-WatcherStop + $launcherRoot = Install-WatcherBundle $shortcutPath = Get-StartupShortcutPath - $watcherPath = Join-Path $PSScriptRoot 'Watch-CodexDeck.ps1' + $watcherPath = Join-Path $launcherRoot 'Watch-CodexDeck.ps1' if (-not (Test-Path -LiteralPath $watcherPath)) { throw "Codex Deck watcher not found: $watcherPath" } $stopPath = Get-WatcherStopPath - New-Item -ItemType Directory -Force -Path (Split-Path -Parent $stopPath) | Out-Null Remove-Item -LiteralPath $stopPath -Force -ErrorAction SilentlyContinue $shell = New-Object -ComObject WScript.Shell $shortcut = $shell.CreateShortcut($shortcutPath) $shortcut.TargetPath = (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') $shortcut.Arguments = "-NoLogo -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$watcherPath`" -RecoverExistingSession" - $shortcut.WorkingDirectory = $PSScriptRoot + $shortcut.WorkingDirectory = $launcherRoot $shortcut.Description = 'Keep the Codex Deck bridge available while Codex is running' $shortcut.IconLocation = "$env:SystemRoot\System32\shell32.dll,44" $shortcut.Save() - Start-BridgeWatcher + Start-BridgeWatcher $launcherRoot Write-Host "Startup shortcut installed: $shortcutPath" + Write-Host "Durable launcher installed: $launcherRoot" Write-Host 'The background watcher is running. An existing normal Codex session was not restarted.' } @@ -55,15 +103,18 @@ if ($InstallStartup) { if ($UninstallStartup) { $shortcutPath = Get-StartupShortcutPath - $stopPath = Get-WatcherStopPath - New-Item -ItemType Directory -Force -Path (Split-Path -Parent $stopPath) | Out-Null - [IO.File]::WriteAllText($stopPath, [DateTimeOffset]::UtcNow.ToString('o'), [Text.UTF8Encoding]::new($false)) + Request-WatcherStop if (Test-Path -LiteralPath $shortcutPath) { Remove-Item -LiteralPath $shortcutPath -Force Write-Host "Startup shortcut removed: $shortcutPath" } else { Write-Host 'No Codex Deck startup shortcut was installed.' } + $installedRoot = Get-InstalledLauncherRoot + if (Test-Path -LiteralPath $installedRoot) { + Remove-Item -LiteralPath $installedRoot -Recurse -Force + Write-Host "Durable launcher removed: $installedRoot" + } exit 0 } diff --git a/launcher/Watch-CodexDeck.ps1 b/launcher/Watch-CodexDeck.ps1 index a5949f3..2b6d320 100644 --- a/launcher/Watch-CodexDeck.ps1 +++ b/launcher/Watch-CodexDeck.ps1 @@ -12,6 +12,8 @@ $launcherPath = Join-Path $PSScriptRoot 'Start-CodexDeck.ps1' $powerShellPath = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' $stateRoot = Join-Path $env:LOCALAPPDATA 'CodexDeck' $statePath = Join-Path $stateRoot 'codex-micro-bridge.json' +$relayConfigPath = Join-Path $stateRoot 'relay-client.json' +$relayTunnelPidPath = Join-Path $stateRoot 'relay-tunnel.pid' $stopPath = Join-Path $stateRoot 'watcher.stop' $logPath = Join-Path $stateRoot 'watcher.log' $mutexName = 'Local\CodexDeckBridgeWatcher' @@ -27,6 +29,16 @@ function Test-RecoveryAllowed( $RecoverExisting -or $SawStopped -or $HadHealthy -or $generationChanged } +function Test-RelayTunnelCommand([string]$CommandLine, [string]$SshHost, [int]$LocalPort, [int]$RemotePort) { + if ([string]::IsNullOrWhiteSpace($CommandLine) -or [string]::IsNullOrWhiteSpace($SshHost)) { return $false } + $forward = "127.0.0.1:${LocalPort}:127.0.0.1:${RemotePort}" + $hostPattern = [Regex]::Escape($SshHost) + $forwardPattern = [Regex]::Escape($forward) + $CommandLine -match '(?i)(?:^|\s)-N(?:\s|$)' -and + $CommandLine -match "(?i)(?:^|\s)-L(?:\s+|=)$forwardPattern(?:\s|$)" -and + $CommandLine -match "(?i)(?:^|\s)$hostPattern\s*$" +} + if ($SelfTest) { $cases = @( @{ Name = 'initial existing session remains untouched'; Expected = $false; Actual = Test-RecoveryAllowed 'v1:100' '' $false $false $false }, @@ -34,7 +46,10 @@ if ($SelfTest) { @{ Name = 'rapid main-process replacement recovers'; Expected = $true; Actual = Test-RecoveryAllowed 'v1:101' 'v1:100' $false $false $false }, @{ Name = 'observed stopped interval recovers'; Expected = $true; Actual = Test-RecoveryAllowed 'v1:101' '' $false $true $false }, @{ Name = 'previous healthy bridge recovers'; Expected = $true; Actual = Test-RecoveryAllowed 'v2:200' 'v1:100' $false $false $true }, - @{ Name = 'login recovery handles startup race'; Expected = $true; Actual = Test-RecoveryAllowed 'v1:100' '' $true $false $false } + @{ Name = 'login recovery handles startup race'; Expected = $true; Actual = Test-RecoveryAllowed 'v1:100' '' $true $false $false }, + @{ Name = 'managed relay tunnel is recognized'; Expected = $true; Actual = Test-RelayTunnelCommand 'ssh.exe -N -T -L 127.0.0.1:47651:127.0.0.1:47651 example-mac' 'example-mac' 47651 47651 }, + @{ Name = 'Codex remote CLI SSH is not adopted'; Expected = $false; Actual = Test-RelayTunnelCommand 'ssh -T example-mac "codex app-server proxy"' 'example-mac' 47651 47651 }, + @{ Name = 'different forwarded port is not adopted'; Expected = $false; Actual = Test-RelayTunnelCommand 'ssh.exe -N -T -L 127.0.0.1:40000:127.0.0.1:40000 example-mac' 'example-mac' 47651 47651 } ) $failures = @($cases | Where-Object { $_.Actual -ne $_.Expected }) if ($failures.Count -gt 0) { throw "Watcher self-test failed: $($failures.Name -join ', ')" } @@ -56,6 +71,77 @@ function Write-WatcherLog([string]$Message) { Write-Host $line } +function Get-RelayTunnelConfig { + if (-not (Test-Path -LiteralPath $relayConfigPath)) { return $null } + $config = Get-Content -LiteralPath $relayConfigPath -Raw | ConvertFrom-Json + if ($config.enabled -ne $true -or [string]::IsNullOrWhiteSpace([string]$config.sshHost)) { return $null } + if ([string]$config.sshHost -notmatch '^[A-Za-z0-9._-]+$') { throw 'Relay sshHost is invalid.' } + if ([string]$config.url -notmatch '^ws://127\.0\.0\.1:(\d+)$') { throw 'Managed SSH relay URL must use 127.0.0.1 with an explicit port.' } + $urlPort = [int]$Matches[1] + $localPort = if ($null -ne $config.localPort) { [int]$config.localPort } else { $urlPort } + $remotePort = if ($null -ne $config.remotePort) { [int]$config.remotePort } else { $localPort } + if ($localPort -lt 1024 -or $localPort -gt 65535 -or $remotePort -lt 1024 -or $remotePort -gt 65535) { + throw 'Relay tunnel ports must be between 1024 and 65535.' + } + if ($localPort -ne $urlPort) { throw 'Relay localPort must match the loopback URL port.' } + [pscustomobject]@{ SshHost = [string]$config.sshHost; LocalPort = $localPort; RemotePort = $remotePort } +} + +function Get-RelayTunnelProcess($Config) { + @(Get-CimInstance Win32_Process -Filter "Name='ssh.exe'" -ErrorAction SilentlyContinue | Where-Object { + Test-RelayTunnelCommand ([string]$_.CommandLine) $Config.SshHost $Config.LocalPort $Config.RemotePort + } | Sort-Object ProcessId | Select-Object -First 1)[0] +} + +function Test-LocalTcpPort([int]$Port) { + $client = [Net.Sockets.TcpClient]::new() + try { + $connect = $client.ConnectAsync('127.0.0.1', $Port) + $connect.Wait(250) -and $client.Connected + } + catch { $false } + finally { $client.Dispose() } +} + +function Save-RelayTunnelPid([int]$ProcessId) { + [IO.File]::WriteAllText($relayTunnelPidPath, "$ProcessId`n", [Text.UTF8Encoding]::new($false)) +} + +function Stop-OwnedRelayTunnel { + if (-not (Test-Path -LiteralPath $relayTunnelPidPath)) { return 'disabled' } + try { + $processId = [int](Get-Content -LiteralPath $relayTunnelPidPath -Raw).Trim() + $process = Get-CimInstance Win32_Process -Filter "ProcessId=$processId" -ErrorAction SilentlyContinue + if ($null -ne $process -and $process.Name -ieq 'ssh.exe' -and [string]$process.CommandLine -match '(?i)(?:^|\s)-N(?:\s|$)' -and [string]$process.CommandLine -match '(?i)(?:^|\s)-L(?:\s|$)') { + Stop-Process -Id $processId -ErrorAction SilentlyContinue + } + } + finally { Remove-Item -LiteralPath $relayTunnelPidPath -Force -ErrorAction SilentlyContinue } + 'disabled' +} + +function Ensure-RelayTunnel { + $config = Get-RelayTunnelConfig + if ($null -eq $config) { return Stop-OwnedRelayTunnel } + + $existing = Get-RelayTunnelProcess $config + if ($null -ne $existing) { + Save-RelayTunnelPid ([int]$existing.ProcessId) + return "connected:$($existing.ProcessId)" + } + + if (Test-LocalTcpPort $config.LocalPort) { return "port-in-use:$($config.LocalPort)" } + $sshPath = (Get-Command ssh.exe -ErrorAction Stop).Source + $arguments = @( + '-N', '-T', '-o', 'BatchMode=yes', '-o', 'ExitOnForwardFailure=yes', + '-o', 'ServerAliveInterval=30', '-o', 'ServerAliveCountMax=3', + '-L', "127.0.0.1:$($config.LocalPort):127.0.0.1:$($config.RemotePort)", $config.SshHost + ) + $process = Start-Process -FilePath $sshPath -ArgumentList $arguments -WindowStyle Hidden -PassThru + Save-RelayTunnelPid $process.Id + "starting:$($process.Id)" +} + function Get-CodexInstallation { $package = Get-AppxPackage -Name 'OpenAI.Codex' -ErrorAction SilentlyContinue | Sort-Object Version -Descending | @@ -147,6 +233,7 @@ $hadHealthyBridge = $false $handledGeneration = '' $lastHealthyGeneration = '' $lastState = '' +$lastRelayState = '' try { Write-WatcherLog "Watcher started (recoverExisting=$($RecoverExistingSession.IsPresent))." @@ -157,6 +244,15 @@ try { } try { + $relayState = Ensure-RelayTunnel + if ($relayState -ne $lastRelayState) { + if ($relayState -like 'connected:*') { Write-WatcherLog "Mac app relay tunnel connected (SSH PID $($relayState.Split(':')[1]))." } + elseif ($relayState -like 'starting:*') { Write-WatcherLog "Starting the separate Mac app relay tunnel (SSH PID $($relayState.Split(':')[1]))." } + elseif ($relayState -like 'port-in-use:*') { Write-WatcherLog "Relay loopback port $($relayState.Split(':')[1]) is occupied by an unmanaged process; no second tunnel was started." } + elseif ($relayState -eq 'disabled') { Write-WatcherLog 'Managed Mac app relay tunnel is disabled.' } + $lastRelayState = $relayState + } + $codex = Get-CodexInstallation $processes = if ($null -eq $codex) { @() } else { Get-CodexProcesses $codex.Root } diff --git a/launcher/macos/codex-deck-macos.ts b/launcher/macos/codex-deck-macos.ts index 044789b..0554120 100644 --- a/launcher/macos/codex-deck-macos.ts +++ b/launcher/macos/codex-deck-macos.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; -import { randomUUID } from "node:crypto"; +import { randomBytes, randomUUID } from "node:crypto"; import { chmod, copyFile, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; @@ -8,6 +8,8 @@ import { createServer } from "node:net"; import { homedir, hostname, platform, tmpdir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { CodexMicroRendererBridge } from "../../src/codex-micro-renderer-bridge.js"; +import { CodexRelayServer, validateRelayServerConfig, type RelayServerConfig } from "../../src/codex-relay-server.js"; import { applyRuntimeOverride, verifyMicroRuntime } from "../runtime-override.js"; import { createWatcherPolicyState, @@ -24,6 +26,7 @@ const HOST_STATE_PATH = join(STATE_ROOT, "host.json"); const WATCHER_STATE_PATH = join(STATE_ROOT, "watcher-state.json"); const WATCHER_LOG_PATH = join(STATE_ROOT, "watcher.log"); const WATCHER_LOCK_PATH = join(STATE_ROOT, "watcher.lock"); +const RELAY_SERVER_CONFIG_PATH = join(STATE_ROOT, "relay-server.json"); const INSTALLED_RUNTIME_PATH = join(STATE_ROOT, "codex-deck-macos.mjs"); const WATCHER_LAUNCHER_PATH = join(STATE_ROOT, "watcher-launch.sh"); const LAUNCH_AGENT_PATH = join(homedir(), "Library", "LaunchAgents", `${AGENT_LABEL}.plist`); @@ -365,7 +368,12 @@ async function runWatcher(): Promise { return 0; } let released = false; + let relayServer: CodexRelayServer | undefined; + let relayControl: CodexMicroRendererBridge | undefined; + let relaySignature = ""; const cleanup = async () => { + await relayServer?.close().catch(() => {}); + relayControl?.close(); if (!released) { released = true; await release(); } }; process.once("SIGTERM", () => { void cleanup().finally(() => process.exit(0)); }); @@ -386,6 +394,28 @@ async function runWatcher(): Promise { policy = decision.state; await atomicWriteJson(WATCHER_STATE_PATH, policy); + const relayConfig = await readJson(RELAY_SERVER_CONFIG_PATH); + const nextRelaySignature = relayConfig?.enabled ? JSON.stringify(relayConfig) : ""; + if (nextRelaySignature !== relaySignature) { + await relayServer?.close(); + relayControl?.close(); + relayServer = undefined; + relayControl = undefined; + relaySignature = ""; + if (relayConfig?.enabled) { + const identity = await hostState(); + relayControl = new CodexMicroRendererBridge((message) => { void log(message); }); + relayServer = new CodexRelayServer( + relayConfig, + { ...identity, platform: "darwin" }, + relayControl, + (message) => { void log(message); } + ); + await relayServer.start(); + } + relaySignature = nextRelaySignature; + } + if (port != null) { const signature = `${main!.generation}:${port}`; if (signature !== enabledSignature) { @@ -521,6 +551,7 @@ async function dryRun(): Promise { const main = findMainProcess(installation); const port = await healthyDebugPort(main); const staleState = await readJson<{ port?: unknown }>(BRIDGE_STATE_PATH); + const relayConfig = await readJson(RELAY_SERVER_CONFIG_PATH); console.log(`Codex app: ${installation.appPath}`); console.log(`Bundle ID: ${installation.bundleId}`); console.log(`Version: ${installation.version} (${installation.buildVersion})`); @@ -528,11 +559,34 @@ async function dryRun(): Promise { console.log(`Main process: ${main ? `${main.pid} (${main.generation})` : "not running"}`); console.log(`Reusable loopback bridge: ${port ?? "none"}`); console.log(`Bridge state file: ${staleState ? "present (not modified in dry-run)" : "absent"}`); + console.log(`Multi-host relay: ${relayConfig?.enabled ? `configured for ${relayConfig.listenHost}:${relayConfig.port}` : "disabled"}`); if (main && !port) console.log("Action: a real restart would be required; dry-run left Codex untouched."); else if (!main) console.log("Action: start Codex with a random loopback port."); else console.log("Action: reuse the current bridge and apply the runtime override."); } +async function configureRelay(listenHost: string | undefined, portValue: string | undefined): Promise { + const port = portValue == null ? 47_651 : Number.parseInt(portValue, 10); + const config: RelayServerConfig = { + enabled: true, + listenHost: listenHost?.trim() ?? "", + port, + token: randomBytes(32).toString("base64url") + }; + validateRelayServerConfig(config); + await mkdir(STATE_ROOT, { recursive: true, mode: 0o700 }); + await atomicWriteJson(RELAY_SERVER_CONFIG_PATH, config); + console.log(`Mac relay configured on ${config.listenHost}:${config.port}.`); + console.log("Copy this file content to the Windows relay configurator; treat the token like a password:"); + console.log(JSON.stringify({ enabled: true, url: `ws://${config.listenHost}:${config.port}`, token: config.token }, null, 2)); + console.log("The running watcher detects this file automatically; Codex is not restarted."); +} + +async function disableRelay(): Promise { + await rm(RELAY_SERVER_CONFIG_PATH, { force: true }); + console.log("Mac relay disabled. The watcher will close the listener without restarting Codex."); +} + async function startOnce(allowRestart: boolean): Promise { let installation = await discoverCodexInstallation(); const main = findMainProcess(installation); @@ -618,10 +672,12 @@ async function main(): Promise { } if (command === "install") { await installLaunchAgent(); return 0; } if (command === "uninstall") { await uninstallLaunchAgent(); return 0; } + if (command === "relay-config") { await configureRelay(process.argv[3], process.argv[4]); return 0; } + if (command === "relay-disable") { await disableRelay(); return 0; } if (command === "watch") return await runWatcher(); if (command === "start") return await startOnce(process.argv.includes("--restart")); if (command === "--restart") return await startOnce(true); - throw new Error("Usage: start-codex-deck.sh [start [--restart]|dry-run|self-test|install|uninstall|watch|print-launch-agent]"); + throw new Error("Usage: start-codex-deck.sh [start [--restart]|dry-run|self-test|install|uninstall|watch|relay-config <127.0.0.1-or-tailscale-ip> [port]|relay-disable|print-launch-agent]"); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { diff --git a/launcher/start-codex-deck.sh b/launcher/start-codex-deck.sh index 8350c4a..b377392 100755 --- a/launcher/start-codex-deck.sh +++ b/launcher/start-codex-deck.sh @@ -9,4 +9,23 @@ if [[ ! -f "$runtime" ]]; then exit 1 fi -exec /usr/bin/env node "$runtime" "${@:-start}" +node_candidates=() +if command -v node >/dev/null 2>&1; then node_candidates+=("$(command -v node)"); fi +node_candidates+=(/opt/homebrew/bin/node /usr/local/bin/node) +for node_candidate in "$HOME"/.nvm/versions/node/*/bin/node(N); do + node_candidates+=("$node_candidate") +done +for app_candidate in /Applications/Codex.app /Applications/ChatGPT.app; do + node_candidates+=("$app_candidate/Contents/Resources/cua_node/bin/node") +done + +for node_candidate in "${node_candidates[@]}"; do + [[ -x "$node_candidate" ]] || continue + node_version=$("$node_candidate" --version 2>/dev/null) || continue + node_major=${${node_version#v}%%.*} + [[ "$node_major" == <-> && "$node_major" -ge 20 ]] || continue + exec "$node_candidate" "$runtime" "${@:-start}" +done + +print -u2 "Codex Deck: Node.js 20 or newer was not found in PATH, Homebrew, NVM, or the Codex app bundle." +exit 1 diff --git a/package-lock.json b/package-lock.json index 933b0d2..88854dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-stream-deck", - "version": "0.5.1", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-stream-deck", - "version": "0.5.1", + "version": "0.6.0", "license": "MIT", "dependencies": { "@elgato/streamdeck": "2.1.0", diff --git a/package.json b/package.json index e666758..312c949 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { "name": "codex-stream-deck", - "version": "0.5.1", + "version": "0.6.0", "private": false, "type": "module", - "description": "Unofficial Windows bridge between the Codex desktop app and Elgato Stream Deck", + "description": "Unofficial Codex Micro bridge for Stream Deck on Windows and macOS, with optional multi-host relay", "license": "MIT", "author": "Dazer", "repository": { @@ -17,10 +17,13 @@ "build": "node scripts/build.mjs && node scripts/build-launcher.mjs", "docs:preview": "tsx scripts/generate-doc-preview.ts", "qa:local-icons": "tsx scripts/generate-local-icon-qa.ts", + "smoke:relay": "tsx scripts/relay-smoke.ts", + "audit:release": "node scripts/audit-release.mjs", "check": "tsc --noEmit", "test": "tsx --test test/*.test.ts", "validate": "npm run build && streamdeck validate dist/com.simeo.codex-deck.sdPlugin", - "pack": "npm run build && streamdeck pack dist/com.simeo.codex-deck.sdPlugin --force" + "pack": "npm run build && streamdeck pack dist/com.simeo.codex-deck.sdPlugin --force", + "release:prepare": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/prepare-release.ps1" }, "dependencies": { "@elgato/streamdeck": "2.1.0", diff --git a/scripts/audit-release.mjs b/scripts/audit-release.mjs new file mode 100644 index 0000000..1235feb --- /dev/null +++ b/scripts/audit-release.mjs @@ -0,0 +1,47 @@ +import { readFile, readdir, stat } from "node:fs/promises"; +import { basename, extname, resolve } from "node:path"; + +const roots = process.argv.slice(2).length + ? process.argv.slice(2).map(resolve) + : [resolve("dist/com.simeo.codex-deck.sdPlugin"), resolve("release/codex-deck-launcher"), resolve("release/codex-deck-launcher-macos")]; + +const forbiddenFiles = new Set([ + "codex-micro-bridge.json", "control-target.json", "host.json", "relay-client.json", "relay-server.json", + "relay-tunnel.pid", "watcher-state.json", "watcher.log", "watcher.log.1", "watcher.log.2", "watcher.log.3" +]); +const protectedKeycaps = new Set("FAST APPR REJ SPLIT MIC CODEX BUG OAI TERM DWN DEL NEW NAV MAGIC DIFF PLAY GIT BRCH MRG PR PAINT LAB PARTY TIME MIND+ MIND- SETUP FOLD UPL APPS".split(" ")); +const forbiddenText = [ + /[A-Z]:\\Users\\(?!Public\\|Default\\|tester\\)[^\\/\s]+/iu, + /\/Users\/(?!Shared\/|tester\/)[^/\s]+/iu, + /\b100\.(?:\d{1,3}\.){2}\d{1,3}\b(?!\/10)/u, + ...String(process.env.CODEX_DECK_PRIVATE_MARKERS ?? "").split("|").filter(Boolean).map((marker) => new RegExp(marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "iu")) +]; +const textExtensions = new Set([".cmd", ".command", ".html", ".js", ".json", ".map", ".md", ".mjs", ".ps1", ".sh", ".svg", ".txt"]); +const failures = []; + +async function walk(path) { + const info = await stat(path); + if (info.isDirectory()) { + for (const entry of await readdir(path)) await walk(resolve(path, entry)); + return; + } + const name = basename(path); + if (forbiddenFiles.has(name.toLowerCase())) failures.push(`${path}: private runtime state must not be packaged`); + if (extname(name).toLowerCase() === ".svg" && protectedKeycaps.has(name.slice(0, -4).toUpperCase())) { + failures.push(`${path}: protected Codex keycap SVG must not be packaged`); + } + if (!textExtensions.has(extname(name).toLowerCase()) || info.size > 8 * 1024 * 1024) return; + const contents = await readFile(path, "utf8"); + for (const pattern of forbiddenText) if (pattern.test(contents)) failures.push(`${path}: contains private setup marker ${pattern}`); +} + +for (const root of roots) { + try { await walk(root); } + catch (error) { failures.push(`${root}: cannot audit (${String(error)})`); } +} + +if (failures.length) { + console.error("Release audit failed:\n" + failures.map((failure) => `- ${failure}`).join("\n")); + process.exit(1); +} +console.log(`Release audit passed for ${roots.length} artifact roots.`); diff --git a/scripts/build-launcher.mjs b/scripts/build-launcher.mjs index 2a35569..d6aca83 100644 --- a/scripts/build-launcher.mjs +++ b/scripts/build-launcher.mjs @@ -1,5 +1,5 @@ import { build } from "esbuild"; -import { cp, mkdir, rm } from "node:fs/promises"; +import { chmod, cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; const output = resolve("release/codex-deck-launcher"); @@ -21,9 +21,14 @@ await build({ await cp(resolve("node_modules/ws"), resolve(output, "node_modules/ws"), { recursive: true }); -for (const filename of ["Start Codex Deck.cmd", "Start-CodexDeck.ps1", "Watch-CodexDeck.ps1", "README.txt"]) { +for (const filename of ["Start Codex Deck.cmd", "Start-CodexDeck.ps1", "Watch-CodexDeck.ps1", "Configure-CodexDeckRelay.ps1", "README.txt"]) { await cp(resolve("launcher", filename), resolve(output, filename)); } +await cp(resolve("docs"), resolve(output, "docs"), { recursive: true }); +await cp(resolve("README.md"), resolve(output, "README.md")); +await cp(resolve("LICENSE"), resolve(output, "LICENSE")); +await cp(resolve("SECURITY.md"), resolve(output, "SECURITY.md")); +await cp(resolve("CONTRIBUTING.md"), resolve(output, "CONTRIBUTING.md")); await build({ entryPoints: [resolve("launcher/macos/codex-deck-macos.ts")], @@ -37,6 +42,13 @@ await build({ }); for (const filename of ["start-codex-deck.sh", "Start Codex Deck.command"]) { - await cp(resolve("launcher", filename), resolve(macOutput, filename)); + const destination = resolve(macOutput, filename); + const contents = await readFile(resolve("launcher", filename), "utf8"); + await writeFile(destination, contents.replace(/\r\n/g, "\n"), { encoding: "utf8", mode: 0o755 }); + await chmod(destination, 0o755); } -await cp(resolve("docs/MACOS.md"), resolve(macOutput, "README.md")); +await cp(resolve("docs"), resolve(macOutput, "docs"), { recursive: true }); +await cp(resolve("README.md"), resolve(macOutput, "README.md")); +await cp(resolve("LICENSE"), resolve(macOutput, "LICENSE")); +await cp(resolve("SECURITY.md"), resolve(macOutput, "SECURITY.md")); +await cp(resolve("CONTRIBUTING.md"), resolve(macOutput, "CONTRIBUTING.md")); diff --git a/scripts/package-macos-release.sh b/scripts/package-macos-release.sh new file mode 100755 index 0000000..e0109ce --- /dev/null +++ b/scripts/package-macos-release.sh @@ -0,0 +1,11 @@ +#!/bin/zsh +set -euo pipefail + +ROOT="${0:A:h:h}" +VERSION="$(node -p "require('$ROOT/package.json').version")" +OUTPUT="${1:-$ROOT/outputs/codex-deck-launcher-macos-v$VERSION.zip}" +mkdir -p "${OUTPUT:h}" +rm -f "$OUTPUT" +chmod 755 "$ROOT/release/codex-deck-launcher-macos/start-codex-deck.sh" "$ROOT/release/codex-deck-launcher-macos/Start Codex Deck.command" +ditto -c -k --sequesterRsrc --keepParent "$ROOT/release/codex-deck-launcher-macos" "$OUTPUT" +echo "macOS launcher archive created: $OUTPUT" diff --git a/scripts/prepare-release.ps1 b/scripts/prepare-release.ps1 new file mode 100644 index 0000000..174fe08 --- /dev/null +++ b/scripts/prepare-release.ps1 @@ -0,0 +1,38 @@ +param([string]$MacArchivePath) + +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$package = Get-Content -LiteralPath (Join-Path $root 'package.json') -Raw | ConvertFrom-Json +$version = [string]$package.version +$output = Join-Path $root "outputs\release-v$version" + +Push-Location $root +try { + & npm run check + if ($LASTEXITCODE -ne 0) { throw 'TypeScript validation failed.' } + & npm test + if ($LASTEXITCODE -ne 0) { throw 'Tests failed.' } + & npm run validate + if ($LASTEXITCODE -ne 0) { throw 'Stream Deck validation failed.' } + & npm run pack + if ($LASTEXITCODE -ne 0) { throw 'Stream Deck packaging failed.' } + & node scripts/audit-release.mjs + if ($LASTEXITCODE -ne 0) { throw 'Release audit failed.' } + + Remove-Item -LiteralPath $output -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force -Path $output | Out-Null + Copy-Item -LiteralPath (Join-Path $root 'com.simeo.codex-deck.streamDeckPlugin') -Destination $output + Compress-Archive -Path (Join-Path $root 'release\codex-deck-launcher') -DestinationPath (Join-Path $output "codex-deck-launcher-windows-v$version.zip") -CompressionLevel Optimal + if (-not [string]::IsNullOrWhiteSpace($MacArchivePath)) { + if (-not (Test-Path -LiteralPath $MacArchivePath -PathType Leaf)) { throw "Mac archive not found: $MacArchivePath" } + Copy-Item -LiteralPath $MacArchivePath -Destination (Join-Path $output "codex-deck-launcher-macos-v$version.zip") + } else { + Write-Warning 'macOS ZIP omitted. Create it with scripts/package-macos-release.sh on macOS so executable bits are preserved, then rerun with -MacArchivePath.' + } + $artifacts = Get-ChildItem -LiteralPath $output -File | Sort-Object Name + $checksums = @($artifacts | ForEach-Object { "{0} {1}" -f (Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName).Hash.ToLowerInvariant(), $_.Name }) + [IO.File]::WriteAllLines((Join-Path $output 'SHA256SUMS.txt'), $checksums, [Text.UTF8Encoding]::new($false)) + Write-Host "Release candidate prepared at: $output" +} finally { + Pop-Location +} diff --git a/scripts/relay-smoke.ts b/scripts/relay-smoke.ts new file mode 100644 index 0000000..28d3c96 --- /dev/null +++ b/scripts/relay-smoke.ts @@ -0,0 +1,21 @@ +import { CodexRelayClient, readRelayClientConfig } from "../src/codex-relay-client.js"; +import type { HostSnapshot } from "../src/relay-protocol.js"; + +const config = await readRelayClientConfig(); +if (!config) throw new Error("No enabled relay-client.json was found in the CodexDeck state directory."); + +let client: CodexRelayClient; +const snapshot = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("Timed out waiting for the Mac relay snapshot.")), 8_000); + client = new CodexRelayClient(config, (value) => { + clearTimeout(timer); + resolve(value); + }, (message) => console.log(message)); + client.start(); +}); + +console.log(`Relay snapshot: ${snapshot.host.hostName} (${snapshot.host.platform}, ${snapshot.host.hostId})`); +for (const slot of snapshot.snapshot.slots) { + console.log(`${slot.id + 1}: ${slot.status}${slot.selected ? " selected" : ""} | local-rollout=${slot.ownedByHost === true ? "yes" : "no"} | activity=${slot.activityAt ?? "unknown"} | ${slot.threadKey ?? "empty"} | ${slot.title ?? ""}`); +} +client!.close(); diff --git a/src/actions.ts b/src/actions.ts index 06df18c..b0c9d04 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -260,3 +260,24 @@ export class NewTask extends SingletonAction { } } } + +@action({ UUID: "com.simeo.codex-deck.host-toggle" }) +export class HostToggle extends SingletonAction { + constructor(private readonly controller: DeckController) { super(); } + + override onWillAppear(ev: WillAppearEvent): void { + if (ev.action.isKey()) this.controller.registerHostToggle(ev.action); + } + + override onWillDisappear(ev: WillDisappearEvent): void { + this.controller.unregisterHostToggle(ev.action); + } + + override async onKeyDown(ev: KeyDownEvent): Promise { + try { await this.controller.toggleTargetHost(); } + catch (error) { + streamDeck.logger.error(`Host toggle failed: ${String(error)}`); + await ev.action.showAlert(); + } + } +} diff --git a/src/codex-micro-renderer-bridge.ts b/src/codex-micro-renderer-bridge.ts index 706d7c8..6c64352 100644 --- a/src/codex-micro-renderer-bridge.ts +++ b/src/codex-micro-renderer-bridge.ts @@ -5,6 +5,7 @@ import { promisify } from "node:util"; import WebSocket from "ws"; import { codexDeckStateRoot } from "./codex-deck-paths.js"; import { OFFICIAL_KEYCAP_IDS, type OfficialKeycapId } from "./keycaps.js"; +import { CodexSessionOwnershipIndex } from "./session-ownership.js"; import type { MicroActionSlot, MicroDirection, MicroSnapshot, ReasoningAdjustment } from "./types.js"; type DebugTarget = { @@ -127,7 +128,19 @@ const SNAPSHOT_EXPRESSION = `(async () => { break; } catch {} } - const slots = found.slots; + const toEpoch = (value) => { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value < 100000000000 ? value * 1000 : value; + if (typeof value === 'string') { + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; + }; + const slots = found.slots.map((slot) => ({ + ...slot, + activityAt: toEpoch(slot.activityAt) ?? toEpoch(slot.updatedAt) ?? toEpoch(slot.lastActivityAt) ?? + toEpoch(slot.thread?.updatedAt) ?? toEpoch(slot.task?.updatedAt) + })); const html = document.documentElement; const body = document.body; @@ -163,13 +176,15 @@ export class CodexMicroRendererBridge { private pending = new Map void; reject: (error: Error) => void; timer: NodeJS.Timeout }>(); private connecting?: Promise; private lastSnapshot?: MicroSnapshot; + private readonly sessionOwnership = new CodexSessionOwnershipIndex(); constructor(private readonly log: (message: string) => void) {} async refresh(): Promise { try { await this.ensureConnected(); - const snapshot = await this.evaluate(SNAPSHOT_EXPRESSION); + const nativeSnapshot = await this.evaluate(SNAPSHOT_EXPRESSION); + const snapshot = await this.sessionOwnership.annotate(nativeSnapshot); this.lastSnapshot = snapshot; return snapshot; } catch (error) { @@ -178,12 +193,16 @@ export class CodexMicroRendererBridge { } } - async sendAgent(slot: number, act: 0 | 1): Promise { + async sendAgent(slot: number, act: 0 | 1, expectedThreadKey?: string): Promise { if (!Number.isInteger(slot) || slot < 0 || slot > 5) throw new Error(`Ungültiger Micro-Agent-Slot: ${slot}`); const snapshot = this.lastSnapshot ?? await this.refresh(); const assignment = snapshot.slots.find((item) => item.id === slot); + const threadKey = expectedThreadKey ?? assignment?.threadKey ?? null; + if (expectedThreadKey && assignment?.threadKey !== expectedThreadKey) { + this.log(`Agent slot ${slot + 1} changed before dispatch; routing the preserved task identity.`); + } await this.dispatch("codex-micro-hid-event", { - event: { key: `AG0${slot}`, act, slot, threadKey: assignment?.threadKey ?? null } + event: { key: `AG0${slot}`, act, slot, threadKey } }, "codex-micro-hid-event"); } diff --git a/src/codex-open.ts b/src/codex-open.ts new file mode 100644 index 0000000..3f1d068 --- /dev/null +++ b/src/codex-open.ts @@ -0,0 +1,43 @@ +import { spawn } from "node:child_process"; +import { win32 } from "node:path"; + +const THREAD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export type CodexOpenSpec = { executable: string; args: string[]; windowsHide: boolean }; + +export function codexThreadUrl(threadId: string): string { + if (threadId !== "new" && !THREAD_ID.test(threadId)) throw new Error(`Invalid Codex task ID: ${threadId}`); + return `codex://threads/${threadId}`; +} + +export function codexOpenSpec( + threadId: string, + targetPlatform = process.platform, + systemRoot = process.env.SystemRoot ?? "C:\\Windows" +): CodexOpenSpec { + const url = codexThreadUrl(threadId); + if (targetPlatform === "darwin") return { executable: "/usr/bin/open", args: [url], windowsHide: false }; + if (targetPlatform === "win32") { + const executable = win32.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); + return { + executable, + args: ["-NoLogo", "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", `Start-Process -FilePath '${url}'`], + windowsHide: true + }; + } + throw new Error(`Opening Codex links is unsupported on ${targetPlatform}.`); +} + +export function openCodexThread(threadId: string): Promise { + const spec = codexOpenSpec(threadId); + return new Promise((resolve, reject) => { + const child = spawn(spec.executable, spec.args, { windowsHide: spec.windowsHide, stdio: ["ignore", "ignore", "pipe"] }); + let errorOutput = ""; + child.stderr.on("data", (data) => { errorOutput += String(data); }); + child.on("error", reject); + child.on("exit", (code) => { + if (code === 0) resolve(); + else reject(new Error(`Codex link could not be opened (${code ?? "unknown"}): ${errorOutput.trim()}`)); + }); + }); +} diff --git a/src/codex-relay-client.ts b/src/codex-relay-client.ts new file mode 100644 index 0000000..0aa8127 --- /dev/null +++ b/src/codex-relay-client.ts @@ -0,0 +1,145 @@ +import { randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import WebSocket from "ws"; +import { codexDeckStateRoot } from "./codex-deck-paths.js"; +import { isAllowedRelayHost } from "./relay-network.js"; +import { + RELAY_PROTOCOL_VERSION, parseRelayServerMessage, + type HostSnapshot, type RelayCommand, type RelayResultMessage +} from "./relay-protocol.js"; +import type { CodexHost } from "./types.js"; + +export type RelayClientConfig = { enabled: boolean; url: string; token: string }; + +const CONFIG_PATH = join(codexDeckStateRoot(), "relay-client.json"); + +export class CodexRelayClient { + private socket?: WebSocket; + private reconnect?: NodeJS.Timeout; + private stopped = false; + private connecting = false; + private host?: CodexHost; + private snapshot?: HostSnapshot; + private readonly pending = new Map void; reject: (error: Error) => void; timer: NodeJS.Timeout }>(); + + constructor( + private readonly config: RelayClientConfig, + private readonly onSnapshot: (snapshot: HostSnapshot) => void, + private readonly log: (message: string) => void + ) { validateRelayClientConfig(config); } + + start(): void { this.stopped = false; void this.connect(); } + + close(): void { + this.stopped = true; + if (this.reconnect) clearTimeout(this.reconnect); + this.reconnect = undefined; + this.socket?.close(1000, "client stopping"); + this.socket = undefined; + this.rejectPending("Remote Codex relay disconnected."); + } + + currentHost(): CodexHost | undefined { return this.host; } + currentSnapshot(): HostSnapshot | undefined { return this.snapshot; } + isConnected(): boolean { return this.socket?.readyState === WebSocket.OPEN && this.host != null; } + + async send(command: RelayCommand): Promise { + const socket = this.socket; + if (!socket || socket.readyState !== WebSocket.OPEN || !this.host) throw new Error("Remote Codex host is offline."); + const requestId = randomUUID(); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(requestId); + reject(new Error("Remote Codex command timed out.")); + }, 5_000); + this.pending.set(requestId, { resolve, reject, timer }); + socket.send(JSON.stringify({ type: "command", protocol: RELAY_PROTOCOL_VERSION, requestId, command })); + }); + } + + private async connect(): Promise { + if (this.stopped || this.connecting || this.socket?.readyState === WebSocket.OPEN) return; + this.connecting = true; + try { + const socket = new WebSocket(this.config.url, { handshakeTimeout: 4_000, maxPayload: 64 * 1024, perMessageDeflate: false }); + this.socket = socket; + socket.on("open", () => socket.send(JSON.stringify({ type: "auth", protocol: RELAY_PROTOCOL_VERSION, token: this.config.token }))); + socket.on("message", (raw) => this.handleMessage(raw.toString())); + socket.on("close", () => this.disconnected(socket)); + socket.on("error", () => this.disconnected(socket)); + } catch (error) { + this.log(`Remote relay connection failed: ${String(error)}`); + this.scheduleReconnect(); + } finally { this.connecting = false; } + } + + private handleMessage(raw: string): void { + let parsed: unknown; + try { parsed = JSON.parse(raw); } + catch { return; } + const message = parseRelayServerMessage(parsed); + if (!message) return; + if (message.type === "ready") { + this.host = message.host; + this.log(`Remote Codex host connected: ${message.host.hostName} (${message.host.platform}).`); + } else if (message.type === "snapshot") { + this.host = message.host; + this.snapshot = { host: message.host, snapshot: message.snapshot, observedAt: message.observedAt }; + this.onSnapshot(this.snapshot); + } else this.handleResult(message); + } + + private handleResult(message: RelayResultMessage): void { + const pending = this.pending.get(message.requestId); + if (!pending) return; + this.pending.delete(message.requestId); + clearTimeout(pending.timer); + if (message.ok) pending.resolve(); + else pending.reject(new Error(message.error || "Remote Codex command failed.")); + } + + private disconnected(expected: WebSocket): void { + if (this.socket !== expected) return; + this.socket = undefined; + this.host = undefined; + this.snapshot = undefined; + this.rejectPending("Remote Codex relay disconnected."); + this.scheduleReconnect(); + } + + private scheduleReconnect(): void { + if (this.stopped || this.reconnect) return; + this.reconnect = setTimeout(() => { + this.reconnect = undefined; + void this.connect(); + }, 2_000); + } + + private rejectPending(message: string): void { + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(new Error(message)); + } + this.pending.clear(); + } +} + +export async function readRelayClientConfig(path = CONFIG_PATH): Promise { + try { + const config = JSON.parse(await readFile(path, "utf8")) as RelayClientConfig; + if (!config.enabled) return null; + validateRelayClientConfig(config); + return config; + } catch { return null; } +} + +export function validateRelayClientConfig(config: RelayClientConfig): void { + if (!config.enabled) throw new Error("Relay client config is disabled."); + let url: URL; + try { url = new URL(config.url); } + catch { throw new Error("Relay URL is invalid."); } + if (url.protocol !== "ws:") throw new Error("Relay URL must use ws:// inside the encrypted SSH or Tailscale transport."); + if (!isAllowedRelayHost(url.hostname)) throw new Error("Relay URL must target loopback or a Tailscale address."); + if (typeof config.token !== "string" || Buffer.byteLength(config.token, "utf8") < 32) throw new Error("Relay token must contain at least 32 bytes."); +} diff --git a/src/codex-relay-server.ts b/src/codex-relay-server.ts new file mode 100644 index 0000000..d3c7c9a --- /dev/null +++ b/src/codex-relay-server.ts @@ -0,0 +1,166 @@ +import { timingSafeEqual } from "node:crypto"; +import { isAllowedRelayHost } from "./relay-network.js"; +import { WebSocketServer, WebSocket } from "ws"; +import type { OfficialKeycapId } from "./keycaps.js"; +import type { CodexMicroRendererBridge } from "./codex-micro-renderer-bridge.js"; +import { + RELAY_PROTOCOL_VERSION, parseRelayCommand, + type RelayAuthMessage, type RelayCommand, type RelayCommandMessage, type RelayResultMessage, type RelaySnapshotMessage +} from "./relay-protocol.js"; +import type { CodexHost } from "./types.js"; + +export type RelayServerConfig = { + enabled: boolean; + listenHost: string; + port: number; + token: string; +}; + +type RelayControl = Pick; + +export class CodexRelayServer { + private server?: WebSocketServer; + private poll?: NodeJS.Timeout; + private snapshotInFlight?: Promise; + private readonly authenticated = new Set(); + + constructor( + private readonly config: RelayServerConfig, + private readonly host: CodexHost, + private readonly control: RelayControl, + private readonly log: (message: string) => void + ) { + validateRelayServerConfig(config); + } + + async start(): Promise { + if (this.server) return; + const server = new WebSocketServer({ + host: this.config.listenHost, + port: this.config.port, + maxPayload: 64 * 1024, + perMessageDeflate: false + }); + this.server = server; + server.on("connection", (socket) => this.handleConnection(socket)); + server.on("error", (error) => this.log(`Relay server error: ${String(error)}`)); + await new Promise((resolve, reject) => { + server.once("listening", resolve); + server.once("error", reject); + }); + this.log(`Relay listening on ${this.config.listenHost}:${this.config.port}; CDP remains loopback-only.`); + this.scheduleSnapshot(0); + } + + async close(): Promise { + if (this.poll) clearTimeout(this.poll); + this.poll = undefined; + for (const socket of this.authenticated) socket.close(1001, "relay stopping"); + this.authenticated.clear(); + const server = this.server; + this.server = undefined; + if (server) await new Promise((resolve) => server.close(() => resolve())); + } + + private handleConnection(socket: WebSocket): void { + const authTimer = setTimeout(() => socket.close(4001, "authentication required"), 3_000); + socket.once("message", (raw) => { + clearTimeout(authTimer); + const auth = safeJson(raw.toString()) as Partial | null; + if (!auth || auth.type !== "auth" || auth.protocol !== RELAY_PROTOCOL_VERSION || !secureEqual(auth.token, this.config.token)) { + socket.close(4003, "authentication failed"); + return; + } + this.authenticated.add(socket); + socket.send(JSON.stringify({ type: "ready", protocol: RELAY_PROTOCOL_VERSION, host: this.host })); + socket.on("message", (message) => void this.handleMessage(socket, message.toString())); + socket.on("close", () => this.authenticated.delete(socket)); + socket.on("error", () => this.authenticated.delete(socket)); + void this.publishSnapshot(socket); + }); + socket.on("close", () => clearTimeout(authTimer)); + } + + private async handleMessage(socket: WebSocket, raw: string): Promise { + const message = safeJson(raw) as Partial | null; + if (!message || message.type !== "command" || message.protocol !== RELAY_PROTOCOL_VERSION || typeof message.requestId !== "string") return; + const command = parseRelayCommand(message.command); + if (!command) { + this.sendResult(socket, message.requestId, false, "Invalid relay command."); + return; + } + try { + await executeRelayCommand(this.control, command); + this.sendResult(socket, message.requestId, true); + await this.publishSnapshot(); + } catch (error) { + this.sendResult(socket, message.requestId, false, error instanceof Error ? error.message : String(error)); + } + } + + private sendResult(socket: WebSocket, requestId: string, ok: boolean, error?: string): void { + if (socket.readyState !== WebSocket.OPEN) return; + const result: RelayResultMessage = { type: "result", protocol: RELAY_PROTOCOL_VERSION, requestId, ok, ...(error ? { error } : {}) }; + socket.send(JSON.stringify(result)); + } + + private scheduleSnapshot(delay = 1_200): void { + if (!this.server) return; + this.poll = setTimeout(async () => { + try { if (this.authenticated.size) await this.publishSnapshot(); } + catch (error) { this.log(`Relay snapshot failed: ${String(error)}`); } + finally { this.scheduleSnapshot(); } + }, delay); + } + + private async publishSnapshot(only?: WebSocket): Promise { + const message = await this.currentSnapshotMessage(); + const encoded = JSON.stringify(message); + for (const socket of only ? [only] : this.authenticated) { + if (socket.readyState === WebSocket.OPEN) socket.send(encoded); + } + } + + private async currentSnapshotMessage(): Promise { + if (this.snapshotInFlight) return this.snapshotInFlight; + const pending = this.control.refresh().then((snapshot): RelaySnapshotMessage => ({ + type: "snapshot", + protocol: RELAY_PROTOCOL_VERSION, + host: this.host, + observedAt: Date.now(), + snapshot + })); + this.snapshotInFlight = pending; + try { return await pending; } + finally { if (this.snapshotInFlight === pending) this.snapshotInFlight = undefined; } + } +} + +export function validateRelayServerConfig(config: RelayServerConfig): void { + if (!config.enabled) throw new Error("Relay server config is disabled."); + if (!config.listenHost || !isAllowedRelayHost(config.listenHost.trim())) throw new Error("Relay listenHost must be loopback or a specific Tailscale address."); + if (!Number.isInteger(config.port) || config.port < 1024 || config.port > 65_535) throw new Error("Relay port must be between 1024 and 65535."); + if (typeof config.token !== "string" || Buffer.byteLength(config.token, "utf8") < 32) throw new Error("Relay token must contain at least 32 bytes."); +} + +async function executeRelayCommand(control: RelayControl, command: RelayCommand): Promise { + if (command.kind === "agent") return control.sendAgent(command.slot, command.act, command.threadKey); + if (command.kind === "action") return control.sendAction(command.slot, command.act); + if (command.kind === "joystick") return control.sendJoystick(command.direction, command.distance); + if (command.kind === "encoder") return control.sendEncoder(command.act); + if (command.kind === "reasoning") return control.adjustReasoning(command.direction); + return control.runKeycap(command.keycapId as OfficialKeycapId); +} + +function secureEqual(left: unknown, right: string): boolean { + if (typeof left !== "string") return false; + const a = Buffer.from(left, "utf8"); + const b = Buffer.from(right, "utf8"); + return a.length === b.length && timingSafeEqual(a, b); +} + +function safeJson(raw: string): unknown { + try { return JSON.parse(raw); } + catch { return null; } +} diff --git a/src/control-target.ts b/src/control-target.ts new file mode 100644 index 0000000..6ab6f42 --- /dev/null +++ b/src/control-target.ts @@ -0,0 +1,33 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { codexDeckStateRoot } from "./codex-deck-paths.js"; +import type { CodexHost } from "./types.js"; + +export type HostPlatform = CodexHost["platform"]; +const CONTROL_TARGET_PATH = join(codexDeckStateRoot(), "control-target.json"); + +export function isRemoteControlRequest( + targetPlatform: HostPlatform, + localPlatform: HostPlatform, + requestedHostId?: string, + localHostId?: string +): boolean { + return requestedHostId != null + ? requestedHostId !== localHostId + : targetPlatform !== localPlatform; +} + +export async function readControlTarget( + path = CONTROL_TARGET_PATH, + fallback: HostPlatform = process.platform === "darwin" ? "darwin" : "win32" +): Promise { + try { + const value = JSON.parse(await readFile(path, "utf8")) as { platform?: unknown }; + return value.platform === "darwin" || value.platform === "win32" ? value.platform : fallback; + } catch { return fallback; } +} + +export async function writeControlTarget(platform: HostPlatform, path = CONTROL_TARGET_PATH): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify({ version: 1, platform })}\n`, { encoding: "utf8", mode: 0o600 }); +} diff --git a/src/controller.ts b/src/controller.ts index a7e10e2..0c26912 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -2,12 +2,16 @@ import streamDeck, { type KeyAction } from "@elgato/streamdeck"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { codexDeckStateRoot } from "./codex-deck-paths.js"; +import { isRemoteControlRequest, readControlTarget, writeControlTarget, type HostPlatform as ControlTarget } from "./control-target.js"; +import { CodexRelayClient, readRelayClientConfig } from "./codex-relay-client.js"; import { CodexMicroRendererBridge } from "./codex-micro-renderer-bridge.js"; +import { getOrCreateHostIdentity } from "./host-identity.js"; import type { OfficialKeycapId } from "./keycaps.js"; +import { HostActivityIndex, type HostSnapshot, type RelayCommand } from "./relay-protocol.js"; import { renderAgentKey, renderBuiltinKeycap, renderFallbackKeycap, renderImportedKeycap, type BuiltinIconName } from "./render.js"; -import { openCodexThread } from "./windows-open.js"; +import { openCodexThread } from "./codex-open.js"; import { visualStatusFromMicro } from "./status.js"; -import type { MicroActionSlot, MicroDirection, MicroSnapshot, ReasoningAdjustment } from "./types.js"; +import type { CodexHost, MicroActionSlot, MicroDirection, MicroSnapshot, ReasoningAdjustment, RoutedAgentSlot } from "./types.js"; export type FixedIconSource = | { kind: "local"; keycapId: string } @@ -27,11 +31,20 @@ export class DeckController { private readonly fixedActions = new Map(); private readonly keycapImages = new Map>(); private readonly lastImages = new Map(); + private readonly hostToggleActions = new Map(); + private readonly activityIndex = new HostActivityIndex(); + private readonly pressedAgents = new Map(); + private readonly pressedControlTargets = new Map(); + private relayClient?: CodexRelayClient; + private localHost?: CodexHost; + private localSnapshot?: HostSnapshot; + private routedSlots: RoutedAgentSlot[] = []; + private targetHostId?: string; + private targetPlatform: ControlTarget = "win32"; private poll?: NodeJS.Timeout; private animation?: NodeJS.Timeout; private stopped = false; private animationFrame = 0; - private snapshot?: MicroSnapshot; private lastError = ""; private lastAssignmentSignature = ""; private lastStatusSignature = ""; @@ -39,6 +52,18 @@ export class DeckController { async start(): Promise { this.stopped = false; + this.localHost = await getOrCreateHostIdentity(); + this.targetPlatform = await readControlTarget(undefined, this.localHost.platform); + if (this.targetPlatform === this.localHost.platform) this.targetHostId = this.localHost.hostId; + const relayConfig = await readRelayClientConfig(); + if (relayConfig) { + this.relayClient = new CodexRelayClient( + relayConfig, + () => {}, + (message) => streamDeck.logger.info(message) + ); + this.relayClient.start(); + } await this.refresh(); this.scheduleRefresh(); this.scheduleAnimation(); @@ -48,6 +73,7 @@ export class DeckController { this.stopped = true; if (this.poll) clearInterval(this.poll); if (this.animation) clearInterval(this.animation); + this.relayClient?.close(); this.microBridge.close(); } @@ -78,90 +104,136 @@ export class DeckController { this.unregister(action, this.fixedActions); } + registerHostToggle(action: KeyAction): void { + this.hostToggleActions.set(action.id, action); + void this.renderHostToggle(action); + } + + unregisterHostToggle(action: ActionIdentity): void { + this.hostToggleActions.delete(action.id); + this.lastImages.delete(action.id); + } + + async toggleTargetHost(): Promise { + const remote = this.relayClient?.currentHost(); + if (!this.localHost) throw new Error("The local Codex host is not ready."); + if (this.targetPlatform === this.localHost.platform) { + if (!remote) throw new Error("No remote Codex host is connected."); + this.targetPlatform = remote.platform; + this.targetHostId = remote.hostId; + } else { + this.targetPlatform = this.localHost.platform; + this.targetHostId = this.localHost.hostId; + } + await writeControlTarget(this.targetPlatform); + await this.renderAll(); + } + async sendAgent(slot: number, act: 0 | 1): Promise { - await this.microBridge.sendAgent(slot, act); + const assignment = act === 0 ? this.pressedAgents.get(slot) : this.routedSlots[slot]; + if (!assignment) throw new Error(`No Codex task is assigned to global agent slot ${slot + 1}.`); + if (act === 1) this.pressedAgents.set(slot, assignment); + else this.pressedAgents.delete(slot); + if (!assignment.threadKey) throw new Error("The selected Codex task has no stable thread identity."); + if (assignment.host.hostId === this.localHost?.hostId) await this.microBridge.sendAgent(assignment.sourceSlot, act, assignment.threadKey); + else await this.sendRemote({ kind: "agent", slot: assignment.sourceSlot, threadKey: assignment.threadKey, act }); } async sendMicroAction(slot: MicroActionSlot, act: 0 | 1): Promise { - await this.microBridge.sendAction(slot, act); + const target = this.pressTarget(`action:${slot}`, act); + await this.sendToHost(target, { kind: "action", slot, act }, () => this.microBridge.sendAction(slot, act)); } async sendJoystick(direction: MicroDirection, distance: 0 | 1): Promise { - await this.microBridge.sendJoystick(direction, distance); + const target = this.pressTarget(`joystick:${direction}`, distance); + await this.sendToHost(target, { kind: "joystick", direction, distance }, () => this.microBridge.sendJoystick(direction, distance)); } async sendEncoder(act: 0 | 1): Promise { - await this.microBridge.sendEncoder(act); + const target = this.pressTarget("encoder", act); + await this.sendToHost(target, { kind: "encoder", act }, () => this.microBridge.sendEncoder(act)); } async adjustReasoning(direction: ReasoningAdjustment): Promise { - await this.microBridge.adjustReasoning(direction); + await this.sendToTarget({ kind: "reasoning", direction }, () => this.microBridge.adjustReasoning(direction)); } async runKeycap(keycapId: OfficialKeycapId): Promise { - await this.microBridge.runKeycap(keycapId); + await this.sendToTarget({ kind: "keycap", keycapId }, () => this.microBridge.runKeycap(keycapId)); } async createTask(): Promise { - await openCodexThread("new"); + if (this.isRemoteTarget()) await this.sendRemote({ kind: "keycap", keycapId: "NEW" }); + else await openCodexThread("new"); } private async refresh(): Promise { try { const snapshot = await this.microBridge.refresh(); - this.snapshot = snapshot; + if (!this.localHost) this.localHost = await getOrCreateHostIdentity(); + this.localSnapshot = { host: this.localHost, snapshot, observedAt: Date.now() }; this.lastError = ""; - - const assignments = snapshot.slots.map((slot) => `${slot.id}=${slot.threadKey ?? "empty"}`).join(" "); - if (assignments !== this.lastAssignmentSignature) { - this.lastAssignmentSignature = assignments; - streamDeck.logger.info(`Codex Micro slots: ${assignments}`); - } - - const statuses = snapshot.slots.map((slot) => `${slot.id}:${slot.status}:${slot.selected}`).join(","); - if (statuses !== this.lastStatusSignature) { - this.lastStatusSignature = statuses; - streamDeck.logger.info(`Codex Micro states: ${snapshot.slots.map((slot) => `${slot.id + 1}=${slot.status}`).join(" ")}`); - } - - const layout = JSON.stringify({ theme: snapshot.theme, slots: snapshot.layout.slots }); - if (layout !== this.lastLayoutSignature) { - this.lastLayoutSignature = layout; - this.keycapImages.clear(); - streamDeck.logger.info(`Codex Micro layout synchronized (${snapshot.agentSource}, ${snapshot.theme} theme).`); - } - - await this.renderAll(); } catch (error) { - this.snapshot = undefined; + this.localSnapshot = undefined; const message = String(error); if (message !== this.lastError) { this.lastError = message; streamDeck.logger.warn(`Codex Micro bridge unavailable: ${message}`); } - await Promise.all([...this.agents.values()].map((registration) => this.renderAgent(registration))); } + await this.refreshDisplay(); + } + + private async refreshDisplay(): Promise { + const remoteSnapshot = this.relayClient?.currentSnapshot(); + if (this.localHost && this.targetPlatform !== this.localHost.platform && remoteSnapshot) this.targetHostId = remoteSnapshot.host.hostId; + else if (this.localHost && this.targetPlatform === this.localHost.platform) this.targetHostId = this.localHost.hostId; + const inputs = [this.localSnapshot, remoteSnapshot].filter((value): value is HostSnapshot => value != null); + this.routedSlots = this.activityIndex.merge(inputs); + + const assignments = this.routedSlots.map((slot) => `${slot.id}=${slot.host.platform}:${slot.threadKey ?? "empty"}`).join(" "); + if (assignments !== this.lastAssignmentSignature) { + this.lastAssignmentSignature = assignments; + streamDeck.logger.info(`Codex multi-host slots: ${assignments || "empty"}`); + } + + const statuses = this.routedSlots.map((slot) => `${slot.host.hostId}:${slot.threadKey}:${slot.status}:${slot.selected}`).join(","); + if (statuses !== this.lastStatusSignature) { + this.lastStatusSignature = statuses; + streamDeck.logger.info(`Codex multi-host states: ${this.routedSlots.map((slot) => `${slot.id + 1}=${slot.status}`).join(" ") || "empty"}`); + } + + const target = this.targetSnapshot(); + const layout = JSON.stringify({ target: this.targetHostId, theme: target?.theme, slots: target?.layout.slots }); + if (layout !== this.lastLayoutSignature) { + this.lastLayoutSignature = layout; + this.keycapImages.clear(); + if (target) streamDeck.logger.info(`Codex Micro layout synchronized (${target.agentSource}, ${target.theme} theme).`); + } + await this.renderAll(); } private async renderAll(): Promise { await Promise.all([ ...[...this.agents.values()].map((registration) => this.renderAgent(registration)), ...[...this.microActions.values()].map((registration) => this.renderMicroAction(registration)), - ...[...this.fixedActions.values()].map((registration) => this.renderFixedAction(registration)) + ...[...this.fixedActions.values()].map((registration) => this.renderFixedAction(registration)), + ...[...this.hostToggleActions.values()].map((action) => this.renderHostToggle(action)) ]); } private async renderAgent({ action, slot }: AgentRegistration): Promise { - const agent = this.snapshot?.slots.find((item) => item.id === slot); - const title = agent?.title ?? (this.snapshot ? "Not assigned" : "Bridge offline"); + const agent = this.routedSlots[slot]; + const title = agent?.title ?? (this.localSnapshot || this.relayClient?.currentSnapshot() ? "Not assigned" : "Bridge offline"); const status = agent ? visualStatusFromMicro(agent.status) : "empty"; - const theme = this.snapshot?.theme ?? "dark"; - await this.setImage(action, renderAgentKey(slot, title, status, agent?.selected ?? false, this.animationFrame, theme)); + const theme = this.targetSnapshot()?.theme ?? this.localSnapshot?.snapshot.theme ?? "dark"; + const hostBadge = agent && this.relayClient ? (agent.host.platform === "darwin" ? "M" : "W") : undefined; + await this.setImage(action, renderAgentKey(slot, title, status, agent?.selected ?? false, this.animationFrame, theme, hostBadge)); } private async renderAnimatedAgents(): Promise { const registrations = [...this.agents.values()].filter(({ slot }) => { - const agent = this.snapshot?.slots.find((item) => item.id === slot); + const agent = this.routedSlots[slot]; if (!agent) return false; const status = visualStatusFromMicro(agent.status); return status === "thinking" || status === "input"; @@ -172,20 +244,64 @@ export class DeckController { } private async renderMicroAction({ action, slot }: MicroActionRegistration): Promise { - const keycapId = this.snapshot?.layout.slots[slot]?.keycapId; + const snapshot = this.targetSnapshot(); + const keycapId = snapshot?.layout.slots[slot]?.keycapId; if (!keycapId) return; - const image = await this.keycapImage(keycapId, this.snapshot?.theme ?? "dark"); + const image = await this.keycapImage(keycapId, snapshot?.theme ?? "dark"); if (image) await this.setImage(action, image); } private async renderFixedAction(registration: FixedIconRegistration): Promise { - const theme = this.snapshot?.theme ?? "dark"; + const theme = this.targetSnapshot()?.theme ?? "dark"; const image = registration.source.kind === "builtin" ? renderBuiltinKeycap(registration.source.name, theme) : await this.keycapImage(registration.source.keycapId, theme); if (image) await this.setImage(registration.action, image); } + private async renderHostToggle(action: KeyAction): Promise { + const label = this.targetPlatform === "darwin" ? "MAC" : "WIN"; + const theme = this.targetSnapshot()?.theme ?? "dark"; + await this.setImage(action, renderFallbackKeycap(label, theme)); + } + + private targetSnapshot(): MicroSnapshot | undefined { + const remote = this.relayClient?.currentSnapshot(); + if (this.localHost && this.targetPlatform !== this.localHost.platform) return remote?.snapshot; + return this.localSnapshot?.snapshot; + } + + private isRemoteTarget(): boolean { + return this.localHost != null && this.targetPlatform !== this.localHost.platform; + } + + private async sendRemote(command: RelayCommand): Promise { + if (!this.relayClient) throw new Error("Remote Codex relay is not configured."); + await this.relayClient.send(command); + } + + private async sendToTarget(command: RelayCommand, local: () => Promise): Promise { + await this.sendToHost(this.targetHostId, command, local); + } + + private async sendToHost(hostId: string | undefined, command: RelayCommand, local: () => Promise): Promise { + const localHostId = this.localHost?.hostId; + const remoteRequested = isRemoteControlRequest(this.targetPlatform, this.localHost?.platform ?? "win32", hostId, localHostId); + if (remoteRequested) await this.sendRemote(command); + else await local(); + } + + private pressTarget(key: string, pressed: 0 | 1): string | undefined { + if (pressed === 1) { + const target = this.targetHostId; + if (target) this.pressedControlTargets.set(key, target); + return target; + } + const target = this.pressedControlTargets.get(key) ?? this.targetHostId; + this.pressedControlTargets.delete(key); + return target; + } + private async setImage(action: KeyAction, image: string): Promise { if (this.lastImages.get(action.id) === image) return; await Promise.all([action.setImage(image), action.setTitle("")]); diff --git a/src/host-identity.ts b/src/host-identity.ts new file mode 100644 index 0000000..0df4278 --- /dev/null +++ b/src/host-identity.ts @@ -0,0 +1,38 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { hostname } from "node:os"; +import { dirname, join } from "node:path"; +import { codexDeckStateRoot } from "./codex-deck-paths.js"; +import type { CodexHost } from "./types.js"; + +const HOST_FILE = join(codexDeckStateRoot(), "host.json"); + +export async function getOrCreateHostIdentity(path = HOST_FILE): Promise { + const existing = await readHostIdentity(path); + const platform = process.platform === "darwin" ? "darwin" : "win32"; + const value: CodexHost = { + hostId: existing?.hostId ?? randomUUID(), + hostName: existing?.hostName || hostname(), + platform + }; + if (!existing || existing.hostName !== value.hostName || existing.platform !== value.platform) { + await mkdir(dirname(path), { recursive: true }); + const temporary = `${path}.${process.pid}.${Date.now()}.tmp`; + await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + await rename(temporary, path); + } + return value; +} + +export async function readHostIdentity(path = HOST_FILE): Promise { + try { + const value = JSON.parse(await readFile(path, "utf8")) as Partial; + if (!isUuid(value.hostId) || typeof value.hostName !== "string" || !value.hostName.trim()) return null; + const platform = value.platform === "darwin" ? "darwin" : "win32"; + return { hostId: value.hostId, hostName: value.hostName.trim(), platform }; + } catch { return null; } +} + +function isUuid(value: unknown): value is string { + return typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); +} diff --git a/src/plugin.ts b/src/plugin.ts index 83fb9bd..d875707 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -3,6 +3,7 @@ import { DeckController } from "./controller.js"; import { Agent1, Agent2, Agent3, Agent4, Agent5, Agent6, Approve, Back, Decline, Dictation, Fast, Fork, Forward, NewTask, + HostToggle, KeycapAddFiles, KeycapAddPhotos, KeycapApprove, KeycapArchive, KeycapBranch, KeycapBrowser, KeycapBug, KeycapCodex, KeycapDiff, KeycapDownload, KeycapGitCommit, KeycapLab, KeycapFast, KeycapMerge, KeycapNewTask, KeycapOpenAiDocs, KeycapOpenFolder, KeycapPin, KeycapPlay, @@ -19,6 +20,7 @@ for (const pluginAction of [ new Fast(controller), new Approve(controller), new Decline(controller), new Fork(controller), new Dictation(controller), new Send(controller), new Plan(controller), new Reasoning(controller), new ReasoningDown(controller), new ReasoningUp(controller), new NewTask(controller), + new HostToggle(controller), new Back(controller), new Forward(controller), new Sidebar(controller), new KeycapFast(controller), new KeycapApprove(controller), new KeycapReject(controller), new KeycapSplit(controller), new KeycapNewTask(controller), new KeycapReasoningUp(controller), diff --git a/src/relay-network.ts b/src/relay-network.ts new file mode 100644 index 0000000..48f2265 --- /dev/null +++ b/src/relay-network.ts @@ -0,0 +1,11 @@ +import { isIP } from "node:net"; + +export function isAllowedRelayHost(value: string): boolean { + const host = value.trim().toLowerCase().replace(/^\[|\]$/g, ""); + if (["127.0.0.1", "localhost", "::1"].includes(host) || host.endsWith(".ts.net")) return true; + if (isIP(host) === 4) { + const [first, second] = host.split(".").map(Number); + return first === 100 && second != null && second >= 64 && second <= 127; + } + return isIP(host) === 6 && host.startsWith("fd7a:115c:a1e0:"); +} diff --git a/src/relay-protocol.ts b/src/relay-protocol.ts new file mode 100644 index 0000000..3070a93 --- /dev/null +++ b/src/relay-protocol.ts @@ -0,0 +1,173 @@ +import { OFFICIAL_KEYCAP_IDS, type OfficialKeycapId } from "./keycaps.js"; +import type { + CodexHost, MicroActionSlot, MicroDirection, MicroSnapshot, ReasoningAdjustment, RoutedAgentSlot +} from "./types.js"; + +export const RELAY_PROTOCOL_VERSION = 1; + +export type RelayCommand = + | { kind: "agent"; slot: number; threadKey: string; act: 0 | 1 } + | { kind: "action"; slot: MicroActionSlot; act: 0 | 1 } + | { kind: "joystick"; direction: MicroDirection; distance: 0 | 1 } + | { kind: "encoder"; act: 0 | 1 } + | { kind: "reasoning"; direction: ReasoningAdjustment } + | { kind: "keycap"; keycapId: OfficialKeycapId }; + +export type RelayAuthMessage = { type: "auth"; protocol: 1; token: string }; +export type RelayReadyMessage = { type: "ready"; protocol: 1; host: CodexHost }; +export type RelaySnapshotMessage = { + type: "snapshot"; + protocol: 1; + host: CodexHost; + observedAt: number; + snapshot: MicroSnapshot; +}; +export type RelayCommandMessage = { type: "command"; protocol: 1; requestId: string; command: RelayCommand }; +export type RelayResultMessage = { type: "result"; protocol: 1; requestId: string; ok: boolean; error?: string }; +export type RelayServerMessage = RelayReadyMessage | RelaySnapshotMessage | RelayResultMessage; + +export type HostSnapshot = { host: CodexHost; snapshot: MicroSnapshot; observedAt: number }; + +type ActivityRecord = { activityAt: number; signature: string; lastSeenAt: number }; + +export class HostActivityIndex { + private readonly activity = new Map(); + + merge(inputs: HostSnapshot[], now = Date.now()): RoutedAgentSlot[] { + const routed: RoutedAgentSlot[] = []; + for (const input of inputs) { + for (const slot of input.snapshot.slots) { + if (!slot.threadKey) continue; + const key = `${input.host.hostId}:${slot.threadKey}`; + const signature = `${slot.status}:${slot.selected}:${slot.title ?? ""}`; + const prior = this.activity.get(key); + const explicit = validTimestamp(slot.activityAt); + const changed = prior != null && prior.signature !== signature; + // A snapshot observation is not task activity. In particular, a newly + // connected host must not make all six of its historical slots appear + // newer than an already connected host. Only native timestamps or an + // actually observed slot change establish cross-host recency. + const activityAt = changed + ? Math.max(explicit ?? 0, input.observedAt) + : explicit ?? prior?.activityAt ?? 0; + this.activity.set(key, { activityAt, signature, lastSeenAt: now }); + routed.push({ ...slot, activityAt, host: input.host, sourceSlot: slot.id, observedAt: input.observedAt }); + } + } + for (const [key, value] of this.activity) { + if (now - value.lastSeenAt > 86_400_000) this.activity.delete(key); + } + const mirrors = new Map(); + for (const slot of routed) { + const candidates = mirrors.get(slot.threadKey!) ?? []; + candidates.push(slot); + mirrors.set(slot.threadKey!, candidates); + } + return [...mirrors.values()].map(mergeMirrors) + .sort(compareActivity) + .slice(0, 6) + .map((slot, id) => ({ ...slot, id })); + } +} + +export function parseRelayServerMessage(value: unknown): RelayServerMessage | null { + if (!isRecord(value) || value.protocol !== RELAY_PROTOCOL_VERSION || typeof value.type !== "string") return null; + if (value.type === "ready" && isHost(value.host)) return value as RelayReadyMessage; + if (value.type === "snapshot" && isHost(value.host) && Number.isFinite(value.observedAt) && isSnapshot(value.snapshot)) { + return value as RelaySnapshotMessage; + } + if (value.type === "result" && typeof value.requestId === "string" && typeof value.ok === "boolean") { + return value as RelayResultMessage; + } + return null; +} + +export function parseRelayCommand(value: unknown): RelayCommand | null { + if (!isRecord(value) || typeof value.kind !== "string") return null; + if (value.kind === "agent" && integerIn(value.slot, 0, 5) && isThreadKey(value.threadKey) && binary(value.act)) return value as RelayCommand; + if (value.kind === "action" && ["ACT06", "ACT07", "ACT08", "ACT09", "ACT10_ACT11", "ACT12"].includes(String(value.slot)) && binary(value.act)) return value as RelayCommand; + if (value.kind === "joystick" && ["up", "right", "down", "left"].includes(String(value.direction)) && binary(value.distance)) return value as RelayCommand; + if (value.kind === "encoder" && binary(value.act)) return value as RelayCommand; + if (value.kind === "reasoning" && ["decrease", "increase"].includes(String(value.direction))) return value as RelayCommand; + if (value.kind === "keycap" && typeof value.keycapId === "string" && OFFICIAL_KEYCAP_IDS.includes(value.keycapId as OfficialKeycapId)) return value as RelayCommand; + return null; +} + +function isSnapshot(value: unknown): value is MicroSnapshot { + if (!isRecord(value) || !Array.isArray(value.slots) || value.slots.length !== 6 || !isRecord(value.layout)) return false; + return value.slots.every((slot, index) => isRecord(slot) && slot.id === index && typeof slot.status === "string"); +} + +function isHost(value: unknown): value is CodexHost { + return isRecord(value) && typeof value.hostId === "string" && typeof value.hostName === "string" && ["win32", "darwin"].includes(String(value.platform)); +} + +function isRecord(value: unknown): value is Record { + return value != null && typeof value === "object" && !Array.isArray(value); +} + +function binary(value: unknown): value is 0 | 1 { return value === 0 || value === 1; } +function integerIn(value: unknown, minimum: number, maximum: number): value is number { + return Number.isInteger(value) && Number(value) >= minimum && Number(value) <= maximum; +} +function validTimestamp(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null; +} + +function compareOwnership(left: RoutedAgentSlot, right: RoutedAgentSlot): number { + const ownership = Number(right.ownedByHost === true) - Number(left.ownedByHost === true); + if (ownership) return ownership; + const status = hostStatusPriority(right.status) - hostStatusPriority(left.status); + if (status) return status; + if (left.selected !== right.selected) return left.selected ? -1 : 1; + return compareActivity(left, right); +} + +function mergeMirrors(candidates: RoutedAgentSlot[]): RoutedAgentSlot { + let owner = candidates[0]!; + for (const candidate of candidates.slice(1)) { + if (compareOwnership(candidate, owner) < 0) owner = candidate; + } + const strongest = [...candidates].sort((left, right) => + mirrorStatusPriority(right.status) - mirrorStatusPriority(left.status) || + Number(right.selected) - Number(left.selected) + )[0]!; + const ownedCandidates = candidates.filter((candidate) => candidate.ownedByHost === true); + const recencyCandidates = ownedCandidates.length ? ownedCandidates : candidates; + return { + ...owner, + status: strongest.status, + selected: candidates.some((candidate) => candidate.selected), + // A delayed status update in a cloud/SSH mirror must not make the task look + // newly active or cause two simultaneously working keys to swap places. + // Status and selection remain aggregated, but recency follows the backing + // rollout owner whenever ownership is known. + activityAt: Math.max(...recencyCandidates.map((candidate) => candidate.activityAt ?? 0)), + observedAt: Math.max(...candidates.map((candidate) => candidate.observedAt)) + }; +} + +function compareActivity(left: RoutedAgentSlot, right: RoutedAgentSlot): number { + if (left.selected !== right.selected) return left.selected ? -1 : 1; + const status = hostStatusPriority(right.status) - hostStatusPriority(left.status); + if (status) return status; + return (right.activityAt ?? 0) - (left.activityAt ?? 0) || left.sourceSlot - right.sourceSlot; +} + +function hostStatusPriority(status: string): number { + if (["working", "thinking", "approval", "awaiting-approval", "awaiting-response"].includes(status)) return 3; + if (["unread", "error"].includes(status)) return 2; + if (status === "idle") return 1; + return 0; +} + +function mirrorStatusPriority(status: string): number { + if (["working", "thinking", "approval", "awaiting-approval", "awaiting-response"].includes(status)) return 4; + if (["unread", "error"].includes(status)) return 3; + if (["complete", "completed", "done"].includes(status)) return 2; + if (status === "idle") return 1; + return 0; +} +function isThreadKey(value: unknown): value is string { + return typeof value === "string" && /^(?:[a-z][a-z0-9_-]{0,15}:)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); +} diff --git a/src/render.ts b/src/render.ts index c59d972..4fd1824 100644 --- a/src/render.ts +++ b/src/render.ts @@ -40,11 +40,11 @@ const SURFACES: Record = { } }; -export function renderAgentKey(slot: number, title: string, status: AgentVisualStatus, selected = false, phase = 0, theme: ThemeMode = "light"): string { - return toDataUrl(renderAgentSvg(slot, title, status, selected, phase, theme)); +export function renderAgentKey(slot: number, title: string, status: AgentVisualStatus, selected = false, phase = 0, theme: ThemeMode = "light", hostBadge?: string): string { + return toDataUrl(renderAgentSvg(slot, title, status, selected, phase, theme, hostBadge)); } -export function renderAgentSvg(_slot: number, title: string, status: AgentVisualStatus, selected = false, phase = 0, theme: ThemeMode = "light"): string { +export function renderAgentSvg(_slot: number, title: string, status: AgentVisualStatus, selected = false, phase = 0, theme: ThemeMode = "light", hostBadge?: string): string { const surface = SURFACES[theme]; const color = SIGNAL_COLORS[theme][status]; const [line1, line2] = splitTitle(title); @@ -76,6 +76,7 @@ export function renderAgentSvg(_slot: number, title: string, status: AgentVisual ${selected ? `` : ""} + ${hostBadge ? `${escapeXml(hostBadge)}` : ""} ${titleMarkup} ${statusMark} `; diff --git a/src/session-ownership.ts b/src/session-ownership.ts new file mode 100644 index 0000000..f8e0c9b --- /dev/null +++ b/src/session-ownership.ts @@ -0,0 +1,71 @@ +import { readdir } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { MicroSnapshot } from "./types.js"; + +const SESSION_FILENAME = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i; +const THREAD_KEY = /(?:^|:)([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i; + +export class CodexSessionOwnershipIndex { + private sessionIds = new Set(); + private refreshedAt = 0; + private refreshInFlight?: Promise; + + constructor( + private readonly roots = defaultSessionRoots(), + private readonly refreshIntervalMs = 5_000 + ) {} + + async annotate(snapshot: MicroSnapshot, now = Date.now()): Promise { + await this.refreshIfNeeded(now); + return { + ...snapshot, + slots: snapshot.slots.map((slot) => { + const sessionId = sessionIdFromThreadKey(slot.threadKey); + return { ...slot, ownedByHost: sessionId != null && this.sessionIds.has(sessionId) }; + }) + }; + } + + private async refreshIfNeeded(now: number): Promise { + if (now - this.refreshedAt < this.refreshIntervalMs) return; + if (this.refreshInFlight) return this.refreshInFlight; + const pending = this.refresh(now); + this.refreshInFlight = pending; + try { await pending; } + finally { if (this.refreshInFlight === pending) this.refreshInFlight = undefined; } + } + + private async refresh(now: number): Promise { + const next = new Set(); + for (const root of this.roots) { + try { + const entries = await readdir(root, { recursive: true, withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile()) continue; + const sessionId = sessionIdFromRolloutFilename(entry.name); + if (sessionId) next.add(sessionId); + } + } + catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") throw error; + } + } + this.sessionIds = next; + this.refreshedAt = now; + } +} + +export function sessionIdFromRolloutFilename(filename: string): string | null { + return filename.match(SESSION_FILENAME)?.[1]?.toLowerCase() ?? null; +} + +export function sessionIdFromThreadKey(threadKey: string | null): string | null { + return threadKey?.match(THREAD_KEY)?.[1]?.toLowerCase() ?? null; +} + +function defaultSessionRoots(): string[] { + const codexHome = process.env.CODEX_HOME || join(homedir(), ".codex"); + return [join(codexHome, "sessions"), join(codexHome, "archived_sessions")]; +} diff --git a/src/types.ts b/src/types.ts index 3b7140f..a80a74a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,6 +7,9 @@ export type MicroAgentSlot = { title: string | null; status: string; selected: boolean; + activityAt?: number; + /** True when this host has the backing Codex rollout file for the task. */ + ownedByHost?: boolean; }; export type MicroActionSlot = "ACT06" | "ACT07" | "ACT08" | "ACT09" | "ACT10_ACT11" | "ACT12"; @@ -26,3 +29,15 @@ export type MicroSnapshot = { lightingAutoOff: string; theme: ThemeMode; }; + +export type CodexHost = { + hostId: string; + hostName: string; + platform: "win32" | "darwin"; +}; + +export type RoutedAgentSlot = MicroAgentSlot & { + host: CodexHost; + sourceSlot: number; + observedAt: number; +}; diff --git a/src/windows-open.ts b/src/windows-open.ts deleted file mode 100644 index bfb7a1c..0000000 --- a/src/windows-open.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { spawn } from "node:child_process"; -import { join } from "node:path"; - -const THREAD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -export function codexThreadUrl(threadId: string): string { - if (threadId !== "new" && !THREAD_ID.test(threadId)) { - throw new Error(`Ungültige Codex-Task-ID: ${threadId}`); - } - return `codex://threads/${threadId}`; -} - -export function openCodexThread(threadId: string): Promise { - const url = codexThreadUrl(threadId); - if (process.platform !== "win32") { - return Promise.reject(new Error("Der native Codex-Link ist derzeit nur unter Windows implementiert.")); - } - - const systemRoot = process.env.SystemRoot ?? "C:\\Windows"; - const powershell = join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); - const command = `Start-Process -FilePath '${url}'`; - - return new Promise((resolve, reject) => { - const child = spawn(powershell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", command], { - windowsHide: true, - stdio: ["ignore", "ignore", "pipe"] - }); - let errorOutput = ""; - child.stderr.on("data", (data) => { errorOutput += String(data); }); - child.on("error", reject); - child.on("exit", (code) => { - if (code === 0) resolve(); - else reject(new Error(`Codex-Link konnte nicht geöffnet werden (${code ?? "unbekannt"}): ${errorOutput.trim()}`)); - }); - }); -} diff --git a/static/manifest.json b/static/manifest.json index 3681b12..b82eec3 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -1,9 +1,9 @@ { "$schema": "https://schemas.elgato.com/streamdeck/plugins/manifest.json", "Name": "Codex Deck", - "Version": "0.5.1.0", + "Version": "0.6.0.0", "Author": "Dazer", - "Description": "Unofficial local bridge between Codex for Windows and Stream Deck.", + "Description": "Unofficial Codex Micro bridge for Stream Deck on Windows and macOS, with optional multi-host relay.", "UUID": "com.simeo.codex-deck", "Category": "Codex Deck", "CategoryIcon": "static/imgs/category-icon", @@ -11,9 +11,12 @@ "CodePath": "bin/plugin.mjs", "SDKVersion": 2, "Software": { "MinimumVersion": "6.6" }, - "OS": [{ "Platform": "windows", "MinimumVersion": "10" }], + "OS": [ + { "Platform": "windows", "MinimumVersion": "10" }, + { "Platform": "mac", "MinimumVersion": "13" } + ], "Nodejs": { "Version": "20", "Debug": "enabled" }, - "ApplicationsToMonitor": { "windows": ["ChatGPT.exe"] }, + "ApplicationsToMonitor": { "windows": ["ChatGPT.exe"], "mac": ["com.openai.codex"] }, "Actions": [ { "UUID": "com.simeo.codex-deck.agent-1", "Name": "Agent 1", "Tooltip": "Open native Codex Micro agent slot 1.", "Icon": "static/imgs/category-icon", "Controllers": ["Keypad"], "States": [{ "Image": "static/imgs/key", "Title": "" }] }, { "UUID": "com.simeo.codex-deck.agent-2", "Name": "Agent 2", "Tooltip": "Open native Codex Micro agent slot 2.", "Icon": "static/imgs/category-icon", "Controllers": ["Keypad"], "States": [{ "Image": "static/imgs/key", "Title": "" }] }, @@ -35,6 +38,7 @@ { "UUID": "com.simeo.codex-deck.reasoning-down", "Name": "Reasoning Effort Down", "Tooltip": "Decrease composer reasoning effort through Codex's internal command path.", "Icon": "static/imgs/category-icon", "Controllers": ["Keypad"], "States": [{ "Image": "static/imgs/key", "Title": "" }] }, { "UUID": "com.simeo.codex-deck.reasoning-up", "Name": "Reasoning Effort Up", "Tooltip": "Increase composer reasoning effort through Codex's internal command path.", "Icon": "static/imgs/category-icon", "Controllers": ["Keypad"], "States": [{ "Image": "static/imgs/key", "Title": "" }] }, { "UUID": "com.simeo.codex-deck.new-task", "Name": "New Task", "Tooltip": "Open a new Codex task.", "Icon": "static/imgs/category-icon", "Controllers": ["Keypad"], "States": [{ "Image": "static/imgs/key", "Title": "" }] }, + { "UUID": "com.simeo.codex-deck.host-toggle", "Name": "Windows / Mac Target", "Tooltip": "Switch function, joystick, and reasoning keys between the local Windows Codex and the connected Mac. Agent keys always route to their originating host.", "Icon": "static/imgs/category-icon", "Controllers": ["Keypad"], "States": [{ "Image": "static/imgs/key", "Title": "" }] }, { "UUID": "com.simeo.codex-deck.keycap-fast", "Name": "Keycap · Fast Mode", "Tooltip": "Toggle Fast mode through the native Codex Micro command.", "Icon": "static/imgs/category-icon", "Controllers": ["Keypad"], "States": [{ "Image": "static/imgs/key", "Title": "" }] }, { "UUID": "com.simeo.codex-deck.keycap-approve", "Name": "Keycap · Approve", "Tooltip": "Approve the current Codex request.", "Icon": "static/imgs/category-icon", "Controllers": ["Keypad"], "States": [{ "Image": "static/imgs/key", "Title": "" }] }, { "UUID": "com.simeo.codex-deck.keycap-reject", "Name": "Keycap · Reject", "Tooltip": "Reject the current Codex request.", "Icon": "static/imgs/category-icon", "Controllers": ["Keypad"], "States": [{ "Image": "static/imgs/key", "Title": "" }] }, diff --git a/test/codex-open.test.ts b/test/codex-open.test.ts new file mode 100644 index 0000000..17aac3e --- /dev/null +++ b/test/codex-open.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { codexOpenSpec, codexThreadUrl } from "../src/codex-open.js"; + +test("Codex task deep links only accept task UUIDs or new", () => { + assert.equal(codexThreadUrl("new"), "codex://threads/new"); + assert.equal(codexThreadUrl("019f6de7-44c2-7fe2-9d17-9322c952e626"), "codex://threads/019f6de7-44c2-7fe2-9d17-9322c952e626"); + assert.throws(() => codexThreadUrl("../../settings"), /Invalid Codex task ID/); +}); + +test("Codex links use native launchers on Windows and macOS", () => { + const windows = codexOpenSpec("new", "win32", "C:\\Windows"); + assert.match(windows.executable, /powershell\.exe$/i); + assert.equal(windows.windowsHide, true); + const mac = codexOpenSpec("new", "darwin"); + assert.deepEqual(mac, { executable: "/usr/bin/open", args: ["codex://threads/new"], windowsHide: false }); + assert.throws(() => codexOpenSpec("new", "linux"), /unsupported/); +}); diff --git a/test/control-target.test.ts b/test/control-target.test.ts new file mode 100644 index 0000000..e5ee47f --- /dev/null +++ b/test/control-target.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { isRemoteControlRequest, readControlTarget } from "../src/control-target.js"; + +test("control targeting treats each platform as local on its own host", () => { + assert.equal(isRemoteControlRequest("win32", "win32"), false); + assert.equal(isRemoteControlRequest("darwin", "darwin"), false); + assert.equal(isRemoteControlRequest("darwin", "win32"), true); + assert.equal(isRemoteControlRequest("win32", "darwin"), true); +}); + +test("an explicit task owner overrides the selected platform", () => { + assert.equal(isRemoteControlRequest("darwin", "darwin", "local", "local"), false); + assert.equal(isRemoteControlRequest("darwin", "darwin", "remote", "local"), true); +}); + +test("invalid persisted targets fall back to the local platform", async () => { + const root = await mkdtemp(join(tmpdir(), "codex-target-")); + try { + const path = join(root, "target.json"); + await writeFile(path, '{"platform":"unknown"}\n'); + assert.equal(await readControlTarget(path, "darwin"), "darwin"); + assert.equal(await readControlTarget(join(root, "missing.json"), "win32"), "win32"); + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/test/launcher.test.ts b/test/launcher.test.ts index 82a1edb..7717d32 100644 --- a/test/launcher.test.ts +++ b/test/launcher.test.ts @@ -37,7 +37,12 @@ test("startup monitoring survives Codex updates without duplicate watchers", asy assert.match(launcher, /Watch-CodexDeck\.ps1/); assert.match(launcher, /-RecoverExistingSession/); assert.match(launcher, /Start-BridgeWatcher/); + assert.match(launcher, /Get-InstalledLauncherRoot/); + assert.match(launcher, /Install-WatcherBundle/); + assert.match(launcher, /LocalAppData.*CodexDeck.*launcher/is); assert.match(build, /Watch-CodexDeck\.ps1/); + assert.match(build, /Configure-CodexDeckRelay\.ps1/); + assert.match(build, /replace\(\/\\r\\n\/g, "\\n"\)/); }); test("watcher recovery decision self-test passes in PowerShell", async () => { @@ -45,7 +50,7 @@ test("watcher recovery decision self-test passes in PowerShell", async () => { const { stdout } = await execFileAsync("powershell.exe", [ "-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", watcherPath, "-SelfTest" ]); - assert.match(stdout, /self-test passed \(6 cases\)/i); + assert.match(stdout, /self-test passed \(9 cases\)/i); }); test("launcher supports the current shared-chunk native detection path", () => { diff --git a/test/macos-launcher.test.ts b/test/macos-launcher.test.ts index 50b568c..82e1a21 100644 --- a/test/macos-launcher.test.ts +++ b/test/macos-launcher.test.ts @@ -45,3 +45,27 @@ test("LaunchAgent uses a dynamic Node resolver instead of pinning an NVM version assert.match(plist, /watcher-launch\.sh/); assert.doesNotMatch(plist, /\.nvm\/versions\/node\/v\d/); }); + +test("manual and double-click launch resolve Node outside an interactive shell", async () => { + const source = await import("node:fs/promises").then(({ readFile }) => readFile(new URL("../launcher/start-codex-deck.sh", import.meta.url), "utf8")); + assert.match(source, /\.nvm\/versions\/node\/\*\/bin\/node/); + assert.match(source, /Contents\/Resources\/cua_node\/bin\/node/); + assert.match(source, /node_major/); + assert.doesNotMatch(source, /exec \/usr\/bin\/env node/); +}); + +test("macOS release packaging preserves executable launchers", async () => { + const source = await import("node:fs/promises").then(({ readFile }) => readFile(new URL("../scripts/package-macos-release.sh", import.meta.url), "utf8")); + assert.match(source, /chmod 755/); + assert.match(source, /start-codex-deck\.sh/); + assert.match(source, /Start Codex Deck\.command/); + assert.match(source, /ditto -c -k/); +}); + +test("macOS runtime supports relay pairing without exposing the CDP listener", async () => { + const source = await import("node:fs/promises").then(({ readFile }) => readFile(new URL("../launcher/macos/codex-deck-macos.ts", import.meta.url), "utf8")); + assert.match(source, /relay-config/); + assert.match(source, /RELAY_SERVER_CONFIG_PATH/); + assert.match(source, /CodexRelayServer/); + assert.doesNotMatch(source, /remote-debugging-address=0\.0\.0\.0/); +}); diff --git a/test/micro-bridge.test.ts b/test/micro-bridge.test.ts index 2e1e3a5..7e7eea2 100644 --- a/test/micro-bridge.test.ts +++ b/test/micro-bridge.test.ts @@ -47,10 +47,12 @@ test("reasoning controls use the official native composer commands", async () => }); test("manifest exposes both dedicated reasoning adjustment buttons", async () => { - const manifest = JSON.parse(await readFile(new URL("../static/manifest.json", import.meta.url), "utf8")) as { Actions: Array<{ UUID: string }> }; + const manifest = JSON.parse(await readFile(new URL("../static/manifest.json", import.meta.url), "utf8")) as { Actions: Array<{ UUID: string }>; OS: Array<{ Platform: string }> }; const actions = new Set(manifest.Actions.map((action) => action.UUID)); assert.equal(actions.has("com.simeo.codex-deck.reasoning-down"), true); assert.equal(actions.has("com.simeo.codex-deck.reasoning-up"), true); + assert.equal(actions.has("com.simeo.codex-deck.host-toggle"), true); + assert.deepEqual(manifest.OS.map(({ Platform }) => Platform).sort(), ["mac", "windows"]); }); test("all official keycaps are covered by standalone or native actions", async () => { @@ -72,10 +74,17 @@ test("standalone keycaps resolve Codex's live registry instead of hardcoding com }); test("controller avoids overlapping polls and redundant image writes", async () => { - const source = await readFile(new URL("../src/controller.ts", import.meta.url), "utf8"); + const [source, targetSource] = await Promise.all([ + readFile(new URL("../src/controller.ts", import.meta.url), "utf8"), + readFile(new URL("../src/control-target.ts", import.meta.url), "utf8") + ]); assert.match(source, /lastImages/); assert.match(source, /this\.lastImages\.get\(action\.id\) === image/); assert.match(source, /scheduleRefresh/); assert.match(source, /status === "thinking" \|\| status === "input"/); + assert.match(source, /pressedAgents/); + assert.match(source, /pressedControlTargets/); + assert.match(targetSource, /control-target\.json/); + assert.match(source, /targetPlatform === "darwin"/); assert.doesNotMatch(source, /setInterval\(/); }); diff --git a/test/relay.test.ts b/test/relay.test.ts new file mode 100644 index 0000000..f12a333 --- /dev/null +++ b/test/relay.test.ts @@ -0,0 +1,270 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:net"; +import test from "node:test"; +import WebSocket from "ws"; +import { isAllowedRelayHost } from "../src/relay-network.js"; +import { CodexRelayServer, validateRelayServerConfig } from "../src/codex-relay-server.js"; +import { HostActivityIndex, RELAY_PROTOCOL_VERSION, parseRelayCommand } from "../src/relay-protocol.js"; +import type { CodexHost, MicroSnapshot } from "../src/types.js"; + +const host: CodexHost = { hostId: "56fd97ad-7073-42cc-85ce-befa17546d7c", hostName: "Test Mac", platform: "darwin" }; +const snapshot: MicroSnapshot = { + slots: Array.from({ length: 6 }, (_, id) => ({ + id, threadKey: `00000000-0000-4000-8000-00000000000${id}`, title: `Task ${id + 1}`, + status: id === 0 ? "working" : "idle", selected: id === 0, activityAt: 1_000 - id + })), + layout: { + version: 1, + slots: { + ACT06: { keycapId: "FAST" }, ACT07: { keycapId: "APPR" }, ACT08: { keycapId: "REJ" }, + ACT09: { keycapId: "SPLIT" }, ACT10_ACT11: { keycapId: "CODEX" }, ACT12: { keycapId: "CODEX" } + }, + analogStick: { up: {}, right: {}, down: {}, left: {} } + }, + agentSource: "recent", + lightingAutoOff: "3-minutes", + theme: "dark" +}; + +test("relay refuses wildcard exposure and short authentication tokens", () => { + assert.throws(() => validateRelayServerConfig({ enabled: true, listenHost: "0.0.0.0", port: 47_651, token: "x".repeat(32) }), /loopback or a specific Tailscale address/); + assert.throws(() => validateRelayServerConfig({ enabled: true, listenHost: "203.0.113.10", port: 47_651, token: "x".repeat(32) }), /loopback or a specific Tailscale/); + assert.throws(() => validateRelayServerConfig({ enabled: true, listenHost: "127.0.0.1", port: 47_651, token: "short" }), /32 bytes/); + assert.equal(isAllowedRelayHost("100.64.0.42"), true); + assert.equal(isAllowedRelayHost("example.tailnet.ts.net"), true); + assert.equal(isAllowedRelayHost("8.8.8.8"), false); +}); + +test("relay command parser permits only the narrow native command surface", () => { + const threadKey = "00000000-0000-4000-8000-000000000005"; + assert.deepEqual(parseRelayCommand({ kind: "agent", slot: 5, threadKey, act: 1 }), { kind: "agent", slot: 5, threadKey, act: 1 }); + assert.deepEqual(parseRelayCommand({ kind: "reasoning", direction: "increase" }), { kind: "reasoning", direction: "increase" }); + assert.equal(parseRelayCommand({ kind: "agent", slot: 6, threadKey, act: 1 }), null); + assert.equal(parseRelayCommand({ kind: "evaluate", expression: "process.exit()" }), null); + assert.equal(parseRelayCommand({ kind: "keycap", keycapId: "NOT_REAL" }), null); + assert.notEqual(parseRelayCommand({ kind: "agent", slot: 1, threadKey: "local:019f6de7-44c2-7fe2-9d17-9322c952e626", act: 1 }), null); + assert.equal(parseRelayCommand({ kind: "agent", slot: 1, threadKey: "local:../../secret", act: 1 }), null); +}); + +test("host activity merge globally orders explicit Mac and Windows timestamps", () => { + const windows: CodexHost = { hostId: "11111111-1111-4111-8111-111111111111", hostName: "Windows", platform: "win32" }; + const macSnapshot = structuredClone(snapshot); + const windowsSnapshot = structuredClone(snapshot); + for (const slot of windowsSnapshot.slots) slot.threadKey = `10000000-0000-4000-8000-00000000000${slot.id}`; + for (const slot of [...macSnapshot.slots, ...windowsSnapshot.slots]) slot.activityAt = 1; + macSnapshot.slots[0]!.activityAt = 100; + windowsSnapshot.slots[0]!.activityAt = 200; + const merged = new HostActivityIndex().merge([ + { host, snapshot: macSnapshot, observedAt: 1_000 }, + { host: windows, snapshot: windowsSnapshot, observedAt: 1_000 } + ]); + assert.equal(merged[0]!.host.platform, "win32"); + assert.equal(merged[0]!.sourceSlot, 0); + assert.ok(merged.some((slot) => slot.host.platform === "darwin")); +}); + +test("a newly connected host cannot make unknown historical activity look recent", () => { + const windows: CodexHost = { hostId: "11111111-1111-4111-8111-111111111111", hostName: "Windows", platform: "win32" }; + const macSnapshot = structuredClone(snapshot); + const windowsSnapshot = structuredClone(snapshot); + for (const slot of [...macSnapshot.slots, ...windowsSnapshot.slots]) { + delete slot.activityAt; + slot.status = "idle"; + slot.selected = false; + } + windowsSnapshot.slots[0]!.selected = true; + windowsSnapshot.slots[0]!.status = "working"; + const merged = new HostActivityIndex().merge([ + { host: windows, snapshot: windowsSnapshot, observedAt: 1_000 }, + { host, snapshot: macSnapshot, observedAt: 9_000 } + ]); + assert.equal(merged[0]!.host.platform, "win32"); + assert.equal(merged[0]!.threadKey, windowsSnapshot.slots[0]!.threadKey); + assert.equal(merged[0]!.activityAt, 0); +}); + +test("an idle cloud thread visible on both hosts keeps the first stable owner", () => { + const windows: CodexHost = { hostId: "11111111-1111-4111-8111-111111111111", hostName: "Windows", platform: "win32" }; + const shared = "00000000-0000-4000-8000-000000000000"; + const macSnapshot = structuredClone(snapshot); + const windowsSnapshot = structuredClone(snapshot); + for (const candidate of [macSnapshot.slots[5]!, windowsSnapshot.slots[5]!]) { + candidate.threadKey = shared; + candidate.status = "idle"; + candidate.selected = false; + delete candidate.activityAt; + } + const match = new HostActivityIndex().merge([ + { host: windows, snapshot: windowsSnapshot, observedAt: 1_000 }, + { host, snapshot: macSnapshot, observedAt: 9_000 } + ]).find((slot) => slot.threadKey === shared); + assert.equal(match?.host.platform, "win32"); +}); + +test("backing rollout ownership beats a mirrored remote-SSH recent entry", () => { + const windows: CodexHost = { hostId: "11111111-1111-4111-8111-111111111111", hostName: "Windows", platform: "win32" }; + const shared = "00000000-0000-4000-8000-000000000000"; + const macSnapshot = structuredClone(snapshot); + const windowsSnapshot = structuredClone(snapshot); + macSnapshot.slots[0] = { ...macSnapshot.slots[0]!, threadKey: shared, status: "idle", selected: false, ownedByHost: true }; + windowsSnapshot.slots[0] = { ...windowsSnapshot.slots[0]!, threadKey: shared, status: "working", selected: true, ownedByHost: false }; + const match = new HostActivityIndex().merge([ + { host: windows, snapshot: windowsSnapshot, observedAt: 2_000 }, + { host, snapshot: macSnapshot, observedAt: 1_000 } + ]).find((slot) => slot.threadKey === shared); + assert.equal(match?.host.platform, "darwin", "commands route to the host with the rollout"); + assert.equal(match?.status, "working", "the strongest mirrored live status remains visible"); + assert.equal(match?.selected, true, "selection is aggregated across both visible mirrors"); +}); + +test("delayed mirror status does not reorder an owned active task", () => { + const windows: CodexHost = { hostId: "11111111-1111-4111-8111-111111111111", hostName: "Windows", platform: "win32" }; + const shared = "00000000-0000-4000-8000-000000000000"; + const macSnapshot = structuredClone(snapshot); + const windowsSnapshot = structuredClone(snapshot); + for (const slot of [...macSnapshot.slots, ...windowsSnapshot.slots]) { + slot.status = "idle"; + slot.selected = false; + delete slot.activityAt; + } + macSnapshot.slots[0] = { ...macSnapshot.slots[0]!, threadKey: shared, ownedByHost: true }; + windowsSnapshot.slots[0] = { ...windowsSnapshot.slots[0]!, threadKey: shared, ownedByHost: false }; + const index = new HostActivityIndex(); + index.merge([ + { host, snapshot: macSnapshot, observedAt: 500 }, + { host: windows, snapshot: windowsSnapshot, observedAt: 500 } + ]); + + macSnapshot.slots[0]!.status = "working"; + macSnapshot.slots[0]!.selected = true; + let match = index.merge([ + { host, snapshot: macSnapshot, observedAt: 1_000 }, + { host: windows, snapshot: windowsSnapshot, observedAt: 1_000 } + ]).find((slot) => slot.threadKey === shared); + assert.equal(match?.activityAt, 1_000); + + windowsSnapshot.slots[0]!.status = "working"; + windowsSnapshot.slots[0]!.selected = true; + match = index.merge([ + { host, snapshot: macSnapshot, observedAt: 2_000 }, + { host: windows, snapshot: windowsSnapshot, observedAt: 2_000 } + ]).find((slot) => slot.threadKey === shared); + assert.equal(match?.activityAt, 1_000, "the delayed non-owner mirror cannot refresh recency"); +}); + +test("the same cloud thread is shown once and owned by its live active host", () => { + const windows: CodexHost = { hostId: "11111111-1111-4111-8111-111111111111", hostName: "Windows", platform: "win32" }; + const macSnapshot = structuredClone(snapshot); + const windowsSnapshot = structuredClone(snapshot); + const shared = "00000000-0000-4000-8000-000000000000"; + macSnapshot.slots[0] = { ...macSnapshot.slots[0]!, threadKey: shared, status: "working", activityAt: 100 }; + windowsSnapshot.slots[0] = { ...windowsSnapshot.slots[0]!, threadKey: shared, status: "idle", activityAt: 200 }; + const index = new HostActivityIndex(); + const merged = index.merge([ + { host, snapshot: macSnapshot, observedAt: 1_000 }, + { host: windows, snapshot: windowsSnapshot, observedAt: 1_000 } + ]); + const matches = merged.filter((slot) => slot.threadKey === shared); + assert.equal(matches.length, 1); + assert.equal(matches[0]!.host.platform, "darwin"); + assert.equal(matches[0]!.status, "working"); + + macSnapshot.slots[0] = { ...macSnapshot.slots[0]!, status: "idle" }; + const afterCompletion = index.merge([ + { host, snapshot: macSnapshot, observedAt: 2_000 }, + { host: windows, snapshot: windowsSnapshot, observedAt: 2_000 } + ]).find((slot) => slot.threadKey === shared); + assert.equal(afterCompletion?.host.platform, "darwin", "the host that completed the task retains ownership"); +}); + +test("authenticated relay publishes snapshots and dispatches typed commands", async () => { + const port = await freePort(); + const calls: unknown[] = []; + const control = { + refresh: async () => snapshot, + sendAgent: async (slot: number, act: 0 | 1) => { calls.push(["agent", slot, act]); }, + sendAction: async () => {}, sendJoystick: async () => {}, sendEncoder: async () => {}, + adjustReasoning: async () => {}, runKeycap: async () => {} + }; + const server = new CodexRelayServer( + { enabled: true, listenHost: "127.0.0.1", port, token: "t".repeat(32) }, host, control, () => {} + ); + await server.start(); + const socket = new WebSocket(`ws://127.0.0.1:${port}`); + const messages = messageQueue(socket); + await onceOpen(socket); + socket.send(JSON.stringify({ type: "auth", protocol: RELAY_PROTOCOL_VERSION, token: "t".repeat(32) })); + const first = await messages.next(); + assert.equal(first.type, "ready"); + const second = await messages.next(); + assert.equal(second.type, "snapshot"); + socket.send(JSON.stringify({ + type: "command", protocol: RELAY_PROTOCOL_VERSION, requestId: "request-1", + command: { kind: "agent", slot: 2, threadKey: "00000000-0000-4000-8000-000000000002", act: 1 } + })); + const result = await messages.next(); + assert.deepEqual(calls, [["agent", 2, 1]]); + assert.equal(result.type, "result"); + assert.equal(result.ok, true); + socket.close(); + await server.close(); +}); + +test("relay rejects a client with the wrong token before publishing state", async () => { + const port = await freePort(); + let refreshes = 0; + const control = { + refresh: async () => { refreshes += 1; return snapshot; }, + sendAgent: async () => {}, sendAction: async () => {}, sendJoystick: async () => {}, + sendEncoder: async () => {}, adjustReasoning: async () => {}, runKeycap: async () => {} + }; + const server = new CodexRelayServer( + { enabled: true, listenHost: "127.0.0.1", port, token: "t".repeat(32) }, host, control, () => {} + ); + await server.start(); + const socket = new WebSocket(`ws://127.0.0.1:${port}`); + await onceOpen(socket); + socket.send(JSON.stringify({ type: "auth", protocol: RELAY_PROTOCOL_VERSION, token: "wrong-token".repeat(4) })); + const closeCode = await new Promise((resolve) => socket.once("close", resolve)); + assert.equal(closeCode, 4003); + assert.equal(refreshes, 0); + await server.close(); +}); + +async function freePort(): Promise { + const server = createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + await new Promise((resolve) => server.close(() => resolve())); + return port; +} + +async function onceOpen(socket: WebSocket): Promise { + if (socket.readyState === WebSocket.OPEN) return; + await new Promise((resolve, reject) => { + socket.once("open", resolve); + socket.once("error", reject); + }); +} + +function messageQueue(socket: WebSocket): { next: () => Promise> } { + const queued: Record[] = []; + const waiting: Array<(value: Record) => void> = []; + socket.on("message", (raw) => { + const value = JSON.parse(raw.toString()) as Record; + const resolve = waiting.shift(); + if (resolve) resolve(value); + else queued.push(value); + }); + return { + next: () => { + const value = queued.shift(); + if (value) return Promise.resolve(value); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("Timed out waiting for relay message.")), 2_000); + waiting.push((message) => { clearTimeout(timer); resolve(message); }); + }); + } + }; +} diff --git a/test/session-ownership.test.ts b/test/session-ownership.test.ts new file mode 100644 index 0000000..99d5b62 --- /dev/null +++ b/test/session-ownership.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { CodexSessionOwnershipIndex, sessionIdFromRolloutFilename, sessionIdFromThreadKey } from "../src/session-ownership.js"; +import type { MicroSnapshot } from "../src/types.js"; + +const owned = "019f7336-04a2-72f1-af41-2f216ccdc3d0"; +const mirrored = "019f6de7-44c2-7fe2-9d17-9322c952e626"; + +test("session ownership is derived from exact rollout filenames, not message references", async () => { + const root = await mkdtemp(join(tmpdir(), "codex-deck-ownership-")); + try { + const dated = join(root, "2026", "07", "18"); + await mkdir(dated, { recursive: true }); + await writeFile(join(dated, `rollout-2026-07-18T00-00-00-${owned}.jsonl`), "{}\n"); + await writeFile(join(dated, "rollout-containing-another-thread-reference.jsonl"), mirrored); + const index = new CodexSessionOwnershipIndex([root], 60_000); + const annotated = await index.annotate(snapshot()); + assert.equal(annotated.slots[0]!.ownedByHost, true); + assert.equal(annotated.slots[1]!.ownedByHost, false); + } + finally { await rm(root, { recursive: true, force: true }); } +}); + +test("rollout and prefixed thread identities use the same UUID", () => { + assert.equal(sessionIdFromRolloutFilename(`rollout-time-${owned}.jsonl`), owned); + assert.equal(sessionIdFromThreadKey(`local:${owned}`), owned); + assert.equal(sessionIdFromThreadKey("local:../../secret"), null); +}); + +function snapshot(): MicroSnapshot { + return { + slots: Array.from({ length: 6 }, (_, id) => ({ + id, + threadKey: `local:${id === 0 ? owned : id === 1 ? mirrored : `00000000-0000-4000-8000-00000000000${id}`}`, + title: `Task ${id + 1}`, + status: "idle", + selected: false + })), + layout: { + version: 1, + slots: { + ACT06: { keycapId: "FAST" }, ACT07: { keycapId: "APPR" }, ACT08: { keycapId: "REJ" }, + ACT09: { keycapId: "SPLIT" }, ACT10_ACT11: { keycapId: "CODEX" }, ACT12: { keycapId: "CODEX" } + }, + analogStick: { up: {}, right: {}, down: {}, left: {} } + }, + agentSource: "recent", + lightingAutoOff: "3-minutes", + theme: "dark" + }; +} diff --git a/test/windows-open.test.ts b/test/windows-open.test.ts deleted file mode 100644 index 55190ff..0000000 --- a/test/windows-open.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { codexThreadUrl } from "../src/windows-open.js"; - -test("Codex task deep links only accept task UUIDs or new", () => { - assert.equal(codexThreadUrl("019f6c28-3135-7d82-ae2d-88288501c2ba"), "codex://threads/019f6c28-3135-7d82-ae2d-88288501c2ba"); - assert.equal(codexThreadUrl("new"), "codex://threads/new"); - assert.throws(() => codexThreadUrl("'; Remove-Item C:\\"), /Ungültige Codex-Task-ID/); -});