diff --git a/.bat-harness-test.ps1 b/.bat-harness-test.ps1 new file mode 100644 index 0000000..12552b5 --- /dev/null +++ b/.bat-harness-test.ps1 @@ -0,0 +1,147 @@ +# Harness: exercises GHOST.bat menu input handling on a TEST COPY. +# Runs locally and in CI (ci.yml windows job, "GHOST.bat menu harness"); +# derives the repo root from its own location so it works on any checkout. +# stdin redirection is unusable here (the launcher's ESC-capture powershell +# child drains redirected stdin, starving every set /p), so the copy reads +# answers from %TESTINPUT% line-by-line via a for /f counter instead. +# Answers: one per line, CRLF; no empty lines (for /f skips them; the +# interactive bare-Enter path is covered by the 'if not defined choice' +# guards and a space-only answer exercises the tokenless-input path). +# NOTE: some cmd builds reject skip=0 inside for /f options with a parse +# error ("delims= was unexpected"), so paging goes through `more +N` +# (which accepts +0) instead of `skip=N`. +$ErrorActionPreference = 'Stop' +$repo = $PSScriptRoot +$work = Join-Path $env:TEMP 'ghost-bat-harness' +if (Test-Path $work) { Remove-Item $work -Recurse -Force } +New-Item -ItemType Directory -Path $work | Out-Null + +# --- build the test copy ------------------------------------------------------- +$src = [System.IO.File]::ReadAllText("$repo\GHOST.bat") +$count = ([regex]::Matches($src, [regex]::Escape('set /p choice=%PROMPT_TEXT%'))).Count +# Floor 5 = main + the four hand-written submenus; generated clone suites +# (tools\new_clone_suite.ps1) add one prompt per new submenu and the global +# rewrite below covers every occurrence, so the check guards the floor only. +if ($count -lt 5) { throw "expected at least 5 set /p sites in GHOST.bat (main + 4 submenus), found $count" } +$copy = $src.Replace('set /p choice=%PROMPT_TEXT%', 'call :TestNextInput choice') +$sub = @' + +:TestNextInput +rem === HARNESS-ONLY: next answer comes from %TESTINPUT% (TESTIDX = next line, 0-based). +rem skip=0 is a parse error on this cmd build, so page with more +N and keep +rem the first line; the read cap stops an exhausted-input runaway loop. +if not defined TESTIDX set "TESTIDX=0" +set /a TESTREADS=TESTREADS+1 +if %TESTREADS% GTR 200 ( + echo HARNESS: read cap hit - input exhausted in a loop + exit 9 +) +set "TESTANSWER=" +for /f "usebackq delims=" %%a in (`more +%TESTIDX% "%TESTINPUT%"`) do ( + if not defined TESTANSWER set "TESTANSWER=%%a" +) +set /a TESTIDX=TESTIDX+1 +set "%~1=%TESTANSWER%" +goto :eof +'@ +$copy = $copy + ($sub -replace "`n", "`r`n") +$testCopy = Join-Path $work 'TestGhost.bat' +[System.IO.File]::WriteAllText($testCopy, $copy, [System.Text.Encoding]::ASCII) + +function New-Input([string]$Name, [string[]]$Lines) { + $p = Join-Path $work "$Name.in.txt" + [System.IO.File]::WriteAllText($p, (($Lines | ForEach-Object { $_ + "`r`n" }) -join '')) + return $p +} + +function Invoke-Scenario([string]$Name, [string[]]$Lines) { + $inFile = New-Input $Name $Lines + $outFile = Join-Path $work "$Name.out.txt" + $env:TESTINPUT = $inFile + $env:TESTIDX = '0' + $env:TESTREADS = '0' + cmd /c "`"$testCopy`" > `"$outFile`" 2>&1" + Remove-Item Env:TESTINPUT -ErrorAction SilentlyContinue + Remove-Item Env:TESTIDX -ErrorAction SilentlyContinue + Remove-Item Env:TESTREADS -ErrorAction SilentlyContinue + return (Get-Content $outFile -Raw) +} + +$failures = @() + +# A) plain exit at main menu -> Done, zero Unknown task, zero literal junk +$out = Invoke-Scenario 'A_plain_exit' @('0') +if ($out -match 'Unknown task') { $failures += 'A: Unknown task on plain exit' } +if ($out -match '%choice|%PENDING_CHAIN%') { $failures += 'A: literal variable text leaked' } +if ($out -notmatch 'Done\.') { $failures += 'A: never reached exit' } + +# B) open ZCode submenu (PENDING_CHAIN undefined), back, exit -> no bogus line +$out = Invoke-Scenario 'B_submenu_render' @('2', '0', '0') +if ($out -match 'Unknown task') { $failures += 'B: Unknown task on plain submenu render' } +if ($out -match '%PENDING_CHAIN%') { $failures += 'B: literal %PENDING_CHAIN% leaked' } +if ($out -notmatch 'ZCode Multi-Instance') { $failures += 'B: ZCode submenu not shown' } + +# C) tokenless input inside ZCode submenu -> silent re-render, then back, exit +$out = Invoke-Scenario 'C_space_submenu' @('2', ' ', '0', '0') +if ($out -match 'Unknown task') { $failures += 'C: Unknown task on tokenless submenu input' } +if ($out -notmatch 'ZCode Multi-Instance') { $failures += 'C: ZCode submenu not shown' } + +# D) invalid main-menu id -> exactly one legit Unknown task "99" +$out = Invoke-Scenario 'D_invalid_main' @('99', '0') +$hits = ([regex]::Matches($out, 'Unknown task "99"')).Count +if ($hits -ne 1) { $failures += "D: expected 1 legit Unknown task, got $hits" } + +# E) main sub-path "2 99" -> chain reaches ZCodeOne, exactly one legit +# Unknown task, and the PENDING_CHAIN literal never appears +$out = Invoke-Scenario 'E_subpath_chain' @('2 99', '0', '0') +$hits = ([regex]::Matches($out, 'Unknown task "99"')).Count +if ($hits -ne 1) { $failures += "E: expected 1 Unknown task from chain, got $hits" } +if ($out -match '%PENDING_CHAIN%') { $failures += 'E: literal %PENDING_CHAIN% leaked' } +if ($out -notmatch 'ZCode Multi-Instance') { $failures += 'E: ZCode submenu not reached via sub-path' } + +# F) new ZCode submenu entry renders: [17] Freeze diagnostic is listed +$out = Invoke-Scenario 'F_new_entry_render' @('2', '0', '0') +if ($out -notmatch '\[17\].*Freeze diagnostic') { $failures += 'F: ZCode submenu [17] Freeze diagnostic not rendered' } + +# G) invalid ZCode submenu id -> exactly one legit Unknown task "42" +$out = Invoke-Scenario 'G_invalid_zcode' @('2', '42', '0', '0') +$hits = ([regex]::Matches($out, 'Unknown task "42"')).Count +if ($hits -ne 1) { $failures += "G: expected 1 Unknown task from ZCode submenu, got $hits" } + +# H) open AutoClaw submenu (PENDING_CHAIN undefined), back, exit -> clean render +$out = Invoke-Scenario 'H_autoclaw_render' @('4', '0', '0') +if ($out -match 'Unknown task') { $failures += 'H: Unknown task on AutoClaw submenu render' } +if ($out -match '%PENDING_CHAIN%') { $failures += 'H: literal %PENDING_CHAIN% leaked' } +if ($out -notmatch 'AutoClaw Multi-Instance') { $failures += 'H: AutoClaw submenu not shown' } + +# I) invalid AutoClaw chain id "4 42" -> exactly one legit Unknown task "42" +$out = Invoke-Scenario 'I_invalid_autoclaw' @('4 42', '0', '0') +$hits = ([regex]::Matches($out, 'Unknown task "42"')).Count +if ($hits -ne 1) { $failures += "I: expected 1 Unknown task from AutoClaw chain, got $hits" } +if ($out -match '%PENDING_CHAIN%') { $failures += 'I: literal %PENDING_CHAIN% leaked' } + +# J) hostile main-menu input - :ValidateChoice must reject (never execute) +# the injected command, show the hint, re-prompt, then exit cleanly +$out = Invoke-Scenario 'J_hostile_main' @('1 & calc', '0') +if ($out -match 'calc') { $failures += 'J: injection attempt leaked through main prompt' } +if ($out -notmatch 'Invalid input') { $failures += 'J: rejection hint missing' } + +# K) hostile main-menu input with parens - PWNED must never execute +$out = Invoke-Scenario 'K_hostile_parens' @('2 ) & echo PWNED', '0') +if ($out -match 'PWNED') { $failures += 'K: paren-amp injection executed' } + +# L) hostile ZCode submenu input - rejected at the submenu prompt +$out = Invoke-Scenario 'L_hostile_zcode' @('2', '1 & calc', '0', '0') +if ($out -match 'calc') { $failures += 'L: injection attempt leaked through ZCode prompt' } +if ($out -notmatch 'Invalid input') { $failures += 'L: rejection hint missing' } + +if ($failures.Count -gt 0) { + Write-Output 'HARNESS FAIL:' + $failures | ForEach-Object { Write-Output " - $_" } + exit 1 +} +Write-Output 'HARNESS PASS: all 12 scenarios clean' +# Explicit exit: pwsh -File propagates the last native \$LASTEXITCODE, and the +# scenarios' cmd exit code inherits GHOST.bat's `timeout` calls, which fail on +# CI consoles (no interactive stdin) AFTER the PASS line was already printed. +exit 0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d673647..173ece1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,8 @@ on: branches: [main] pull_request: branches: [main] + # Called by release.yml so a tag can never publish an untested tree. + workflow_call: permissions: contents: read @@ -45,6 +47,26 @@ jobs: Invoke-Pester -Configuration $config if ($config.Run.Exit) { exit $LASTEXITCODE } + - name: Node engines - asar patcher syntax + ENGINE_VERSION marker + shell: pwsh + run: | + foreach ($f in @('tools/patch_zcode_icon_override.mjs', 'tools/patch_autoclaw_ports.mjs')) { + node --check $f + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $src = Get-Content -LiteralPath $f -Raw + # \s* before $: the runner checks out CRLF and (?m)$ does not + # match before the CR (first CI run caught this). + if ($src -notmatch '(?m)^const ENGINE_VERSION = (\d+);\s*$') { + Write-Error "ENGINE_VERSION marker missing in $f" + exit 1 + } + Write-Host "$f ENGINE_VERSION=$($Matches[1])" + } + + - name: GHOST.bat menu harness + shell: pwsh + run: ./.bat-harness-test.ps1 + linux: name: Shell lint runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1eaf2c5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,96 @@ +name: Release + +# Publishes a GitHub Release with a ZIP asset when a vX.Y.Z tag is pushed. +# The ZIP is a plain `git archive` (tracked files only) so its content matches +# exactly what update_ghost.ps1's download-ZIP path fetches and verifies. +# Prerequisites (enforced, never silent): the tag must have a matching +# "## [X.Y.Z]" section in CHANGELOG.md - its content becomes the release notes. + +on: + push: + tags: ['v*'] + +permissions: + contents: write + +jobs: + # The full CI suite runs first: a tag never publishes an untested tree. + tests: + name: CI gates (called) + uses: ./.github/workflows/ci.yml + + release: + name: Build ZIP + publish GitHub Release + needs: tests + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Guard - tag must match a CHANGELOG section + shell: pwsh + run: | + $tag = $env:GITHUB_REF_NAME + if ($tag -notmatch '^v\d+\.\d+\.\d+$') { + Write-Error "Unexpected tag format '$tag' - expected vX.Y.Z" + exit 1 + } + $ver = $tag.Substring(1) + $section = Select-String -Path CHANGELOG.md -Pattern ('^## \[' + [regex]::Escape($ver) + '\]') + if (-not $section) { + Write-Error "CHANGELOG.md has no '## [$ver]' section - write the release notes before tagging $tag" + exit 1 + } + Write-Host "Tag $tag -> CHANGELOG line $($section.LineNumber): $($section.Line)" + + - name: Build ZIP (git archive = tracked files, mirrors update_ghost.ps1's ZIP copy) + shell: pwsh + run: | + $tag = $env:GITHUB_REF_NAME + $zip = "ghost-$tag.zip" + git archive --format=zip --prefix="ghost-$tag/" --output="$zip" HEAD + $item = Get-Item -LiteralPath $zip + if ($item.Length -lt 100KB) { + Write-Error "ZIP suspiciously small: $($item.Length) bytes" + exit 1 + } + Write-Host "Built $zip ($([math]::Round($item.Length / 1MB, 2)) MB)" + + - name: Extract release notes from the CHANGELOG section + shell: pwsh + run: | + $tag = $env:GITHUB_REF_NAME + $ver = $tag.Substring(1) + $lines = Get-Content -LiteralPath CHANGELOG.md + $start = -1 + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match ('^## \[' + [regex]::Escape($ver) + '\]')) { $start = $i; break } + } + if ($start -lt 0) { Write-Error "Section '## [$ver]' vanished between guard and extract"; exit 1 } + $end = $lines.Count + for ($j = $start + 1; $j -lt $lines.Count; $j++) { + if ($lines[$j] -match '^## \[') { $end = $j; break } + } + $notes = $lines[$start..($end - 1)] -join "`r`n" + Set-Content -LiteralPath release-notes.md -Value $notes -Encoding utf8 + Write-Host "Extracted $($end - $start) note lines for [$ver]" + + - name: Create the GitHub Release with the ZIP asset + env: + GH_TOKEN: ${{ github.token }} + shell: pwsh + run: | + $tag = $env:GITHUB_REF_NAME + gh release create "$tag" "ghost-$tag.zip" --title "GHOST $tag" --notes-file release-notes.md --verify-tag + + - name: Verify-after - the asset is on the release (never silent-success) + env: + GH_TOKEN: ${{ github.token }} + shell: pwsh + run: | + $tag = $env:GITHUB_REF_NAME + $assets = @(gh release view "$tag" --json assets --jq '.assets[].name') + if ($assets -notcontains "ghost-$tag.zip") { + Write-Error "ZIP asset missing from release $tag (assets: $($assets -join ', '))" + exit 1 + } + Write-Host "Verified: release $tag carries ghost-$tag.zip" diff --git a/.gitignore b/.gitignore index d44e1b1..a9f3ccf 100644 --- a/.gitignore +++ b/.gitignore @@ -91,7 +91,3 @@ tools/block_qoder_domains.ps1 # Shortcuts are per-machine binaries (paths embed the repo location) — regenerate # with tools\install_ghost_shortcut.ps1 instead of committing. *.lnk - -# Throwaway test harness for GHOST.bat menu input handling — local-only, regenerated -# on demand by re-running the harness author against a TEST COPY of GHOST.bat. -.bat-harness-test.ps1 diff --git a/AGENTS.md b/AGENTS.md index 09d2d7e..d5ae02c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,22 +2,26 @@ ## Project overview -Fresh-start toolkit for local AI-IDE identity stores — **Cursor**, **Windsurf/Devin Desktop**, **Trae**, **Qoder**, **ZCode**, **QoderWork**, **MiniMax Agent/OpenCode** — for privacy hygiene, multi-account testing, and dev-environment resets. Windows PowerShell (primary) and Linux Bash scripts. +Fresh-start toolkit for local AI-IDE identity stores — **Cursor**, **Windsurf/Devin Desktop**, **Trae**, **Qoder**, **ZCode**, **QoderWork**, **MiniMax Agent/OpenCode**, **AutoClaw** — for privacy hygiene, multi-account testing, and dev-environment resets. Windows PowerShell (primary) and Linux Bash scripts. ## Repository layout ``` README.md AGENTS.md GHOST.bat/ghost.sh # root launchers (scripts live under src/; # CLI cheat sheet: docs/cli.md); GHOST.bat = - # 3 submenus with CONTINUOUS LOCAL numbering - # (IDE resets [1]-[8] / ZCode Multi-Instance & + # 4 submenus with CONTINUOUS LOCAL numbering + # (IDE resets [1]-[9] / ZCode Multi-Instance & # Clones [1]-[17] / System & GHOST Maintenance - # [1]-[2]) + ANSI colors + local chained input + # [1]-[2] / AutoClaw Multi-Instance & Clones + # [1]-[12]) + ANSI colors + local chained input # per submenu (e.g. "1 3 4"), main-menu sub-paths # ("2 1 3 4" = ZCode then run its [1] [3] [4]), # global gid jump from the main menu, and # [U] Check for updates on the main menu src/windows/ # current .ps1 resetters (unversioned names) + identity_utils.ps1 + change_device_id.ps1 + # + launch_autoclaw_clone.ps1 (AutoClaw clone launcher, + # -Instance inst1..inst4 + -Status) + Launch-AutoClaw-Clone.bat + # + reset_autoclaw.ps1 (-Target Primary|inst1..4|All) src/linux/ # .sh scripts (id_reset_common.sh + per-IDE resetters) src/python/ # stdlib-only Python core (ghost/ package) + ghost_cli.py bootstrap — # owns the shared SQLite/JSON identity logic; the PS bridges @@ -25,7 +29,9 @@ src/python/ # stdlib-only Python core (ghost/ package) + ghos # refresh_zcode_second_chats chat-check) invoke it by absolute # path; tested by tests/python/test_ghost_core.py (unittest) docs/ # IMPLEMENTATION_GUIDE/PLAN, WALKTHROUGH, research notes (local-only); - # ZCODE_CHAT_SYNC_WATCHER_PLAN.md (tracked — chat-sync root cause) + # ZCODE_CHAT_SYNC_WATCHER_PLAN.md (tracked — chat-sync root cause); + # PYTHON_HYBRID_ROADMAP.md (tracked — Phase 2 cancellation decision + + # Phase 3 thin-manifest pilot design + acceptance criteria) archive/ # superseded versions (local-only fallbacks — do not run, never committed) assets/ # ghost.ico + ghost-source.png (launcher icon; regenerate via # tools/make_ghost_icon.ps1 — never hand-edit) @@ -122,6 +128,40 @@ tools/ # reviewed utilities (block_qoder_domains.ps1 # overwrite-edits prompt); GHOST.bat replaced by either path # prints a close-and-relaunch note (cmd reads the .bat # incrementally) + # patch_autoclaw_ports.ps1 (+ .mjs engine v10) — one-time app.asar + # patch widening AutoClaw's hardcoded MCP candidate-port array + # [19681..19684] -> [19681..19688] + home-override (the state-dir +# isolation marker .autoclaw-home; see the ELEVATION bullet below) + # multi-instance ceiling; inst4 has no port without it); + # backup + marker (autoclaw-ports-patch.marker.json) + -Restore + # (verified byte-identical) + node --check gate; AutoClaw must be + # closed; an app update reverts it (all instances run with + # system.autoUpdate=false as the guard); AutoClaw submenu [11]; + # install_autoclaw_clone_shortcuts.ps1 — per-user Desktop + + # Start-menu shortcuts for inst1..inst4 (AppUserModel.ID = + # com.zhipuai.autoclaw.inst1..4; base read from the Primary .lnk + # or the verified app constant; -Instance to scope; -Remove; + # -RepairPrimaryAumid strips a hijacked .instN suffix + restores + # ,0 icon; .lnk AUMIDs read ONLY via Shell.Application + # ExtendedProperty); per-clone IconLocation = + # tools\autoclaw--branding\tray_icon.ico,0 (blue/yellow/ + # green/red ← inst1..4; missing dir warns + falls back to the + # exe icon); AutoClaw submenu [2] + # new_clone_suite.ps1 — clone-suite scaffolding generator (playbook + # phases 3-10 mechanics): intake JSON → reset/launcher/.bat/ + # shortcuts/SPEC skeletons + GHOST.bat submenu anchor-edit with + # verify-after; parse-checks every emitted .ps1; Phases 0-2 stay + # with the agent as slots + # make_clone_icon.ps1 — GENERIC clone-icon generator (any app, + # PS 5.1 + System.Drawing only): corner-sampled background → + # tile detection (LongestLowRun profiles, -BgTolerance, or + # -ManualCrop x,y,size) → inset crop → 1024 master + + # rounded-corner alpha mask → tray_icon.ico (16..256 + # PNG-in-ICO) + icon_windows.png + source. provenance; + # -HueRotate derives extra colors (autoclaw-red-branding = + # blue master +150°); re-runs reuse the transparent-corner + # master; verify-after + TEMP previews; built for the + # opencode/cline future clones — do not delete ``` ## Architecture @@ -130,7 +170,6 @@ tools/ # reviewed utilities (block_qoder_domains.ps1 src/windows/identity_utils.ps1 # Shared library, dot-sourced by all IDE scripts New-IdentitySet # -> { devDeviceId, machineId, macMachineId, sqmId } Assert-Administrator # -> exit 1 if not elevated - Stop-AppProcesses # -> kill loop (auto-expands Windsurf->Devin+codeium, Trae->Broker) Backup-FileToTimestampDir # -> copy to \ID_Backups\\